Skip to content

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

Merged
f0ssel merged 5 commits into
mainfrom
fix/plat-229-oidc-linked-id-email-fallback
Jun 4, 2026
Merged

fix(coderd)!: restrict OIDC email fallback to first-time account linking#25712
f0ssel merged 5 commits into
mainfrom
fix/plat-229-oidc-linked-id-email-fallback

Conversation

@f0ssel

@f0ssel f0ssel commented May 27, 2026

Copy link
Copy Markdown
Member

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

Implementation details

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).

Note

This PR was authored by Coder Agents on behalf of @f0ssel.

@f0ssel

f0ssel commented May 27, 2026

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Review posted | Chat
Requested: 2026-05-27 11:44 UTC by @f0ssel
Spend: $41.10 / $100.00

Review history
  • R1 (2026-05-27): 18 reviewers, 4 Nit, 1 Note, 3 P2, 6 P3, COMMENT. Review

deep-review v0.5.0 | Round 1 | 20b50dd..1d1e257

Last posted: Round 1, 14 findings (3 P2, 6 P3, 4 Nit, 1 Note), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open coderd/userauth.go:2171 Silent return on blocked email fallback produces no log or distinguishable signal R1 Netero Yes
CRF-2 Nit Open coderd/userauth.go:2108 Double negative in doc comment: "doesn't not" R1 Netero, Hisoka, Kurapika Yes
CRF-3 P2 Open coderd/userauth.go:1870 TOCTOU: no re-validation of linked_id inside oauthLogin transaction allows concurrent attacker to bypass security check on legacy links R1 Kurapika P2, Ryosuke P2, Meruem P2 Yes
CRF-4 P2 Open coderd/userauth.go:2170 Blocked email fallback returns "no user found," causing 500 + DB constraint info leak when signups enabled R1 Mafuuu P2, Knov P2, Netero P3 Yes
CRF-5 P2 Open coderd/userauth_test.go:1761 OIDCLegacyLinkBackfill asserts non-empty linked_id but never verifies the backfilled value is correct R1 Bisky P2, Mafu-san P3, Chopper P3, Kite P3, Knov Note, Razor Note, Meruem Note Yes
CRF-6 P3 Open coderd/userauth.go:2109 Doc comment claims "first-time account linking only" but code also allows legacy links with empty linked_id R1 Razor P2, Chopper Nit Yes
CRF-7 P3 Open coderd/userauth_test.go:1624 No test coverage for GitHub OAuth email fallback blocking R1 Bisky P3, Knov P3 Yes
CRF-8 P3 Open coderd/userauth_test.go:1630 OIDCEmailFallbackBlockedByExistingLink only tests AllowSignups=false; untested AllowSignups=true path produces 500 R1 Knov Yes
CRF-9 P3 Open coderd/userauth_test.go:1715 OIDCFirstTimeLinkByEmailAllowed doesn't verify the created link has a populated linked_id R1 Bisky Yes
CRF-10 P3 Open coderd/userauth_test.go:1655 OIDCEmailFallbackBlockedByExistingLink uses mismatched issuer format, weakening attack scenario isolation R1 Kite P2 Yes
CRF-11 Nit Open coderd/userauth.go:2164 Security guard comment first sentence restates the if condition below it R1 Gon P2 Yes
CRF-12 Nit Open coderd/userauth.go:1874 Backfill comment second sentence restates the if guard on the next line R1 Gon P2 Yes
CRF-13 Nit Open coderd/database/queries/user_links.sql:53 UpdateUserLinkedID breaks the UserLink naming pattern used by every other user_links query R1 Gon Yes
CRF-14 Note Open coderd/userauth.go:2169 Legacy accounts (empty linked_id) remain exploitable until each user individually logs in; residual risk needs human decision on tracking R1 Hisoka P2, Pariston P2, Kurapika Note Yes

Contested and acknowledged

(none)

Round log

Round 1

