coder/coderd/userauth.go

1868 lines
62 KiB
Go

package coderd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/mail"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"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/moby/moby/pkg/namesgenerator"
"golang.org/x/exp/slices"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"cdr.dev/slog"
"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"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/parameter"
"github.com/coder/coder/v2/coderd/promoauth"
"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"
)
const (
userAuthLoggerName = "userauth"
OAuthConvertCookieValue = "coder_oauth_convert_jwt"
mergeStateStringPrefix = "convert-"
)
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,
// Must be SameSite to work on the redirected auth flow from the
// oauth provider.
SameSite: http.SameSiteLaxMode,
})
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.OAuthConversionResponse{
StateString: stateString,
ExpiresAt: claims.ExpiresAt.Time,
ToType: claims.ToLoginType,
UserID: claims.UserID,
})
}
// 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()
logger = api.Logger.Named(userAuthLoggerName)
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
}
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{
UserID: user.ID,
LoginType: database.LoginTypePassword,
RemoteAddr: r.RemoteAddr,
DefaultLifetime: api.DeploymentValues.Sessions.DefaultDuration.Value(),
})
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) {
logger := api.Logger.Named(userAuthLoggerName)
//nolint:gocritic // In order to login, we need to get the user first!
user, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
Email: req.Email,
})
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
logger.Error(ctx, "unable to fetch user by email", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
// If the user doesn't exist, it will be a default struct.
equal, err := userpassword.Compare(string(user.HashedPassword), req.Password)
if err != nil {
logger.Error(ctx, "unable to compare passwords", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
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.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
// If password authentication is disabled and the user does not have the
// owner role, block the request.
if api.DeploymentValues.DisablePasswordAuth {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Password authentication is disabled.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
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),
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
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,
UpdatedAt: dbtime.Now(),
})
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
}
}
//nolint:gocritic // System needs to fetch user roles in order to login user.
roles, err := api.Database.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID)
if err != nil {
logger.Error(ctx, "unable to fetch authorization user roles", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
// If the user logged into a suspended account, reject the login request.
if roles.Status != database.UserStatusActive {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: fmt.Sprintf("Your account is %s. Contact an admin to reactivate your account.", roles.Status),
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
return user, roles, true
}
// 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
logger := api.Logger.Named(userAuthLoggerName)
err := api.Database.DeleteAPIKeyByID(ctx, apiKey.ID)
if err != nil {
logger.Error(ctx, "unable to delete API key", slog.F("api_key", apiKey.ID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting API key.",
Detail: err.Error(),
})
return
}
// 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 {
logger.Error(ctx, "unable to invalidate subdomain app tokens", slog.F("user_id", apiKey.UserID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting app tokens.",
Detail: err.Error(),
})
return
}
aReq.New = database.APIKey{}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: "Logged out!",
})
}
// GithubOAuth2Team represents a team scoped to an organization.
type GithubOAuth2Team struct {
Organization string
Slug string
}
// GithubOAuth2Provider exposes required functions for the Github authentication flow.
type GithubOAuth2Config struct {
promoauth.OAuth2Config
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)
TeamMembership func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error)
AllowSignups bool
AllowEveryone bool
AllowOrganizations []string
AllowTeams []GithubOAuth2Team
}
// @Summary Get authentication methods
// @ID get-authentication-methods
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Success 200 {object} codersdk.AuthMethods
// @Router /users/authmethods [get]
func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
var signInText string
var iconURL string
if api.OIDCConfig != nil {
signInText = api.OIDCConfig.SignInText
}
if api.OIDCConfig != nil {
iconURL = api.OIDCConfig.IconURL
}
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AuthMethods{
TermsOfServiceURL: api.DeploymentValues.TermsOfServiceURL.Value(),
Password: codersdk.AuthMethod{
Enabled: !api.DeploymentValues.DisablePasswordAuth.Value(),
},
Github: codersdk.AuthMethod{Enabled: api.GithubOAuth2Config != nil},
OIDC: codersdk.OIDCAuthMethod{
AuthMethod: codersdk.AuthMethod{Enabled: api.OIDCConfig != nil},
SignInText: signInText,
IconURL: iconURL,
},
})
}
// @Summary OAuth 2.0 GitHub Callback
// @ID oauth-20-github-callback
// @Security CoderSessionToken
// @Tags Users
// @Success 307
// @Router /users/oauth2/github/callback [get]
func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
var (
// userOAuth2Github is a system function.
//nolint:gocritic
ctx = dbauthz.AsSystemRestricted(r.Context())
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,
})
)
aReq.Old = database.APIKey{}
defer commitAudit()
oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(state.Token))
logger := api.Logger.Named(userAuthLoggerName)
var selectedMemberships []*github.Membership
var organizationNames []string
redirect := state.Redirect
if !api.GithubOAuth2Config.AllowEveryone {
memberships, err := api.GithubOAuth2Config.ListOrganizationMemberships(ctx, oauthClient)
if err != nil {
logger.Error(ctx, "unable to list organization members", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching authenticated Github user organizations.",
Detail: err.Error(),
})
return
}
for _, membership := range memberships {
if membership.GetState() != "active" {
continue
}
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 {
httpmw.CustomRedirectToLogin(rw, r, redirect, "You aren't a member of the authorized Github organizations!", http.StatusUnauthorized)
return
}
}
ghUser, err := api.GithubOAuth2Config.AuthenticatedUser(ctx, oauthClient)
if err != nil {
logger.Error(ctx, "oauth2: unable to fetch authenticated user", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching authenticated Github user.",
Detail: err.Error(),
})
return
}
// The default if no teams are specified is to allow all.
if !api.GithubOAuth2Config.AllowEveryone && len(api.GithubOAuth2Config.AllowTeams) > 0 {
var allowedTeam *github.Membership
for _, allowTeam := range api.GithubOAuth2Config.AllowTeams {
if allowedTeam != nil {
break
}
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
}
}
}
if allowedTeam == nil {
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)
return
}
}
emails, err := api.GithubOAuth2Config.ListEmails(ctx, oauthClient)
if err != nil {
logger.Error(ctx, "oauth2: unable to list emails", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching personal Github user.",
Detail: err.Error(),
})
return
}
var verifiedEmail *github.UserEmail
for _, email := range emails {
if email.GetVerified() && email.GetPrimary() {
verifiedEmail = email
break
}
}
if verifiedEmail == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Your primary email must be verified on GitHub!",
})
return
}
// 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
}
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), verifiedEmail.GetEmail())
if err != nil {
logger.Error(ctx, "oauth2: unable to find linked user", slog.F("gh_user", ghUser.Name), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to find linked user.",
Detail: err.Error(),
})
return
}
// 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
}
params := (&oauthLoginParams{
User: user,
Link: link,
State: state,
LinkedID: githubLinkedID(ghUser),
LoginType: database.LoginTypeGithub,
AllowSignups: api.GithubOAuth2Config.AllowSignups,
Email: verifiedEmail.GetEmail(),
Username: ghUser.GetLogin(),
AvatarURL: ghUser.GetAvatarURL(),
DebugContext: OauthDebugContext{},
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
return audit.InitRequest[database.User](rw, params)
})
cookies, key, err := api.oauthLogin(r, params)
defer params.CommitAuditLogs()
var httpErr httpError
if xerrors.As(err, &httpErr) {
httpErr.Write(rw, r)
return
}
if err != nil {
logger.Error(ctx, "oauth2: login failed", slog.F("user", user.Username), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to process OAuth login.",
Detail: err.Error(),
})
return
}
aReq.New = key
aReq.UserID = key.UserID
for _, cookie := range cookies {
http.SetCookie(rw, cookie)
}
if redirect == "" {
redirect = "/"
}
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}
type OIDCConfig struct {
promoauth.OAuth2Config
Provider *oidc.Provider
Verifier *oidc.IDTokenVerifier
// EmailDomains are the domains to enforce when a user authenticates.
EmailDomain []string
AllowSignups bool
// IgnoreEmailVerified allows ignoring the email_verified claim
// from an upstream OIDC provider. See #5065 for context.
IgnoreEmailVerified bool
// UsernameField selects the claim field to be used as the created user's
// username.
UsernameField string
// 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
// 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
// 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
// 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
// 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
// GroupMapping controls how groups returned by the OIDC provider get mapped
// to groups within Coder.
// map[oidcGroupName]coderGroupName
GroupMapping map[string]string
// 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
// 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
// SignupsDisabledText is the text do display on the static error page.
SignupsDisabledText string
}
func (cfg OIDCConfig) RoleSyncEnabled() bool {
return cfg.UserRoleField != ""
}
// @Summary OpenID Connect Callback
// @ID openid-connect-callback
// @Security CoderSessionToken
// @Tags Users
// @Success 307
// @Router /users/oidc/callback [get]
func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
var (
// userOIDC is a system function.
//nolint:gocritic
ctx = dbauthz.AsSystemRestricted(r.Context())
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,
})
)
aReq.Old = database.APIKey{}
defer commitAudit()
// See the example here: https://github.com/coreos/go-oidc
rawIDToken, ok := state.Token.Extra("id_token").(string)
if !ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "id_token not found in response payload. Ensure your OIDC callback is configured correctly!",
})
return
}
idToken, err := api.OIDCConfig.Verifier.Verify(ctx, rawIDToken)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to verify OIDC token.",
Detail: err.Error(),
})
return
}
logger := api.Logger.Named(userAuthLoggerName)
// "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.
idtokenClaims := map[string]interface{}{}
err = idToken.Claims(&idtokenClaims)
if err != nil {
logger.Error(ctx, "oauth2: unable to extract OIDC claims", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to extract OIDC claims.",
Detail: err.Error(),
})
return
}
logger.Debug(ctx, "got oidc claims",
slog.F("source", "id_token"),
slog.F("claim_fields", claimFields(idtokenClaims)),
slog.F("blank", blankFields(idtokenClaims)),
)
// 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
// 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.
userInfoClaims := make(map[string]interface{})
// If user info is skipped, the idtokenClaims are the claims.
mergedClaims := idtokenClaims
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 {
logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to unmarshal user info claims.",
Detail: err.Error(),
})
return
}
logger.Debug(ctx, "got oidc claims",
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.
mergedClaims = mergeClaims(idtokenClaims, userInfoClaims)
// Log all of the field names after merging.
logger.Debug(ctx, "got oidc claims",
slog.F("source", "merged"),
slog.F("claim_fields", claimFields(mergedClaims)),
slog.F("blank", blankFields(mergedClaims)),
)
} else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") {
logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to obtain user information claims.",
Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(),
})
return
} 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.
logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token")
}
}
usernameRaw, ok := mergedClaims[api.OIDCConfig.UsernameField]
var username string
if ok {
username, _ = usernameRaw.(string)
}
emailRaw, ok := mergedClaims[api.OIDCConfig.EmailField]
if !ok {
// 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
}
email, ok := emailRaw.(string)
if !ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Email in OIDC payload isn't a string. Got: %t", emailRaw),
})
return
}
verifiedRaw, ok := mergedClaims["email_verified"]
if ok {
verified, ok := verifiedRaw.(bool)
if ok && !verified {
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
}
logger.Warn(ctx, "allowing unverified oidc email %q")
}
}
// 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.
usernameValid := httpapi.NameValid(username)
if usernameValid != nil {
// 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.
if username == "" {
username = email
}
username = httpapi.UsernameFrom(username)
}
if len(api.OIDCConfig.EmailDomain) > 0 {
ok = false
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]
for _, domain := range api.OIDCConfig.EmailDomain {
if strings.EqualFold(userEmailDomain, domain) {
ok = true
break
}
}
if !ok {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: fmt.Sprintf("Your email %q is not in domains %q!", email, api.OIDCConfig.EmailDomain),
})
return
}
}
var picture string
pictureRaw, ok := mergedClaims["picture"]
if ok {
picture, _ = pictureRaw.(string)
}
ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username))
usingGroups, groups, groupErr := api.oidcGroups(ctx, mergedClaims)
if groupErr != nil {
groupErr.Write(rw, r)
return
}
roles, roleErr := api.oidcRoles(ctx, mergedClaims)
if roleErr != nil {
roleErr.Write(rw, r)
return
}
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), email)
if err != nil {
logger.Error(ctx, "oauth2: unable to find linked user", slog.F("email", email), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to find linked user.",
Detail: err.Error(),
})
return
}
// 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
}
params := (&oauthLoginParams{
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,
UsingGroups: usingGroups,
Groups: groups,
CreateMissingGroups: api.OIDCConfig.CreateMissingGroups,
GroupFilter: api.OIDCConfig.GroupFilter,
DebugContext: OauthDebugContext{
IDTokenClaims: idtokenClaims,
UserInfoClaims: userInfoClaims,
},
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
return audit.InitRequest[database.User](rw, params)
})
cookies, key, err := api.oauthLogin(r, params)
defer params.CommitAuditLogs()
var httpErr httpError
if xerrors.As(err, &httpErr) {
httpErr.Write(rw, r)
return
}
if err != nil {
logger.Error(ctx, "oauth2: login failed", slog.F("user", user.Username), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to process OAuth login.",
Detail: err.Error(),
})
return
}
aReq.New = key
aReq.UserID = key.UserID
for i := range cookies {
http.SetCookie(rw, cookies[i])
}
redirect := state.Redirect
if redirect == "" {
redirect = "/"
}
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}
// oidcGroups returns the groups for the user from the OIDC claims.
func (api *API) oidcGroups(ctx context.Context, mergedClaims map[string]interface{}) (bool, []string, *httpError) {
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 != "" {
// 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
usingGroups = true
groupsRaw, ok := mergedClaims[api.OIDCConfig.GroupField]
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),
)
return false, nil, &httpError{
code: http.StatusBadRequest,
msg: "Failed to sync groups from OIDC claims",
detail: err.Error(),
renderStaticPage: false,
}
}
api.Logger.Debug(ctx, "groups returned in oidc claims",
slog.F("len", len(parsedGroups)),
slog.F("groups", parsedGroups),
)
for _, group := range parsedGroups {
if mappedGroup, ok := api.OIDCConfig.GroupMapping[group]; ok {
group = mappedGroup
}
if _, ok := api.OIDCConfig.GroupAllowList[group]; ok {
inAllowList = true
}
groups = append(groups, group)
}
}
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,
}
}
}
// 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.
func (api *API) oidcRoles(ctx context.Context, mergedClaims map[string]interface{}) ([]string, *httpError) {
roles := api.OIDCConfig.UserRolesDefault
if !api.OIDCConfig.RoleSyncEnabled() {
return roles, nil
}
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{}{}
}
parsedRoles, err := parseStringSliceClaim(rolesRow)
if err != nil {
api.Logger.Error(ctx, "oidc claims user roles field was an unknown type",
slog.F("type", fmt.Sprintf("%T", rolesRow)),
slog.Error(err),
)
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,
}
}
api.Logger.Debug(ctx, "roles returned in oidc claims",
slog.F("len", len(parsedRoles)),
slog.F("roles", parsedRoles),
)
for _, role := range parsedRoles {
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)
}
return roles, nil
}
// 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
}
// 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"`
}
type oauthLoginParams struct {
User database.User
Link database.UserLink
State httpmw.OAuth2State
LinkedID string
LoginType database.LoginType
// The following are necessary in order to
// create new users.
AllowSignups bool
Email string
Username string
AvatarURL string
// Is UsingGroups is true, then the user will be assigned
// to the Groups provided.
UsingGroups bool
CreateMissingGroups bool
// These are the group names from the IDP. Internally, they will map to
// some organization groups.
Groups []string
GroupFilter *regexp.Regexp
// Is UsingRoles is true, then the user will be assigned
// the roles provided.
UsingRoles bool
Roles []string
DebugContext OauthDebugContext
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()
}
}
type httpError struct {
code int
msg string
detail string
renderStaticPage bool
renderDetailMarkdown bool
}
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",
RenderDescriptionMarkdown: e.renderDetailMarkdown,
})
return
}
httpapi.Write(r.Context(), rw, e.code, codersdk.Response{
Message: e.msg,
Detail: e.detail,
})
}
func (e httpError) Error() string {
if e.detail != "" {
return e.detail
}
return e.msg
}
func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.Cookie, database.APIKey, error) {
var (
ctx = r.Context()
user database.User
cookies []*http.Cookie
logger = api.Logger.Named(userAuthLoggerName)
)
var isConvertLoginType bool
err := api.Database.InTx(func(tx database.Store) error {
var (
link database.UserLink
err error
)
user = params.User
link = params.Link
// 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
isConvertLoginType = true
}
if user.ID == uuid.Nil && !params.AllowSignups {
signupsDisabledText := "Please contact your Coder administrator to request access."
if api.OIDCConfig != nil && api.OIDCConfig.SignupsDisabledText != "" {
signupsDisabledText = parameter.HTML(api.OIDCConfig.SignupsDisabledText)
}
return httpError{
code: http.StatusForbidden,
msg: "Signups are disabled",
detail: signupsDisabledText,
renderStaticPage: true,
renderDetailMarkdown: true,
}
}
if user.ID != uuid.Nil && user.LoginType != params.LoginType {
return wrongLoginTypeHTTPError(user.LoginType, params.LoginType)
}
// 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 {
// Until proper multi-org support, all users will be added to the default organization.
// The default organization should always be present.
//nolint:gocritic
defaultOrganization, err := tx.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
if err != nil {
return xerrors.Errorf("unable to fetch default organization: %w", err)
}
//nolint:gocritic
_, err = tx.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
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)
//nolint:gocritic
_, err := tx.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
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),
}
}
}
//nolint:gocritic
user, _, err = api.CreateUser(dbauthz.AsSystemRestricted(ctx), tx, CreateUserRequest{
CreateUserRequest: codersdk.CreateUserRequest{
Email: params.Email,
Username: params.Username,
OrganizationID: defaultOrganization.ID,
},
LoginType: params.LoginType,
})
if err != nil {
return xerrors.Errorf("create user: %w", err)
}
}
// 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,
UpdatedAt: dbtime.Now(),
})
if err != nil {
logger.Error(ctx, "unable to update user status to active", slog.Error(err))
return xerrors.Errorf("update user status: %w", err)
}
}
debugContext, err := json.Marshal(params.DebugContext)
if err != nil {
return xerrors.Errorf("marshal debug context: %w", err)
}
if link.UserID == uuid.Nil {
//nolint:gocritic // System needs to insert the user link (linked_id, oauth_token, oauth_expiry).
link, err = tx.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{
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,
DebugContext: debugContext,
})
if err != nil {
return xerrors.Errorf("insert user link: %w", err)
}
}
if link.UserID != uuid.Nil {
//nolint:gocritic // System needs to update the user link (linked_id, oauth_token, oauth_expiry).
link, err = tx.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
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,
DebugContext: debugContext,
})
if err != nil {
return xerrors.Errorf("update user link: %w", err)
}
}
// Ensure groups are correct.
// 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.
if params.UsingGroups {
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)
}
}
}
//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)
}
//nolint:gocritic
err = api.Options.SetUserGroups(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, map[uuid.UUID][]string{
defaultOrganization.ID: filtered,
}, params.CreateMissingGroups)
if err != nil {
return xerrors.Errorf("set user groups: %w", err)
}
}
// 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
err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered)
if err != nil {
return httpError{
code: http.StatusBadRequest,
msg: "Invalid roles through OIDC claims",
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),
)
}
}
needsUpdate := false
if user.AvatarURL != params.AvatarURL {
user.AvatarURL = params.AvatarURL
needsUpdate = true
}
// 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 {
user.Email = params.Email
needsUpdate = true
}
if needsUpdate {
// 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.
//nolint:gocritic
user, err = tx.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Username: user.Username,
UpdatedAt: dbtime.Now(),
AvatarURL: user.AvatarURL,
})
if err != nil {
return xerrors.Errorf("update user profile: %w", err)
}
}
return nil
}, nil)
if err != nil {
return nil, database.APIKey{}, xerrors.Errorf("in tx: %w", err)
}
var key database.APIKey
oldKey, _, ok := httpmw.APIKeyFromRequest(ctx, api.Database, nil, r)
if ok && oldKey != nil && isConvertLoginType {
// 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,
})
// This is intentional setting the key to the deleted old key,
// as the user needs to be forced to log back in.
key = *oldKey
} else {
//nolint:gocritic
cookie, newKey, err := api.createAPIKey(dbauthz.AsSystemRestricted(ctx), apikey.CreateParams{
UserID: user.ID,
LoginType: params.LoginType,
DefaultLifetime: api.DeploymentValues.Sessions.DefaultDuration.Value(),
RemoteAddr: r.RemoteAddr,
})
if err != nil {
return nil, database.APIKey{}, xerrors.Errorf("create API key: %w", err)
}
cookies = append(cookies, cookie)
key = *newKey
}
return cookies, key, nil
}
// 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
}
// 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)
}
if !user.Deleted {
return user, link, nil
}
// If the user was deleted, act as if no account link exists.
user = database.User{}
}
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
}
func isMergeStateString(state string) bool {
return strings.HasPrefix(state, mergeStateStringPrefix)
}
func clearOAuthConvertCookie() *http.Cookie {
return &http.Cookie{
Name: OAuthConvertCookieValue,
Path: "/",
MaxAge: -1,
}
}
func wrongLoginTypeHTTPError(user database.LoginType, params database.LoginType) httpError {
addedMsg := ""
if user == database.LoginTypePassword {
addedMsg = " You can convert your account to use this login type by visiting your account settings."
}
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),
}
}
// 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)
}