mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: add sourcing secondary claims from access_token (#16517)
Niche edge case, assumes access_token is jwt. Some `access_token`s are JWT's with potential useful claims. These claims would be nearly equivalent to `user_info` claims. This is not apart of the oauth spec, so this feature should not be loudly advertised. If using this feature, alternate solutions are preferred.
This commit is contained in:
@ -46,6 +46,14 @@ import (
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
type MergedClaimsSource string
|
||||
|
||||
var (
|
||||
MergedClaimsSourceNone MergedClaimsSource = "none"
|
||||
MergedClaimsSourceUserInfo MergedClaimsSource = "user_info"
|
||||
MergedClaimsSourceAccessToken MergedClaimsSource = "access_token"
|
||||
)
|
||||
|
||||
const (
|
||||
userAuthLoggerName = "userauth"
|
||||
OAuthConvertCookieValue = "coder_oauth_convert_jwt"
|
||||
@ -1116,11 +1124,13 @@ type OIDCConfig struct {
|
||||
// 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
|
||||
// SecondaryClaims indicates where to source additional claim information from.
|
||||
// The standard is either 'MergedClaimsSourceNone' or 'MergedClaimsSourceUserInfo'.
|
||||
//
|
||||
// The OIDC compliant way is to use the userinfo endpoint. This option
|
||||
// is useful when the userinfo endpoint does not exist or causes undesirable
|
||||
// behavior.
|
||||
SecondaryClaims MergedClaimsSource
|
||||
// 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
|
||||
@ -1216,50 +1226,39 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
// 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(),
|
||||
})
|
||||
supplementaryClaims := make(map[string]interface{})
|
||||
switch api.OIDCConfig.SecondaryClaims {
|
||||
case MergedClaimsSourceUserInfo:
|
||||
supplementaryClaims, ok = api.userInfoClaims(ctx, rw, state, logger)
|
||||
if !ok {
|
||||
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")
|
||||
}
|
||||
|
||||
// The precedence ordering is userInfoClaims > idTokenClaims.
|
||||
// Note: Unsure why exactly this is the case. idTokenClaims feels more
|
||||
// important?
|
||||
mergedClaims = mergeClaims(idtokenClaims, supplementaryClaims)
|
||||
case MergedClaimsSourceAccessToken:
|
||||
supplementaryClaims, ok = api.accessTokenClaims(ctx, rw, state, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// idTokenClaims take priority over accessTokenClaims. The order should
|
||||
// not matter. It is just safer to assume idTokenClaims is the truth,
|
||||
// and accessTokenClaims are supplemental.
|
||||
mergedClaims = mergeClaims(supplementaryClaims, idtokenClaims)
|
||||
case MergedClaimsSourceNone:
|
||||
// noop, keep the userInfoClaims empty
|
||||
default:
|
||||
// This should never happen and is a developer error
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Invalid source for secondary user claims.",
|
||||
Detail: fmt.Sprintf("invalid source: %q", api.OIDCConfig.SecondaryClaims),
|
||||
})
|
||||
return // Invalid MergedClaimsSource
|
||||
}
|
||||
|
||||
usernameRaw, ok := mergedClaims[api.OIDCConfig.UsernameField]
|
||||
@ -1413,7 +1412,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
RoleSync: roleSync,
|
||||
UserClaims: database.UserLinkClaims{
|
||||
IDTokenClaims: idtokenClaims,
|
||||
UserInfoClaims: userInfoClaims,
|
||||
UserInfoClaims: supplementaryClaims,
|
||||
MergedClaims: mergedClaims,
|
||||
},
|
||||
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
|
||||
@ -1447,6 +1446,68 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (api *API) accessTokenClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (accessTokenClaims map[string]interface{}, ok bool) {
|
||||
// Assume the access token is a jwt, and signed by the provider.
|
||||
accessToken, err := api.OIDCConfig.Verifier.Verify(ctx, state.Token.AccessToken)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "oauth2: unable to verify access token as secondary claims source", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to verify access token.",
|
||||
Detail: fmt.Sprintf("sourcing secondary claims from access token: %s", err.Error()),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
rawClaims := make(map[string]any)
|
||||
err = accessToken.Claims(&rawClaims)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "oauth2: unable to unmarshal access token claims", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to unmarshal access token claims.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return rawClaims, true
|
||||
}
|
||||
|
||||
func (api *API) userInfoClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (userInfoClaims map[string]interface{}, ok bool) {
|
||||
userInfoClaims = make(map[string]interface{})
|
||||
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 nil, false
|
||||
}
|
||||
logger.Debug(ctx, "got oidc claims",
|
||||
slog.F("source", "userinfo"),
|
||||
slog.F("claim_fields", claimFields(userInfoClaims)),
|
||||
slog.F("blank", blankFields(userInfoClaims)),
|
||||
)
|
||||
} 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 nil, false
|
||||
} 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",
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
return userInfoClaims, true
|
||||
}
|
||||
|
||||
// claimFields returns the sorted list of fields in the claims map.
|
||||
func claimFields(claims map[string]interface{}) []string {
|
||||
fields := []string{}
|
||||
|
Reference in New Issue
Block a user