Panel. 3 P2, 5 P3, 3 Nit, 1 Note new. 0 dropped. Reviewed against 20b50dd..1d1e257.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Kurapika security
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid security fix. The core defense in findLinkedUser is correct, the SQL-level WHERE linked_id = '' guard is proper defense-in-depth, and the loginType parameter change fixes a real correctness bug beyond the security goal. The test suite covers the three critical paths (attacker blocked, first-time link, legacy backfill).

3 P2, 5 P3, 3 Nit, 1 Note.

The two P2s that need attention before merge: (1) a TOCTOU race between findLinkedUser (outside the transaction) and the backfill (inside), allowing a concurrent attacker to get a session on a legacy account, fixable with a 4-line re-check after UpdateUserLink; and (2) when signups are enabled and the email fallback blocks an attacker, the silent nil-error return causes oauthLogin to attempt CreateUser, hitting a unique constraint and returning 500 with raw DB error details instead of a clean 403. The third P2 is a test weakness: the backfill test asserts NotEmpty but never verifies the value is correct.

Legacy accounts with empty linked_id remain exploitable via the same attack vector until each user individually logs in. This is a documented design constraint (the alternative breaks all legacy users), but the residual risk is not tracked in a separate issue. This needs a human decision: file a tracking ticket or explicitly accept the gap.

"The backfill turns a repeatable-access vulnerability into a one-shot permanent-lockout-plus-takeover for legacy accounts. The PR is a net improvement, but the first login wins and the loser is locked out." (Hisoka)

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/userauth.go
Comment thread coderd/userauth.go
Comment thread coderd/userauth_test.go Outdated
Comment thread coderd/userauth.go Outdated
Comment thread coderd/userauth_test.go
Comment thread coderd/userauth.go Outdated
Comment thread coderd/userauth.go Outdated
Comment thread coderd/database/queries/user_links.sql
Comment thread coderd/userauth.go
Comment thread coderd/userauth.go
@f0ssel f0ssel marked this pull request as ready for review May 27, 2026 14:14
@coder-tasks

coder-tasks Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

No documentation changes are needed for this PR.

This is a security fix that:

  • Blocks an OAuth/OIDC account takeover vulnerability transparently
  • Preserves all legitimate user behaviors (first-time linking, legacy links, normal logins)
  • Requires no configuration changes
  • Has no user-facing API changes

Automated review via Coder Agents

@f0ssel f0ssel requested a review from Emyrk May 27, 2026 18:31
Comment thread coderd/userauth.go Outdated
// Defense-in-depth: if a concurrent transaction backfilled
// linked_id between findLinkedUser and this point, reject.
if link.LinkedID != "" && link.LinkedID != params.LinkedID {
return xerrors.Errorf("identity mismatch: account bound to different subject")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this error result in a 5xx status code? I think we want this to result in a 4xx. Seems like idpsync.HTTPError can be used for this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, that bare error would have surfaced as a 500. Fixed in aea4e59: it now returns an idpsync.HTTPError with http.StatusForbidden, which both callers already render via idpsync.IsHTTPError(err) + hErr.Write(...). It uses the same message and static error page as the primary errLinkedIDAlreadyBound guard, so the rare race renders an identical clean 403.

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,
    }
}

Note on testing: this branch only fires on a concurrent backfill that lands between findLinkedUser and UpdateUserLink. In the non-concurrent case findLinkedUser already returns errLinkedIDAlreadyBound for a populated, mismatched linked_id, so it never reaches here. Triggering it deterministically needs a store shim that returns an empty linked_id on the findLinkedUser read but a different one from UpdateUserLink. I left it untested since it's defense-in-depth, but happy to add the shim-based test if you'd like it covered.

Generated by Coder Agents on behalf of @f0ssel.

@Emyrk

Emyrk commented May 29, 2026

Copy link
Copy Markdown
Member

We should mark this a breaking change. I was looking for an issue I could not find. But I know we had an issue in the past with more than 1 account being bound to the same Coder account or something strange like this (if no linked id exists).

That behavior is obviously not good, but is often not malicious. Just the same person twice or w/e. (I can't recall the specifics).

Regardless, mark this behavior a breaking change so if something does happen, we can point to the release notes.

Comment thread coderd/database/dbauthz/dbauthz.go Outdated
Comment on lines +7479 to +7486
func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) {
fetch := func(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) {
return q.db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
UserID: arg.UserID,
LoginType: arg.LoginType,
})
}
return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpdateUserLinkedID)(ctx, arg)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be ActionUpdate instead?

