package integration import ( "sort" "testing" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/integration/hsic" "github.com/oauth2-proxy/mockoidc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestOIDCGroupsPersisted verifies that the `groups` claim from an OIDC // provider is persisted on the user and exposed through the gRPC API. // // Implementation under test: // - users.groups TEXT column added by migration 202505141323. // - hscontrol/types.User.SetGroups / GetGroups round-trip via JSON. // - FromClaim calls SetGroups(claims.Groups) so login populates the // column. OIDCClaims.Groups is FlexibleStringSlice, so providers like // JumpCloud that return a single string instead of an array also work. // - v1.User.Groups (proto field 9) is populated by User.Proto() so // external tools (Headplane) can read group membership over gRPC. func TestOIDCGroupsPersisted(t *testing.T) { IntegrationSkip(t) // mockoidc serves logins from a strict queue, so keep NodesPerUser=1. spec := ScenarioSpec{ NodesPerUser: 1, Users: []string{"admin", "dev", "solo"}, OIDCUsers: []mockoidc.MockUser{ oidcMockUserWithGroups("admin", true, []string{"admins", "engineering"}), oidcMockUserWithGroups("dev", true, []string{"engineering"}), // User with empty groups — round-trips as no Groups. oidcMockUserWithGroups("solo", true, nil), }, } scenario, err := NewScenario(spec) require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) oidcMap := map[string]string{ "HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(), "HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(), "CREDENTIALS_DIRECTORY_TEST": "/tmp", "HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret", // Make sure the OIDC scope set includes "groups" so mockoidc emits the claim. "HEADSCALE_OIDC_SCOPE": "openid,profile,email,groups", } err = scenario.CreateHeadscaleEnvWithLoginURL( nil, hsic.WithTestName("oidcgroups"), hsic.WithConfigEnv(oidcMap), hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())), ) requireNoErrHeadscaleEnv(t, err) // Drive the OIDC login flow for every client. _, err = scenario.ListTailscaleClients() requireNoErrListClients(t, err) err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) headscale, err := scenario.Headscale() require.NoError(t, err) users, err := headscale.ListUsers() require.NoError(t, err) got := groupsByOIDCUserName(users) want := map[string][]string{ "admin": {"admins", "engineering"}, "dev": {"engineering"}, "solo": nil, } for name, wantGroups := range want { gotGroups, ok := got[name] assert.True(t, ok, "OIDC user %q not present in ListUsers response", name) assert.ElementsMatch(t, wantGroups, gotGroups, "groups mismatch for user %q", name) } } // groupsByOIDCUserName picks out OIDC users from a ListUsers response and // returns a map of username -> sorted groups. CLI-created users (no provider) // are filtered out so the assertions don't need to know about them. func groupsByOIDCUserName(users []*v1.User) map[string][]string { out := map[string][]string{} for _, u := range users { if u.GetProvider() != "oidc" { continue } g := append([]string(nil), u.GetGroups()...) sort.Strings(g) if len(g) == 0 { g = nil } out[u.GetName()] = g } return out } // oidcMockUserWithGroups extends [oidcMockUser] with a Groups claim. // mockoidc populates the id_token / userinfo from this struct verbatim. func oidcMockUserWithGroups(username string, emailVerified bool, groups []string) mockoidc.MockUser { u := oidcMockUser(username, emailVerified) u.Groups = groups return u }