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:
- 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.
- 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, defaultfalse, and the default behaviour above is unchanged. See the Amendment for the trade-off.
2. Resolution order in GET /api/auth/oidc/callback
- Subject match. An account with
oidc_subject = sub, under anyauth_provider, signs in. The callback requires neither an email claim noremail_verifiedhere, because the binding was established under the gate in step 3 (or by a legacy provisioning row). A deactivated account is refused (account_deactivated). - Email present. No subject match and an empty or missing email claim →
OIDC_MISSING_EMAIL(403, redirectmissing_email). This check sits after the subject lookup on purpose, so a bound subject never depends on the provider continuing to send an email. - Verified-email gate.
email_verified !== true→OIDC_EMAIL_UNVERIFIED(403, redirectoidc_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. - 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, redirectoidc_no_matching_account). The callback never guesses between accounts. Case folding uses SQLitelower(), which folds ASCII letters only, so emails that differ only in the case of a non-ASCII letter do not match. That fails closed. - Deactivated accounts are never linked and never signed in (
account_deactivated). - Link. The callback sets
oidc_subject = subon the matched account.id,role,auth_providerandpassword_hashdo 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 atwarn. The previous subject then stops resolving.
3. auth_provider and oidcLinked are separate facts
auth_providerrecords how the account was created (localmeans setup or admin-created, with a password;oidcmeans 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 refusesauth_provider = 'oidc'.oidc_subject IS NOT NULLrecords whether the account can sign in through OIDC. The API exposes it as the derivedoidcLinked: booleanon 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 asOIDC_JIT_PROVISIONING(default off,memberonly), 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_providertooidcon link. Rejected:auth_providerwould 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
warnlog 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.emailUNIQUE constraint usesBINARYcollation, and the duplicate-email checks inPOST /api/users/PATCH /api/users/:idcompare 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 alocalaccount 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'. Neveradmin, and there is no setting to change that.auth_provider = 'oidc',oidc_subject = sub, and no password.emailfrom the claim.display_namefrom the first non-empty ofname, thenpreferred_username, then the email. The value is trimmed and capped at 100 code points.handleCallbacknow also reads thenameandpreferred_usernameclaims. The requested scope is alreadyopenid 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:
- Ambiguous email. The email matches more than one stored case variant (the §2 ambiguity rule). The callback fails closed rather than add another variant.
- No users yet. If the
userstable is empty, initial setup has not run.POST /api/auth/setuponly runs while there are no users, so provisioning amemberfirst 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
oidcaccounts 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. passwordmust 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
adminSSO-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: truebefore 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
localaccount has to the link step, but anadminSSO-only account makes it an admin-level binding.