Read is ResouceSystem which is not good. But write is ActionUpdate

// TODO: Should this be in system.go?
func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLinkParams) (database.UserLink, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUserObject(arg.UserID)); err != nil {
return database.UserLink{}, err
}
return q.db.InsertUserLink(ctx, arg)
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, switched to ActionUpdate in 61fa6e0. UpdateUserLinkedID now authorizes policy.ActionUpdate on rbac.ResourceUserObject(arg.UserID), matching the sibling InsertUserLink, instead of the fetchAndQuery(..., ActionUpdatePersonal, ...) form. That drops the redundant GetUserLinkByUserIDLoginType read and checks the write directly against the user object. Updated the dbauthz accounting test to assert ActionUpdate.

Generated by Coder Agents on behalf of @f0ssel.

Emyrk commented May 29, 2026

Copy link
Copy Markdown
Member

Take this with a grain of salt

Test review

Reviewed the three new tests against the intended behavior.

What's covered correctly

OIDCEmailFallbackBlockedByExistingLink — core security fix is well tested. Both SignupsEnabled and SignupsDisabled subtests are present (the CRF-4 500 concern is a non-issue; the caller at line 1448 correctly checks errors.Is(err, errLinkedIDAlreadyBound) before any signup logic). Asserts 403 on the attacker and verifies the victim's linked_id is unchanged.

OIDCLegacyLinkBackfill — correctly verifies the exact backfilled value (issuer||sub), not just non-empty.

OIDCFirstTimeLinkByEmailAllowed — covers the SCIM/API-created user (no user_link row) path and verifies the correct linked_id is set after first login.

What's missing

1. No test for the issuer URL change scenario — this is the most impactful breaking behavior this PR introduces. A user with linked_id = "https://old.issuer||sub" who logs in after an admin changes CODER_OIDC_ISSUER_URL gets a 403 (new linked_id has new issuer prefix, old linked_id is non-empty and differs). Before this PR the email fallback rescued them silently. That scenario has no test, so the regression is undetected by the suite.

2. No GitHub OAuth equivalent for errLinkedIDAlreadyBound — the handler at line 1039 correctly handles the error, but there is no test exercising that code path.

3. OIDCLegacyLinkBackfill does not assert the HTTP status code explicitly — it infers success from client.User succeeding rather than checking require.Equal(t, http.StatusOK, resp.StatusCode). Minor, but inconsistent with the other tests.

  • As Steven's Agent

@f0ssel f0ssel changed the title fix(coderd): restrict OIDC email fallback to first-time account linking fix(coderd)!: restrict OIDC email fallback to first-time account linking Jun 4, 2026
@f0ssel f0ssel added the release/breaking This label is applied to PRs to detect breaking changes as part of the release process label Jun 4, 2026 — with Coder

f0ssel commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

Thanks @Emyrk. Addressed in 61fa6e0.

Breaking change — Marked the PR release/breaking and added ! to the title (fix(coderd)!: ...), plus a "Breaking change" section in the description. It calls out that an account whose user_link has a populated linked_id that no longer matches the IdP subject is now denied login (403) instead of silently resolving via email, with the most likely trigger being a CODER_OIDC_ISSUER_URL change (the linked_id is issuer||subject) or two identities sharing one email. Legacy empty linked_id accounts are unaffected and are backfilled on next login. This is the residual-risk decision the bot's CRF-14 flagged: accept the legacy window and point to the release notes.

