Architecture Decision Record
ADR-036: External OAuth Login (Federated Google/GitHub) 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).
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, GitHub) 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 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 and/or GitHub only when that provider'sOAuth:<Provider>:ClientIdis present; with neither 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. A configuredClientIdwith a missingClientSecretfails fast at startup. This is the same signal the UI'sConfigurationOAuthUISettingsuses to light up the Google/GitHub buttons (GoogleEnabled/GitHubEnabled). - 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:123) first looks the user up byLoginProvider+ProviderKey(AuthenticationService.cs:144). Missing, it looks up by email (AuthenticationService.cs:158) and, when a local or other-provider account already owns that email, applies an account-takeover guard before linking: it asksIExternalLoginEmailVerifier.IsCurrentExternalLoginEmailVerifiedAsync(AuthenticationService.cs:173) 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:179) instead of linking, so an unverified provider assertion cannot claim an existing local account. Only when the provider did assert a verified email does it link the external provider to that account (User.LinkExternalProvider,AuthenticationService.cs:184). When no account owns the email it creates a newAttendeeviaUser.CreateExternal(AuthenticationService.cs:190; an external user has empty password hash/salt and carriesLoginProvider/ProviderKey). In the link and create cases it rotates the refresh token, saves, and mints the access token, 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. (The earlier standaloneAddExternalLoginProviderFieldsmigration survives only in the frozen combined single-DB archive underMMCA.ADC.Migrations.SqlServer/and is never applied to the per-service database.)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:55. 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:179), 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:74), 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.
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).