fix(coderd)!: restrict OIDC email fallback to first-time account linking (#25712)

## Problem

`findLinkedUser` in `coderd/userauth.go` falls back to email-based user
lookup when no `linked_id` match is found. This fallback was used for
**all logins**, not just first-time linking. An attacker who registers
the victim's email at the IdP (with a different OIDC subject) bypasses
the `linked_id` check and gets matched to the victim's Coder account.

Combined with the `email_verified` type assertion bypass (PLAT-228),
this creates a chained account-takeover vector.

## Fix

Restrict the email fallback in `findLinkedUser` so that when a user
found by email already has a `user_link` with a non-empty `linked_id`
that **differs** from the current login's `linked_id`, the function
returns no user. This blocks account takeover while preserving:

- **First-time linking**: No existing `user_link` exists, email fallback
works as before.
- **Legacy links**: Empty `linked_id` (pre-migration), email fallback
still works.
- **Normal logins**: Matching `linked_id` resolves via the primary path,
no fallback needed.

Also adds a `UpdateUserLinkedID` query to backfill `linked_id` on legacy
links (only when currently empty) during login, gradually migrating them
to the secure path.

The `findLinkedUser` signature now accepts `loginType` explicitly
instead of relying on `user.LoginType`, ensuring the correct link is
checked in the legacy lookup.

## Breaking change

Marked `release/breaking`. An account whose `user_link` already has a
populated `linked_id` that does not match the subject the IdP presents
will now be denied login (403) instead of silently resolving via the
email fallback. The most likely trigger is changing
`CODER_OIDC_ISSUER_URL` (the `linked_id` is `issuer||subject`), or two
identities sharing one email. Accounts with an empty (legacy)
`linked_id` are unaffected and are backfilled on their next login.

Fixes: https://linear.app/codercom/issue/PLAT-229

<details><summary>Implementation details</summary>

### Files changed

- `coderd/userauth.go`: Core fix in `findLinkedUser` + backfill logic in
`oauthLogin`
- `coderd/database/queries/user_links.sql`: New `UpdateUserLinkedID`
query
- `coderd/database/dbauthz/dbauthz.go`: Authorization for new query
(`ActionUpdate` on the user object, matching `InsertUserLink`)
- `coderd/userauth_test.go`: New OIDC and GitHub tests
- Generated files: `queries.sql.go`, `querier.go`, `dbmock.go`,
`querymetrics.go`

### New tests

- `TestUserOIDC/OIDCEmailFallbackBlockedByExistingLink`: Attacker with a
different `sub` but the same email is rejected (403) when the victim has
an existing link (covers signups enabled and disabled).
- `TestUserOIDC/OIDCFirstTimeLinkByEmailAllowed`: User created via
SCIM/API (no `user_link`) can still link via email on first OIDC login,
and the `linked_id` is populated.
- `TestUserOIDC/OIDCLegacyLinkBackfill`: User with empty `linked_id` can
login and their `linked_id` is backfilled with the correct value.
- `TestUserOIDC/OIDCEmailFallbackBlockedByIssuerChange`: Existing link
recorded under a previous issuer is rejected (403) after the issuer
changes (documents the breaking behavior).
- `TestUserOAuth2Github/EmailFallbackBlockedByExistingLink`: GitHub
attacker with a different user ID but the victim's email is rejected
(403).

</details>

> [!NOTE]
> This PR was authored by Coder Agents on behalf of @f0ssel.

---------

Co-authored-by: Coder Agents <agents@coder.com>
This commit is contained in:
Garrett Delfosse
2026-06-04 14:36:56 -04:00
committed by GitHub
co-authored by Coder Agents
parent 76bf462bbf
commit 53d287a139
9 changed files with 454 additions and 5 deletions
+7
View File
@@ -7573,6 +7573,13 @@ func (q *querier) UpdateUserLink(ctx context.Context, arg database.UpdateUserLin
return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpdateUserLink)(ctx, arg)
}
func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUserObject(arg.UserID)); err != nil {
return database.UserLink{}, err
}
return q.db.UpdateUserLinkedID(ctx, arg)
}
func (q *querier) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return database.User{}, err
+6
View File
@@ -3170,6 +3170,12 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().UpdateUserLink(gomock.Any(), arg).Return(link, nil).AnyTimes()
check.Args(arg).Asserts(link, policy.ActionUpdatePersonal).Returns(link)
}))
s.Run("UpdateUserLinkedID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
link := testutil.Fake(s.T(), faker, database.UserLink{})
arg := database.UpdateUserLinkedIDParams{LinkedID: link.LinkedID, UserID: link.UserID, LoginType: link.LoginType}
dbm.EXPECT().UpdateUserLinkedID(gomock.Any(), arg).Return(link, nil).AnyTimes()
check.Args(arg).Asserts(link, policy.ActionUpdate).Returns(link)
}))
s.Run("UpdateUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}})
o := u
+8
View File
@@ -5393,6 +5393,14 @@ func (m queryMetricsStore) UpdateUserLink(ctx context.Context, arg database.Upda
return r0, r1
}
func (m queryMetricsStore) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserLinkedID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserLinkedID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserLinkedID").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserLoginType(ctx, arg)
+15
View File
@@ -10167,6 +10167,21 @@ func (mr *MockStoreMockRecorder) UpdateUserLink(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLink", reflect.TypeOf((*MockStore)(nil).UpdateUserLink), ctx, arg)
}
// UpdateUserLinkedID mocks base method.
func (m *MockStore) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserLinkedID", ctx, arg)
ret0, _ := ret[0].(database.UserLink)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserLinkedID indicates an expected call of UpdateUserLinkedID.
func (mr *MockStoreMockRecorder) UpdateUserLinkedID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLinkedID", reflect.TypeOf((*MockStore)(nil).UpdateUserLinkedID), ctx, arg)
}
// UpdateUserLoginType mocks base method.
func (m *MockStore) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
m.ctrl.T.Helper()
+4
View File
@@ -1301,6 +1301,10 @@ type sqlcQuerier interface {
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error
UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error)
UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error)
// Backfills linked_id for legacy user_links that were created before
// linked_id tracking was added. Only updates when linked_id is empty
// to avoid overwriting a valid binding.
UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error)
UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error)
UpdateUserNotificationPreferences(ctx context.Context, arg UpdateUserNotificationPreferencesParams) (int64, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error)
+35
View File
@@ -27224,6 +27224,41 @@ func (q *sqlQuerier) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParam
return i, err
}
const updateUserLinkedID = `-- name: UpdateUserLinkedID :one
UPDATE
user_links
SET
linked_id = $1
WHERE
user_id = $2 AND login_type = $3 AND linked_id = '' RETURNING user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, claims
`
type UpdateUserLinkedIDParams struct {
LinkedID string `db:"linked_id" json:"linked_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
LoginType LoginType `db:"login_type" json:"login_type"`
}
// Backfills linked_id for legacy user_links that were created before
// linked_id tracking was added. Only updates when linked_id is empty
// to avoid overwriting a valid binding.
func (q *sqlQuerier) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) {
row := q.db.QueryRowContext(ctx, updateUserLinkedID, arg.LinkedID, arg.UserID, arg.LoginType)
var i UserLink
err := row.Scan(
&i.UserID,
&i.LoginType,
&i.LinkedID,
&i.OAuthAccessToken,
&i.OAuthRefreshToken,
&i.OAuthExpiry,
&i.OAuthAccessTokenKeyID,
&i.OAuthRefreshTokenKeyID,
&i.Claims,
)
return i, err
}
const createUserSecret = `-- name: CreateUserSecret :one
INSERT INTO user_secrets (
id,
+11
View File
@@ -50,6 +50,17 @@ SET
WHERE
user_id = $7 AND login_type = $8 RETURNING *;
-- name: UpdateUserLinkedID :one
-- Backfills linked_id for legacy user_links that were created before
-- linked_id tracking was added. Only updates when linked_id is empty
-- to avoid overwriting a valid binding.
UPDATE
user_links
SET
linked_id = @linked_id
WHERE
user_id = @user_id AND login_type = @login_type AND linked_id = '' RETURNING *;
-- name: OIDCClaimFields :many
-- OIDCClaimFields returns a list of distinct keys in the the merged_claims fields.
-- This query is used to generate the list of available sync fields for idp sync settings.
+69 -5
View File
@@ -1036,7 +1036,16 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
})
return
}
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), verifiedEmail.GetEmail())
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, verifiedEmail.GetEmail())
if errors.Is(err, errLinkedIDAlreadyBound) {
logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity",
slog.F("email", verifiedEmail.GetEmail()),
)
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "This account is already linked to a different identity provider subject.",
})
return
}
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{
@@ -1436,7 +1445,22 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
}
ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name))
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), email)
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, email)
if errors.Is(err, errLinkedIDAlreadyBound) {
logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity",
slog.F("email", email),
)
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusForbidden,
HideStatus: true,
Title: "Account already linked",
Description: "This account is already linked to a different identity provider subject. Contact your administrator.",
Actions: []site.Action{
{URL: "/login", Text: "Back to login"},
},
})
return
}
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{
@@ -1870,6 +1894,31 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
if err != nil {
return xerrors.Errorf("update user link: %w", err)
}
// Defense-in-depth: if a concurrent transaction backfilled
// linked_id between findLinkedUser and this point, reject the
// login with a 403 instead of letting it bubble up as a 500.
if link.LinkedID != "" && link.LinkedID != params.LinkedID {
return &idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "Account already linked",
Detail: "This account is already linked to a different identity provider subject. Contact your administrator.",
RenderStaticPage: true,
}
}
// Backfill linked_id for legacy links.
if link.LinkedID == "" && params.LinkedID != "" {
//nolint:gocritic // System needs to update the user link.
link, err = tx.UpdateUserLinkedID(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkedIDParams{
LinkedID: params.LinkedID,
UserID: user.ID,
LoginType: params.LoginType,
})
if err != nil {
return xerrors.Errorf("backfill user linked id: %w", err)
}
}
}
err = api.IDPSync.SyncOrganizations(ctx, tx, user, params.OrganizationSync)
@@ -2090,9 +2139,17 @@ func oidcLinkedID(tok *oidc.IDToken) string {
return strings.Join([]string{tok.Issuer, tok.Subject}, "||")
}
// errLinkedIDAlreadyBound is returned by findLinkedUser when the user
// found by email already has a user_link with a different linked_id.
var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a different identity provider 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) {
// If it does not find a match, it falls back to email-based lookup.
// The email fallback is restricted to first-time account linking and
// legacy links (empty linked_id) only. If the user found by email
// already has a link with a different linked_id, errLinkedIDAlreadyBound
// is returned to prevent account takeover via IdP email reuse.
func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, emails ...string) (database.User, database.UserLink, error) {
var (
user database.User
link database.UserLink
@@ -2137,12 +2194,19 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, ema
// possible that a user_link exists without a populated 'linked_id'.
link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: user.LoginType,
LoginType: 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)
}
// Block email fallback when an existing link has a different linked_id.
// Prevents account takeover via IdP email reuse; first-time and legacy
// (empty linked_id) links pass through.
if err == nil && link.LinkedID != "" && link.LinkedID != linkedID {
return database.User{}, database.UserLink{}, errLinkedIDAlreadyBound
}
return user, link, nil
}
+299
View File
@@ -386,6 +386,67 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("EmailFallbackBlockedByExistingLink", func(t *testing.T) {
t.Parallel()
// A victim already has a GitHub link bound to a specific GitHub user
// ID. An attacker authenticates with a different GitHub user ID but
// the victim's verified email. The email fallback must not hand the
// attacker the victim's account, even with signups enabled.
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowSignups: true,
AllowEveryone: true,
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
return []*github.Membership{}, nil
},
TeamMembership: func(_ context.Context, _ *http.Client, _, _, _ string) (*github.Membership, error) {
return nil, xerrors.New("no teams")
},
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
// Attacker's GitHub ID differs from the victim's link.
return &github.User{
ID: github.Int64(200),
Login: github.String("attacker"),
Name: github.String("Attacker"),
}, nil
},
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("victim@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
// Seed the victim with an existing GitHub link (a different linked_id).
victim := dbgen.User(t, db, database.User{
Email: "victim@coder.com",
LoginType: database.LoginTypeGithub,
})
const victimLinkedID = "100"
dbgen.UserLink(t, db, database.UserLink{
UserID: victim.ID,
LoginType: database.LoginTypeGithub,
LinkedID: victimLinkedID,
})
resp := oauth2Callback(t, owner)
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"attacker with a different GitHub ID must not authenticate as the victim")
// The victim's link must be untouched.
victimLink, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{
UserID: victim.ID,
LoginType: database.LoginTypeGithub,
})
require.NoError(t, err)
require.Equal(t, victimLinkedID, victimLink.LinkedID,
"victim's linked_id must remain unchanged")
})
t.Run("Signup", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
@@ -1624,6 +1685,244 @@ func TestUserOIDC(t *testing.T) {
require.Equal(t, codersdk.UserStatusActive, me.Status)
})
// Tests that an attacker with a different OIDC subject but the same
// email cannot hijack an existing linked account. The email fallback
// must be restricted to first-time linking only.
t.Run("OIDCEmailFallbackBlockedByExistingLink", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
for _, tc := range []struct {
name string
allowSignups bool
}{
{"SignupsDisabled", false},
{"SignupsEnabled", true},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = tc.allowSignups
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Create a victim user with an existing OIDC link.
// Use the fake IDP's issuer so the linked_id format is
// realistic (same issuer, different subject).
victim := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
victimLinkedID := fake.IssuerURL().String() + "||" + "victim-subject"
dbgen.UserLink(t, db, database.UserLink{
UserID: victim.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: victimLinkedID,
})
// Attacker tries to login with a different subject but the
// same email. The email fallback is blocked because the victim
// already has a user_link with a different linked_id.
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": victim.Email,
"sub": "attacker-subject",
})
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"attacker must not authenticate as the victim")
// Verify the victim's link is unchanged.
victimLink, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{
UserID: victim.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
require.Equal(t, victimLinkedID, victimLink.LinkedID,
"victim's linked_id must remain unchanged")
})
}
})
// Tests that a first-time OIDC user can still link via email when no
// user_link exists (e.g. a dormant OIDC user created via SCIM or API).
t.Run("OIDCFirstTimeLinkByEmailAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Create a user with OIDC login type but NO user_link.
// This simulates a user created via SCIM or the API.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
// Login with a new OIDC subject and matching email.
// This should succeed because no user_link exists.
sub := uuid.NewString()
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, me.ID,
"should authenticate as the existing user")
// Verify the created link has a populated linked_id.
link, err := db.GetUserLinkByUserIDLoginType(
dbauthz.AsSystemRestricted(context.Background()),
database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
expectedLinkedID := fake.IssuerURL().String() + "||" + sub
require.Equal(t, expectedLinkedID, link.LinkedID,
"link should have the correct linked_id after first-time linking")
})
// Tests that a legacy user with an empty linked_id can still login
// and that their linked_id is backfilled with the correct value.
t.Run("OIDCLegacyLinkBackfill", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Create a legacy user with an empty linked_id.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: "", // Legacy: empty linked_id
})
sub := uuid.NewString()
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, me.ID,
"legacy user should still be able to login via email fallback")
// Verify the linked_id was backfilled with the correct value.
link, err := db.GetUserLinkByUserIDLoginType(
dbauthz.AsSystemRestricted(context.Background()),
database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
expectedLinkedID := fake.IssuerURL().String() + "||" + sub
require.Equal(t, expectedLinkedID, link.LinkedID,
"linked_id should be backfilled with the correct value after login")
})
// Tests that changing the OIDC issuer URL blocks an existing user whose
// linked_id was recorded under the old issuer. This is a deliberate
// breaking change: before this fix the email fallback silently rescued
// such users. Now the login is rejected because the existing link's
// linked_id (old issuer) differs from the newly computed one (new issuer).
t.Run("OIDCEmailFallbackBlockedByIssuerChange", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Seed a user whose link was created under a different (old) issuer
// but with the same subject the IdP presents on login.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
const sub = "stable-subject"
oldLinkedID := "https://old-issuer.example.com||" + sub
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: oldLinkedID,
})
// Login presents the same subject but the current issuer, so the
// computed linked_id differs from the stored one and is blocked.
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": sub,
})
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"issuer change must block the email fallback for an existing link")
// The stored link must remain unchanged.
link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
require.Equal(t, oldLinkedID, link.LinkedID,
"linked_id must not be modified when the login is blocked")
})
t.Run("OIDCConvert", func(t *testing.T) {
t.Parallel()