Skip to content

Commit 17683bf

Browse files
committed
feat(coderd): add OIDC ID token support with CODER_WORKSPACE_OWNER_OIDC_ID_TOKEN env var
- Add oauth_id_token column to user_links table (migration 402) - Capture and store ID token during OIDC authentication - Implement token refresh with ID token preservation - Add obtainOIDCIdToken() function for token retrieval - Pass ID token to provisioner via proto metadata - Expose as CODER_WORKSPACE_OWNER_OIDC_ID_TOKEN environment variable - Fix OAuthIdToken -> OAuthIDToken field naming (Go conventions) - Add OAuthIDToken to all UpdateUserLinkParams/InsertUserLinkParams structs - Update TypeScript and Go proto bindings - Regenerate database queries with correct column ordering This enables Azure OIDC authentication which requires the ID token for subsequent API calls.
1 parent 782a105 commit 17683bf

File tree

17 files changed

+127
-12
lines changed

17 files changed

+127
-12
lines changed

coderd/coderdtest/oidctest/helper.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func (*LoginHelper) ExpireOauthToken(t *testing.T, db database.Store, user *code
8888
OAuthExpiry: time.Now().Add(time.Hour * -1),
8989
UserID: link.UserID,
9090
LoginType: link.LoginType,
91+
OAuthIDToken: link.OAuthIDToken,
9192
Claims: database.UserLinkClaims{},
9293
})
9394
require.NoError(t, err, "expire user link")

coderd/database/dbgen/dbgen.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,7 @@ func UserLink(t testing.TB, db database.Store, orig database.UserLink) database.
10371037
OAuthAccessTokenKeyID: takeFirst(orig.OAuthAccessTokenKeyID, sql.NullString{}),
10381038
OAuthRefreshToken: takeFirst(orig.OAuthRefreshToken, uuid.NewString()),
10391039
OAuthRefreshTokenKeyID: takeFirst(orig.OAuthRefreshTokenKeyID, sql.NullString{}),
1040+
OAuthIDToken: takeFirst(orig.OAuthIDToken),
10401041
OAuthExpiry: takeFirst(orig.OAuthExpiry, dbtime.Now().Add(time.Hour*24)),
10411042
Claims: orig.Claims,
10421043
})

coderd/database/dump.sql

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Remove oauth_id_token column from user_links table
2+
ALTER TABLE user_links DROP COLUMN oauth_id_token;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Add oauth_id_token column to user_links table to support ID token storage for OIDC providers like Azure
2+
ALTER TABLE user_links ADD COLUMN oauth_id_token text DEFAULT ''::text NOT NULL;

coderd/database/models.go

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 19 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/user_links.sql

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ INSERT INTO
3232
oauth_refresh_token,
3333
oauth_refresh_token_key_id,
3434
oauth_expiry,
35+
oauth_id_token,
3536
claims
3637
)
3738
VALUES
38-
( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING *;
39+
( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) RETURNING *;
3940

4041
-- name: UpdateUserLinkedID :one
4142
UPDATE
@@ -54,9 +55,10 @@ SET
5455
oauth_refresh_token = $3,
5556
oauth_refresh_token_key_id = $4,
5657
oauth_expiry = $5,
57-
claims = $6
58+
oauth_id_token = $6,
59+
claims = $7
5860
WHERE
59-
user_id = $7 AND login_type = $8 RETURNING *;
61+
user_id = $8 AND login_type = $9 RETURNING *;
6062

6163
-- name: OIDCClaimFields :many
6264
-- OIDCClaimFields returns a list of distinct keys in the the merged_claims fields.

coderd/httpmw/apikey.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
354354
OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required
355355
OAuthRefreshToken: link.OAuthRefreshToken,
356356
OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
357+
OAuthIDToken: link.OAuthIDToken,
357358
OAuthExpiry: link.OAuthExpiry,
358359
// Refresh should keep the same debug context because we use
359360
// the original claims for the group/role sync.

coderd/provisionerdserver/provisionerdserver.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,13 +544,18 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
544544
}
545545

546546
var workspaceOwnerOIDCAccessToken string
547+
var workspaceOwnerOIDCIdToken string
547548
// The check `s.OIDCConfig != nil` is not as strict, since it can be an interface
548549
// pointing to a typed nil.
549550
if !reflect.ValueOf(s.OIDCConfig).IsNil() {
550551
workspaceOwnerOIDCAccessToken, err = obtainOIDCAccessToken(ctx, s.Database, s.OIDCConfig, owner.ID)
551552
if err != nil {
552553
return nil, failJob(fmt.Sprintf("obtain OIDC access token: %s", err))
553554
}
555+
workspaceOwnerOIDCIdToken, err = obtainOIDCIdToken(ctx, s.Database, s.OIDCConfig, owner.ID)
556+
if err != nil {
557+
return nil, failJob(fmt.Sprintf("obtain OIDC ID token: %s", err))
558+
}
554559
}
555560

