Skip to content

Commit 658825c

Browse files
authored
feat: add sourcing secondary claims from access_token (coder#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.
1 parent e005e4e commit 658825c

File tree

12 files changed

+281
-99
lines changed

12 files changed

+281
-99
lines changed

cli/server.go

+12-1
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,17 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
172172
groupAllowList[group] = true
173173
}
174174

175+
secondaryClaimsSrc := coderd.MergedClaimsSourceUserInfo
176+
if !vals.OIDC.IgnoreUserInfo && vals.OIDC.UserInfoFromAccessToken {
177+
return nil, xerrors.Errorf("to use 'oidc-access-token-claims', 'oidc-ignore-userinfo' must be set to 'false'")
178+
}
179+
if vals.OIDC.IgnoreUserInfo {
180+
secondaryClaimsSrc = coderd.MergedClaimsSourceNone
181+
}
182+
if vals.OIDC.UserInfoFromAccessToken {
183+
secondaryClaimsSrc = coderd.MergedClaimsSourceAccessToken
184+
}
185+
175186
return &coderd.OIDCConfig{
176187
OAuth2Config: useCfg,
177188
Provider: oidcProvider,
@@ -187,7 +198,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
187198
NameField: vals.OIDC.NameField.String(),
188199
EmailField: vals.OIDC.EmailField.String(),
189200
AuthURLParams: vals.OIDC.AuthURLParams.Value,
190-
IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(),
201+
SecondaryClaims: secondaryClaimsSrc,
191202
SignInText: vals.OIDC.SignInText.String(),
192203
SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(),
193204
IconURL: vals.OIDC.IconURL.String(),

cli/testdata/server-config.yaml.golden

+6
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,12 @@ oidc:
329329
# Ignore the userinfo endpoint and only use the ID token for user information.
330330
# (default: false, type: bool)
331331
ignoreUserInfo: false
332+
# Source supplemental user claims from the 'access_token'. This assumes the token
333+
# is a jwt signed by the same issuer as the id_token. Using this requires setting
334+
# 'oidc-ignore-userinfo' to true. This setting is not compliant with the OIDC
335+
# specification and is not recommended. Use at your own risk.
336+
# (default: false, type: bool)
337+
accessTokenClaims: false
332338
# This field must be set if using the organization sync feature. Set to the claim
333339
# to be used for organizations.
334340
# (default: <unset>, type: string)

coderd/apidoc/docs.go

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderdtest/oidctest/idp.go

+24-7
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ type FakeIDP struct {
105105
// "Authorized Redirect URLs". This can be used to emulate that.
106106
hookValidRedirectURL func(redirectURL string) error
107107
hookUserInfo func(email string) (jwt.MapClaims, error)
108+
hookAccessTokenJWT func(email string, exp time.Time) jwt.MapClaims
108109
// defaultIDClaims is if a new client connects and we didn't preset
109110
// some claims.
110111
defaultIDClaims jwt.MapClaims
@@ -154,6 +155,12 @@ func WithMiddlewares(mws ...func(http.Handler) http.Handler) func(*FakeIDP) {
154155
}
155156
}
156157

158+
func WithAccessTokenJWTHook(hook func(email string, exp time.Time) jwt.MapClaims) func(*FakeIDP) {
159+
return func(f *FakeIDP) {
160+
f.hookAccessTokenJWT = hook
161+
}
162+
}
163+
157164
func WithHookWellKnown(hook func(r *http.Request, j *ProviderJSON) error) func(*FakeIDP) {
158165
return func(f *FakeIDP) {
159166
f.hookWellKnown = hook
@@ -316,8 +323,7 @@ const (
316323
func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP {
317324
t.Helper()
318325

319-
block, _ := pem.Decode([]byte(testRSAPrivateKey))
320-
pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
326+
pkey, err := FakeIDPKey()
321327
require.NoError(t, err)
322328

323329
idp := &FakeIDP{
@@ -676,8 +682,13 @@ func (f *FakeIDP) newCode(state string) string {
676682

677683
// newToken enforces the access token exchanged is actually a valid access token
678684
// created by the IDP.
679-
func (f *FakeIDP) newToken(email string, expires time.Time) string {
685+
func (f *FakeIDP) newToken(t testing.TB, email string, expires time.Time) string {
680686
accessToken := uuid.NewString()
687+
if f.hookAccessTokenJWT != nil {
688+
claims := f.hookAccessTokenJWT(email, expires)
689+
accessToken = f.encodeClaims(t, claims)
690+
}
691+
681692
f.accessTokens.Store(accessToken, token{
682693
issued: time.Now(),
683694
email: email,
@@ -963,7 +974,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
963974
email := getEmail(claims)
964975
refreshToken := f.newRefreshTokens(email)
965976
token := map[string]interface{}{
966-
"access_token": f.newToken(email, exp),
977+
"access_token": f.newToken(t, email, exp),
967978
"refresh_token": refreshToken,
968979
"token_type": "Bearer",
969980
"expires_in": int64((f.defaultExpire).Seconds()),
@@ -1465,9 +1476,10 @@ func (f *FakeIDP) internalOIDCConfig(ctx context.Context, t testing.TB, scopes [
14651476
Verifier: oidc.NewVerifier(f.provider.Issuer, &oidc.StaticKeySet{
14661477
PublicKeys: []crypto.PublicKey{f.key.Public()},
14671478
}, verifierConfig),
1468-
UsernameField: "preferred_username",
1469-
EmailField: "email",
1470-
AuthURLParams: map[string]string{"access_type": "offline"},
1479+
UsernameField: "preferred_username",
1480+
EmailField: "email",
1481+
AuthURLParams: map[string]string{"access_type": "offline"},
1482+
SecondaryClaims: coderd.MergedClaimsSourceUserInfo,
14711483
}
14721484

14731485
for _, opt := range opts {
@@ -1552,3 +1564,8 @@ d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf
15521564
sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u
15531565
QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8
15541566
-----END RSA PRIVATE KEY-----`
1567+
1568+
func FakeIDPKey() (*rsa.PrivateKey, error) {
1569+
block, _ := pem.Decode([]byte(testRSAPrivateKey))
1570+
return x509.ParsePKCS1PrivateKey(block.Bytes)
1571+
}

coderd/userauth.go

+106-45
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ import (
4646
"github.com/coder/coder/v2/cryptorand"
4747
)
4848

49+
type MergedClaimsSource string
50+
51+
var (
52+
MergedClaimsSourceNone MergedClaimsSource = "none"
53+
MergedClaimsSourceUserInfo MergedClaimsSource = "user_info"
54+
MergedClaimsSourceAccessToken MergedClaimsSource = "access_token"
55+
)
56+
4957
const (
5058
userAuthLoggerName = "userauth"
5159
OAuthConvertCookieValue = "coder_oauth_convert_jwt"
@@ -1116,11 +1124,13 @@ type OIDCConfig struct {
11161124
// AuthURLParams are additional parameters to be passed to the OIDC provider
11171125
// when requesting an access token.
11181126
AuthURLParams map[string]string
1119-
// IgnoreUserInfo causes Coder to only use claims from the ID token to
1120-
// process OIDC logins. This is useful if the OIDC provider does not
1121-
// support the userinfo endpoint, or if the userinfo endpoint causes
1122-
// undesirable behavior.
1123-
IgnoreUserInfo bool
1127+
// SecondaryClaims indicates where to source additional claim information from.
1128+
// The standard is either 'MergedClaimsSourceNone' or 'MergedClaimsSourceUserInfo'.
1129+
//
1130+
// The OIDC compliant way is to use the userinfo endpoint. This option
1131+
// is useful when the userinfo endpoint does not exist or causes undesirable
1132+
// behavior.
1133+
SecondaryClaims MergedClaimsSource
11241134
// SignInText is the text to display on the OIDC login button
11251135
SignInText string
11261136
// 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) {
12161226
// Some providers (e.g. ADFS) do not support custom OIDC claims in the
12171227
// UserInfo endpoint, so we allow users to disable it and only rely on the
12181228
// ID token.
1219-
userInfoClaims := make(map[string]interface{})
1229+
//
12201230
// If user info is skipped, the idtokenClaims are the claims.
12211231
mergedClaims := idtokenClaims
1222-
if !api.OIDCConfig.IgnoreUserInfo {
1223-
userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token))
1224-
if err == nil {
1225-
err = userInfo.Claims(&userInfoClaims)
1226-
if err != nil {
1227-
logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err))
1228-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1229-
Message: "Failed to unmarshal user info claims.",
1230-
Detail: err.Error(),
1231-
})
1232-
return
1233-
}
1234-
logger.Debug(ctx, "got oidc claims",
1235-
slog.F("source", "userinfo"),
1236-
slog.F("claim_fields", claimFields(userInfoClaims)),
1237-
slog.F("blank", blankFields(userInfoClaims)),
1238-
)
1239-
1240-
// Merge the claims from the ID token and the UserInfo endpoint.
1241-
// Information from UserInfo takes precedence.
1242-
mergedClaims = mergeClaims(idtokenClaims, userInfoClaims)
1232+
supplementaryClaims := make(map[string]interface{})
1233+
switch api.OIDCConfig.SecondaryClaims {
1234+
case MergedClaimsSourceUserInfo:
1235+
supplementaryClaims, ok = api.userInfoClaims(ctx, rw, state, logger)
1236+
if !ok {
1237+
return
1238+
}
12431239

1244-
// Log all of the field names after merging.
1245-
logger.Debug(ctx, "got oidc claims",
1246-
slog.F("source", "merged"),
1247-
slog.F("claim_fields", claimFields(mergedClaims)),
1248-
slog.F("blank", blankFields(mergedClaims)),
1249-
)
1250-
} else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") {
1251-
logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err))
1252-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1253-
Message: "Failed to obtain user information claims.",
1254-
Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(),
1255-
})
1240+
// The precedence ordering is userInfoClaims > idTokenClaims.
1241+
// Note: Unsure why exactly this is the case. idTokenClaims feels more
1242+
// important?
1243+
mergedClaims = mergeClaims(idtokenClaims, supplementaryClaims)
1244+
case MergedClaimsSourceAccessToken:
1245+
supplementaryClaims, ok = api.accessTokenClaims(ctx, rw, state, logger)
1246+
if !ok {
12561247
return
1257-
} else {
1258-
// The OIDC provider does not support the UserInfo endpoint.
1259-
// This is not an error, but we should log it as it may mean
1260-
// that some claims are missing.
1261-
logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token")
12621248
}
1249+
// idTokenClaims take priority over accessTokenClaims. The order should
1250+
// not matter. It is just safer to assume idTokenClaims is the truth,
1251+
// and accessTokenClaims are supplemental.
1252+
mergedClaims = mergeClaims(supplementaryClaims, idtokenClaims)
1253+
case MergedClaimsSourceNone:
1254+
// noop, keep the userInfoClaims empty
1255+
default:
1256+
// This should never happen and is a developer error
1257+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1258+
Message: "Invalid source for secondary user claims.",
1259+
Detail: fmt.Sprintf("invalid source: %q", api.OIDCConfig.SecondaryClaims),
1260+
})
1261+
return // Invalid MergedClaimsSource
12631262
}
12641263

12651264
usernameRaw, ok := mergedClaims[api.OIDCConfig.UsernameField]
@@ -1413,7 +1412,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
14131412
RoleSync: roleSync,
14141413
UserClaims: database.UserLinkClaims{
14151414
IDTokenClaims: idtokenClaims,
1416-
UserInfoClaims: userInfoClaims,
1415+
UserInfoClaims: supplementaryClaims,
14171416
MergedClaims: mergedClaims,
14181417
},
14191418
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
@@ -1447,6 +1446,68 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
14471446
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
14481447
}
14491448

1449+
func (api *API) accessTokenClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (accessTokenClaims map[string]interface{}, ok bool) {
1450+
// Assume the access token is a jwt, and signed by the provider.
1451+
accessToken, err := api.OIDCConfig.Verifier.Verify(ctx, state.Token.AccessToken)
1452+
if err != nil {
1453+
logger.Error(ctx, "oauth2: unable to verify access token as secondary claims source", slog.Error(err))
1454+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
1455+
Message: "Failed to verify access token.",
1456+
Detail: fmt.Sprintf("sourcing secondary claims from access token: %s", err.Error()),
1457+
})
1458+
return nil, false
1459+
}
1460+
1461+
rawClaims := make(map[string]any)
1462+
err = accessToken.Claims(&rawClaims)
1463+
if err != nil {
1464+
logger.Error(ctx, "oauth2: unable to unmarshal access token claims", slog.Error(err))
1465+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1466+
Message: "Failed to unmarshal access token claims.",
1467+
Detail: err.Error(),
1468+
})
1469+
return nil, false
1470+
}
1471+
1472+
return rawClaims, true
1473+
}
1474+
1475+
func (api *API) userInfoClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (userInfoClaims map[string]interface{}, ok bool) {
1476+
userInfoClaims = make(map[string]interface{})
1477+
userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token))
1478+
if err == nil {
1479+
err = userInfo.Claims(&userInfoClaims)
1480+
if err != nil {
1481+
logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err))
1482+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1483+
Message: "Failed to unmarshal user info claims.",
1484+
Detail: err.Error(),
1485+
})
1486+
return nil, false
1487+
}
1488+
logger.Debug(ctx, "got oidc claims",
1489+
slog.F("source", "userinfo"),
1490+
slog.F("claim_fields", claimFields(userInfoClaims)),
1491+
slog.F("blank", blankFields(userInfoClaims)),
1492+
)
1493+
} else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") {
1494+
logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err))
1495+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1496+
Message: "Failed to obtain user information claims.",
1497+
Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(),
1498+
})
1499+
return nil, false
1500+
} else {
1501+
// The OIDC provider does not support the UserInfo endpoint.
1502+
// This is not an error, but we should log it as it may mean
1503+
// that some claims are missing.
1504+
logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token",
1505+
slog.Error(err),
1506+
)
1507+
}
1508+
return userInfoClaims, true
1509+
}
1510+
14501511
// claimFields returns the sorted list of fields in the claims map.
14511512
func claimFields(claims map[string]interface{}) []string {
14521513
fields := []string{}

0 commit comments

Comments
 (0)