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.
AddExternalAuthProvidersreads theOAuthsection and enables Google, GitHub and/or Apple, each one only when that provider'sOAuth:<Provider>:ClientIdis 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, soAddCommonAuthentication'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 configuredClientIdwith a missingClientSecretfails 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 isOAuth:Apple:TeamId(:131),OAuth:Apple:KeyId(:134) andOAuth:Apple:PrivateKeyPem(:137) instead, and its callback path is/auth/callback/apple(:143). The same per-provider signal is what the UI'sConfigurationOAuthUISettingsuses 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
ExternalLogincookie 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 theuser:emailscope because its default scope omits the email the exchange needs. OAuthControllerBase.CompleteAsyncperforms the exchange. The challenge endpoints redirect to the provider and back toCompleteAsync, which authenticates theExternalLogincookie, extracts(provider, providerKey, email, firstName, lastName)from the standard claims, and callsIAuthenticationService.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.
CompleteAsyncmints 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 toOAuth:UIBaseUrl, but when the stashedreturnUrlis an absolute URI whose custom scheme is allow-listed inOAuth:AllowedReturnUrlSchemesit targets that native-app URL instead, so a system-browserWebAuthenticatorwindow captures the code and closes (ADR-043); an empty allowlist (the default) keeps the web-only behavior exactly, and http/httpsreturnUrls never match, so the allowlist cannot become an open redirect. Completion errors follow the same branch. The UI callsExchangeAsync(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, theRefererheader, 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 inExternalLoginCoreAsync(AuthenticationService.cs:226), which first looks the user up byLoginProvider+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 aResult, and an unparseable claim value rejects the sign-in with anError.Validationcarrying codeAuth.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 validatedEmailvalue object rather than the raw claim string. When an account already owns that email, three guards run inTryLinkProviderToExistingAccountAsyncbefore anything is linked. First, one provider link per user: an account that is already externally linked to a different provider (IsExternalLoginplus aLoginProvidermismatch,AuthenticationService.cs:359) is rejected with anError.Conflictcarrying codeAuth.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 asksIExternalLoginEmailVerifier.IsCurrentExternalLoginEmailVerifiedAsync(AuthenticationService.cs:372-373) whether the provider asserted the incoming email as verified, and when it did not it rejects the sign-in withAuth.ExternalEmailNotVerified(defined inline atAuthenticationService.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 atAuthenticationService.cs:390) is rejected withAuth.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 newAttendeeviaUser.CreateExternal(AuthenticationService.cs:293; an external user has empty password hash/salt and carriesLoginProvider/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 toUser.CreateExternalas a sixthemailVerifiedargument and rides theUserRegisteredevent. 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 callsIssueTokensAsync(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 sameAuthenticationResponseshape as a local login. - The linkage is two nullable columns and a filtered unique index.
User.LoginProvider(varchar(50)) andUser.ProviderKey(varchar(256)) are null for local accounts;IsExternalLoginis derived fromLoginProvider 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 IdentityInitialCreatemigration (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/Migrations/20260606053130_InitialCreate.cs:63, index at20260606053130_InitialCreate.cs:100), so two external identities cannot map to the same local account while local (null,null) accounts are unconstrained. (ADC'sSource/Hosting/carries four per-module migration projects, Identity, Conference, Engagement and Notification, alongsideMMCA.ADC.AppHost, so the Identity database's ownInitialCreateis 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.Anonymizeclears both fields on erasure (ADR-005). - New external users re-use the local registration side-effect. A brand-new external
Userpublishes the same post-commitUserRegisteredintegration event the localRegisterAsyncpath publishes, so Conference runs the BR-207 speaker email-match auto-link asynchronously. The first token does not yet carryspeaker_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
GETthat 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-bandPOST. - 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
ClientIdand defaultingExternalLoginAsyncto 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
Userfactory 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__UIBaseUrlredirect target). A host that registers schemes but forgets the controller, or configures aClientIdwithout 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-livedExternalLogincookie principal byMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:17, which reads the provider'semail_verifiedclaim (HttpContextExternalLoginEmailVerifier.cs:33) and is wired atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:56. Google'semail_verifiedclaim passes the guard; GitHub's OAuth flow asserts nothing, so a GitHub sign-in whose email matches an existing account is rejected withAuth.ExternalEmailNotVerified, not linked. The framework itself still performs no such check:Auth.ExternalEmailNotVerifiedis 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
Userbefore 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 onLoginProvider/ProviderKey; code that assumes everyUserhas a usable password must checkIsExternalLoginfirst. 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.
- 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.LoginAsyncnow 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).ChangePasswordHandlerBaserefuses 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. - 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). - 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
ExternalLoginAsyncfirst 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, answeringAuth.ExternalEmailNotVerifiedat:309). It then refuses when the matched local account already signs in with a password (:321, answeringAuth.ExternalLinkRequiresLocalSignInat: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 asUser.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). - The same verified-email requirement gates ADC's speaker auto-link. The
UserRegisteredintegration event carriesEmailVerified(defaultfalse,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 throughPUT /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.csare 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:359with its code at:362and message at:363, the verifier calls:372-373and:286-287,Auth.ExternalEmailNotVerified:378,HasLocalPassword:390,Auth.ExternalLinkRequiresLocalSignIn:393,LinkExternalProvider:398,User.CreateExternal:293,IssueTokensAsync:338), andIsEmailConfirmed = emailVerifiedinUser.csto: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, predicateHasStoredCredentialat:607); the change-password refusal isChangePasswordHandlerBase.cs:82;OAuthFlowStateStoreBeginAsyncis at:51andTryCompleteAsyncat:84(class:20, key:23andIsEnforced:39unchanged);User.HasLocalPasswordis atUser.cs:168; and the Conference handler's verified-email gate isUserRegisteredHandler.cs:99. - All other anchors (
ExternalAuthExtensions.cs, the IdentityInitialCreatemigration,IExternalLoginEmailVerifier.cs:11,HttpContextExternalLoginEmailVerifier.cs:17/:33, Identity.APIDependencyInjection.cs:56,IAuthenticationService.cs:139) were re-verified against current source and still hold.
Related
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).