2022-04-23 22:58:57 +00:00
package coderd
import (
"context"
"database/sql"
2023-11-27 16:47:23 +00:00
"encoding/json"
2022-04-23 22:58:57 +00:00
"errors"
"fmt"
"net/http"
2022-10-17 19:14:49 +00:00
"net/mail"
2023-08-08 16:37:49 +00:00
"regexp"
2023-04-05 08:07:43 +00:00
"sort"
2022-08-17 23:00:53 +00:00
"strconv"
2022-08-01 04:05:35 +00:00
"strings"
2023-06-30 12:38:48 +00:00
"sync"
"time"
2022-04-23 22:58:57 +00:00
2022-08-01 04:05:35 +00:00
"github.com/coreos/go-oidc/v3/oidc"
2023-06-30 12:38:48 +00:00
"github.com/golang-jwt/jwt/v4"
2022-04-23 22:58:57 +00:00
"github.com/google/go-github/v43/github"
"github.com/google/uuid"
2022-10-18 03:07:11 +00:00
"github.com/moby/moby/pkg/namesgenerator"
2024-02-16 17:09:19 +00:00
"golang.org/x/exp/slices"
2022-04-23 22:58:57 +00:00
"golang.org/x/oauth2"
2022-08-17 23:00:53 +00:00
"golang.org/x/xerrors"
2022-04-23 22:58:57 +00:00
2023-02-06 20:12:50 +00:00
"cdr.dev/slog"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
2023-09-01 16:50:12 +00:00
"github.com/coder/coder/v2/coderd/database/dbtime"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
2024-02-01 17:01:25 +00:00
"github.com/coder/coder/v2/coderd/parameter"
2024-01-10 15:13:30 +00:00
"github.com/coder/coder/v2/coderd/promoauth"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/site"
2022-04-23 22:58:57 +00:00
)
2023-06-22 18:09:33 +00:00
const (
2023-06-30 12:38:48 +00:00
userAuthLoggerName = "userauth"
OAuthConvertCookieValue = "coder_oauth_convert_jwt"
mergeStateStringPrefix = "convert-"
2023-06-22 18:09:33 +00:00
)
2023-06-30 12:38:48 +00:00
type OAuthConvertStateClaims struct {
jwt . RegisteredClaims
UserID uuid . UUID ` json:"user_id" `
State string ` json:"state" `
FromLoginType codersdk . LoginType ` json:"from_login_type" `
ToLoginType codersdk . LoginType ` json:"to_login_type" `
}
// postConvertLoginType replies with an oauth state token capable of converting
// the user to an oauth user.
//
// @Summary Convert user from password to oauth authentication
// @ID convert-user-from-password-to-oauth-authentication
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Authorization
// @Param request body codersdk.ConvertLoginRequest true "Convert request"
// @Param user path string true "User ID, name, or me"
// @Success 201 {object} codersdk.OAuthConversionResponse
// @Router /users/{user}/convert-login [post]
func ( api * API ) postConvertLoginType ( rw http . ResponseWriter , r * http . Request ) {
var (
user = httpmw . UserParam ( r )
ctx = r . Context ( )
auditor = api . Auditor . Load ( )
aReq , commitAudit = audit . InitRequest [ database . AuditOAuthConvertState ] ( rw , & audit . RequestParams {
Audit : * auditor ,
Log : api . Logger ,
Request : r ,
Action : database . AuditActionCreate ,
} )
)
aReq . Old = database . AuditOAuthConvertState { }
defer commitAudit ( )
var req codersdk . ConvertLoginRequest
if ! httpapi . Read ( ctx , rw , r , & req ) {
return
}
switch req . ToType {
case codersdk . LoginTypeGithub , codersdk . LoginTypeOIDC :
// Allowed!
case codersdk . LoginTypeNone , codersdk . LoginTypePassword , codersdk . LoginTypeToken :
// These login types are not allowed to be converted to at this time.
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
Message : fmt . Sprintf ( "Cannot convert to login type %q." , req . ToType ) ,
} )
return
default :
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
Message : fmt . Sprintf ( "Unknown login type %q." , req . ToType ) ,
} )
return
}
// This handles the email/pass checking.
user , _ , ok := api . loginRequest ( ctx , rw , codersdk . LoginWithPasswordRequest {
Email : user . Email ,
Password : req . Password ,
} )
if ! ok {
return
}
// Only support converting from password auth.
if user . LoginType != database . LoginTypePassword {
// This is checked in loginRequest, but checked again here in case that shared
// function changes its checks. Just some defensive programming.
// This login type is **required** to be password based to prevent
// users from converting other login types to OIDC.
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
Message : "User account must have password based authentication." ,
} )
return
}
stateString , err := cryptorand . String ( 32 )
if err != nil {
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error generating state string." ,
Detail : err . Error ( ) ,
} )
return
}
// The prefix is used to identify this state string as a conversion state
// without needing to hit the database. The random string is the CSRF protection.
stateString = fmt . Sprintf ( "%s%s" , mergeStateStringPrefix , stateString )
// This JWT is the signed payload to authorize the convert to oauth request.
// When the user does the oauth flow, this jwt will be sent back to coderd.
// The included information in this payload links it to a state string, so
// this request is tied 1:1 with an oauth state.
// This JWT also includes information to tie it 1:1 with a coder deployment
// and user account. This is mainly to inform the user if they are accidentally
// switching between coder deployments if the OIDC is misconfigured.
// Eg: Developers with more than 1 deployment.
now := time . Now ( )
claims := & OAuthConvertStateClaims {
RegisteredClaims : jwt . RegisteredClaims {
Issuer : api . DeploymentID ,
Subject : stateString ,
Audience : [ ] string { user . ID . String ( ) } ,
ExpiresAt : jwt . NewNumericDate ( now . Add ( time . Minute * 5 ) ) ,
NotBefore : jwt . NewNumericDate ( now . Add ( time . Second * - 1 ) ) ,
IssuedAt : jwt . NewNumericDate ( now ) ,
ID : uuid . NewString ( ) ,
} ,
UserID : user . ID ,
State : stateString ,
FromLoginType : codersdk . LoginType ( user . LoginType ) ,
ToLoginType : req . ToType ,
}
token := jwt . NewWithClaims ( jwt . SigningMethodHS512 , claims )
// Key must be a byte slice, not an array. So make sure to include the [:]
tokenString , err := token . SignedString ( api . OAuthSigningKey [ : ] )
if err != nil {
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error signing state jwt." ,
Detail : err . Error ( ) ,
} )
return
}
aReq . New = database . AuditOAuthConvertState {
CreatedAt : claims . IssuedAt . Time ,
ExpiresAt : claims . ExpiresAt . Time ,
FromLoginType : database . LoginType ( claims . FromLoginType ) ,
ToLoginType : database . LoginType ( claims . ToLoginType ) ,
UserID : claims . UserID ,
}
http . SetCookie ( rw , & http . Cookie {
Name : OAuthConvertCookieValue ,
Path : "/" ,
Value : tokenString ,
Expires : claims . ExpiresAt . Time ,
Secure : api . SecureAuthCookie ,
HttpOnly : true ,
2023-08-16 17:50:44 +00:00
// Must be SameSite to work on the redirected auth flow from the
// oauth provider.
SameSite : http . SameSiteLaxMode ,
2023-06-30 12:38:48 +00:00
} )
httpapi . Write ( ctx , rw , http . StatusCreated , codersdk . OAuthConversionResponse {
StateString : stateString ,
ExpiresAt : claims . ExpiresAt . Time ,
ToType : claims . ToLoginType ,
UserID : claims . UserID ,
} )
}
2023-02-06 20:12:50 +00:00
// Authenticates the user with an email and password.
//
// @Summary Log in user
// @ID log-in-user
// @Accept json
// @Produce json
// @Tags Authorization
// @Param request body codersdk.LoginWithPasswordRequest true "Login request"
// @Success 201 {object} codersdk.LoginWithPasswordResponse
// @Router /users/login [post]
func ( api * API ) postLogin ( rw http . ResponseWriter , r * http . Request ) {
var (
ctx = r . Context ( )
auditor = api . Auditor . Load ( )
2023-06-30 12:38:48 +00:00
logger = api . Logger . Named ( userAuthLoggerName )
2023-02-06 20:12:50 +00:00
aReq , commitAudit = audit . InitRequest [ database . APIKey ] ( rw , & audit . RequestParams {
Audit : * auditor ,
Log : api . Logger ,
Request : r ,
Action : database . AuditActionLogin ,
} )
)
aReq . Old = database . APIKey { }
defer commitAudit ( )
var loginWithPassword codersdk . LoginWithPasswordRequest
if ! httpapi . Read ( ctx , rw , r , & loginWithPassword ) {
return
}
2023-06-30 12:38:48 +00:00
user , roles , ok := api . loginRequest ( ctx , rw , loginWithPassword )
// 'user.ID' will be empty, or will be an actual value. Either is correct
// here.
aReq . UserID = user . ID
if ! ok {
// user failed to login
return
}
userSubj := rbac . Subject {
ID : user . ID . String ( ) ,
Roles : rbac . RoleNames ( roles . Roles ) ,
Groups : roles . Groups ,
Scope : rbac . ScopeAll ,
}
//nolint:gocritic // Creating the API key as the user instead of as system.
cookie , key , err := api . createAPIKey ( dbauthz . As ( ctx , userSubj ) , apikey . CreateParams {
2024-01-22 20:42:55 +00:00
UserID : user . ID ,
LoginType : database . LoginTypePassword ,
RemoteAddr : r . RemoteAddr ,
2024-04-10 15:34:49 +00:00
DefaultLifetime : api . DeploymentValues . Sessions . DefaultDuration . Value ( ) ,
2023-06-30 12:38:48 +00:00
} )
if err != nil {
logger . Error ( ctx , "unable to create API key" , slog . Error ( err ) )
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Failed to create API key." ,
Detail : err . Error ( ) ,
} )
return
}
aReq . New = * key
http . SetCookie ( rw , cookie )
httpapi . Write ( ctx , rw , http . StatusCreated , codersdk . LoginWithPasswordResponse {
SessionToken : cookie . Value ,
} )
}
// loginRequest will process a LoginWithPasswordRequest and return the user if
// the credentials are correct. If 'false' is returned, the authentication failed
// and the appropriate error will be written to the ResponseWriter.
//
// The user struct is always returned, even if authentication failed. This is
// to support knowing what user attempted to login.
func ( api * API ) loginRequest ( ctx context . Context , rw http . ResponseWriter , req codersdk . LoginWithPasswordRequest ) ( database . User , database . GetAuthorizationUserRolesRow , bool ) {
2023-06-22 18:09:33 +00:00
logger := api . Logger . Named ( userAuthLoggerName )
2023-02-14 14:27:06 +00:00
//nolint:gocritic // In order to login, we need to get the user first!
2023-02-15 16:14:37 +00:00
user , err := api . Database . GetUserByEmailOrUsername ( dbauthz . AsSystemRestricted ( ctx ) , database . GetUserByEmailOrUsernameParams {
2023-06-30 12:38:48 +00:00
Email : req . Email ,
2023-02-06 20:12:50 +00:00
} )
if err != nil && ! xerrors . Is ( err , sql . ErrNoRows ) {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "unable to fetch user by email" , slog . Error ( err ) )
2023-02-06 20:12:50 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error." ,
} )
2023-06-30 12:38:48 +00:00
return user , database . GetAuthorizationUserRolesRow { } , false
2023-02-06 20:12:50 +00:00
}
// If the user doesn't exist, it will be a default struct.
2023-06-30 12:38:48 +00:00
equal , err := userpassword . Compare ( string ( user . HashedPassword ) , req . Password )
2023-02-06 20:12:50 +00:00
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "unable to compare passwords" , slog . Error ( err ) )
2023-02-06 20:12:50 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error." ,
} )
2023-06-30 12:38:48 +00:00
return user , database . GetAuthorizationUserRolesRow { } , false
2023-02-06 20:12:50 +00:00
}
2023-06-30 12:38:48 +00:00
2023-02-06 20:12:50 +00:00
if ! equal {
// This message is the same as above to remove ease in detecting whether
// users are registered or not. Attackers still could with a timing attack.
httpapi . Write ( ctx , rw , http . StatusUnauthorized , codersdk . Response {
Message : "Incorrect email or password." ,
} )
2023-06-30 12:38:48 +00:00
return user , database . GetAuthorizationUserRolesRow { } , false
2023-02-06 20:12:50 +00:00
}
// If password authentication is disabled and the user does not have the
// owner role, block the request.
2023-03-07 21:10:01 +00:00
if api . DeploymentValues . DisablePasswordAuth {
2023-02-14 14:58:12 +00:00
httpapi . Write ( ctx , rw , http . StatusForbidden , codersdk . Response {
Message : "Password authentication is disabled." ,
} )
2023-06-30 12:38:48 +00:00
return user , database . GetAuthorizationUserRolesRow { } , false
2023-02-06 20:12:50 +00:00
}
if user . LoginType != database . LoginTypePassword {
httpapi . Write ( ctx , rw , http . StatusForbidden , codersdk . Response {
Message : fmt . Sprintf ( "Incorrect login type, attempting to use %q but user is of login type %q" , database . LoginTypePassword , user . LoginType ) ,
} )
2023-06-30 12:38:48 +00:00
return user , database . GetAuthorizationUserRolesRow { } , false
2023-02-06 20:12:50 +00:00
}
2023-08-02 14:31:25 +00:00
if user . Status == database . UserStatusDormant {
//nolint:gocritic // System needs to update status of the user account (dormant -> active).
user , err = api . Database . UpdateUserStatus ( dbauthz . AsSystemRestricted ( ctx ) , database . UpdateUserStatusParams {
ID : user . ID ,
Status : database . UserStatusActive ,
2023-09-01 16:50:12 +00:00
UpdatedAt : dbtime . Now ( ) ,
2023-08-02 14:31:25 +00:00
} )
if err != nil {
logger . Error ( ctx , "unable to update user status to active" , slog . Error ( err ) )
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error occurred. Try again later, or contact an admin for assistance." ,
} )
return user , database . GetAuthorizationUserRolesRow { } , false
}
}
2023-02-14 14:27:06 +00:00
//nolint:gocritic // System needs to fetch user roles in order to login user.
2023-02-15 16:14:37 +00:00
roles , err := api . Database . GetAuthorizationUserRoles ( dbauthz . AsSystemRestricted ( ctx ) , user . ID )
2023-02-14 14:27:06 +00:00
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "unable to fetch authorization user roles" , slog . Error ( err ) )
2023-02-14 14:27:06 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error." ,
} )
2023-06-30 12:38:48 +00:00
return user , database . GetAuthorizationUserRolesRow { } , false
2023-02-14 14:27:06 +00:00
}
2023-02-06 20:12:50 +00:00
// If the user logged into a suspended account, reject the login request.
2023-02-14 14:27:06 +00:00
if roles . Status != database . UserStatusActive {
2023-02-06 20:12:50 +00:00
httpapi . Write ( ctx , rw , http . StatusUnauthorized , codersdk . Response {
2023-08-02 14:31:25 +00:00
Message : fmt . Sprintf ( "Your account is %s. Contact an admin to reactivate your account." , roles . Status ) ,
2023-02-06 20:12:50 +00:00
} )
2023-06-30 12:38:48 +00:00
return user , database . GetAuthorizationUserRolesRow { } , false
2023-02-06 20:12:50 +00:00
}
2023-06-30 12:38:48 +00:00
return user , roles , true
2023-02-06 20:12:50 +00:00
}
// Clear the user's session cookie.
//
// @Summary Log out user
// @ID log-out-user
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Success 200 {object} codersdk.Response
// @Router /users/logout [post]
func ( api * API ) postLogout ( rw http . ResponseWriter , r * http . Request ) {
var (
ctx = r . Context ( )
auditor = api . Auditor . Load ( )
aReq , commitAudit = audit . InitRequest [ database . APIKey ] ( rw , & audit . RequestParams {
Audit : * auditor ,
Log : api . Logger ,
Request : r ,
Action : database . AuditActionLogout ,
} )
)
defer commitAudit ( )
// Get a blank token cookie.
cookie := & http . Cookie {
// MaxAge < 0 means to delete the cookie now.
MaxAge : - 1 ,
Name : codersdk . SessionTokenCookie ,
Path : "/" ,
}
http . SetCookie ( rw , cookie )
// Delete the session token from database.
apiKey := httpmw . APIKey ( r )
aReq . Old = apiKey
2023-06-22 18:09:33 +00:00
logger := api . Logger . Named ( userAuthLoggerName )
2023-02-06 20:12:50 +00:00
err := api . Database . DeleteAPIKeyByID ( ctx , apiKey . ID )
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "unable to delete API key" , slog . F ( "api_key" , apiKey . ID ) , slog . Error ( err ) )
2023-02-06 20:12:50 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error deleting API key." ,
Detail : err . Error ( ) ,
} )
return
}
2023-04-05 18:41:55 +00:00
// Invalidate all subdomain app tokens. This saves us from having to
// track which app tokens are associated which this browser session and
// doesn't inconvenience the user as they'll just get redirected if they try
// to access the app again.
err = api . Database . DeleteApplicationConnectAPIKeysByUserID ( ctx , apiKey . UserID )
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "unable to invalidate subdomain app tokens" , slog . F ( "user_id" , apiKey . UserID ) , slog . Error ( err ) )
2023-04-05 18:41:55 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error deleting app tokens." ,
Detail : err . Error ( ) ,
} )
return
2023-02-06 20:12:50 +00:00
}
aReq . New = database . APIKey { }
httpapi . Write ( ctx , rw , http . StatusOK , codersdk . Response {
Message : "Logged out!" ,
} )
}
2022-07-09 02:37:18 +00:00
// GithubOAuth2Team represents a team scoped to an organization.
type GithubOAuth2Team struct {
Organization string
Slug string
}
2022-04-23 22:58:57 +00:00
// GithubOAuth2Provider exposes required functions for the Github authentication flow.
type GithubOAuth2Config struct {
2024-01-10 15:13:30 +00:00
promoauth . OAuth2Config
2022-04-23 22:58:57 +00:00
AuthenticatedUser func ( ctx context . Context , client * http . Client ) ( * github . User , error )
ListEmails func ( ctx context . Context , client * http . Client ) ( [ ] * github . UserEmail , error )
ListOrganizationMemberships func ( ctx context . Context , client * http . Client ) ( [ ] * github . Membership , error )
2022-07-22 18:54:08 +00:00
TeamMembership func ( ctx context . Context , client * http . Client , org , team , username string ) ( * github . Membership , error )
2022-04-23 22:58:57 +00:00
AllowSignups bool
2022-11-15 16:56:46 +00:00
AllowEveryone bool
2022-04-23 22:58:57 +00:00
AllowOrganizations [ ] string
2022-07-09 02:37:18 +00:00
AllowTeams [ ] GithubOAuth2Team
2022-04-23 22:58:57 +00:00
}
2023-01-11 13:08:04 +00:00
// @Summary Get authentication methods
// @ID get-authentication-methods
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Success 200 {object} codersdk.AuthMethods
// @Router /users/authmethods [get]
2022-09-21 22:07:00 +00:00
func ( api * API ) userAuthMethods ( rw http . ResponseWriter , r * http . Request ) {
2023-01-31 18:33:25 +00:00
var signInText string
var iconURL string
if api . OIDCConfig != nil {
signInText = api . OIDCConfig . SignInText
}
if api . OIDCConfig != nil {
iconURL = api . OIDCConfig . IconURL
}
2022-09-21 22:07:00 +00:00
httpapi . Write ( r . Context ( ) , rw , http . StatusOK , codersdk . AuthMethods {
2024-04-25 22:36:51 +00:00
TermsOfServiceURL : api . DeploymentValues . TermsOfServiceURL . Value ( ) ,
2023-02-06 14:58:21 +00:00
Password : codersdk . AuthMethod {
2023-03-07 21:10:01 +00:00
Enabled : ! api . DeploymentValues . DisablePasswordAuth . Value ( ) ,
2023-02-06 14:58:21 +00:00
} ,
Github : codersdk . AuthMethod { Enabled : api . GithubOAuth2Config != nil } ,
2023-01-31 18:33:25 +00:00
OIDC : codersdk . OIDCAuthMethod {
AuthMethod : codersdk . AuthMethod { Enabled : api . OIDCConfig != nil } ,
SignInText : signInText ,
IconURL : iconURL ,
} ,
2022-04-23 22:58:57 +00:00
} )
}
2023-01-11 13:08:04 +00:00
// @Summary OAuth 2.0 GitHub Callback
2023-01-13 11:27:21 +00:00
// @ID oauth-20-github-callback
2023-01-11 13:08:04 +00:00
// @Security CoderSessionToken
// @Tags Users
// @Success 307
// @Router /users/oauth2/github/callback [get]
2022-05-26 03:14:08 +00:00
func ( api * API ) userOAuth2Github ( rw http . ResponseWriter , r * http . Request ) {
2022-08-17 23:00:53 +00:00
var (
2023-03-04 20:32:07 +00:00
// userOAuth2Github is a system function.
//nolint:gocritic
ctx = dbauthz . AsSystemRestricted ( r . Context ( ) )
2023-02-06 20:12:50 +00:00
state = httpmw . OAuth2 ( r )
auditor = api . Auditor . Load ( )
aReq , commitAudit = audit . InitRequest [ database . APIKey ] ( rw , & audit . RequestParams {
Audit : * auditor ,
Log : api . Logger ,
Request : r ,
Action : database . AuditActionLogin ,
} )
2022-08-17 23:00:53 +00:00
)
2023-02-06 20:12:50 +00:00
aReq . Old = database . APIKey { }
defer commitAudit ( )
2022-04-23 22:58:57 +00:00
2022-08-17 23:00:53 +00:00
oauthClient := oauth2 . NewClient ( ctx , oauth2 . StaticTokenSource ( state . Token ) )
2022-11-15 16:56:46 +00:00
2023-06-22 18:09:33 +00:00
logger := api . Logger . Named ( userAuthLoggerName )
2022-11-15 16:56:46 +00:00
var selectedMemberships [ ] * github . Membership
var organizationNames [ ] string
2023-12-07 14:19:31 +00:00
redirect := state . Redirect
2022-11-15 16:56:46 +00:00
if ! api . GithubOAuth2Config . AllowEveryone {
memberships , err := api . GithubOAuth2Config . ListOrganizationMemberships ( ctx , oauthClient )
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "unable to list organization members" , slog . Error ( err ) )
2022-11-15 16:56:46 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Internal error fetching authenticated Github user organizations." ,
Detail : err . Error ( ) ,
} )
return
2022-10-07 16:53:58 +00:00
}
2022-11-15 16:56:46 +00:00
for _ , membership := range memberships {
if membership . GetState ( ) != "active" {
2022-04-23 22:58:57 +00:00
continue
}
2022-11-15 16:56:46 +00:00
for _ , allowed := range api . GithubOAuth2Config . AllowOrganizations {
if * membership . Organization . Login != allowed {
continue
}
selectedMemberships = append ( selectedMemberships , membership )
organizationNames = append ( organizationNames , membership . Organization . GetLogin ( ) )
break
}
}
if len ( selectedMemberships ) == 0 {
2023-12-07 14:19:31 +00:00
httpmw . CustomRedirectToLogin ( rw , r , redirect , "You aren't a member of the authorized Github organizations!" , http . StatusUnauthorized )
2022-11-15 16:56:46 +00:00
return
2022-04-23 22:58:57 +00:00
}
}
2022-08-17 23:00:53 +00:00
ghUser , err := api . GithubOAuth2Config . AuthenticatedUser ( ctx , oauthClient )
2022-07-22 18:54:08 +00:00
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: unable to fetch authenticated user" , slog . Error ( err ) )
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
2022-07-22 18:54:08 +00:00
Message : "Internal error fetching authenticated Github user." ,
Detail : err . Error ( ) ,
} )
return
}
2022-07-09 02:37:18 +00:00
// The default if no teams are specified is to allow all.
2022-11-15 16:56:46 +00:00
if ! api . GithubOAuth2Config . AllowEveryone && len ( api . GithubOAuth2Config . AllowTeams ) > 0 {
2022-07-22 18:54:08 +00:00
var allowedTeam * github . Membership
2022-07-13 00:45:43 +00:00
for _ , allowTeam := range api . GithubOAuth2Config . AllowTeams {
2022-11-15 16:56:46 +00:00
if allowedTeam != nil {
break
2022-07-09 02:37:18 +00:00
}
2022-11-15 16:56:46 +00:00
for _ , selectedMembership := range selectedMemberships {
if allowTeam . Organization != * selectedMembership . Organization . Login {
// This needs to continue because multiple organizations
// could exist in the allow/team listings.
continue
}
allowedTeam , err = api . GithubOAuth2Config . TeamMembership ( ctx , oauthClient , allowTeam . Organization , allowTeam . Slug , * ghUser . Login )
// The calling user may not have permission to the requested team!
if err != nil {
continue
}
2022-07-13 00:45:43 +00:00
}
}
2022-07-09 02:37:18 +00:00
if allowedTeam == nil {
2023-12-07 14:19:31 +00:00
httpmw . CustomRedirectToLogin ( rw , r , redirect , fmt . Sprintf ( "You aren't a member of an authorized team in the %v Github organization(s)!" , organizationNames ) , http . StatusUnauthorized )
2022-07-09 02:37:18 +00:00
return
}
}
2022-08-17 23:00:53 +00:00
emails , err := api . GithubOAuth2Config . ListEmails ( ctx , oauthClient )
2022-04-23 22:58:57 +00:00
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: unable to list emails" , slog . Error ( err ) )
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
2022-06-07 14:33:06 +00:00
Message : "Internal error fetching personal Github user." ,
2022-06-03 21:48:09 +00:00
Detail : err . Error ( ) ,
2022-04-23 22:58:57 +00:00
} )
return
}
2022-08-22 23:13:46 +00:00
var verifiedEmail * github . UserEmail
2022-04-23 22:58:57 +00:00
for _ , email := range emails {
2022-08-22 23:13:46 +00:00
if email . GetVerified ( ) && email . GetPrimary ( ) {
verifiedEmail = email
break
2022-04-23 22:58:57 +00:00
}
2022-08-17 23:00:53 +00:00
}
2022-08-22 23:13:46 +00:00
if verifiedEmail == nil {
2023-01-13 14:30:48 +00:00
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
2022-08-22 23:13:46 +00:00
Message : "Your primary email must be verified on GitHub!" ,
2022-04-23 22:58:57 +00:00
} )
2022-08-17 23:00:53 +00:00
return
}
2024-01-29 15:13:46 +00:00
// If we have a nil GitHub ID, that is a big problem. That would mean we link
// this user and all other users with this bug to the same uuid.
// We should instead throw an error. This should never occur in production.
//
// Verified that the lowest ID on GitHub is "1", so 0 should never occur.
if ghUser . GetID ( ) == 0 {
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "The GitHub user ID is missing, this should never happen. Please report this error." ,
// If this happens, the User could either be:
// - Empty, in which case all these fields would also be empty.
// - Not a user, in which case the "Type" would be something other than "User"
Detail : fmt . Sprintf ( "Other user fields: name=%q, email=%q, type=%q" ,
ghUser . GetName ( ) ,
ghUser . GetEmail ( ) ,
ghUser . GetType ( ) ,
) ,
} )
return
}
2023-02-06 20:12:50 +00:00
user , link , err := findLinkedUser ( ctx , api . Database , githubLinkedID ( ghUser ) , verifiedEmail . GetEmail ( ) )
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: unable to find linked user" , slog . F ( "gh_user" , ghUser . Name ) , slog . Error ( err ) )
2023-02-06 20:12:50 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Failed to find linked user." ,
Detail : err . Error ( ) ,
} )
return
}
2023-04-12 18:46:16 +00:00
// If a new user is authenticating for the first time
// the audit action is 'register', not 'login'
if user . ID == uuid . Nil {
aReq . Action = database . AuditActionRegister
}
2023-06-30 12:38:48 +00:00
params := ( & oauthLoginParams {
2023-02-06 20:12:50 +00:00
User : user ,
Link : link ,
2022-08-22 23:13:46 +00:00
State : state ,
LinkedID : githubLinkedID ( ghUser ) ,
LoginType : database . LoginTypeGithub ,
AllowSignups : api . GithubOAuth2Config . AllowSignups ,
Email : verifiedEmail . GetEmail ( ) ,
Username : ghUser . GetLogin ( ) ,
2022-09-04 16:44:27 +00:00
AvatarURL : ghUser . GetAvatarURL ( ) ,
2023-11-27 16:47:23 +00:00
DebugContext : OauthDebugContext { } ,
2023-06-30 12:38:48 +00:00
} ) . SetInitAuditRequest ( func ( params * audit . RequestParams ) ( * audit . Request [ database . User ] , func ( ) ) {
return audit . InitRequest [ database . User ] ( rw , params )
2022-08-22 23:13:46 +00:00
} )
2023-06-30 12:38:48 +00:00
cookies , key , err := api . oauthLogin ( r , params )
defer params . CommitAuditLogs ( )
2022-08-22 23:13:46 +00:00
var httpErr httpError
if xerrors . As ( err , & httpErr ) {
2023-07-07 15:33:31 +00:00
httpErr . Write ( rw , r )
2022-08-17 23:00:53 +00:00
return
}
2022-08-22 23:13:46 +00:00
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: login failed" , slog . F ( "user" , user . Username ) , slog . Error ( err ) )
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
2022-08-22 23:13:46 +00:00
Message : "Failed to process OAuth login." ,
Detail : err . Error ( ) ,
2022-08-17 23:00:53 +00:00
} )
return
2022-04-23 22:58:57 +00:00
}
2023-02-06 20:12:50 +00:00
aReq . New = key
2023-03-22 19:52:13 +00:00
aReq . UserID = key . UserID
2022-04-23 22:58:57 +00:00
2023-06-30 12:38:48 +00:00
for _ , cookie := range cookies {
http . SetCookie ( rw , cookie )
}
2022-04-23 22:58:57 +00:00
if redirect == "" {
redirect = "/"
}
http . Redirect ( rw , r , redirect , http . StatusTemporaryRedirect )
}
2022-08-01 04:05:35 +00:00
type OIDCConfig struct {
2024-01-10 15:13:30 +00:00
promoauth . OAuth2Config
2022-08-01 04:05:35 +00:00
2023-01-16 22:06:39 +00:00
Provider * oidc . Provider
2022-08-01 04:05:35 +00:00
Verifier * oidc . IDTokenVerifier
2022-12-05 18:20:53 +00:00
// EmailDomains are the domains to enforce when a user authenticates.
EmailDomain [ ] string
2022-08-01 04:05:35 +00:00
AllowSignups bool
2022-11-25 10:10:09 +00:00
// IgnoreEmailVerified allows ignoring the email_verified claim
// from an upstream OIDC provider. See #5065 for context.
IgnoreEmailVerified bool
2023-01-04 21:16:31 +00:00
// UsernameField selects the claim field to be used as the created user's
// username.
UsernameField string
2023-03-30 08:36:57 +00:00
// EmailField selects the claim field to be used as the created user's
// email.
EmailField string
// AuthURLParams are additional parameters to be passed to the OIDC provider
// when requesting an access token.
AuthURLParams map [ string ] string
2023-04-05 08:07:43 +00:00
// IgnoreUserInfo causes Coder to only use claims from the ID token to
// process OIDC logins. This is useful if the OIDC provider does not
// support the userinfo endpoint, or if the userinfo endpoint causes
// undesirable behavior.
IgnoreUserInfo bool
2023-03-10 05:31:38 +00:00
// GroupField selects the claim field to be used as the created user's
// groups. If the group field is the empty string, then no group updates
// will ever come from the OIDC provider.
GroupField string
2023-08-08 16:37:49 +00:00
// CreateMissingGroups controls whether groups returned by the OIDC provider
// are automatically created in Coder if they are missing.
CreateMissingGroups bool
// GroupFilter is a regular expression that filters the groups returned by
// the OIDC provider. Any group not matched by this regex will be ignored.
// If the group filter is nil, then no group filtering will occur.
GroupFilter * regexp . Regexp
2023-12-08 16:14:19 +00:00
// GroupAllowList is a list of groups that are allowed to log in.
// If the list length is 0, then the allow list will not be applied and
// this feature is disabled.
GroupAllowList map [ string ] bool
2023-03-21 19:25:45 +00:00
// GroupMapping controls how groups returned by the OIDC provider get mapped
// to groups within Coder.
// map[oidcGroupName]coderGroupName
GroupMapping map [ string ] string
2023-07-24 12:34:24 +00:00
// UserRoleField selects the claim field to be used as the created user's
// roles. If the field is the empty string, then no role updates
// will ever come from the OIDC provider.
UserRoleField string
// UserRoleMapping controls how groups returned by the OIDC provider get mapped
// to roles within Coder.
// map[oidcRoleName][]coderRoleName
UserRoleMapping map [ string ] [ ] string
// UserRolesDefault is the default set of roles to assign to a user if role sync
// is enabled.
UserRolesDefault [ ] string
2023-01-31 18:33:25 +00:00
// SignInText is the text to display on the OIDC login button
SignInText string
// IconURL points to the URL of an icon to display on the OIDC login button
IconURL string
2024-02-01 17:01:25 +00:00
// SignupsDisabledText is the text do display on the static error page.
SignupsDisabledText string
2022-08-01 04:05:35 +00:00
}
2023-07-24 12:34:24 +00:00
func ( cfg OIDCConfig ) RoleSyncEnabled ( ) bool {
return cfg . UserRoleField != ""
}
2023-01-11 13:08:04 +00:00
// @Summary OpenID Connect Callback
2023-01-13 11:27:21 +00:00
// @ID openid-connect-callback
2023-01-11 13:08:04 +00:00
// @Security CoderSessionToken
// @Tags Users
// @Success 307
// @Router /users/oidc/callback [get]
2022-08-01 04:05:35 +00:00
func ( api * API ) userOIDC ( rw http . ResponseWriter , r * http . Request ) {
2022-08-17 23:00:53 +00:00
var (
2023-03-04 20:32:07 +00:00
// userOIDC is a system function.
//nolint:gocritic
ctx = dbauthz . AsSystemRestricted ( r . Context ( ) )
2023-02-06 20:12:50 +00:00
state = httpmw . OAuth2 ( r )
auditor = api . Auditor . Load ( )
aReq , commitAudit = audit . InitRequest [ database . APIKey ] ( rw , & audit . RequestParams {
Audit : * auditor ,
Log : api . Logger ,
Request : r ,
Action : database . AuditActionLogin ,
} )
2022-08-17 23:00:53 +00:00
)
2023-02-06 20:12:50 +00:00
aReq . Old = database . APIKey { }
defer commitAudit ( )
2022-08-01 04:05:35 +00:00
// See the example here: https://github.com/coreos/go-oidc
rawIDToken , ok := state . Token . Extra ( "id_token" ) . ( string )
if ! ok {
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
2022-08-01 04:05:35 +00:00
Message : "id_token not found in response payload. Ensure your OIDC callback is configured correctly!" ,
} )
return
}
2022-08-17 23:00:53 +00:00
idToken , err := api . OIDCConfig . Verifier . Verify ( ctx , rawIDToken )
2022-08-01 04:05:35 +00:00
if err != nil {
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
2022-08-01 04:05:35 +00:00
Message : "Failed to verify OIDC token." ,
Detail : err . Error ( ) ,
} )
return
}
2023-06-22 18:09:33 +00:00
logger := api . Logger . Named ( userAuthLoggerName )
2022-09-08 14:06:00 +00:00
// "email_verified" is an optional claim that changes the behavior
// of our OIDC handler, so each property must be pulled manually out
// of the claim mapping.
2023-11-27 16:47:23 +00:00
idtokenClaims := map [ string ] interface { } { }
err = idToken . Claims ( & idtokenClaims )
2022-08-01 04:05:35 +00:00
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: unable to extract OIDC claims" , slog . Error ( err ) )
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
2022-08-01 04:05:35 +00:00
Message : "Failed to extract OIDC claims." ,
Detail : err . Error ( ) ,
} )
return
}
2023-01-16 22:06:39 +00:00
2023-06-22 18:09:33 +00:00
logger . Debug ( ctx , "got oidc claims" ,
2023-04-05 08:07:43 +00:00
slog . F ( "source" , "id_token" ) ,
2023-11-27 16:47:23 +00:00
slog . F ( "claim_fields" , claimFields ( idtokenClaims ) ) ,
slog . F ( "blank" , blankFields ( idtokenClaims ) ) ,
2023-04-05 08:07:43 +00:00
)
2023-01-16 22:06:39 +00:00
// Not all claims are necessarily embedded in the `id_token`.
// In GitLab, the username is left empty and must be fetched in UserInfo.
//
// The OIDC specification says claims can be in either place, so we fetch
2023-04-05 08:07:43 +00:00
// user info if required and merge the two claim sets to be sure we have
// all of the correct data.
//
// Some providers (e.g. ADFS) do not support custom OIDC claims in the
// UserInfo endpoint, so we allow users to disable it and only rely on the
// ID token.
2023-11-27 16:47:23 +00:00
userInfoClaims := make ( map [ string ] interface { } )
// If user info is skipped, the idtokenClaims are the claims.
mergedClaims := idtokenClaims
2023-04-05 08:07:43 +00:00
if ! api . OIDCConfig . IgnoreUserInfo {
userInfo , err := api . OIDCConfig . Provider . UserInfo ( ctx , oauth2 . StaticTokenSource ( state . Token ) )
if err == nil {
err = userInfo . Claims ( & userInfoClaims )
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: unable to unmarshal user info claims" , slog . Error ( err ) )
2023-04-05 08:07:43 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Failed to unmarshal user info claims." ,
Detail : err . Error ( ) ,
} )
return
}
2023-06-22 18:09:33 +00:00
logger . Debug ( ctx , "got oidc claims" ,
2023-04-05 08:07:43 +00:00
slog . F ( "source" , "userinfo" ) ,
slog . F ( "claim_fields" , claimFields ( userInfoClaims ) ) ,
slog . F ( "blank" , blankFields ( userInfoClaims ) ) ,
)
// Merge the claims from the ID token and the UserInfo endpoint.
// Information from UserInfo takes precedence.
2023-11-27 16:47:23 +00:00
mergedClaims = mergeClaims ( idtokenClaims , userInfoClaims )
2023-04-05 08:07:43 +00:00
// Log all of the field names after merging.
2023-06-22 18:09:33 +00:00
logger . Debug ( ctx , "got oidc claims" ,
2023-04-05 08:07:43 +00:00
slog . F ( "source" , "merged" ) ,
2023-11-27 16:47:23 +00:00
slog . F ( "claim_fields" , claimFields ( mergedClaims ) ) ,
slog . F ( "blank" , blankFields ( mergedClaims ) ) ,
2023-04-05 08:07:43 +00:00
)
} else if ! strings . Contains ( err . Error ( ) , "user info endpoint is not supported by this provider" ) {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: unable to obtain user information claims" , slog . Error ( err ) )
2023-01-16 22:06:39 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
2023-04-05 08:07:43 +00:00
Message : "Failed to obtain user information claims." ,
Detail : "The attempt to fetch claims via the UserInfo endpoint failed: " + err . Error ( ) ,
2023-01-16 22:06:39 +00:00
} )
return
2023-04-05 08:07:43 +00:00
} else {
// The OIDC provider does not support the UserInfo endpoint.
// This is not an error, but we should log it as it may mean
// that some claims are missing.
2023-06-22 18:09:33 +00:00
logger . Warn ( ctx , "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token" )
2023-01-16 22:06:39 +00:00
}
2023-03-17 00:33:45 +00:00
}
2023-11-27 16:47:23 +00:00
usernameRaw , ok := mergedClaims [ api . OIDCConfig . UsernameField ]
2022-10-17 19:14:49 +00:00
var username string
if ok {
username , _ = usernameRaw . ( string )
}
2023-02-02 19:53:48 +00:00
2023-11-27 16:47:23 +00:00
emailRaw , ok := mergedClaims [ api . OIDCConfig . EmailField ]
2022-09-08 14:06:00 +00:00
if ! ok {
2022-10-17 19:14:49 +00:00
// Email is an optional claim in OIDC and
// instead the email is frequently sent in
// "preferred_username". See:
// https://github.com/coder/coder/issues/4472
_ , err = mail . ParseAddress ( username )
if err != nil {
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
Message : "No email found in OIDC payload!" ,
} )
return
}
emailRaw = username
2022-08-01 04:05:35 +00:00
}
2023-02-02 19:53:48 +00:00
2022-09-08 14:06:00 +00:00
email , ok := emailRaw . ( string )
if ! ok {
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
2022-09-08 14:06:00 +00:00
Message : fmt . Sprintf ( "Email in OIDC payload isn't a string. Got: %t" , emailRaw ) ,
2022-08-01 04:05:35 +00:00
} )
return
}
2023-02-02 19:53:48 +00:00
2023-11-27 16:47:23 +00:00
verifiedRaw , ok := mergedClaims [ "email_verified" ]
2022-09-08 14:06:00 +00:00
if ok {
verified , ok := verifiedRaw . ( bool )
if ok && ! verified {
2022-11-25 10:10:09 +00:00
if ! api . OIDCConfig . IgnoreEmailVerified {
httpapi . Write ( ctx , rw , http . StatusForbidden , codersdk . Response {
Message : fmt . Sprintf ( "Verify the %q email address on your OIDC provider to authenticate!" , email ) ,
} )
return
}
2023-06-22 18:09:33 +00:00
logger . Warn ( ctx , "allowing unverified oidc email %q" )
2022-09-08 14:06:00 +00:00
}
}
2023-02-02 19:53:48 +00:00
2022-08-01 04:05:35 +00:00
// The username is a required property in Coder. We make a best-effort
// attempt at using what the claims provide, but if that fails we will
// generate a random username.
2022-11-10 20:51:09 +00:00
usernameValid := httpapi . NameValid ( username )
2022-10-25 00:46:24 +00:00
if usernameValid != nil {
2022-08-01 04:05:35 +00:00
// If no username is provided, we can default to use the email address.
// This will be converted in the from function below, so it's safe
// to keep the domain.
2022-09-08 14:06:00 +00:00
if username == "" {
username = email
2022-08-01 04:05:35 +00:00
}
2022-09-08 14:06:00 +00:00
username = httpapi . UsernameFrom ( username )
2022-08-01 04:05:35 +00:00
}
2023-02-02 19:53:48 +00:00
2022-12-05 18:20:53 +00:00
if len ( api . OIDCConfig . EmailDomain ) > 0 {
ok = false
2024-03-04 17:52:03 +00:00
emailSp := strings . Split ( email , "@" )
if len ( emailSp ) == 1 {
httpapi . Write ( ctx , rw , http . StatusForbidden , codersdk . Response {
Message : fmt . Sprintf ( "Your email %q is not in domains %q!" , email , api . OIDCConfig . EmailDomain ) ,
} )
return
}
userEmailDomain := emailSp [ len ( emailSp ) - 1 ]
2022-12-05 18:20:53 +00:00
for _ , domain := range api . OIDCConfig . EmailDomain {
2024-03-04 17:52:03 +00:00
if strings . EqualFold ( userEmailDomain , domain ) {
2022-12-05 18:20:53 +00:00
ok = true
break
}
}
if ! ok {
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusForbidden , codersdk . Response {
2024-03-04 17:52:03 +00:00
Message : fmt . Sprintf ( "Your email %q is not in domains %q!" , email , api . OIDCConfig . EmailDomain ) ,
2022-08-01 04:05:35 +00:00
} )
return
}
}
2023-02-02 19:53:48 +00:00
2022-09-08 14:06:00 +00:00
var picture string
2023-11-27 16:47:23 +00:00
pictureRaw , ok := mergedClaims [ "picture" ]
2022-09-08 14:06:00 +00:00
if ok {
picture , _ = pictureRaw . ( string )
}
2022-08-01 04:05:35 +00:00
2023-12-08 16:14:19 +00:00
ctx = slog . With ( ctx , slog . F ( "email" , email ) , slog . F ( "username" , username ) )
2023-12-06 16:27:40 +00:00
usingGroups , groups , groupErr := api . oidcGroups ( ctx , mergedClaims )
if groupErr != nil {
groupErr . Write ( rw , r )
2023-11-29 15:24:00 +00:00
return
}
2023-12-06 16:27:40 +00:00
roles , roleErr := api . oidcRoles ( ctx , mergedClaims )
if roleErr != nil {
roleErr . Write ( rw , r )
2023-11-29 15:24:00 +00:00
return
}
2023-02-06 20:12:50 +00:00
user , link , err := findLinkedUser ( ctx , api . Database , oidcLinkedID ( idToken ) , email )
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: unable to find linked user" , slog . F ( "email" , email ) , slog . Error ( err ) )
2023-02-06 20:12:50 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Failed to find linked user." ,
Detail : err . Error ( ) ,
} )
return
}
2023-04-12 18:46:16 +00:00
// If a new user is authenticating for the first time
// the audit action is 'register', not 'login'
if user . ID == uuid . Nil {
aReq . Action = database . AuditActionRegister
}
2023-06-30 12:38:48 +00:00
params := ( & oauthLoginParams {
2023-08-08 16:37:49 +00:00
User : user ,
Link : link ,
State : state ,
LinkedID : oidcLinkedID ( idToken ) ,
LoginType : database . LoginTypeOIDC ,
AllowSignups : api . OIDCConfig . AllowSignups ,
Email : email ,
Username : username ,
AvatarURL : picture ,
UsingRoles : api . OIDCConfig . RoleSyncEnabled ( ) ,
Roles : roles ,
2023-11-29 15:24:00 +00:00
UsingGroups : usingGroups ,
2023-08-08 16:37:49 +00:00
Groups : groups ,
CreateMissingGroups : api . OIDCConfig . CreateMissingGroups ,
GroupFilter : api . OIDCConfig . GroupFilter ,
2023-11-27 16:47:23 +00:00
DebugContext : OauthDebugContext {
IDTokenClaims : idtokenClaims ,
UserInfoClaims : userInfoClaims ,
} ,
2023-06-30 12:38:48 +00:00
} ) . SetInitAuditRequest ( func ( params * audit . RequestParams ) ( * audit . Request [ database . User ] , func ( ) ) {
return audit . InitRequest [ database . User ] ( rw , params )
2022-08-22 23:13:46 +00:00
} )
2023-06-30 12:38:48 +00:00
cookies , key , err := api . oauthLogin ( r , params )
defer params . CommitAuditLogs ( )
2022-08-22 23:13:46 +00:00
var httpErr httpError
if xerrors . As ( err , & httpErr ) {
2023-07-07 15:33:31 +00:00
httpErr . Write ( rw , r )
2022-08-22 23:13:46 +00:00
return
}
2022-08-17 23:00:53 +00:00
if err != nil {
2023-06-22 18:09:33 +00:00
logger . Error ( ctx , "oauth2: login failed" , slog . F ( "user" , user . Username ) , slog . Error ( err ) )
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
2022-08-22 23:13:46 +00:00
Message : "Failed to process OAuth login." ,
2022-08-17 23:00:53 +00:00
Detail : err . Error ( ) ,
} )
return
}
2023-02-06 20:12:50 +00:00
aReq . New = key
2023-03-22 19:52:13 +00:00
aReq . UserID = key . UserID
2022-08-17 23:00:53 +00:00
2023-06-30 12:38:48 +00:00
for i := range cookies {
http . SetCookie ( rw , cookies [ i ] )
}
2022-08-22 23:13:46 +00:00
redirect := state . Redirect
if redirect == "" {
redirect = "/"
2022-08-17 23:00:53 +00:00
}
2022-08-22 23:13:46 +00:00
http . Redirect ( rw , r , redirect , http . StatusTemporaryRedirect )
}
2022-08-17 23:00:53 +00:00
2023-11-29 15:24:00 +00:00
// oidcGroups returns the groups for the user from the OIDC claims.
2023-12-06 16:27:40 +00:00
func ( api * API ) oidcGroups ( ctx context . Context , mergedClaims map [ string ] interface { } ) ( bool , [ ] string , * httpError ) {
2023-11-29 15:24:00 +00:00
logger := api . Logger . Named ( userAuthLoggerName )
usingGroups := false
var groups [ ] string
// If the GroupField is the empty string, then groups from OIDC are not used.
// This is so we can support manual group assignment.
if api . OIDCConfig . GroupField != "" {
2023-12-08 16:14:19 +00:00
// If the allow list is empty, then the user is allowed to log in.
// Otherwise, they must belong to at least 1 group in the allow list.
inAllowList := len ( api . OIDCConfig . GroupAllowList ) == 0
2023-11-29 15:24:00 +00:00
usingGroups = true
groupsRaw , ok := mergedClaims [ api . OIDCConfig . GroupField ]
2023-12-04 16:01:45 +00:00
if ok {
parsedGroups , err := parseStringSliceClaim ( groupsRaw )
if err != nil {
api . Logger . Debug ( ctx , "groups field was an unknown type in oidc claims" ,
slog . F ( "type" , fmt . Sprintf ( "%T" , groupsRaw ) ) ,
slog . Error ( err ) ,
2023-11-29 15:24:00 +00:00
)
2023-12-06 16:27:40 +00:00
return false , nil , & httpError {
code : http . StatusBadRequest ,
msg : "Failed to sync groups from OIDC claims" ,
detail : err . Error ( ) ,
renderStaticPage : false ,
}
2023-12-04 16:01:45 +00:00
}
2023-11-29 15:24:00 +00:00
2023-12-04 16:01:45 +00:00
api . Logger . Debug ( ctx , "groups returned in oidc claims" ,
slog . F ( "len" , len ( parsedGroups ) ) ,
slog . F ( "groups" , parsedGroups ) ,
)
2023-11-29 15:24:00 +00:00
2023-12-04 16:01:45 +00:00
for _ , group := range parsedGroups {
if mappedGroup , ok := api . OIDCConfig . GroupMapping [ group ] ; ok {
group = mappedGroup
2023-11-29 15:24:00 +00:00
}
2023-12-08 16:14:19 +00:00
if _ , ok := api . OIDCConfig . GroupAllowList [ group ] ; ok {
inAllowList = true
}
2023-12-04 16:01:45 +00:00
groups = append ( groups , group )
2023-11-29 15:24:00 +00:00
}
}
2023-12-08 16:14:19 +00:00
if ! inAllowList {
logger . Debug ( ctx , "oidc group claim not in allow list, rejecting login" ,
slog . F ( "allow_list_count" , len ( api . OIDCConfig . GroupAllowList ) ) ,
slog . F ( "user_group_count" , len ( groups ) ) ,
)
detail := "Ask an administrator to add one of your groups to the whitelist"
if len ( groups ) == 0 {
detail = "You are currently not a member of any groups! Ask an administrator to add you to an authorized group to login."
}
return usingGroups , groups , & httpError {
code : http . StatusForbidden ,
msg : "Not a member of an allowed group" ,
detail : detail ,
renderStaticPage : true ,
}
}
2023-11-29 15:24:00 +00:00
}
// This conditional is purely to warn the user they might have misconfigured their OIDC
// configuration.
if _ , groupClaimExists := mergedClaims [ "groups" ] ; ! usingGroups && groupClaimExists {
logger . Debug ( ctx , "claim 'groups' was returned, but 'oidc-group-field' is not set, check your coder oidc settings" )
}
return usingGroups , groups , nil
}
// oidcRoles returns the roles for the user from the OIDC claims.
// If the function returns false, then the caller should return early.
// All writes to the response writer are handled by this function.
// It would be preferred to just return an error, however this function
// decorates returned errors with the appropriate HTTP status codes and details
// that are hard to carry in a standard `error` without more work.
2023-12-06 16:27:40 +00:00
func ( api * API ) oidcRoles ( ctx context . Context , mergedClaims map [ string ] interface { } ) ( [ ] string , * httpError ) {
2023-11-29 15:24:00 +00:00
roles := api . OIDCConfig . UserRolesDefault
if ! api . OIDCConfig . RoleSyncEnabled ( ) {
2023-12-06 16:27:40 +00:00
return roles , nil
2023-11-29 15:24:00 +00:00
}
rolesRow , ok := mergedClaims [ api . OIDCConfig . UserRoleField ]
if ! ok {
// If no claim is provided than we can assume the user is just
// a member. This is because there is no way to tell the difference
// between []string{} and nil for OIDC claims. IDPs omit claims
// if they are empty ([]string{}).
// Use []interface{}{} so the next typecast works.
rolesRow = [ ] interface { } { }
}
2023-12-04 16:01:45 +00:00
parsedRoles , err := parseStringSliceClaim ( rolesRow )
if err != nil {
api . Logger . Error ( ctx , "oidc claims user roles field was an unknown type" ,
2023-11-29 15:24:00 +00:00
slog . F ( "type" , fmt . Sprintf ( "%T" , rolesRow ) ) ,
2023-12-04 16:01:45 +00:00
slog . Error ( err ) ,
2023-11-29 15:24:00 +00:00
)
2023-12-06 16:27:40 +00:00
return nil , & httpError {
code : http . StatusInternalServerError ,
msg : "Login disabled until OIDC config is fixed" ,
detail : fmt . Sprintf ( "Roles claim must be an array of strings, type found: %T. Disabling role sync will allow login to proceed." , rolesRow ) ,
renderStaticPage : false ,
}
2023-11-29 15:24:00 +00:00
}
api . Logger . Debug ( ctx , "roles returned in oidc claims" ,
2023-12-04 16:01:45 +00:00
slog . F ( "len" , len ( parsedRoles ) ) ,
slog . F ( "roles" , parsedRoles ) ,
2023-11-29 15:24:00 +00:00
)
2023-12-04 16:01:45 +00:00
for _ , role := range parsedRoles {
2023-11-29 15:24:00 +00:00
if mappedRoles , ok := api . OIDCConfig . UserRoleMapping [ role ] ; ok {
if len ( mappedRoles ) == 0 {
continue
}
// Mapped roles are added to the list of roles
roles = append ( roles , mappedRoles ... )
continue
}
roles = append ( roles , role )
}
2023-12-06 16:27:40 +00:00
return roles , nil
2023-11-29 15:24:00 +00:00
}
2023-04-05 08:07:43 +00:00
// claimFields returns the sorted list of fields in the claims map.
func claimFields ( claims map [ string ] interface { } ) [ ] string {
fields := [ ] string { }
for field := range claims {
fields = append ( fields , field )
}
sort . Strings ( fields )
return fields
}
// blankFields returns the list of fields in the claims map that are
// an empty string.
func blankFields ( claims map [ string ] interface { } ) [ ] string {
fields := make ( [ ] string , 0 )
for field , value := range claims {
if valueStr , ok := value . ( string ) ; ok && valueStr == "" {
fields = append ( fields , field )
}
}
sort . Strings ( fields )
return fields
}
// mergeClaims merges the claims from a and b and returns the merged set.
// claims from b take precedence over claims from a.
func mergeClaims ( a , b map [ string ] interface { } ) map [ string ] interface { } {
c := make ( map [ string ] interface { } )
for k , v := range a {
c [ k ] = v
}
for k , v := range b {
c [ k ] = v
}
return c
}
2023-11-27 16:47:23 +00:00
// OauthDebugContext provides helpful information for admins to debug
// OAuth login issues.
type OauthDebugContext struct {
IDTokenClaims map [ string ] interface { } ` json:"id_token_claims" `
UserInfoClaims map [ string ] interface { } ` json:"user_info_claims" `
}
2022-08-22 23:13:46 +00:00
type oauthLoginParams struct {
2023-02-06 20:12:50 +00:00
User database . User
Link database . UserLink
2022-08-22 23:13:46 +00:00
State httpmw . OAuth2State
LinkedID string
LoginType database . LoginType
// The following are necessary in order to
// create new users.
AllowSignups bool
Email string
Username string
2022-09-04 16:44:27 +00:00
AvatarURL string
2023-03-10 05:31:38 +00:00
// Is UsingGroups is true, then the user will be assigned
// to the Groups provided.
2023-08-08 16:37:49 +00:00
UsingGroups bool
CreateMissingGroups bool
2024-02-16 17:09:19 +00:00
// These are the group names from the IDP. Internally, they will map to
// some organization groups.
Groups [ ] string
GroupFilter * regexp . Regexp
2023-07-24 12:34:24 +00:00
// Is UsingRoles is true, then the user will be assigned
// the roles provided.
UsingRoles bool
Roles [ ] string
2023-06-30 12:38:48 +00:00
2023-11-27 16:47:23 +00:00
DebugContext OauthDebugContext
2023-06-30 12:38:48 +00:00
commitLock sync . Mutex
initAuditRequest func ( params * audit . RequestParams ) * audit . Request [ database . User ]
commits [ ] func ( )
}
func ( p * oauthLoginParams ) SetInitAuditRequest ( f func ( params * audit . RequestParams ) ( * audit . Request [ database . User ] , func ( ) ) ) * oauthLoginParams {
p . initAuditRequest = func ( params * audit . RequestParams ) * audit . Request [ database . User ] {
p . commitLock . Lock ( )
defer p . commitLock . Unlock ( )
req , commit := f ( params )
p . commits = append ( p . commits , commit )
return req
}
return p
}
func ( p * oauthLoginParams ) CommitAuditLogs ( ) {
p . commitLock . Lock ( )
defer p . commitLock . Unlock ( )
for _ , f := range p . commits {
f ( )
}
2022-08-22 23:13:46 +00:00
}
type httpError struct {
2023-07-07 15:33:31 +00:00
code int
msg string
detail string
renderStaticPage bool
2024-02-01 17:01:25 +00:00
renderDetailMarkdown bool
2023-07-07 15:33:31 +00:00
}
func ( e httpError ) Write ( rw http . ResponseWriter , r * http . Request ) {
if e . renderStaticPage {
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : e . code ,
HideStatus : true ,
Title : e . msg ,
Description : e . detail ,
RetryEnabled : false ,
DashboardURL : "/login" ,
2024-02-01 17:01:25 +00:00
RenderDescriptionMarkdown : e . renderDetailMarkdown ,
2023-07-07 15:33:31 +00:00
} )
return
}
httpapi . Write ( r . Context ( ) , rw , e . code , codersdk . Response {
Message : e . msg ,
Detail : e . detail ,
} )
2022-08-22 23:13:46 +00:00
}
func ( e httpError ) Error ( ) string {
if e . detail != "" {
return e . detail
2022-08-17 23:00:53 +00:00
}
2022-08-22 23:13:46 +00:00
return e . msg
}
2022-08-17 23:00:53 +00:00
2023-06-30 12:38:48 +00:00
func ( api * API ) oauthLogin ( r * http . Request , params * oauthLoginParams ) ( [ ] * http . Cookie , database . APIKey , error ) {
2022-08-22 23:13:46 +00:00
var (
2023-06-30 12:38:48 +00:00
ctx = r . Context ( )
user database . User
cookies [ ] * http . Cookie
2023-07-24 12:34:24 +00:00
logger = api . Logger . Named ( userAuthLoggerName )
2022-08-22 23:13:46 +00:00
)
2023-07-10 14:25:41 +00:00
var isConvertLoginType bool
2022-08-22 23:13:46 +00:00
err := api . Database . InTx ( func ( tx database . Store ) error {
var (
link database . UserLink
err error
)
2023-02-06 20:12:50 +00:00
user = params . User
link = params . Link
2022-08-22 23:13:46 +00:00
2023-06-30 12:38:48 +00:00
// If you do a convert to OIDC and your email does not match, we need to
// catch this and not make a new account.
if isMergeStateString ( params . State . StateString ) {
// Always clear this cookie. If it succeeds, we no longer need it.
// If it fails, we no longer care about it.
cookies = append ( cookies , clearOAuthConvertCookie ( ) )
user , err = api . convertUserToOauth ( ctx , r , tx , params )
if err != nil {
return err
}
params . User = user
2023-07-10 14:25:41 +00:00
isConvertLoginType = true
2023-06-30 12:38:48 +00:00
}
2022-08-22 23:13:46 +00:00
if user . ID == uuid . Nil && ! params . AllowSignups {
2024-02-01 17:01:25 +00:00
signupsDisabledText := "Please contact your Coder administrator to request access."
if api . OIDCConfig != nil && api . OIDCConfig . SignupsDisabledText != "" {
signupsDisabledText = parameter . HTML ( api . OIDCConfig . SignupsDisabledText )
}
2022-08-22 23:13:46 +00:00
return httpError {
2024-02-01 17:01:25 +00:00
code : http . StatusForbidden ,
msg : "Signups are disabled" ,
detail : signupsDisabledText ,
renderStaticPage : true ,
renderDetailMarkdown : true ,
2022-08-22 23:13:46 +00:00
}
}
if user . ID != uuid . Nil && user . LoginType != params . LoginType {
2023-07-07 15:33:31 +00:00
return wrongLoginTypeHTTPError ( user . LoginType , params . LoginType )
2022-08-22 23:13:46 +00:00
}
// This can happen if a user is a built-in user but is signing in
// with OIDC for the first time.
if user . ID == uuid . Nil {
2024-03-06 13:29:28 +00:00
// Until proper multi-org support, all users will be added to the default organization.
// The default organization should always be present.
2023-02-14 14:27:06 +00:00
//nolint:gocritic
2024-03-06 13:29:28 +00:00
defaultOrganization , err := tx . GetDefaultOrganization ( dbauthz . AsSystemRestricted ( ctx ) )
if err != nil {
return xerrors . Errorf ( "unable to fetch default organization: %w" , err )
}
2022-08-22 23:13:46 +00:00
2023-02-14 14:27:06 +00:00
//nolint:gocritic
2024-03-06 13:29:28 +00:00
_ , err = tx . GetUserByEmailOrUsername ( dbauthz . AsSystemRestricted ( ctx ) , database . GetUserByEmailOrUsernameParams {
2022-10-18 03:07:11 +00:00
Username : params . Username ,
} )
if err == nil {
var (
original = params . Username
validUsername bool
)
for i := 0 ; i < 10 ; i ++ {
alternate := fmt . Sprintf ( "%s-%s" , original , namesgenerator . GetRandomName ( 1 ) )
params . Username = httpapi . UsernameFrom ( alternate )
2023-02-14 14:27:06 +00:00
//nolint:gocritic
2023-02-15 16:14:37 +00:00
_ , err := tx . GetUserByEmailOrUsername ( dbauthz . AsSystemRestricted ( ctx ) , database . GetUserByEmailOrUsernameParams {
2022-10-18 03:07:11 +00:00
Username : params . Username ,
} )
if xerrors . Is ( err , sql . ErrNoRows ) {
validUsername = true
break
}
if err != nil {
return xerrors . Errorf ( "get user by email/username: %w" , err )
}
}
if ! validUsername {
return httpError {
code : http . StatusConflict ,
msg : fmt . Sprintf ( "exhausted alternatives for taken username %q" , original ) ,
}
}
}
2023-02-14 14:27:06 +00:00
//nolint:gocritic
2023-02-15 16:14:37 +00:00
user , _ , err = api . CreateUser ( dbauthz . AsSystemRestricted ( ctx ) , tx , CreateUserRequest {
2022-08-22 23:13:46 +00:00
CreateUserRequest : codersdk . CreateUserRequest {
Email : params . Email ,
Username : params . Username ,
2024-03-06 13:29:28 +00:00
OrganizationID : defaultOrganization . ID ,
2022-08-22 23:13:46 +00:00
} ,
2024-03-06 13:29:28 +00:00
LoginType : params . LoginType ,
2022-08-17 23:00:53 +00:00
} )
2022-08-22 23:13:46 +00:00
if err != nil {
return xerrors . Errorf ( "create user: %w" , err )
}
2022-08-17 23:00:53 +00:00
}
2023-08-02 14:31:25 +00:00
// Activate dormant user on sigin
if user . Status == database . UserStatusDormant {
//nolint:gocritic // System needs to update status of the user account (dormant -> active).
user , err = tx . UpdateUserStatus ( dbauthz . AsSystemRestricted ( ctx ) , database . UpdateUserStatusParams {
ID : user . ID ,
Status : database . UserStatusActive ,
2023-09-01 16:50:12 +00:00
UpdatedAt : dbtime . Now ( ) ,
2023-08-02 14:31:25 +00:00
} )
if err != nil {
logger . Error ( ctx , "unable to update user status to active" , slog . Error ( err ) )
return xerrors . Errorf ( "update user status: %w" , err )
}
}
2023-11-27 16:47:23 +00:00
debugContext , err := json . Marshal ( params . DebugContext )
if err != nil {
return xerrors . Errorf ( "marshal debug context: %w" , err )
}
2022-08-22 23:13:46 +00:00
if link . UserID == uuid . Nil {
2023-10-03 08:23:45 +00:00
//nolint:gocritic // System needs to insert the user link (linked_id, oauth_token, oauth_expiry).
2023-02-15 16:14:37 +00:00
link , err = tx . InsertUserLink ( dbauthz . AsSystemRestricted ( ctx ) , database . InsertUserLinkParams {
2023-10-03 08:23:45 +00:00
UserID : user . ID ,
LoginType : params . LoginType ,
LinkedID : params . LinkedID ,
OAuthAccessToken : params . State . Token . AccessToken ,
OAuthAccessTokenKeyID : sql . NullString { } , // set by dbcrypt if required
OAuthRefreshToken : params . State . Token . RefreshToken ,
OAuthRefreshTokenKeyID : sql . NullString { } , // set by dbcrypt if required
OAuthExpiry : params . State . Token . Expiry ,
2023-11-27 16:47:23 +00:00
DebugContext : debugContext ,
2022-08-17 23:00:53 +00:00
} )
2022-08-22 23:13:46 +00:00
if err != nil {
return xerrors . Errorf ( "insert user link: %w" , err )
}
2022-08-17 23:00:53 +00:00
}
2022-08-22 23:13:46 +00:00
if link . UserID != uuid . Nil {
2023-10-03 08:23:45 +00:00
//nolint:gocritic // System needs to update the user link (linked_id, oauth_token, oauth_expiry).
2023-02-15 16:14:37 +00:00
link , err = tx . UpdateUserLink ( dbauthz . AsSystemRestricted ( ctx ) , database . UpdateUserLinkParams {
2023-10-03 08:23:45 +00:00
UserID : user . ID ,
LoginType : params . LoginType ,
OAuthAccessToken : params . State . Token . AccessToken ,
OAuthAccessTokenKeyID : sql . NullString { } , // set by dbcrypt if required
OAuthRefreshToken : params . State . Token . RefreshToken ,
OAuthRefreshTokenKeyID : sql . NullString { } , // set by dbcrypt if required
OAuthExpiry : params . State . Token . Expiry ,
2023-11-27 16:47:23 +00:00
DebugContext : debugContext ,
2022-08-18 04:06:03 +00:00
} )
2022-08-22 23:13:46 +00:00
if err != nil {
return xerrors . Errorf ( "update user link: %w" , err )
}
2022-08-18 04:06:03 +00:00
}
2023-02-02 19:53:48 +00:00
// Ensure groups are correct.
2024-02-16 17:09:19 +00:00
// This places all groups into the default organization.
// To go multi-org, we need to add a mapping feature here to know which
// groups go to which orgs.
2023-03-10 05:31:38 +00:00
if params . UsingGroups {
2023-08-08 16:37:49 +00:00
filtered := params . Groups
if params . GroupFilter != nil {
filtered = make ( [ ] string , 0 , len ( params . Groups ) )
for _ , group := range params . Groups {
if params . GroupFilter . MatchString ( group ) {
filtered = append ( filtered , group )
}
}
}
2024-02-16 17:09:19 +00:00
//nolint:gocritic // No user present in the context.
defaultOrganization , err := tx . GetDefaultOrganization ( dbauthz . AsSystemRestricted ( ctx ) )
if err != nil {
// If there is no default org, then we can't assign groups.
// By default, we assume all groups belong to the default org.
return xerrors . Errorf ( "get default organization: %w" , err )
}
//nolint:gocritic // No user present in the context.
memberships , err := tx . GetOrganizationMembershipsByUserID ( dbauthz . AsSystemRestricted ( ctx ) , user . ID )
if err != nil {
return xerrors . Errorf ( "get organization memberships: %w" , err )
}
// If the user is not in the default organization, then we can't assign groups.
// A user cannot be in groups to an org they are not a member of.
if ! slices . ContainsFunc ( memberships , func ( member database . OrganizationMember ) bool {
return member . OrganizationID == defaultOrganization . ID
} ) {
return xerrors . Errorf ( "user %s is not a member of the default organization, cannot assign to groups in the org" , user . ID )
}
2023-02-14 14:27:06 +00:00
//nolint:gocritic
2024-02-16 17:09:19 +00:00
err = api . Options . SetUserGroups ( dbauthz . AsSystemRestricted ( ctx ) , logger , tx , user . ID , map [ uuid . UUID ] [ ] string {
defaultOrganization . ID : filtered ,
} , params . CreateMissingGroups )
2023-02-02 19:53:48 +00:00
if err != nil {
return xerrors . Errorf ( "set user groups: %w" , err )
}
}
2023-07-24 12:34:24 +00:00
// Ensure roles are correct.
if params . UsingRoles {
ignored := make ( [ ] string , 0 )
filtered := make ( [ ] string , 0 , len ( params . Roles ) )
for _ , role := range params . Roles {
if _ , err := rbac . RoleByName ( role ) ; err == nil {
filtered = append ( filtered , role )
} else {
ignored = append ( ignored , role )
}
}
//nolint:gocritic
2023-08-08 16:37:49 +00:00
err := api . Options . SetUserSiteRoles ( dbauthz . AsSystemRestricted ( ctx ) , logger , tx , user . ID , filtered )
2023-07-24 12:34:24 +00:00
if err != nil {
return httpError {
code : http . StatusBadRequest ,
2023-12-04 16:01:45 +00:00
msg : "Invalid roles through OIDC claims" ,
2023-07-24 12:34:24 +00:00
detail : fmt . Sprintf ( "Error from role assignment attempt: %s" , err . Error ( ) ) ,
renderStaticPage : true ,
}
}
if len ( ignored ) > 0 {
logger . Debug ( ctx , "OIDC roles ignored in assignment" ,
slog . F ( "ignored" , ignored ) ,
slog . F ( "assigned" , filtered ) ,
slog . F ( "user_id" , user . ID ) ,
)
}
}
2022-09-04 16:44:27 +00:00
needsUpdate := false
2023-12-11 17:09:51 +00:00
if user . AvatarURL != params . AvatarURL {
user . AvatarURL = params . AvatarURL
2022-09-04 16:44:27 +00:00
needsUpdate = true
}
2022-08-22 23:13:46 +00:00
// If the upstream email or username has changed we should mirror
// that in Coder. Many enterprises use a user's email/username as
// security auditing fields so they need to stay synced.
// NOTE: username updating has been halted since it can have infrastructure
// provisioning consequences (updates to usernames may delete persistent
// resources such as user home volumes).
if user . Email != params . Email {
2022-09-04 16:44:27 +00:00
user . Email = params . Email
needsUpdate = true
}
if needsUpdate {
2022-08-22 23:13:46 +00:00
// TODO(JonA): Since we're processing updates to a user's upstream
// email/username, it's possible for a different built-in user to
// have already claimed the username.
// In such cases in the current implementation this user can now no
// longer sign in until an administrator finds the offending built-in
// user and changes their username.
2023-02-14 14:27:06 +00:00
//nolint:gocritic
2023-02-15 16:14:37 +00:00
user , err = tx . UpdateUserProfile ( dbauthz . AsSystemRestricted ( ctx ) , database . UpdateUserProfileParams {
2022-08-22 23:13:46 +00:00
ID : user . ID ,
2022-09-04 16:44:27 +00:00
Email : user . Email ,
2024-01-17 12:20:45 +00:00
Name : user . Name ,
2022-08-22 23:13:46 +00:00
Username : user . Username ,
2023-09-01 16:50:12 +00:00
UpdatedAt : dbtime . Now ( ) ,
2022-09-04 16:44:27 +00:00
AvatarURL : user . AvatarURL ,
2022-08-17 23:00:53 +00:00
} )
2022-08-22 23:13:46 +00:00
if err != nil {
return xerrors . Errorf ( "update user profile: %w" , err )
}
2022-08-17 23:00:53 +00:00
}
2022-08-22 23:13:46 +00:00
return nil
2022-11-14 17:57:33 +00:00
} , nil )
2022-08-22 23:13:46 +00:00
if err != nil {
2023-02-06 20:12:50 +00:00
return nil , database . APIKey { } , xerrors . Errorf ( "in tx: %w" , err )
2022-08-17 23:00:53 +00:00
}
2023-07-10 14:25:41 +00:00
var key database . APIKey
2023-08-08 15:05:12 +00:00
oldKey , _ , ok := httpmw . APIKeyFromRequest ( ctx , api . Database , nil , r )
if ok && oldKey != nil && isConvertLoginType {
2023-07-10 14:25:41 +00:00
// If this is a convert login type, and it succeeds, then delete the old
// session. Force the user to log back in.
err := api . Database . DeleteAPIKeyByID ( r . Context ( ) , oldKey . ID )
if err != nil {
// Do not block this login if we fail to delete the old API key.
// Just delete the cookie and continue.
api . Logger . Warn ( r . Context ( ) , "failed to delete old API key in convert to oidc" ,
slog . Error ( err ) ,
slog . F ( "old_api_key_id" , oldKey . ID ) ,
slog . F ( "user_id" , user . ID ) ,
)
}
cookies = append ( cookies , & http . Cookie {
Name : codersdk . SessionTokenCookie ,
Path : "/" ,
MaxAge : - 1 ,
Secure : api . SecureAuthCookie ,
HttpOnly : true ,
} )
2023-08-08 15:05:12 +00:00
// This is intentional setting the key to the deleted old key,
// as the user needs to be forced to log back in.
key = * oldKey
2023-07-10 14:25:41 +00:00
} else {
//nolint:gocritic
cookie , newKey , err := api . createAPIKey ( dbauthz . AsSystemRestricted ( ctx ) , apikey . CreateParams {
2024-01-22 20:42:55 +00:00
UserID : user . ID ,
LoginType : params . LoginType ,
2024-04-10 15:34:49 +00:00
DefaultLifetime : api . DeploymentValues . Sessions . DefaultDuration . Value ( ) ,
2024-01-22 20:42:55 +00:00
RemoteAddr : r . RemoteAddr ,
2023-07-10 14:25:41 +00:00
} )
if err != nil {
return nil , database . APIKey { } , xerrors . Errorf ( "create API key: %w" , err )
}
cookies = append ( cookies , cookie )
key = * newKey
2022-08-01 04:05:35 +00:00
}
2023-07-10 14:25:41 +00:00
return cookies , key , nil
2023-06-30 12:38:48 +00:00
}
// convertUserToOauth will convert a user from password base loginType to
// an oauth login type. If it fails, it will return a httpError
func ( api * API ) convertUserToOauth ( ctx context . Context , r * http . Request , db database . Store , params * oauthLoginParams ) ( database . User , error ) {
user := params . User
// Trying to convert to OIDC, but the email does not match.
// So do not make a new user, just block the request.
if user . ID == uuid . Nil {
return database . User { } , httpError {
code : http . StatusBadRequest ,
msg : fmt . Sprintf ( "The oidc account with the email %q does not match the email of the account you are trying to convert. Contact your administrator to resolve this issue." , params . Email ) ,
}
}
jwtCookie , err := r . Cookie ( OAuthConvertCookieValue )
if err != nil {
return database . User { } , httpError {
code : http . StatusBadRequest ,
msg : fmt . Sprintf ( "Convert to oauth cookie not found. Missing signed jwt to authorize this action. " +
"Please try again." ) ,
}
}
var claims OAuthConvertStateClaims
token , err := jwt . ParseWithClaims ( jwtCookie . Value , & claims , func ( token * jwt . Token ) ( interface { } , error ) {
return api . OAuthSigningKey [ : ] , nil
} )
if xerrors . Is ( err , jwt . ErrSignatureInvalid ) || ! token . Valid {
// These errors are probably because the user is mixing 2 coder deployments.
return database . User { } , httpError {
code : http . StatusBadRequest ,
msg : "Using an invalid jwt to authorize this action. Ensure there is only 1 coder deployment and try again." ,
}
}
if err != nil {
return database . User { } , httpError {
code : http . StatusInternalServerError ,
msg : fmt . Sprintf ( "Error parsing jwt: %v" , err ) ,
}
}
// At this point, this request could be an attempt to convert from
// password auth to oauth auth. Always log these attempts.
var (
auditor = * api . Auditor . Load ( )
oauthConvertAudit = params . initAuditRequest ( & audit . RequestParams {
Audit : auditor ,
Log : api . Logger ,
Request : r ,
Action : database . AuditActionWrite ,
} )
)
oauthConvertAudit . UserID = claims . UserID
oauthConvertAudit . Old = user
if claims . RegisteredClaims . Issuer != api . DeploymentID {
return database . User { } , httpError {
code : http . StatusForbidden ,
msg : "Request to convert login type failed. Issuer mismatch. Found a cookie from another coder deployment, please try again." ,
}
}
if params . State . StateString != claims . State {
return database . User { } , httpError {
code : http . StatusForbidden ,
msg : "Request to convert login type failed. State mismatch." ,
}
}
// Make sure the merge state generated matches this OIDC login request.
// It needs to have the correct login type information for this
// user.
if user . ID != claims . UserID ||
codersdk . LoginType ( user . LoginType ) != claims . FromLoginType ||
codersdk . LoginType ( params . LoginType ) != claims . ToLoginType {
return database . User { } , httpError {
code : http . StatusForbidden ,
msg : fmt . Sprintf ( "Request to convert login type from %s to %s failed" , user . LoginType , params . LoginType ) ,
}
}
// Convert the user and default to the normal login flow.
// If the login succeeds, this transaction will commit and the user
// will be converted.
// nolint:gocritic // system query to update user login type. The user already
// provided their password to authenticate this request.
user , err = db . UpdateUserLoginType ( dbauthz . AsSystemRestricted ( ctx ) , database . UpdateUserLoginTypeParams {
NewLoginType : params . LoginType ,
UserID : user . ID ,
} )
if err != nil {
return database . User { } , httpError {
code : http . StatusInternalServerError ,
msg : "Failed to convert user to new login type" ,
}
}
oauthConvertAudit . New = user
return user , nil
2022-08-01 04:05:35 +00:00
}
2022-08-17 23:00:53 +00:00
// githubLinkedID returns the unique ID for a GitHub user.
func githubLinkedID ( u * github . User ) string {
return strconv . FormatInt ( u . GetID ( ) , 10 )
}
// oidcLinkedID returns the uniqued ID for an OIDC user.
// See https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability .
func oidcLinkedID ( tok * oidc . IDToken ) string {
return strings . Join ( [ ] string { tok . Issuer , tok . Subject } , "||" )
}
// findLinkedUser tries to find a user by their unique OAuth-linked ID.
// If it doesn't not find it, it returns the user by their email.
func findLinkedUser ( ctx context . Context , db database . Store , linkedID string , emails ... string ) ( database . User , database . UserLink , error ) {
var (
user database . User
link database . UserLink
)
link , err := db . GetUserLinkByLinkedID ( ctx , linkedID )
if err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return user , link , xerrors . Errorf ( "get user auth by linked ID: %w" , err )
}
if err == nil {
user , err = db . GetUserByID ( ctx , link . UserID )
if err != nil {
return database . User { } , database . UserLink { } , xerrors . Errorf ( "get user by id: %w" , err )
}
2022-09-13 12:33:35 +00:00
if ! user . Deleted {
return user , link , nil
}
// If the user was deleted, act as if no account link exists.
user = database . User { }
2022-08-17 23:00:53 +00:00
}
for _ , email := range emails {
user , err = db . GetUserByEmailOrUsername ( ctx , database . GetUserByEmailOrUsernameParams {
Email : email ,
} )
if err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return user , link , xerrors . Errorf ( "get user by email: %w" , err )
}
if errors . Is ( err , sql . ErrNoRows ) {
continue
}
break
}
if user . ID == uuid . Nil {
// No user found.
return database . User { } , database . UserLink { } , nil
}
// LEGACY: This is annoying but we have to search for the user_link
// again except this time we search by user_id and login_type. It's
// possible that a user_link exists without a populated 'linked_id'.
link , err = db . GetUserLinkByUserIDLoginType ( ctx , database . GetUserLinkByUserIDLoginTypeParams {
UserID : user . ID ,
LoginType : user . LoginType ,
} )
if err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return database . User { } , database . UserLink { } , xerrors . Errorf ( "get user link by user id and login type: %w" , err )
}
return user , link , nil
}
2023-06-30 12:38:48 +00:00
func isMergeStateString ( state string ) bool {
return strings . HasPrefix ( state , mergeStateStringPrefix )
}
func clearOAuthConvertCookie ( ) * http . Cookie {
return & http . Cookie {
Name : OAuthConvertCookieValue ,
Path : "/" ,
MaxAge : - 1 ,
}
}
2023-07-07 15:33:31 +00:00
func wrongLoginTypeHTTPError ( user database . LoginType , params database . LoginType ) httpError {
addedMsg := ""
if user == database . LoginTypePassword {
2023-08-09 10:05:46 +00:00
addedMsg = " You can convert your account to use this login type by visiting your account settings."
2023-07-07 15:33:31 +00:00
}
return httpError {
code : http . StatusForbidden ,
renderStaticPage : true ,
msg : "Incorrect login type" ,
detail : fmt . Sprintf ( "Attempting to use login type %q, but the user has the login type %q.%s" ,
params , user , addedMsg ) ,
}
}
2023-12-04 16:01:45 +00:00
// parseStringSliceClaim parses the claim for groups and roles, expected []string.
//
// Some providers like ADFS return a single string instead of an array if there
// is only 1 element. So this function handles the edge cases.
func parseStringSliceClaim ( claim interface { } ) ( [ ] string , error ) {
groups := make ( [ ] string , 0 )
if claim == nil {
return groups , nil
}
// The simple case is the type is exactly what we expected
asStringArray , ok := claim . ( [ ] string )
if ok {
return asStringArray , nil
}
asArray , ok := claim . ( [ ] interface { } )
if ok {
for i , item := range asArray {
asString , ok := item . ( string )
if ! ok {
return nil , xerrors . Errorf ( "invalid claim type. Element %d expected a string, got: %T" , i , item )
}
groups = append ( groups , asString )
}
return groups , nil
}
asString , ok := claim . ( string )
if ok {
if asString == "" {
// Empty string should be 0 groups.
return [ ] string { } , nil
}
// If it is a single string, first check if it is a csv.
// If a user hits this, it is likely a misconfiguration and they need
// to reconfigure their IDP to send an array instead.
if strings . Contains ( asString , "," ) {
return nil , xerrors . Errorf ( "invalid claim type. Got a csv string (%q), change this claim to return an array of strings instead." , asString )
}
return [ ] string { asString } , nil
}
// Not sure what the user gave us.
return nil , xerrors . Errorf ( "invalid claim type. Expected an array of strings, got: %T" , claim )
}