↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Architecture Decision Record

ADR-036: External OAuth Login (Federated Google/GitHub/Apple) with Local-JWT Exchange

Status

Accepted (2026-07-02, migration attribution corrected 2026-07-06, native-callback redirect branch added 2026-07-17 per ADR-043, email-verified account-takeover guard before linking added 2026-07-21, provider-email validation ahead of the by-email lookup documented 2026-07-25, one-provider-link-per-user conflict branch documented and the provider-columns migration attribution corrected 2026-09-01). Revised 2026-09-07 (an external account's empty credential is explicitly non-authenticating on both login and change-password, an OAuth completion must match a flow this client started, ADC's link-by-email requires both a provider-verified address and a local account that has no password, and ADC's speaker auto-link takes the same verified-email requirement). Revised 2026-09-19 (Apple joins Google and GitHub as a third config-gated provider, the by-email link runs three guards rather than two, token issuance goes through the shared refresh-session workflow, and an external create carries the provider's assertion into the new account's confirmation state).

Context

The framework's Identity story so far is entirely first-party: a user registers with an email and password, the credentials are hashed (ADR-032), and Identity mints its own RS256 JWT pair. Every auth ADR to date sits inside that first-party boundary: ADR-004 validates Identity's own tokens across extracted services, ADR-020 layers permissions over the token's role, ADR-022 carries the same token in browser cookies, ADR-029 throttles the anonymous credential surface, ADR-032 hashes the password, and ADR-033 checks resource ownership from a token claim. None of them lets a third-party identity provider vouch for the user. The word "federated" already appears in the ADR set (ADR-007 / ADR-008), but it means service-to-service JWKS trust between our own hosts, not a Google or GitHub account signing a person in.

We wanted social sign-in (Google and GitHub at the outset, Apple since) without giving up the invariant that the rest of the system depends on: inside the app, a user is always our local User carrying our own JWT. External identity should be an entry path that terminates in the same local token pair every other flow produces, not a parallel identity system that downstream services would have to learn to validate. It also had to be optional: most hosts, tests, and local dev runs have no OAuth secrets and must keep the JWT-only pipeline untouched, the same inert-until-configured posture as AddPermissions (ADR-020).

Decision

Add an opt-in external-login path that federates Google, GitHub and Apple sign-in at the edge and immediately exchanges the external identity for the app's own local JWT pair, linking the external account to a local User.

  • Scheme registration is config-gated per provider. AddExternalAuthProviders reads the OAuth section and enables Google, GitHub and/or Apple, each one only when that provider's OAuth:<Provider>:ClientId is present (MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:60-73); with none of the three configured it returns without touching the pipeline, so AddCommonAuthentication's JWT-only default is left exactly as it was. AddAuthentication() is called with no argument so it appends schemes rather than resetting the JWT bearer default. For Google and GitHub a configured ClientId with a missing ClientSecret fails fast at startup. Apple carries no static client secret: the handler mints a short-lived ES256 JWT from the developer's private key (GenerateClientSecret, ExternalAuthExtensions.cs:130), so its fail-fast set is OAuth:Apple:TeamId (:131), OAuth:Apple:KeyId (:134) and OAuth:Apple:PrivateKeyPem (:137) instead, and its callback path is /auth/callback/apple (:143). The same per-provider signal is what the UI's ConfigurationOAuthUISettings uses to light up the Google/GitHub/Apple buttons (GoogleEnabled / GitHubEnabled / AppleEnabled).
  • A short-lived cookie carries the external principal, nothing more. The provider callback signs into a dedicated ExternalLogin cookie scheme (mmca_external_login, HttpOnly, SameSite=Lax, a 10-minute lifetime). It exists only to hand the external claims from the provider callback to the controller; it is never the app session. GitHub is asked for the user:email scope because its default scope omits the email the exchange needs.
  • OAuthControllerBase.CompleteAsync performs the exchange. The challenge endpoints redirect to the provider and back to CompleteAsync, which authenticates the ExternalLogin cookie, extracts (provider, providerKey, email, firstName, lastName) from the standard claims, and calls IAuthenticationService.ExternalLoginAsync. On success it signs the external cookie out immediately, so the external principal lives no longer than the exchange.
  • Tokens never ride the redirect URL. CompleteAsync mints a single-use opaque code, stashes the minted token pair server-side in the cache under a short TTL, and redirects carrying only that code. The redirect target defaults to OAuth:UIBaseUrl, but when the stashed returnUrl is an absolute URI whose custom scheme is allow-listed in OAuth:AllowedReturnUrlSchemes it targets that native-app URL instead, so a system-browser WebAuthenticator window captures the code and closes (ADR-043); an empty allowlist (the default) keeps the web-only behavior exactly, and http/https returnUrls never match, so the allowlist cannot become an open redirect. Completion errors follow the same branch. The UI calls ExchangeAsync (POST) out of band to swap the code for the tokens; the code is burned on first use, and a missing, replayed, or expired code yields HTTP 400. Access and refresh tokens therefore never land in the address bar, browser history, the Referer header, or upstream access logs.
  • The exchange resolves to a local User, three ways, and guards the by-email link. ExternalLoginAsync (app-level, MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:214) opens the transaction (AuthenticationService.cs:221) and runs the workflow body in ExternalLoginCoreAsync (AuthenticationService.cs:226), which first looks the user up by LoginProvider + ProviderKey (AuthenticationService.cs:235-236). Missing, it validates the provider-supplied email before it searches on it: Email.Create(email) (AuthenticationService.cs:254) is checked as a Result, and an unparseable claim value rejects the sign-in with an Error.Validation carrying code Auth.ExternalEmailInvalid (AuthenticationService.cs:258). This address is the one email in the system no request-level validator has already gated (it arrives in an OAuth claim, not in a validated request), and the by-email lookup that follows (AuthenticationService.cs:264) compares against the validated Email value object rather than the raw claim string. When an account already owns that email, three guards run in TryLinkProviderToExistingAccountAsync before anything is linked. First, one provider link per user: an account that is already externally linked to a different provider (IsExternalLogin plus a LoginProvider mismatch, AuthenticationService.cs:359) is rejected with an Error.Conflict carrying code Auth.ExternalProviderAlreadyLinked (AuthenticationService.cs:362), because the aggregate holds a single (LoginProvider, ProviderKey) pair and linking a second provider would overwrite the first and strand the original login; the check runs ahead of the verifier, so saying no costs no external round trip. Second, the account-takeover guard: it asks IExternalLoginEmailVerifier.IsCurrentExternalLoginEmailVerifiedAsync (AuthenticationService.cs:372-373) whether the provider asserted the incoming email as verified, and when it did not it rejects the sign-in with Auth.ExternalEmailNotVerified (defined inline at AuthenticationService.cs:378) instead of linking, so an unverified provider assertion cannot claim an existing local account. Third, the pre-registration takeover guard: an account that still signs in with a password (User.HasLocalPassword, checked at AuthenticationService.cs:390) is rejected with Auth.ExternalLinkRequiresLocalSignIn (:393), because a provider-verified address proves only the provider's side and nothing proves the local row's address was ever confirmed (the reasoning is in the 2026-09-07 revision, item 3). Only when all three guards pass does it link the external provider to that account (User.LinkExternalProvider, AuthenticationService.cs:398). When no account owns the email it creates a new Attendee via User.CreateExternal (AuthenticationService.cs:293; an external user has empty password hash/salt and carries LoginProvider / ProviderKey). The create path asks the same verifier first (AuthenticationService.cs:286-287), so the verifier runs on both branches: the provider's assertion does not gate the create (GitHub asserts nothing, and refusing would close GitHub sign-up), but it is passed to User.CreateExternal as a sixth emailVerified argument and rides the UserRegistered event. It also settles the account's confirmation state (ADR-116): a verified assertion starts the account confirmed (IsEmailConfirmed = emailVerified, MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:288), while a provider that asserts nothing starts it unconfirmed and it receives a confirmation link like a local registration. In the link and create cases the exchange then calls IssueTokensAsync (AuthenticationService.cs:338), which opens a refresh session for the device through the shared workflow (hash at rest, per-user cap, rotation chain) instead of stamping a plaintext refresh token on the aggregate, so the caller receives the same AuthenticationResponse shape as a local login.
  • The linkage is two nullable columns and a filtered unique index. User.LoginProvider (varchar(50)) and User.ProviderKey (varchar(256)) are null for local accounts; IsExternalLogin is derived from LoginProvider is not null. In ADC's per-service Identity database both columns and a unique index over the pair (filtered to non-null rows) are created by the Identity InitialCreate migration (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/Migrations/20260606053130_InitialCreate.cs:63, index at 20260606053130_InitialCreate.cs:100), so two external identities cannot map to the same local account while local (null,null) accounts are unconstrained. (ADC's Source/Hosting/ carries four per-module migration projects, Identity, Conference, Engagement and Notification, alongside MMCA.ADC.AppHost, so the Identity database's own InitialCreate is the only place these columns are created: there is no standalone provider-fields migration and no combined single-DB migration project in the repo.) User.Anonymize clears both fields on erasure (ADR-005).
  • New external users re-use the local registration side-effect. A brand-new external User publishes the same post-commit UserRegistered integration event the local RegisterAsync path publishes, so Conference runs the BR-207 speaker email-match auto-link asynchronously. The first token does not yet carry speaker_id; it is picked up on the next refresh (the same eventual consistency the local path accepts).

The default lives in the framework but the behavior is app-supplied: the IAuthenticationService interface member ExternalLoginAsync has a default implementation that returns an Auth.ExternalLoginNotSupported failure, so a host that never wires the flow degrades safely rather than silently succeeding.

Adoption is ADC only, and partial adoption is by design (ADR-018 / ADR-020 model the same "shipped in the framework, adopted by one app" shape). ADC's Identity module supplies a sealed OAuthController subclass carrying the class-level [ApiController] / [Route("auth/oauth")] / [ApiVersion("1.0")] attributes (not reliably inherited), its AuthenticationService overrides ExternalLoginAsync, its Identity service host calls AddExternalAuthProviders, and the AppHost passes the UI's HTTPS endpoint to the Identity service as OAuth__UIBaseUrl so the post-exchange redirect lands on the UI host. MMCA.Store does not adopt external login: it registers no OAuth schemes, defines no OAuthController, adds no provider fields to its User, and leaves ExternalLoginAsync at the not-supported default. Its Identity story stays local-credential + RS256 only.

Rationale

  • Terminate federation at the edge, keep one internal identity. Exchanging the external principal for a local JWT the moment the callback returns means every downstream concern (JWKS validation, permissions, ownership, session cookies) sees the token it already understands. External identity never leaks past the controller, so the rest of the system needs no changes.
  • Code-in-cache beats tokens-in-URL. A redirect is a GET that the browser records and proxies log; putting the tokens behind a single-use, short-TTL, server-side code keeps credentials out of every place a URL is written down, at the cost of one extra out-of-band POST.
  • Link-by-email, do not fork the account. Matching an incoming external email to an existing account and attaching the provider (rather than minting a second User) keeps a person's bookmarks, notifications, and speaker link on one identity regardless of how they signed in.
  • Inert until configured mirrors the framework's other opt-ins. Gating each provider on its ClientId and defaulting ExternalLoginAsync to a not-supported failure means a host with no OAuth secrets (most tests, local dev, and Store) behaves exactly as before, the same posture as ADR-020.
  • The app owns account creation, the framework owns the handshake. The OAuth handshake, cookie, and code exchange are generic and live in the framework; the User factory surface, default role, and claim set are app-specific and stay in the subclass, so the flow composes with each app's existing registration rules instead of duplicating them.

Trade-offs

  • Opt-in per app, and easy to half-wire. The flow needs four cooperating pieces (scheme registration, the controller subclass, the service override, and the OAuth__UIBaseUrl redirect target). A host that registers schemes but forgets the controller, or configures a ClientId without the matching UI flags, gets a broken or invisible button, the same audit-the-inventory caveat as ADR-020. Adopting it also requires the migration that adds the provider columns.
  • The verified-email guard is ADC-level, not framework-provided. Link-by-email would let an unverified address attach to an existing account, so ADC gates the auto-link on a provider-asserted verified email rather than trusting the address outright. The gate is IExternalLoginEmailVerifier (interface in the Identity Application layer, MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/IExternalLoginEmailVerifier.cs:11), implemented at the API edge over the short-lived ExternalLogin cookie principal by MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:17, which reads the provider's email_verified claim (HttpContextExternalLoginEmailVerifier.cs:33) and is wired at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:56. Google's email_verified claim passes the guard; GitHub's OAuth flow asserts nothing, so a GitHub sign-in whose email matches an existing account is rejected with Auth.ExternalEmailNotVerified, not linked. The framework itself still performs no such check: Auth.ExternalEmailNotVerified is defined inline in ADC's override (AuthenticationService.cs:378), and a non-adopting host (Store, Helpdesk) gets only the framework's not-supported default (Auth.ExternalLoginNotSupported, MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:139), so the guard is ADC's own edge, not a framework guarantee.
  • Not exactly-once account creation across the redirect. The exchange commits the User before the UI redeems the code; an abandoned redemption still creates (or links) the account. That is the intended tradeoff (the identity is real once the provider vouched for it), but it means a completed challenge can leave a local account whose token pair was never collected.
  • A second credential shape on User. External accounts carry empty password hash/salt and rely on LoginProvider / ProviderKey; code that assumes every User has a usable password must check IsExternalLogin first. The pair is single-valued, so a person holds at most one provider link: a second provider arriving with the same email is rejected (Auth.ExternalProviderAlreadyLinked) rather than relinked, and the rejection message points the person back at the provider already linked, or at Forgot password to set a local one (AuthenticationService.cs:363).

Revision (2026-09-07)

Four changes from the 2026-09-07 security review.

  1. The empty credential an external account carries is explicitly non-authenticating (SEC-Common-01). This record already said an external-login account is created with no password; what it did not say is what the password paths do with that shape. AuthenticationServiceBase.LoginAsync now refuses password login for an account with no stored credential before it verifies anything (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:151, predicate at :758), answering exactly as it does for an unknown address, cost included (:153). ChangePasswordHandlerBase refuses ahead of its own verify (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:73): an account with no current password has no current password to prove, so it has no change-password path either. The consequence for adopters is that linking a local password to an external account is an explicit flow, never a side effect of change-password.
  2. A completion is bound to the flow this client started (SEC-Common-85). OAuthFlowStateStore (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/OAuthFlowStateStore.cs:20) mints a per-attempt value before the challenge (BeginAsync, :46) into device-local storage under one key (:23), and the completion page round-trips it (TryCompleteAsync, :68). A code that arrives without a matching local attempt is dropped, so a deep-linked or emailed completion URL cannot force the browser to sign in as the attacker's account. Enforcement follows the store's availability (IsEnforced, :39).
  3. ADC's link-by-email requires a provider-verified address AND a password-less local account. Both halves are load-bearing, and each answers a different failure. The verifier proves only the provider side of the address, so ExternalLoginAsync first refuses a link when the provider did not assert the email as verified (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:306, answering Auth.ExternalEmailNotVerified at :309). It then refuses when the matched local account already signs in with a password (:321, answering Auth.ExternalLinkRequiresLocalSignIn at :324), because nothing proves that local row's address was ever confirmed: the account may have been opened by whoever merely typed the address into the registration form, and linking would hand that person the provider owner's account while leaving their chosen password in place (:314-320). Only an account nobody can sign into with a password is claimable by an email match, which the aggregate states as User.HasLocalPassword (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:120, with the reasoning at :114-119); the state lives on the aggregate rather than being inferred from the raw hash at the call site. Everyone else proves possession by signing in with the password first and linking the provider from their profile, which is what the refusal message tells them (:325).
  4. The same verified-email requirement gates ADC's speaker auto-link. The UserRegistered integration event carries EmailVerified (default false, MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:42), and the Conference-side handler links a speaker only when that flag says an external provider asserted the address (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:14-22, match at :70). An address typed into a local registration form proves nothing, and a speaker profile carries rights over that speaker's own record and sessions, so a local password registration under a speaker's address links nothing and is logged as an organizer cue instead (:31-33). Sessionize-imported speakers, whose email is always null, are linked deliberately by an organizer through PUT /Speakers/{id}/link (BR-209).

Revision (2026-10-01)

Anchor refresh only: no decision or rationale changed. The Decision and Trade-offs citations into ADC's AuthenticationService.cs are re-anchored to the current file (ExternalLoginAsync :196, the transaction :203, ExternalLoginCoreAsync :208, the provider lookup :217, Email.Create :236, Auth.ExternalEmailInvalid :240, the by-email lookup :246, the one-provider guard :341 with its code at :344 and message at :345, the verifier calls :354-355 and :268-269, Auth.ExternalEmailNotVerified :360, HasLocalPassword :372, Auth.ExternalLinkRequiresLocalSignIn :375, LinkExternalProvider :380, User.CreateExternal :275, IssueTokensAsync :320), and the verifier registration in Identity.API DependencyInjection.cs is re-anchored to :56. Anchors inside the 2026-09-07 revision are left as that record wrote them.

Revision (2026-10-06)

Anchor refresh only: no decision or rationale changed.

  • The Decision and Trade-offs citations into ADC's AuthenticationService.cs are re-anchored to the current file (ExternalLoginAsync :214, the transaction :221, ExternalLoginCoreAsync :226, the provider lookup :235-236, Email.Create :254, Auth.ExternalEmailInvalid :258, the by-email lookup :264, the one-provider guard :359 with its code at :362 and message at :363, the verifier calls :372-373 and :286-287, Auth.ExternalEmailNotVerified :378, HasLocalPassword :390, Auth.ExternalLinkRequiresLocalSignIn :393, LinkExternalProvider :398, User.CreateExternal :293, IssueTokensAsync :338), and IsEmailConfirmed = emailVerified in User.cs to :288.
  • Facts recorded in the 2026-09-07 revision whose anchors moved (that section keeps its original anchors): the password-login refusal is now MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:153 (cost burned at :155, predicate HasStoredCredential at :607); the change-password refusal is ChangePasswordHandlerBase.cs:82; OAuthFlowStateStore BeginAsync is at :51 and TryCompleteAsync at :84 (class :20, key :23 and IsEnforced :39 unchanged); User.HasLocalPassword is at User.cs:168; and the Conference handler's verified-email gate is UserRegisteredHandler.cs:99.
  • All other anchors (ExternalAuthExtensions.cs, the Identity InitialCreate migration, IExternalLoginEmailVerifier.cs:11, HttpContextExternalLoginEmailVerifier.cs:17 / :33, Identity.API DependencyInjection.cs:56, IAuthenticationService.cs:139) were re-verified against current source and still hold.

ADR-004 (the RS256/JWKS token this flow exchanges the external identity for, and validates everywhere after), ADR-022 (the browser cookies that carry the resulting session), ADR-029 (the brute-force protection on the first-party credential surface this flow sits beside), ADR-032 (the password hashing external accounts deliberately skip), ADR-020 (the inert-until-configured opt-in posture this mirrors), ADR-005 (User.Anonymize clears the provider fields on erasure), ADR-043 (the native-app callback that adds the allow-listed custom-scheme redirect branch to this flow's CompleteAsync).