mirror of https://github.com/coder/coder.git
1868 lines
62 KiB
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)
|
|
}
|