556561
var sessionToken string
@@ -724,6 +729,7 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
724729
WorkspaceOwnerName: owner.Name,
725730
WorkspaceOwnerGroups: ownerGroupNames,
726731
WorkspaceOwnerOidcAccessToken: workspaceOwnerOIDCAccessToken,
732+
WorkspaceOwnerOidcIdToken: workspaceOwnerOIDCIdToken,
727733
WorkspaceId: workspace.ID.String(),
728734
WorkspaceOwnerId: owner.ID.String(),
729735
TemplateId: template.ID.String(),
@@ -3145,6 +3151,9 @@ func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig pr
31453151
link.OAuthRefreshToken = token.RefreshToken
31463152
link.OAuthExpiry = token.Expiry
31473153

3154+
// Extract the ID token from the refreshed token if available
3155+
idToken, _ := token.Extra("id_token").(string)
3156+
31483157
link, err = db.UpdateUserLink(ctx, database.UpdateUserLinkParams{
31493158
UserID: userID,
31503159
LoginType: database.LoginTypeOIDC,
@@ -3153,6 +3162,7 @@ func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig pr
31533162
OAuthRefreshToken: link.OAuthRefreshToken,
31543163
OAuthRefreshTokenKeyID: sql.NullString{}, // set by dbcrypt if required
31553164
OAuthExpiry: link.OAuthExpiry,
3165+
OAuthIDToken: idToken,
31563166
Claims: link.Claims,
31573167
})
31583168
if err != nil {
@@ -3163,6 +3173,61 @@ func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig pr
31633173
return link.OAuthAccessToken, nil
31643174
}
31653175

3176+
// obtainOIDCIdToken returns a valid OpenID Connect ID token
3177+
// for the user if it's able to obtain one, otherwise it returns an empty string.
3178+
// The ID token is used by some providers like Azure for authentication.
3179+
func obtainOIDCIdToken(ctx context.Context, db database.Store, oidcConfig promoauth.OAuth2Config, userID uuid.UUID) (string, error) {
3180+
link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
3181+
UserID: userID,
3182+
LoginType: database.LoginTypeOIDC,
3183+
})
3184+
if errors.Is(err, sql.ErrNoRows) {
3185+
return "", nil
3186+
}
3187+
if err != nil {
3188+
return "", xerrors.Errorf("get owner oidc link: %w", err)
3189+
}
3190+
3191+
// If the token is expired and we have a refresh token, refresh it
3192+
if link.OAuthExpiry.Before(dbtime.Now()) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
3193+
token, err := oidcConfig.TokenSource(ctx, &oauth2.Token{
3194+
AccessToken: link.OAuthAccessToken,
3195+
RefreshToken: link.OAuthRefreshToken,
3196+
Expiry: link.OAuthExpiry,
3197+
}).Token()
3198+
if err != nil {
3199+
// If OIDC fails to refresh, we return an empty string and don't fail.
3200+
// There isn't a way to hard-opt in to OIDC from a template, so we don't
3201+
// want to fail builds if users haven't authenticated for a while or something.
3202+
return "", nil
3203+
}
3204+
3205+
// Extract the ID token from the refreshed token
3206+
idToken, _ := token.Extra("id_token").(string)
3207+
link.OAuthAccessToken = token.AccessToken
3208+
link.OAuthRefreshToken = token.RefreshToken
3209+
link.OAuthExpiry = token.Expiry
3210+
link.OAuthIDToken = idToken
3211+
3212+
link, err = db.UpdateUserLink(ctx, database.UpdateUserLinkParams{
3213+
UserID: userID,
3214+
LoginType: database.LoginTypeOIDC,
3215+
OAuthAccessToken: link.OAuthAccessToken,
3216+
OAuthAccessTokenKeyID: sql.NullString{}, // set by dbcrypt if required
3217+
OAuthRefreshToken: link.OAuthRefreshToken,
3218+
OAuthRefreshTokenKeyID: sql.NullString{}, // set by dbcrypt if required
3219+
OAuthExpiry: link.OAuthExpiry,
3220+
OAuthIDToken: link.OAuthIDToken,
3221+
Claims: link.Claims,
3222+
})
3223+
if err != nil {
3224+
return "", xerrors.Errorf("update user link: %w", err)
3225+
}
3226+
}
3227+
3228+
return link.OAuthIDToken, nil
3229+
}
3230+
31663231
func convertLogLevel(logLevel sdkproto.LogLevel) (database.LogLevel, error) {
31673232
switch logLevel {
31683233
case sdkproto.LogLevel_TRACE:

0 commit comments

Comments
 (0)