Test review

  1. Issuer URL change — Added TestUserOIDC/OIDCEmailFallbackBlockedByIssuerChange. It seeds a link under https://old-issuer.example.com||<sub>, logs in with the same subject but the IdP's current issuer, and asserts 403 plus that the stored link is untouched. This locks in the breaking behavior so it can't silently regress.
  2. GitHub equivalent — Added TestUserOAuth2Github/EmailFallbackBlockedByExistingLink. The victim has a GitHub link (linked_id "100"); an attacker authenticates with a different GitHub ID but the victim's verified email and is rejected with 403, exercising the errLinkedIDAlreadyBound handler at the GitHub callsite (line 1039).
  3. Explicit status assertion in OIDCLegacyLinkBackfill — this is already present on the current revision (edd54fb): it asserts require.Equal(t, http.StatusOK, resp.StatusCode) immediately after AttemptLogin, then checks the backfilled linked_id equals issuer||sub. No change needed there, but happy to be told otherwise if you were looking at an earlier diff.

Also applied your dbauthz suggestion: UpdateUserLinkedID now authorizes ActionUpdate on the user object like InsertUserLink (replied on that thread).

Generated by Coder Agents on behalf of @f0ssel.

f0ssel and others added 4 commits June 4, 2026 12:25
The findLinkedUser function 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, allowing an attacker with the victim's email
at the IdP (but a different subject) to hijack an existing account.

Restrict the email fallback 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 does not return that user.
This blocks account takeover while preserving first-time linking
(no existing link) and legacy links (empty linked_id).

Also backfill linked_id on legacy user_links via a new
UpdateUserLinkedID query that only sets the value when currently empty.

Fixes: https://linear.app/codercom/issue/PLAT-229
The TestMethodTestSuite/Accounting test requires every database method
to have an authorization test. Add the missing test for the new
UpdateUserLinkedID query.
CRF-3 (P2): Add defense-in-depth TOCTOU check inside oauthLogin
transaction. After UpdateUserLink, verify link.LinkedID matches
params.LinkedID to catch concurrent backfill races on legacy links.

CRF-4 (P2): Return sentinel errLinkedIDAlreadyBound from findLinkedUser
instead of nil. Both OIDC and GitHub callers now render a clean 403
with a descriptive message instead of leaking DB constraint errors.

CRF-5 (P2): Save the sub, compute expected linked_id from
fake.IssuerURL(), and assert exact equality in OIDCLegacyLinkBackfill.

CRF-1 (P3): Callers now log a warn-level message when the sentinel
error is returned, providing operator visibility.

CRF-2 (Nit): Fix double-negative typo in findLinkedUser doc comment.

CRF-6 (P3): Update doc comment to accurately describe email fallback
scope (first-time linking and legacy links with empty linked_id).

CRF-8 (P3): Add AllowSignups=true subtest to OIDCEmailFallbackBlocked
to cover both signup paths.

CRF-9 (P3): Add linked_id verification to OIDCFirstTimeLinkByEmailAllowed.

CRF-10 (P3): Use fake.IssuerURL() for victim's linked_id in tests
so the issuer format is realistic (same issuer, different subject).

CRF-11, CRF-12 (Nit): Tighten comments to avoid restating guards.
- Authorize UpdateUserLinkedID with ActionUpdate on the user object,
  matching the sibling InsertUserLink, instead of ActionUpdatePersonal.
- Add a GitHub OAuth test for the errLinkedIDAlreadyBound email-fallback
  block (previously only the OIDC path was covered).
- Add an OIDC issuer-URL-change test documenting the deliberate breaking
  behavior for existing links recorded under a previous issuer.

Co-authored-by: Coder Agents <agents@coder.com>
@f0ssel f0ssel force-pushed the fix/plat-229-oidc-linked-id-email-fallback branch from 61fa6e0 to fae5721 Compare June 4, 2026 12:30
The defense-in-depth re-check inside the oauthLogin transaction returned
a bare error, which surfaced as a 500. Return an idpsync.HTTPError with
StatusForbidden so the concurrent-backfill race renders the same clean
403 as the primary findLinkedUser guard.

Co-authored-by: Coder Agents <agents@coder.com>
@f0ssel f0ssel merged commit 53d287a into main Jun 4, 2026
27 of 28 checks passed
@f0ssel f0ssel deleted the fix/plat-229-oidc-linked-id-email-fallback branch June 4, 2026 18:36
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 4, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release/breaking This label is applied to PRs to detect breaking changes as part of the release process

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants