ADR 035 OIDC Account Linking - steilerDev/cornerstone GitHub Wiki

ADR-035: OIDC Account Linking by Verified Email, No Self-Registration

Status

Accepted (2026-09-30). Amends ADR-010: supersedes its "automatic user provisioning on first OIDC login" requirement. Amended 2026-10-01 (#2142): opt-in just-in-time provisioning behind OIDC_JIT_PROVISIONING (default false). See Amendment (#2142). Amended 2026-10-02 (#2122): admins can create SSO-only accounts without a password. See Amendments.

Context

ADR-010 specified OIDC as the primary sign-in method with automatic provisioning: any OIDC login whose subject was unknown created a new member account. The callback looked users up by (auth_provider = 'oidc', oidc_subject) and rejected an OIDC login whose email matched an existing local account (email_conflict).

Issue #1865 showed that this was wrong in both directions for a self-hosted, single-household app:

  1. Self-registration. With an open provider (for example Google, which accepts any Google account), anyone who found the login URL could create an account in a private household instance.
  2. No linking. The setup admin, who always has a local account, could never sign in through OIDC with the same email. The conflict path ended in a redirect code that the API documented as EMAIL_CONFLICT (409), but that code was never emitted.

The trust model was decided on the issue: the operator controls the OIDC client configuration, and an automatic link on a verified email match from the provider's ID token is acceptable. The issue does not allow a password-confirmation or manual linking step.

Decision

1. OIDC never creates accounts (by default)

Accounts are created only by initial setup (POST /api/auth/setup) or by an admin (POST /api/users, which creates a local account with a password or, since #2122, an SSO-only oidc account without one). An OIDC identity that resolves to no account is rejected with OIDC_NO_MATCHING_ACCOUNT (403), surfaced as the redirect /login?error=oidc_no_matching_account.

Amended by #2142. The original text said there was no opt-in flag, and that one would only come with a domain/email allow-list. The homeowner asked for the flag without the allow-list. It now exists as OIDC_JIT_PROVISIONING, default false, and the default behaviour above is unchanged. See the Amendment for the trade-off.

2. Resolution order in GET /api/auth/oidc/callback

  1. Subject match. An account with oidc_subject = sub, under any auth_provider, signs in. The callback requires neither an email claim nor email_verified here, because the binding was established under the gate in step 3 (or by a legacy provisioning row). A deactivated account is refused (account_deactivated).
  2. Email present. No subject match and an empty or missing email claim → OIDC_MISSING_EMAIL (403, redirect missing_email). This check sits after the subject lookup on purpose, so a bound subject never depends on the provider continuing to send an email.
  3. Verified-email gate. email_verified !== true → OIDC_EMAIL_UNVERIFIED (403, redirect oidc_email_unverified). The gate runs before any email lookup, so an unverified token cannot tell which emails have accounts. The gate applies to the link path only. It has no opt-out flag.
  4. Case-insensitive email match. An exact match is preferred. If there is no exact match and more than one account matches case-insensitively, the match is ambiguous and treated as no match (OIDC_NO_MATCHING_ACCOUNT, 403, redirect oidc_no_matching_account). The callback never guesses between accounts. Case folding uses SQLite lower(), which folds ASCII letters only, so emails that differ only in the case of a non-ASCII letter do not match. That fails closed.
  5. Deactivated accounts are never linked and never signed in (account_deactivated).
  6. Link. The callback sets oidc_subject = sub on the matched account. id, role, auth_provider and password_hash do not change, so local password sign-in keeps working and all data stays attached. If the account was already bound to a different subject, the callback accepts the re-bind and logs it at warn. The previous subject then stops resolving.

3. auth_provider and oidcLinked are separate facts

  • auth_provider records how the account was created (local means setup or admin-created, with a password; oidc means created without a password: legacy auto-provisioned rows, JIT-provisioned accounts (#2142) and admin-created SSO-only accounts (#2122)). Linking never changes it. Existing gates keep their meaning: the password-change endpoint still refuses auth_provider = 'oidc'.
  • oidc_subject IS NOT NULL records whether the account can sign in through OIDC. The API exposes it as the derived oidcLinked: boolean on every user response. The subject value is never exposed.

4. Index: subject is the only correlation key

Migration 0046 rebuilds idx_users_oidc_lookup as UNIQUE (oidc_subject) WHERE oidc_subject IS NOT NULL (previously (auth_provider, oidc_subject)). Lookups ignore auth_provider, so uniqueness must too. Otherwise one subject could be bound to both a local and an oidc account. There is no backfill.

5. Removed codes

The EMAIL_CONFLICT error code and the email_conflict redirect code are removed.

6. One redirect-URI expression for both legs (#2026)

The authorization request and the token exchange both derive redirect_uri from EXTERNAL_URL || request origin + /api/auth/oidc/callback. The callback leg keeps its query string. Before this change the callback leg ignored EXTERNAL_URL, and the exchange failed behind a reverse proxy without TRUST_PROXY.

Alternatives Considered

  • Keep provisioning behind a flag (OIDC_AUTO_PROVISION). Rejected on 2026-09-30: the safe default would be off, and an allow-list is the only safe form of "on". That is more configuration than current requirements justify. Adopted in a narrower form by #2142 as OIDC_JIT_PROVISIONING (default off, member only), without the allow-list. See the Amendment.
  • Require a password confirmation before linking. Ruled out by the issue's trust-model decision. It also cannot work for auth_provider = 'oidc' accounts, which have no password.
  • Switch auth_provider to oidc on link. Rejected: auth_provider would then stop describing the account. The password-change gate would lock a linked admin out of changing a password they still use, and unlinking would have to restore the old value. A derived boolean holds no extra state.
  • Reject re-binding to a different subject. Rejected: providers rotate subjects, for example when the operator recreates the OAuth client or switches providers. The link path already requires a verified email that matches the account, which is the same evidence that authorized the first binding. The warn log makes a re-bind visible.
  • Case-sensitive email matching. Rejected: providers normalise email case inconsistently (Google lower-cases, others echo what the user typed). Exact-match preference plus the ambiguity rule covers the only hazard: the users.email UNIQUE constraint uses BINARY collation, and the duplicate-email checks in POST /api/users / PATCH /api/users/:id compare exactly, so case variants can coexist.

Consequences

Easier

  • The setup admin can use SSO without a second account. Either sign-in method resolves to the same id.
  • An open provider no longer grants access to a private instance.

More Difficult

  • Adding a household member now requires an admin to create the account first (POST /api/users). That endpoint always creates a local account with a password, so a member who only ever uses SSO still gets a password they never use. A passwordless admin-created account is a separate story.

    Amended by #2122. That story shipped: the admin can create an SSO-only account with no password, and it cannot be given one later. See Amendments.

  • Providers that do not emit email_verified: true (some Azure AD / Entra configurations) cannot link an account. Accounts they have already linked keep working through the subject path. The operator has to fix the provider's claim mapping. Cornerstone offers no bypass on purpose.

  • Anyone who controls a verified mailbox at the provider for an account's email can take over that account's OIDC binding. This is inherent in the accepted trust model and is bounded by the operator's choice of provider.

  • There is no unlink operation. An admin can only remove a binding in the database.

Amendment (#2142, 2026-10-01): opt-in JIT provisioning

Decision

A new environment variable, OIDC_JIT_PROVISIONING (boolean, default false), lets an OIDC identity that matches no account create one. With the flag off, the callback behaves exactly as in §1 and §2.

Provisioning is the last step of §2. It runs only after every existing guard has passed: no subject match, an email claim is present (OIDC_MISSING_EMAIL otherwise), email_verified is true (OIDC_EMAIL_UNVERIFIED otherwise), and the case-insensitive email lookup found no account. A deactivated email match still counts as "not linked" and is still rejected. An active match still links. Provisioning never overrides a refusal from an earlier step.

When it runs, the callback creates a user with:

  • role = 'member'. Never admin, and there is no setting to change that.
  • auth_provider = 'oidc', oidc_subject = sub, and no password.
  • email from the claim.
  • display_name from the first non-empty of name, then preferred_username, then the email. The value is trimmed and capped at 100 code points. handleCallback now also reads the name and preferred_username claims. The requested scope is already openid email profile.

The outcome is provisioned. The server writes an info log that carries the new userId only, with no email or other claims.

Even with the flag on, the callback refuses with OIDC_NO_MATCHING_ACCOUNT in two cases:

  1. Ambiguous email. The email matches more than one stored case variant (the §2 ambiguity rule). The callback fails closed rather than add another variant.
  2. No users yet. If the users table is empty, initial setup has not run. POST /api/auth/setup only runs while there are no users, so provisioning a member first would block setup for good and leave the instance without an admin.

Concurrency. The insert runs in the same transaction as the lookup. Within one process, better-sqlite3 transactions are synchronous and run one at a time, so a second sign-in for the same sub finds the new account at the subject-match step and never reaches the insert. The unique index on oidc_subject (migration 0046) is the backstop for writes from outside this process, such as a second instance on the same database file or an external tool. If the insert hits that index, the callback catches the violation, re-reads the account by subject, and signs in with outcome matched_subject.

Why this reverses §1

§1 rejected a flag because "on" is only safe with an allow-list. The homeowner asked for the flag anyway (#2142). When the provider already restricts who can sign in (a self-hosted Authentik or Keycloak realm, or a Google Workspace tenant), the provider acts as the allow-list. Requiring an admin to pre-create every account is the friction the issue reports. The default stays off, so no existing deployment changes behaviour.

Accepted risk

There is no domain or email allow-list; #2142 keeps it out of scope. With an open provider (for example consumer Google), turning the flag on lets anyone who finds the login URL register as a member. That is the self-registration hazard described under Context. The flag is only safe when the provider itself restricts sign-in, and the operator docs carry that warning. Provisioned accounts are always member, so the hazard never grants admin access, and an admin can deactivate an unwanted account. An allow-list can be added later as a separate variable without changing this decision.

Consequences

  • Household members can be onboarded by signing in once, without an admin creating their account.
  • The "More Difficult" note that admin-created accounts get an unused password does not apply to provisioned accounts. They are oidc accounts with no password. (Since #2122 it no longer applies to admin-created accounts either, when the admin chooses SSO-only.)
  • An operator who enables the flag with an open provider re-opens self-registration. The code cannot detect that configuration error.

Amendments

#2122 (2026-10-02): admin-created SSO-only accounts

Decision. POST /api/users accepts an optional authProvider: "local" | "oidc" (default "local"). With "oidc" the admin creates an SSO-only account:

  • Stored as auth_provider = 'oidc', password_hash = NULL, oidc_subject = NULL. This is the JIT-provisioned shape (#2142) without the subject, so there is no schema change or migration.
  • password must be absent (400 VALIDATION_ERROR, details.field: "password"), and OIDC must be enabled (400 OIDC_NOT_CONFIGURED). A local account still requires a password, so omitting one by mistake never yields an SSO-only account.
  • Any role, including admin. Unlike JIT provisioning, a person chose the role.

The account is bound by the existing §2 link step: the first OIDC sign-in with a verified email that matches it sets oidc_subject, and auth_provider, password_hash and role stay as they are. No resolution step changes. Before that sign-in, the account cannot sign in at all. Local login takes the no-password branch (INVALID_CREDENTIALS, the same response as an unknown email, also when the account is deactivated), and the password-change endpoint refuses oidc accounts (403), as §3 already provided.

SSO-only is permanent (maintainer decision D2). No endpoint sets a password for an account that has none, and authProvider is never changed, by linking (§3) or by PATCH /api/users/:id. Emails are never released, because deactivation is a soft delete and the create-time conflict check includes deactivated accounts. To move a person to a local account, the admin changes the SSO-only account's email to a placeholder, deactivates it, and creates a new local account with the real email. History stays with the old account.

Unchanged. The create-time email check stays exact and case-sensitive, so case variants can still coexist (§2, Alternatives). An SSO-only account whose email differs from another account only by case is subject to the §2 ambiguity rule like any other.

Consequences.

  • Easier: a household member who only uses SSO gets an account with no unused password, and an admin can pre-create an admin SSO-only account, which JIT provisioning never does.
  • Harder: an SSO-only account depends entirely on the provider. If OIDC is disabled later, if the provider stops sending email_verified: true before the first sign-in, or if the email at the provider differs from the one the admin typed, the account cannot sign in, and the only fix is the recreate workaround above.
  • The §2 trust model applies before the first sign-in too: whoever controls a verified mailbox at the provider for that email takes the account, including its role. This is the same exposure a local account has to the link step, but an admin SSO-only account makes it an admin-level binding.