package coderd_test import ( "context" "crypto" "fmt" "net/http" "net/http/cookiejar" "net/url" "strings" "testing" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt/v4" "github.com/google/go-github/v43/github" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "golang.org/x/xerrors" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/testutil" ) // This test specifically tests logging in with OIDC when an expired // OIDC session token exists. // The token refreshing should not happen since we are reauthenticating. // nolint:bodyclose func TestOIDCOauthLoginWithExisting(t *testing.T) { t.Parallel() fake := oidctest.NewFakeIDP(t, oidctest.WithRefresh(func(_ string) error { return xerrors.New("refreshing token should never occur") }), oidctest.WithServing(), ) cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true cfg.IgnoreUserInfo = true }) client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ OIDCConfig: cfg, }) const username = "alice" claims := jwt.MapClaims{ "email": "alice@coder.com", "email_verified": true, "preferred_username": username, } helper := oidctest.NewLoginHelper(client, fake) // Signup alice userClient, _ := helper.Login(t, claims) // Expire the link. This will force the client to refresh the token. helper.ExpireOauthToken(t, api.Database, userClient) // Instead of refreshing, just log in again. helper.Login(t, claims) } func TestUserLogin(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ Email: anotherUser.Email, Password: "SomeSecurePassword!", }) require.NoError(t, err) }) t.Run("UserDeleted", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) client.DeleteUser(context.Background(), anotherUser.ID) _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ Email: anotherUser.Email, Password: "SomeSecurePassword!", }) require.Error(t, err) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) // Password auth should fail if the user is made without password login. t.Run("DisableLoginDeprecatedField", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { r.Password = "" r.DisableLogin = true }) _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ Email: anotherUser.Email, Password: "SomeSecurePassword!", }) require.Error(t, err) }) t.Run("LoginTypeNone", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { r.Password = "" r.UserLoginType = codersdk.LoginTypeNone }) _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ Email: anotherUser.Email, Password: "SomeSecurePassword!", }) require.Error(t, err) }) } func TestUserAuthMethods(t *testing.T) { t.Parallel() t.Run("Password", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() methods, err := client.AuthMethods(ctx) require.NoError(t, err) require.True(t, methods.Password.Enabled) require.False(t, methods.Github.Enabled) }) t.Run("Github", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ GithubOAuth2Config: &coderd.GithubOAuth2Config{}, }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() methods, err := client.AuthMethods(ctx) require.NoError(t, err) require.True(t, methods.Password.Enabled) require.True(t, methods.Github.Enabled) }) } // nolint:bodyclose func TestUserOAuth2Github(t *testing.T) { t.Parallel() stateActive := "active" statePending := "pending" t.Run("NotInAllowedOrganization", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ GithubOAuth2Config: &coderd.GithubOAuth2Config{ OAuth2Config: &testutil.OAuth2Config{}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &stateActive, Organization: &github.Organization{ Login: github.String("kyle"), }, }}, nil }, }, }) resp := oauth2Callback(t, client) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) t.Run("NotInAllowedTeam", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ GithubOAuth2Config: &coderd.GithubOAuth2Config{ AllowOrganizations: []string{"coder"}, AllowTeams: []coderd.GithubOAuth2Team{{"another", "something"}, {"coder", "frontend"}}, OAuth2Config: &testutil.OAuth2Config{}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &stateActive, Organization: &github.Organization{ Login: github.String("coder"), }, }}, nil }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ ID: github.Int64(100), Login: github.String("kyle"), }, nil }, TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) { return nil, xerrors.New("no perms") }, }, }) resp := oauth2Callback(t, client) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) t.Run("UnverifiedEmail", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ GithubOAuth2Config: &coderd.GithubOAuth2Config{ OAuth2Config: &testutil.OAuth2Config{}, AllowOrganizations: []string{"coder"}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &stateActive, Organization: &github.Organization{ Login: github.String("coder"), }, }}, nil }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{}, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("testuser@coder.com"), Verified: github.Bool(false), }}, nil }, }, }) _ = coderdtest.CreateFirstUser(t, client) resp := oauth2Callback(t, client) require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("BlockSignups", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ GithubOAuth2Config: &coderd.GithubOAuth2Config{ OAuth2Config: &testutil.OAuth2Config{}, AllowOrganizations: []string{"coder"}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &stateActive, Organization: &github.Organization{ Login: github.String("coder"), }, }}, nil }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ ID: github.Int64(100), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("testuser@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), }}, nil }, }, }) resp := oauth2Callback(t, client) require.Equal(t, http.StatusForbidden, resp.StatusCode) }) t.Run("MultiLoginNotAllowed", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ GithubOAuth2Config: &coderd.GithubOAuth2Config{ OAuth2Config: &testutil.OAuth2Config{}, AllowOrganizations: []string{"coder"}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &stateActive, Organization: &github.Organization{ Login: github.String("coder"), }, }}, nil }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ ID: github.Int64(100), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("testuser@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), }}, nil }, }, }) // Creates the first user with login_type 'password'. _ = coderdtest.CreateFirstUser(t, client) // Attempting to login should give us a 403 since the user // already has a login_type of 'password'. resp := oauth2Callback(t, client) require.Equal(t, http.StatusForbidden, resp.StatusCode) }) t.Run("Signup", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, GithubOAuth2Config: &coderd.GithubOAuth2Config{ OAuth2Config: &testutil.OAuth2Config{}, AllowOrganizations: []string{"coder"}, AllowSignups: true, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &stateActive, Organization: &github.Organization{ Login: github.String("coder"), }, }}, nil }, AuthenticatedUser: func(ctx context.Context, _ *http.Client) (*github.User, error) { return &github.User{ Login: github.String("kyle"), ID: i64ptr(1234), AvatarURL: github.String("/hello-world"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("kyle@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), }}, nil }, }, }) numLogs := len(auditor.AuditLogs()) resp := oauth2Callback(t, client) numLogs++ // add an audit log for login require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) client.SetSessionToken(authCookieValue(resp.Cookies())) user, err := client.User(context.Background(), "me") require.NoError(t, err) require.Equal(t, "kyle@coder.com", user.Email) require.Equal(t, "kyle", user.Username) require.Equal(t, "/hello-world", user.AvatarURL) require.Len(t, auditor.AuditLogs(), numLogs) require.NotEqual(t, auditor.AuditLogs()[numLogs-1].UserID, uuid.Nil) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) t.Run("SignupAllowedTeam", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, GithubOAuth2Config: &coderd.GithubOAuth2Config{ AllowSignups: true, AllowOrganizations: []string{"coder"}, AllowTeams: []coderd.GithubOAuth2Team{{"coder", "frontend"}}, OAuth2Config: &testutil.OAuth2Config{}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &stateActive, Organization: &github.Organization{ Login: github.String("coder"), }, }}, nil }, TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) { return &github.Membership{}, nil }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ ID: github.Int64(100), Login: github.String("kyle"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("kyle@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), }}, nil }, }, }) numLogs := len(auditor.AuditLogs()) resp := oauth2Callback(t, client) numLogs++ // add an audit log for login require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) t.Run("SignupAllowedTeamInFirstOrganization", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, GithubOAuth2Config: &coderd.GithubOAuth2Config{ AllowSignups: true, AllowOrganizations: []string{"coder", "nil"}, AllowTeams: []coderd.GithubOAuth2Team{{"coder", "backend"}}, OAuth2Config: &testutil.OAuth2Config{}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{ { State: &stateActive, Organization: &github.Organization{ Login: github.String("coder"), }, }, { State: &stateActive, Organization: &github.Organization{ Login: github.String("nil"), }, }, }, nil }, TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) { return &github.Membership{}, nil }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ ID: github.Int64(100), Login: github.String("mathias"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("mathias@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), }}, nil }, }, }) numLogs := len(auditor.AuditLogs()) resp := oauth2Callback(t, client) numLogs++ // add an audit log for login require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) t.Run("SignupAllowedTeamInSecondOrganization", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, GithubOAuth2Config: &coderd.GithubOAuth2Config{ AllowSignups: true, AllowOrganizations: []string{"coder", "nil"}, AllowTeams: []coderd.GithubOAuth2Team{{"nil", "null"}}, OAuth2Config: &testutil.OAuth2Config{}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{ { State: &stateActive, Organization: &github.Organization{ Login: github.String("coder"), }, }, { State: &stateActive, Organization: &github.Organization{ Login: github.String("nil"), }, }, }, nil }, TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) { return &github.Membership{}, nil }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ ID: github.Int64(100), Login: github.String("mathias"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("mathias@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), }}, nil }, }, }) numLogs := len(auditor.AuditLogs()) resp := oauth2Callback(t, client) numLogs++ // add an audit log for login require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) t.Run("SignupAllowEveryone", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, GithubOAuth2Config: &coderd.GithubOAuth2Config{ AllowSignups: true, AllowEveryone: true, OAuth2Config: &testutil.OAuth2Config{}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{}, nil }, TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) { return nil, xerrors.New("no teams") }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ ID: github.Int64(100), Login: github.String("mathias"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("mathias@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), }}, nil }, }, }) numLogs := len(auditor.AuditLogs()) resp := oauth2Callback(t, client) numLogs++ // add an audit log for login require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) t.Run("SignupFailedInactiveInOrg", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ GithubOAuth2Config: &coderd.GithubOAuth2Config{ AllowSignups: true, AllowOrganizations: []string{"coder"}, AllowTeams: []coderd.GithubOAuth2Team{{"coder", "frontend"}}, OAuth2Config: &testutil.OAuth2Config{}, ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &statePending, Organization: &github.Organization{ Login: github.String("coder"), }, }}, nil }, TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) { return &github.Membership{}, nil }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ ID: github.Int64(100), Login: github.String("kyle"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return []*github.UserEmail{{ Email: github.String("kyle@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), }}, nil }, }, }) resp := oauth2Callback(t, client) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) // The bug only is exercised when a deleted user with the same linked_id exists. // Still related open issues: // - https://github.com/coder/coder/issues/12116 // - https://github.com/coder/coder/issues/12115 t.Run("ChangedEmail", func(t *testing.T) { t.Parallel() fake := oidctest.NewFakeIDP(t, oidctest.WithServing(), oidctest.WithCallbackPath("/api/v2/users/oauth2/github/callback"), ) const ghID = int64(7777) auditor := audit.NewMock() coderEmail := &github.UserEmail{ Email: github.String("alice@coder.com"), Verified: github.Bool(true), Primary: github.Bool(true), } gmailEmail := &github.UserEmail{ Email: github.String("alice@gmail.com"), Verified: github.Bool(true), Primary: github.Bool(false), } emails := []*github.UserEmail{ gmailEmail, coderEmail, } owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ Auditor: auditor, GithubOAuth2Config: &coderd.GithubOAuth2Config{ AllowSignups: true, AllowEveryone: true, OAuth2Config: promoauth.NewFactory(prometheus.NewRegistry()).NewGithub("test-github", fake.OIDCConfig(t, []string{})), ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { return []*github.Membership{}, nil }, TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) { return nil, xerrors.New("no teams") }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ Login: github.String("alice"), ID: github.Int64(ghID), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { return emails, nil }, }, }) first := coderdtest.CreateFirstUser(t, owner) ctx := testutil.Context(t, testutil.WaitLong) ownerUser, err := owner.User(context.Background(), "me") require.NoError(t, err) // Create the user, then delete the user, then create again. // This causes the email change to fail. client := codersdk.New(owner.URL) client, _ = fake.Login(t, client, jwt.MapClaims{}) deleted, err := client.User(ctx, "me") require.NoError(t, err) err = owner.DeleteUser(ctx, deleted.ID) require.NoError(t, err) // Check no user links for the user links, err := db.GetUserLinksByUserID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(ownerUser, first.OrganizationID)), deleted.ID) require.NoError(t, err) require.Empty(t, links) // Make sure a user_link cannot be created with a deleted user. // nolint:gocritic // Unit test _, err = db.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{ UserID: deleted.ID, LoginType: "github", LinkedID: "100", OAuthAccessToken: "random", OAuthRefreshToken: "random", OAuthExpiry: time.Now(), DebugContext: []byte(`{}`), }) require.ErrorContains(t, err, "Cannot create user_link for deleted user") // Create the user again. client, _ = fake.Login(t, client, jwt.MapClaims{}) user, err := client.User(ctx, "me") require.NoError(t, err) userID := user.ID require.Equal(t, user.Email, *coderEmail.Email) // Now the user is registered, let's change their primary email. coderEmail.Primary = github.Bool(false) gmailEmail.Primary = github.Bool(true) client, _ = fake.Login(t, client, jwt.MapClaims{}) user, err = client.User(ctx, "me") require.NoError(t, err) require.Equal(t, user.ID, userID, "user_id is different, a new user was likely created") require.Equal(t, user.Email, *gmailEmail.Email) // Entirely change emails. newEmail := "alice@newdomain.com" emails = []*github.UserEmail{ { Email: github.String("alice@newdomain.com"), Primary: github.Bool(true), Verified: github.Bool(true), }, } client, _ = fake.Login(t, client, jwt.MapClaims{}) user, err = client.User(ctx, "me") require.NoError(t, err) require.Equal(t, user.ID, userID, "user_id is different, a new user was likely created") require.Equal(t, user.Email, newEmail) }) } // nolint:bodyclose func TestUserOIDC(t *testing.T) { t.Parallel() for _, tc := range []struct { Name string IDTokenClaims jwt.MapClaims UserInfoClaims jwt.MapClaims AllowSignups bool EmailDomain []string Username string AvatarURL string StatusCode int IgnoreEmailVerified bool IgnoreUserInfo bool }{ { Name: "EmailOnly", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", }, AllowSignups: true, StatusCode: http.StatusOK, Username: "kyle", }, { Name: "EmailNotVerified", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": false, }, AllowSignups: true, StatusCode: http.StatusForbidden, }, { Name: "EmailNotAString", IDTokenClaims: jwt.MapClaims{ "email": 3.14159, "email_verified": false, }, AllowSignups: true, StatusCode: http.StatusBadRequest, }, { Name: "EmailNotVerifiedIgnored", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": false, }, AllowSignups: true, StatusCode: http.StatusOK, Username: "kyle", IgnoreEmailVerified: true, }, { Name: "NotInRequiredEmailDomain", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": true, }, AllowSignups: true, EmailDomain: []string{ "coder.com", }, StatusCode: http.StatusForbidden, }, { Name: "EmailDomainCaseInsensitive", IDTokenClaims: jwt.MapClaims{ "email": "kyle@KWC.io", "email_verified": true, }, AllowSignups: true, EmailDomain: []string{ "kwc.io", }, StatusCode: http.StatusOK, }, { Name: "EmailDomainSubset", IDTokenClaims: jwt.MapClaims{ "email": "colin@gmail.com", "email_verified": true, }, AllowSignups: true, EmailDomain: []string{ "mail.com", }, StatusCode: http.StatusForbidden, }, { Name: "EmptyClaims", IDTokenClaims: jwt.MapClaims{}, AllowSignups: true, StatusCode: http.StatusBadRequest, }, { Name: "NoSignups", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": true, }, StatusCode: http.StatusForbidden, }, { Name: "UsernameFromEmail", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": true, }, Username: "kyle", AllowSignups: true, StatusCode: http.StatusOK, }, { Name: "UsernameFromClaims", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": true, "preferred_username": "hotdog", }, Username: "hotdog", AllowSignups: true, StatusCode: http.StatusOK, }, { // Services like Okta return the email as the username: // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present Name: "UsernameAsEmail", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": true, "preferred_username": "kyle@kwc.io", }, Username: "kyle", AllowSignups: true, StatusCode: http.StatusOK, }, { // See: https://github.com/coder/coder/issues/4472 Name: "UsernameIsEmail", IDTokenClaims: jwt.MapClaims{ "preferred_username": "kyle@kwc.io", }, Username: "kyle", AllowSignups: true, StatusCode: http.StatusOK, }, { Name: "WithPicture", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": true, "preferred_username": "kyle", "picture": "/example.png", }, Username: "kyle", AllowSignups: true, AvatarURL: "/example.png", StatusCode: http.StatusOK, }, { Name: "WithUserInfoClaims", IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": true, }, UserInfoClaims: jwt.MapClaims{ "preferred_username": "potato", "picture": "/example.png", }, Username: "potato", AllowSignups: true, AvatarURL: "/example.png", StatusCode: http.StatusOK, }, { Name: "GroupsDoesNothing", IDTokenClaims: jwt.MapClaims{ "email": "coolin@coder.com", "groups": []string{"pingpong"}, }, AllowSignups: true, StatusCode: http.StatusOK, }, { Name: "UserInfoOverridesIDTokenClaims", IDTokenClaims: jwt.MapClaims{ "email": "internaluser@internal.domain", "email_verified": false, }, UserInfoClaims: jwt.MapClaims{ "email": "externaluser@external.domain", "email_verified": true, "preferred_username": "user", }, Username: "user", AllowSignups: true, IgnoreEmailVerified: false, StatusCode: http.StatusOK, }, { Name: "InvalidUserInfo", IDTokenClaims: jwt.MapClaims{ "email": "internaluser@internal.domain", "email_verified": false, }, UserInfoClaims: jwt.MapClaims{ "email": 1, }, AllowSignups: true, IgnoreEmailVerified: false, StatusCode: http.StatusInternalServerError, }, { Name: "IgnoreUserInfo", IDTokenClaims: jwt.MapClaims{ "email": "user@internal.domain", "email_verified": true, "preferred_username": "user", }, UserInfoClaims: jwt.MapClaims{ "email": "user.mcname@external.domain", "preferred_username": "Mr. User McName", }, Username: "user", IgnoreUserInfo: true, AllowSignups: true, StatusCode: http.StatusOK, }, { Name: "HugeIDToken", IDTokenClaims: inflateClaims(t, jwt.MapClaims{ "email": "user@domain.tld", "email_verified": true, }, 65536), Username: "user", AllowSignups: true, StatusCode: http.StatusOK, }, { Name: "HugeClaims", IDTokenClaims: jwt.MapClaims{ "email": "user@domain.tld", "email_verified": true, }, UserInfoClaims: inflateClaims(t, jwt.MapClaims{}, 65536), Username: "user", AllowSignups: true, StatusCode: http.StatusOK, }, } { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() fake := oidctest.NewFakeIDP(t, oidctest.WithRefresh(func(_ string) error { return xerrors.New("refreshing token should never occur") }), oidctest.WithServing(), oidctest.WithStaticUserInfo(tc.UserInfoClaims), ) cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = tc.AllowSignups cfg.EmailDomain = tc.EmailDomain cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified cfg.IgnoreUserInfo = tc.IgnoreUserInfo }) auditor := audit.NewMock() logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) owner := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, OIDCConfig: cfg, Logger: &logger, }) numLogs := len(auditor.AuditLogs()) client, resp := fake.AttemptLogin(t, owner, tc.IDTokenClaims) numLogs++ // add an audit log for login require.Equal(t, tc.StatusCode, resp.StatusCode) ctx := testutil.Context(t, testutil.WaitLong) if tc.Username != "" { user, err := client.User(ctx, "me") require.NoError(t, err) require.Equal(t, tc.Username, user.Username) require.Len(t, auditor.AuditLogs(), numLogs) require.NotEqual(t, auditor.AuditLogs()[numLogs-1].UserID, uuid.Nil) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) } if tc.AvatarURL != "" { user, err := client.User(ctx, "me") require.NoError(t, err) require.Equal(t, tc.AvatarURL, user.AvatarURL) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) } }) } t.Run("OIDCConvert", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() fake := oidctest.NewFakeIDP(t, oidctest.WithRefresh(func(_ string) error { return xerrors.New("refreshing token should never occur") }), oidctest.WithServing(), ) cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true }) client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, OIDCConfig: cfg, }) owner := coderdtest.CreateFirstUser(t, client) user, userData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) claims := jwt.MapClaims{ "email": userData.Email, } var err error user.HTTPClient.Jar, err = cookiejar.New(nil) require.NoError(t, err) ctx := testutil.Context(t, testutil.WaitShort) convertResponse, err := user.ConvertLoginType(ctx, codersdk.ConvertLoginRequest{ ToType: codersdk.LoginTypeOIDC, Password: "SomeSecurePassword!", }) require.NoError(t, err) fake.LoginWithClient(t, user, claims, func(r *http.Request) { r.URL.RawQuery = url.Values{ "oidc_merge_state": {convertResponse.StateString}, }.Encode() r.Header.Set(codersdk.SessionTokenHeader, user.SessionToken()) cookies := user.HTTPClient.Jar.Cookies(r.URL) for _, cookie := range cookies { r.AddCookie(cookie) } }) }) t.Run("AlternateUsername", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() fake := oidctest.NewFakeIDP(t, oidctest.WithRefresh(func(_ string) error { return xerrors.New("refreshing token should never occur") }), oidctest.WithServing(), ) cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true }) client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, OIDCConfig: cfg, }) numLogs := len(auditor.AuditLogs()) claims := jwt.MapClaims{ "email": "jon@coder.com", } userClient, _ := fake.Login(t, client, claims) numLogs++ // add an audit log for login ctx := testutil.Context(t, testutil.WaitLong) user, err := userClient.User(ctx, "me") require.NoError(t, err) require.Equal(t, "jon", user.Username) // Pass a different subject field so that we prompt creating a // new user userClient, _ = fake.Login(t, client, jwt.MapClaims{ "email": "jon@example2.com", "sub": "diff", }) numLogs++ // add an audit log for login user, err = userClient.User(ctx, "me") require.NoError(t, err) require.True(t, strings.HasPrefix(user.Username, "jon-"), "username %q should have prefix %q", user.Username, "jon-") require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) t.Run("Disabled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) oauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback") require.NoError(t, err) req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) require.NoError(t, err) resp, err := client.HTTPClient.Do(req) require.NoError(t, err) resp.Body.Close() require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("NoIDToken", func(t *testing.T) { t.Parallel() fake := oidctest.NewFakeIDP(t, oidctest.WithRefresh(func(_ string) error { return xerrors.New("refreshing token should never occur") }), oidctest.WithServing(), ) cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true }) client := coderdtest.New(t, &coderdtest.Options{ OIDCConfig: cfg, }) _, resp := fake.AttemptLogin(t, client, jwt.MapClaims{}) require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("BadVerify", func(t *testing.T) { t.Parallel() badVerifier := oidc.NewVerifier("", &oidc.StaticKeySet{ PublicKeys: []crypto.PublicKey{}, }, &oidc.Config{}) badProvider := &oidc.Provider{} fake := oidctest.NewFakeIDP(t, oidctest.WithRefresh(func(_ string) error { return xerrors.New("refreshing token should never occur") }), oidctest.WithServing(), ) cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true cfg.Provider = badProvider cfg.Verifier = badVerifier }) client := coderdtest.New(t, &coderdtest.Options{ OIDCConfig: cfg, }) _, resp := fake.AttemptLogin(t, client, jwt.MapClaims{}) require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) } func TestUserLogout(t *testing.T) { t.Parallel() // Create a custom database so it's easier to make scoped tokens for // testing. db, pubSub := dbtestutil.NewDB(t) client := coderdtest.New(t, &coderdtest.Options{ Database: db, Pubsub: pubSub, }) firstUser := coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Create a user with built-in auth. const ( email = "dean.was.here@test.coder.com" username = "dean" //nolint:gosec password = "SomeSecurePassword123!" ) newUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ Email: email, Username: username, Password: password, OrganizationID: firstUser.OrganizationID, }) require.NoError(t, err) // Log in with basic auth and keep the the session token (but don't use it). userClient := codersdk.New(client.URL) loginRes1, err := userClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ Email: email, Password: password, }) require.NoError(t, err) // Log in again but actually set the token this time. loginRes2, err := userClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ Email: email, Password: password, }) require.NoError(t, err) userClient.SetSessionToken(loginRes2.SessionToken) // Add the user's second session token to the list of API keys that should // be deleted. shouldBeDeleted := map[string]string{ "user login 2 (logging out with this)": loginRes2.SessionToken, } // Add the user's first token, and the admin's session token to the list of // API keys that should not be deleted. shouldNotBeDeleted := map[string]string{ "user login 1 (not logging out of)": loginRes1.SessionToken, "admin login": client.SessionToken(), } // Create a few application_connect-scoped API keys that should be deleted. for i := 0; i < 3; i++ { key, _ := dbgen.APIKey(t, db, database.APIKey{ UserID: newUser.ID, Scope: database.APIKeyScopeApplicationConnect, }) shouldBeDeleted[fmt.Sprintf("application_connect key owned by logout user %d", i)] = key.ID } // Create a few application_connect-scoped API keys for the admin user that // should not be deleted. for i := 0; i < 3; i++ { key, _ := dbgen.APIKey(t, db, database.APIKey{ UserID: firstUser.UserID, Scope: database.APIKeyScopeApplicationConnect, }) shouldNotBeDeleted[fmt.Sprintf("application_connect key owned by admin user %d", i)] = key.ID } // Log out of the new user. err = userClient.Logout(ctx) require.NoError(t, err) // Ensure the new user's session token is no longer valid. _, err = userClient.User(ctx, codersdk.Me) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) // Check that the deleted keys are gone. for name, id := range shouldBeDeleted { id := strings.Split(id, "-")[0] _, err := db.GetAPIKeyByID(ctx, id) require.Error(t, err, name) } // Check that the other keys are still there. for name, id := range shouldNotBeDeleted { id := strings.Split(id, "-")[0] _, err := db.GetAPIKeyByID(ctx, id) require.NoError(t, err, name) } } func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } state := "somestate" oauthURL, err := client.URL.Parse("/api/v2/users/oauth2/github/callback?code=asd&state=" + state) require.NoError(t, err) req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) require.NoError(t, err) req.AddCookie(&http.Cookie{ Name: codersdk.OAuth2StateCookie, Value: state, }) res, err := client.HTTPClient.Do(req) require.NoError(t, err) t.Cleanup(func() { _ = res.Body.Close() }) return res } func i64ptr(i int64) *int64 { return &i } func authCookieValue(cookies []*http.Cookie) string { for _, cookie := range cookies { if cookie.Name == codersdk.SessionTokenCookie { return cookie.Value } } return "" } // inflateClaims 'inflates' a jwt.MapClaims from a seed by // adding a ridiculously large key-value pair of length size. func inflateClaims(t testing.TB, seed jwt.MapClaims, size int) jwt.MapClaims { t.Helper() junk, err := cryptorand.String(size) require.NoError(t, err) seed["random_data"] = junk return seed }