diff --git a/README.md b/README.md index 8aaa457..3cdcc75 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - Razor Pages app under `src/Bifrost.Web` - PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations` - Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating +- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a fresh Google step-up proof ([docs/linked-accounts.md](docs/linked-accounts.md)) - explicit member roles and admin-managed patron or contributor tier snapshots - patron support event recording with derived patron point summaries and tier refresh for voting weight - shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index a251b00..8163eee 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -105,6 +105,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. +A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`). + Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. ### Patron Collection Crossing diff --git a/docs/linked-accounts.md b/docs/linked-accounts.md new file mode 100644 index 0000000..4ecf3e9 --- /dev/null +++ b/docs/linked-accounts.md @@ -0,0 +1,63 @@ +# Linked accounts + +`/Membership/LinkedAccounts` (`src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml[.cs]`) is where a signed-in member sees the provider logins attached to their Heimdall account, links another one, and unlinks one. Heimdall owns the logins and every rule about them; this page starts the flows and shows what Heimdall answered. A member reaches it from the membership status page. A session with no Heimdall account (a Bifrost-native GitHub sign-in) gets a note instead of controls. + +## What it does + +| Action | What Bifrost does | Heimdall call | +|---|---|---| +| Show my logins | A fresh ordinary Heimdall sign-in as the session's account, through the proof provider (below); inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer | +| Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Needs a current list. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` | +| Unlink | A sign-in through Google with `stepUp: true` (app secret); inside its completion, the unlink | `POST /v1/me/identities/{id}/unlink` with that sign-in's bearer | + +The linkable providers are the ones Heimdall's `bifrost` profile admits (`identityProviders` in Heimdall `src/app-profiles.ts`): Discord, Patreon, Google. Twitch is offered for sign-in elsewhere in Bifrost but the profile refuses it, so it is not offered here. + +## How the bearer is kept: it is not + +Bifrost keeps no Heimdall token. Each action is an attempt: a Data Protection cookie (`__Host-bifrost_heimdall_`, HttpOnly, Secure, ten minutes) holding a purpose (`ListProof`, `UnlinkProof`, `LinkStart`, `LinkAwaitGoogle`, `LinkConfirm`, `LinkAwaitDirect`), the account the session must be, and a target (the provider to link, or the identity to unlink). The browser returns from Heimdall to `/auth/heimdall/complete`; Bifrost redeems the code, verifies the token, requires that the proven account is the attempt's account and that the browser's session is that same account, calls Heimdall with the bearer inside that one request, and drops it. The bearer is never stored, logged, put in a cookie or a redirect, or rendered. Heimdall's member routes go through an HTTP client that carries no app secret (`HeimdallMemberClient`), so a Bifrost fault cannot call them without a member present. + +The list is kept for five minutes in this process only (`LinkedAccountsSnapshots`), keyed by the Heimdall account id, so the page can render after the completion's redirect: identity id, provider, display name (or username when there is none) and link date. Never the bearer, the email, the provider user id or the scopes. It is not given to the browser. A restart or the five minutes empties it and the page offers "Show my linked logins" again. + +Every action is a POST with antiforgery; a GET starts nothing. The completion route's answers are `Cache-Control: no-store`. The page and every redirect carry only a code from a closed set (`HeimdallLinkOutcomes`, `?result=`); the wording is Bifrost's, never Heimdall's text or the query's. + +## Which provider proves the account + +An ordinary proof sign-in through Discord or Patreon when the account no longer holds that login makes Heimdall mint a new, unrelated account that holds it (those providers are `mints_account`), and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. Google is `requires_link`: Heimdall refuses a Google sign-in for a login no account holds (`identity_not_linked`) and never mints. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now). This guarantee covers this page's proofs only: the older anonymous `GET /auth/heimdall/{provider}` still starts an ordinary sign-in through any named provider, and can overwrite an in-flight attempt slot; that route is unchanged here. + +- With a current list (the five-minute snapshot), the proof goes through a provider on it: Discord, else Patreon, else Google. +- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Google (cannot create an account) / Discord / Patreon" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named Discord or Patreon the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`). +- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. After an unlink whose result is not a definitive refusal (a 500, a lost or malformed answer, not found) the list is dropped, since Heimdall may have applied it; only `403 step_up_required`, `409 last_identity` and `401` leave it. + +## The proof limit + +Unlinking needs fresh proof: Heimdall refuses `403 step_up_required` unless the access token carries `step_up_at` from a step-up sign-in within 600 seconds (`docs/service-contract.md`, `POST /v1/me/identities/{identityId}/unlink`, in the Heimdall repository). Only Google can give that to a `bifrost` sign-in: Discord and Patreon have no re-authentication parameter (an open provider session completes without credentials), and Twitch and YouTube, which do, are not in the `bifrost` profile. So: + +- An account with no Google login linked cannot unlink anything from here. The page says so and shows no Unlink button; a posted unlink starts nothing. +- Heimdall answers `503 provider_not_configured` for Google until a Google OAuth client is configured on the Heimdall host (follow-up `heimdall-body:follow_up:google-console-client-registration`; not set live as of 2026-11-13). The step-up start is then refused and the page says fresh proof could not be started. Nothing loops: the refusal sets no attempt cookie. +- Linking Google needs the same client. Until then the Google link button ends in "That could not be completed. Nothing was changed." +- A Google step-up proof is as strong as Google's `prompt=login`; Heimdall says "asked", not "verified" (`stepUpRequested`). + +The unlink completion must also say a step-up was requested (`stepUpRequested`, set by Heimdall when the start asked for one); one that does not is refused without calling Heimdall. Heimdall's other answers are shown as plain outcomes with nothing changed: `409 last_identity` (the last login is never removed), `404 identity_not_found` (the list is dropped as stale), `401 sign_in_required`, `403 step_up_required`, and any failure to reach Heimdall. + +A link refused because the login already belongs to another account (`409 identity_linked_elsewhere` from `links/confirm`; for Discord and Patreon the callback's `link_initiator_mismatch`, carried on by the complete page as a fixed result) is shown as "That login belongs to another account." The other account is never named. + +## Not here + +- Merging two accounts. A refused link ends there; nothing is moved or merged. +- Step-up through Discord, Patreon or Twitch. Heimdall does not offer it to the `bifrost` profile. +- Linking Twitch or GitHub: not admitted by the `bifrost` profile, and Heimdall's GitHub runtime is not implemented. +- A list of other apps' logins, grants or consent receipts (Heimdall's grants page). + +## Where the code is + +- `Pages/Membership/LinkedAccounts.cshtml[.cs]`: the page and its three POST handlers. +- `Features/Heimdall/HeimdallAuthService.cs`: the attempt machinery (`StartListAsync`, `StartLinkAsync`, `StartUnlinkAsync`, `ContinueLinkAsync`), shared with sign-in. +- `Features/Heimdall/HeimdallMemberClient.cs`: the two member calls and their outcome mapping. +- `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list. +- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`. + +## Known leftover + +Several exits leave the held attempt cookie (`__Host-bifrost_heimdall_`, HttpOnly, so script cannot clear it) in the browser until its ten minutes end: the complete page's redirect when Discord or Patreon refuses a link because the login belongs to another account, every other `heimdall_status=error` redirect, and a return with no code or no attempt. The script redirects without posting anything; clearing the cookie would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do. + +A start takes a free slot, else overwrites the slot that expires first, live or dead (`ChooseSlot`, two slots). So three member starts within ten minutes can cost a still-live older attempt, which then finds no cookie and is refused; the member starts again. diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 8ab2add..12e028e 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -20,7 +20,9 @@ namespace Bifrost.Web.Features.Heimdall; /// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the /// browser that began it. The browser that Heimdall authenticated presents the completion code from its /// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the -/// attempt's cookie and the redeemed return target names the same attempt. +/// attempt's cookie and the redeemed return target names the same attempt. The Linked accounts page's steps (list, +/// link, unlink) are the same machine with other attempt purposes: each is a fresh Heimdall sign-in whose bearer is +/// used inside that one request and dropped. /// public sealed class HeimdallAuthService( HttpClient httpClient, @@ -29,7 +31,9 @@ public sealed class HeimdallAuthService( BifrostIdentityService bifrostIdentityService, IOptions heimdallOptions, IDataProtectionProvider dataProtectionProvider, - TimeProvider timeProvider) + TimeProvider timeProvider, + HeimdallMemberClient memberClient, + LinkedAccountsSnapshots snapshots) { public const string ErrorRedirect = "/?auth=heimdall-error"; public const string AppSecretHeader = "x-heimdall-app-secret"; @@ -58,38 +62,16 @@ public sealed class HeimdallAuthService( } } - private const int RefusalBodyLimit = 4096; - /// - /// Names the HTTP status and, when the first 4 KiB of the body carry one, Heimdall's error code. The code - /// is shown only if it is a short snake_case token, so nothing else from the body (free text, tokens) - /// reaches a log. A body that cannot be read or parsed leaves the status alone: a refusal never becomes a 500. + /// Names the HTTP status and, when the body carries one, Heimdall's error code (a short snake_case token only, + /// see ), so nothing else from the body reaches a log. /// private static async Task RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var detail = $"HTTP {(int)response.StatusCode}"; - try - { - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - var head = new byte[RefusalBodyLimit]; - var length = 0; - while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read) - { - length += read; - } - - using var body = JsonDocument.Parse(head.AsMemory(0, length)); - if (body.RootElement.ValueKind == JsonValueKind.Object - && body.RootElement.TryGetProperty("error", out var error) - && error.ValueKind == JsonValueKind.String - && error.GetString() is { Length: > 0 and <= 64 } code - && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) - { - detail += ", " + code; - } - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) + if (await HeimdallRefusal.CodeAsync(response, cancellationToken) is { } code) { + detail += ", " + code; } return $"Heimdall rejected the auth start ({detail})."; @@ -105,9 +87,13 @@ public sealed class HeimdallAuthService( HeimdallAttemptPurpose purpose = HeimdallAttemptPurpose.SignIn, string? expectedAccountId = null, string? pendingLinkId = null, - string? replaceSlot = null) + string? replaceSlot = null, + bool stepUp = false, + string? target = null) { - if (!AllowedProviders.Contains(provider)) + // The public sign-in routes offer AllowedProviders; the proofs of the Linked accounts page may also use Google + // (a step-up proof only Google can give a bifrost sign-in, an ordinary one for an account that holds Google). + if (!(purpose == HeimdallAttemptPurpose.SignIn ? AllowedProviders : ProofProviders).Contains(provider)) { throw new HeimdallAuthException("Unsupported Heimdall provider."); } @@ -128,14 +114,15 @@ public sealed class HeimdallAuthService( "sign_in", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), - entitlementPolicy); + entitlementPolicy, + stepUp ? true : null); // Headers only: a refusal body is read through RefusedStartMessageAsync's own 4 KiB bound, not buffered whole. using var startMessage = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{provider}/start") { Content = JsonContent.Create(request) }; - using var response = await httpClient.SendAsync(startMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await SendStartAsync(startMessage, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -156,36 +143,128 @@ public sealed class HeimdallAuthService( timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime, purpose, expectedAccountId, - pendingLinkId); + pendingLinkId, + target); return new HeimdallAuthStartResult( new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt)); } - /// The provider a link's fresh sign-in uses: the one the person signed in with, else Discord. - public static string LinkSignInProvider(string? sessionProvider) => - sessionProvider is not null && AllowedProviders.Contains(sessionProvider) ? sessionProvider.ToLowerInvariant() : "discord"; + /// A start that cannot reach Heimdall is a refusal like any other, never a 500. + private async Task SendStartAsync(HttpRequestMessage message, CancellationToken cancellationToken) + { + try + { + return await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + throw new HeimdallAuthException("Heimdall could not be reached for the auth start."); + } + } /// - /// The account page's link button: an ordinary Heimdall sign-in whose attempt says it is only the proof for a - /// link, and which person it must prove. The session is never replaced by it. + /// The provider an ordinary proof sign-in uses, or null when none is safe. A sign-in through a provider the account no + /// longer holds makes Heimdall mint an unrelated account that holds that login (Discord and Patreon; Google refuses instead), so it is never guessed: with the account's + /// current list, the first held one of the person's choice, Discord, Patreon, Google; with no list, only a provider the + /// member named (), who is the one who knows what they hold. The session's provider claim is not + /// consulted: it records how they signed in once, not what the account holds now. /// - public Task StartLinkAsync( + public static string? ProofProvider(IReadOnlyCollection? held, string? via) + { + var chosen = via is not null && ProofProviders.Contains(via) ? via.ToLowerInvariant() : null; + if (held is null) + { + return chosen; + } + + return new[] { chosen, "discord", "patreon", "google" } + .FirstOrDefault(x => x is not null && held.Contains(x, StringComparer.OrdinalIgnoreCase)); + } + + private string ProofProviderFor(string accountId, string? via) => + ProofProvider(snapshots.Get(accountId)?.Select(x => x.Provider).ToArray(), via) + ?? throw new HeimdallAuthException("No provider the account is known to hold can prove it."); + + /// + /// The page's proof sign-ins. Each is a Heimdall sign-in for the account already signed in here, kept as an attempt + /// that says what it is the proof for (list, link, unlink) and which person it must prove. The session is never replaced. + /// + private Task StartProofAsync( + HeimdallAttemptPurpose purpose, + string provider, string sessionAccountId, - string? sessionProvider, string callbackBaseUrl, IEnumerable> requestCookies, + bool stepUp, + string? target, CancellationToken cancellationToken) => StartAsync( - LinkSignInProvider(sessionProvider), + provider, callbackBaseUrl, LocalReturnPath.Parse(LinkReturnPath), requireMemberAccess: false, requestCookies, cancellationToken, - HeimdallAttemptPurpose.LinkStart, - sessionAccountId); + purpose, + sessionAccountId, + stepUp: stepUp, + target: target); - public const string LinkReturnPath = "/Membership/Status"; + /// + /// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. Through a provider on + /// the current list, or, when there is none, the one the member names in . + /// + public Task StartListAsync( + string sessionAccountId, string? via, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.ListProof, ProofProviderFor(sessionAccountId, via), + sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken); + + /// + /// Links another provider: an ordinary sign-in as proof through a provider on the account's current list (there is no + /// guessing without one), then the link start for with its bearer. + /// + public Task StartLinkAsync( + string sessionAccountId, string targetProvider, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) + { + if (!LinkableProviders.Contains(targetProvider)) + { + throw new HeimdallAuthException("Unsupported provider to link."); + } + + return StartProofAsync( + HeimdallAttemptPurpose.LinkStart, ProofProviderFor(sessionAccountId, null), + sessionAccountId, callbackBaseUrl, requestCookies, false, targetProvider.ToLowerInvariant(), cancellationToken); + } + + /// + /// Unlinks one login: a step-up sign-in through Google, then POST /v1/me/identities/{id}/unlink with its bearer. The + /// identity must be on the account's current snapshot and the account must hold Google; otherwise no proof can be had + /// and Heimdall is not asked. + /// + public Task StartUnlinkAsync( + string sessionAccountId, string identityId, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.UnlinkProof, StepUpProvider, sessionAccountId, callbackBaseUrl, requestCookies, true, identityId, cancellationToken); + + /// + /// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, identityProviders): a link or a proof can use them. + /// Twitch is offered for sign-in here but the profile refuses it, so it is not linkable. + /// + public static readonly IReadOnlyList LinkableProviders = ["discord", "patreon", "google"]; + + private static readonly HashSet ProofProviders = new(LinkableProviders, StringComparer.OrdinalIgnoreCase); + + /// + /// The one provider whose step-up a bifrost sign-in can use (docs/service-contract.md, step-up: Discord and Patreon cannot + /// re-authenticate, Twitch and YouTube are not in the bifrost profile). Heimdall answers 503 until a Google client is configured. + /// + public const string StepUpProvider = "google"; + + public const string LinkReturnPath = "/Membership/LinkedAccounts"; /// /// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose @@ -237,7 +316,7 @@ public sealed class HeimdallAuthService( public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose; /// Redeems the code of an attempt and verifies the token it carries. The token is returned to the caller's stack frame and nowhere else. - private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemVerifiedAsync( + private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemVerifiedAsync( string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(code)) @@ -257,7 +336,7 @@ public sealed class HeimdallAuthService( } var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken); - return (identity, redeemed.AccessToken); + return (identity, redeemed.AccessToken, redeemed); } public async Task CompleteAsync( @@ -274,7 +353,7 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException("Heimdall completion is not a sign-in attempt."); } - var (identity, _) = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); + var (identity, _, _) = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); if (attempt.RequiresMemberAccess && !identity.HasMemberAccess) { throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); @@ -291,9 +370,10 @@ public sealed class HeimdallAuthService( } /// - /// One step of a Google link, chosen by the purpose of the attempt this browser holds. The browser's session must be - /// the account the link was begun for, checked before Heimdall is asked anything and again against the identity the - /// fresh sign-in proves. Each step consumes the held attempt: the next step is a new attempt in the same slot, or none. + /// One step of a Linked accounts action, chosen by the purpose of the attempt this browser holds. The browser's session + /// must be the account the action was begun for, checked before Heimdall is asked anything and again against the identity + /// the fresh sign-in proves. Each step consumes the held attempt: the next step is a new attempt in the same slot, or none. + /// The bearer a step redeems is used inside the step and dropped. /// public async Task ContinueLinkAsync( string callbackBaseUrl, @@ -318,7 +398,7 @@ public sealed class HeimdallAuthService( case HeimdallAttemptPurpose.LinkStart: { var (_, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); - var authorizationUrl = await StartGoogleLinkAsync(callbackBaseUrl, attempt, bearer, cancellationToken); + var authorizationUrl = await StartLinkProviderAsync(callbackBaseUrl, attempt, bearer, cancellationToken); return new HeimdallLinkStep(authorizationUrl.Url, cookieName, authorizationUrl.CookieValue, null); } @@ -330,7 +410,7 @@ public sealed class HeimdallAuthService( } var next = await StartAsync( - LinkSignInProvider(attempt.Provider), + attempt.Provider, callbackBaseUrl, attempt.ReturnPath, requireMemberAccess: false, @@ -351,19 +431,86 @@ public sealed class HeimdallAuthService( { // Membership follows the verified identity exactly as at sign-in; the session principal is not replaced. await UpsertUserAccountAsync(identity, attempt.Provider, grantMemberAccess: false, cancellationToken); + snapshots.Remove(identity.AccountId); } return new HeimdallLinkStep(null, cookieName, null, outcome); } + case HeimdallAttemptPurpose.LinkAwaitDirect: + { + // A provider other than Google attaches in Heimdall's callback; the browser returns with a link completion. + var (identity, _, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (redeemed.Mode != "link" || !string.Equals(redeemed.Provider, attempt.Target, StringComparison.Ordinal)) + { + throw new HeimdallAuthException("Heimdall completion is not this link."); + } + + await UpsertUserAccountAsync(identity, attempt.Target!, grantMemberAccess: false, cancellationToken); + snapshots.Remove(identity.AccountId); + return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.Linked); + } + + case HeimdallAttemptPurpose.ListProof: + { + var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var listed = await memberClient.ListAsync(bearer, cancellationToken); + if (listed.Call == HeimdallMemberCall.Ok) + { + snapshots.Put(identity.AccountId, listed.Identities!); + } + + return new HeimdallLinkStep(null, cookieName, null, listed.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Listed : OutcomeOf(listed.Call)); + } + + case HeimdallAttemptPurpose.UnlinkProof: + { + var (identity, bearer, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (redeemed.StepUpRequested != true) + { + // Heimdall reports "asked" on a step-up completion; one that does not was not a step-up sign-in. + return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.StepUpRequired); + } + + var unlinked = await memberClient.UnlinkAsync(bearer, attempt.Target ?? string.Empty, cancellationToken); + // Only Heimdall's definitive refusals leave the account as the list showed it. Any other end (not found, a lost + // answer, a 500, a malformed 200) may have applied the unlink, so the list is dropped rather than trusted. + if (unlinked.Call == HeimdallMemberCall.Ok) + { + snapshots.Put(identity.AccountId, unlinked.Identities!); + } + else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity)) + { + snapshots.Remove(identity.AccountId); + } + + return new HeimdallLinkStep(null, cookieName, null, unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call)); + } + default: throw new HeimdallAuthException("Heimdall completion is not a link attempt."); } } + private static HeimdallLinkOutcome OutcomeOf(HeimdallMemberCall call) => call switch + { + HeimdallMemberCall.SignInRequired => HeimdallLinkOutcome.SignInRequired, + HeimdallMemberCall.StepUpRequired => HeimdallLinkOutcome.StepUpRequired, + HeimdallMemberCall.IdentityNotFound => HeimdallLinkOutcome.IdentityNotFound, + HeimdallMemberCall.LastIdentity => HeimdallLinkOutcome.LastIdentity, + _ => HeimdallLinkOutcome.Error + }; + /// Redeems a link attempt's code and requires it to prove the account the link was begun for. private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemOwnerAsync( string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) + { + var (identity, bearer, _) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken); + return (identity, bearer); + } + + private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemOwnerWithBodyAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) { var proven = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); if (!string.Equals(proven.Identity.AccountId, attempt.ExpectedAccountId, StringComparison.Ordinal)) @@ -374,18 +521,29 @@ public sealed class HeimdallAuthService( return proven; } - private async Task<(Uri Url, string CookieValue)> StartGoogleLinkAsync( + /// + /// Starts the link to the attempt's target provider with the member's bearer. Google attaches in two phases (the + /// callback writes a pending link, a second fresh sign-in confirms it); the other admitted providers attach in the callback. + /// A cookie written before the target existed names no provider and is a Google link. + /// + private async Task<(Uri Url, string CookieValue)> StartLinkProviderAsync( string callbackBaseUrl, HeimdallAttempt attempt, string bearer, CancellationToken cancellationToken) { + var target = attempt.Target ?? "google"; + if (!LinkableProviders.Contains(target)) + { + throw new HeimdallAuthException("Unsupported provider to link."); + } + var options = heimdallOptions.Value; var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); - using var message = new HttpRequestMessage(HttpMethod.Post, "/v1/oauth/google/start") + using var message = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{target}/start") { Content = JsonContent.Create(new HeimdallStartRequest( - options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null)) + options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null, null)) }; message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); - using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await SendStartAsync(message, cancellationToken); if (!response.IsSuccessStatusCode) { throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken)); @@ -403,9 +561,10 @@ public sealed class HeimdallAuthService( false, attempt.ReturnPath, timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime, - HeimdallAttemptPurpose.LinkAwaitGoogle, + target == "google" ? HeimdallAttemptPurpose.LinkAwaitGoogle : HeimdallAttemptPurpose.LinkAwaitDirect, attempt.ExpectedAccountId, - null); + null, + target); return (new Uri(payload.AuthorizationUrl, UriKind.Absolute), Protect(awaiting)); } @@ -464,7 +623,7 @@ public sealed class HeimdallAuthService( private string Protect(HeimdallAttempt attempt) => _protector.Protect(JsonSerializer.Serialize(new AttemptWire( attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt, - attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId))); + attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target))); /// The live attempt a cookie value protects when it belongs to this nonce, else null. private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => @@ -488,7 +647,7 @@ public sealed class HeimdallAuthService( return new HeimdallAttempt( wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt, - wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId); + wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId, wire.Target); } catch (Exception error) when (error is CryptographicException or JsonException or FormatException) { @@ -504,7 +663,8 @@ public sealed class HeimdallAuthService( DateTimeOffset ExpiresAt, HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, - string? PendingLinkId = null); + string? PendingLinkId = null, + string? Target = null); private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, @@ -659,51 +819,87 @@ public sealed record HeimdallAttempt( DateTimeOffset ExpiresAt, HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, - string? PendingLinkId = null); + string? PendingLinkId = null, + string? Target = null); /// /// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in. -/// The link purposes are the fresh sign-ins and the Google round trip of an explicit link; they never sign anyone in. +/// The other purposes belong to the Linked accounts page: the fresh sign-ins that prove an account for a list, a link or +/// an unlink, and the provider round trips of a link. They never sign anyone in. Values are persisted in cookies: append only. /// public enum HeimdallAttemptPurpose { SignIn = 0, LinkStart = 1, LinkAwaitGoogle = 2, - LinkConfirm = 3 + LinkConfirm = 3, + ListProof = 4, + UnlinkProof = 5, + LinkAwaitDirect = 6 } +/// How a Linked accounts action ended. is its closed wire form. public enum HeimdallLinkOutcome { Linked, IdentityLinkedElsewhere, Expired, - Error + Error, + Listed, + Unlinked, + LastIdentity, + StepUpRequired, + IdentityNotFound, + SignInRequired, + ProofUnavailable, + ListExpired, + ProviderNeeded } /// -/// Where a link step sends the browser: to with a new attempt cookie in the held slot, or, when the -/// link is finished, back to the account page with . +/// Where a Linked accounts step sends the browser: to with a new attempt cookie in the held +/// slot, or, when the action is finished, back to the page with the outcome's code in ?result=. /// public static class HeimdallLinkOutcomes { - public static string Redirect(HeimdallLinkOutcome outcome) => HeimdallAuthService.LinkReturnPath + "?link=" + Code(outcome); + public const string ResultParameter = "result"; + + public static string Redirect(HeimdallLinkOutcome outcome) => + HeimdallAuthService.LinkReturnPath + "?" + ResultParameter + "=" + Code(outcome); public static string Code(HeimdallLinkOutcome outcome) => outcome switch { HeimdallLinkOutcome.Linked => "linked", HeimdallLinkOutcome.IdentityLinkedElsewhere => "identity_linked_elsewhere", HeimdallLinkOutcome.Expired => "expired", + HeimdallLinkOutcome.Listed => "listed", + HeimdallLinkOutcome.Unlinked => "unlinked", + HeimdallLinkOutcome.LastIdentity => "last_identity", + HeimdallLinkOutcome.StepUpRequired => "step_up_required", + HeimdallLinkOutcome.IdentityNotFound => "identity_not_found", + HeimdallLinkOutcome.SignInRequired => "sign_in_required", + HeimdallLinkOutcome.ProofUnavailable => "proof_unavailable", + HeimdallLinkOutcome.ListExpired => "list_expired", + HeimdallLinkOutcome.ProviderNeeded => "provider_needed", _ => "error" }; - /// The words for an outcome code: a closed set, never Heimdall's text. Anything else gets none. + /// The words for an outcome code: a closed set, never Heimdall's text and never the query's. Anything else gets none. public static string? Wording(string? code) => code switch { - "linked" => "Google is now linked to your Heimdall account.", - "identity_linked_elsewhere" => "That Google identity is already linked to a different Heimdall account. Nothing was linked.", - "expired" => "The Google link expired before it was confirmed. Nothing was linked; start the link again.", - "error" => "The Google link could not be completed. Nothing was linked.", + "linked" => "The login is now linked to your Heimdall account.", + "identity_linked_elsewhere" => "That login belongs to another account. Nothing was linked.", + "expired" => "The link expired before it was confirmed. Nothing was linked; start the link again.", + "listed" => null, + "unlinked" => "The login was unlinked.", + "last_identity" => "That is the last login on your account, and Heimdall will not remove it. Nothing was changed.", + "step_up_required" => "Heimdall did not accept the sign-in as fresh proof. Nothing was changed; try again.", + "identity_not_found" => "That login is no longer on your account. Nothing was changed; the list below is read again when you ask.", + "sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.", + "proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.", + "provider_needed" => "Bifrost does not know which logins this account holds right now, so it will not guess. Pick one you know it holds to read the list. Nothing was changed.", + "list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.", + "error" => "That could not be completed. Nothing was changed.", _ => null }; } @@ -722,7 +918,9 @@ public sealed record HeimdallStartRequest( [property: JsonPropertyName("returnTo")] string ReturnTo, [property: JsonPropertyName("handoff")] HeimdallHandoff Handoff, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy); + [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [property: JsonPropertyName("stepUp")] bool? StepUp = null); public sealed record HeimdallHandoff( [property: JsonPropertyName("kind")] string Kind); @@ -745,7 +943,10 @@ public sealed record HeimdallRedeemRequest( public sealed record HeimdallRedeemResponse( [property: JsonPropertyName("returnTo")] string? ReturnTo, [property: JsonPropertyName("accessToken")] string? AccessToken, - [property: JsonPropertyName("account")] HeimdallAccount? Account); + [property: JsonPropertyName("account")] HeimdallAccount? Account, + [property: JsonPropertyName("mode")] string? Mode = null, + [property: JsonPropertyName("provider")] string? Provider = null, + [property: JsonPropertyName("stepUpRequested")] bool? StepUpRequested = null); public sealed record HeimdallAccount( [property: JsonPropertyName("id")] string Id); diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs index b7d67b2..4488d7b 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs @@ -33,6 +33,12 @@ public static class HeimdallCompletePage var pendingLinkId = fragment.get("heimdall_pending_link_id"); var failed = fragment.get("heimdall_status") === "error"; history.replaceState(null, "", location.pathname + location.search); + // A provider other than Google attaches in Heimdall's callback, which refuses an identity another account + // holds. Only this one refusal is carried on, as a fixed result the page words itself; nothing from the fragment is shown. + if (failed && attempt && fragment.get("heimdall_error") === "link_initiator_mismatch") { + location.replace("/Membership/LinkedAccounts?result=identity_linked_elsewhere"); + return; + } if (failed || !(code || pendingLinkId) || !attempt) { location.replace("/?auth=heimdall-error"); return; diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs new file mode 100644 index 0000000..04a29d9 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Bifrost.Web.Configuration; + +namespace Bifrost.Web.Features.Heimdall; + +/// One login a Heimdall account holds, as the Linked accounts page shows it. No provider user id, scope or email. +public sealed record LinkedIdentitySummary(string Id, string Provider, string? DisplayName, DateTimeOffset? LinkedAt); + +public enum HeimdallMemberCall +{ + Ok, + SignInRequired, + StepUpRequired, + IdentityNotFound, + LastIdentity, + Error +} + +public sealed record HeimdallMemberResult(HeimdallMemberCall Call, IReadOnlyList? Identities); + +/// +/// Heimdall's member routes (docs/service-contract.md: GET /v1/me/identities, POST /v1/me/identities/{id}/unlink). Each +/// takes the member's bearer as an argument, uses it for that one request and keeps nothing. Its HttpClient carries no +/// app secret: these routes act only for a member who is present, so a Bifrost fault cannot reach them without one. +/// +public sealed class HeimdallMemberClient(HttpClient httpClient) +{ + private const int MaxIdentities = 32; + private const int MaxDisplayName = 80; + + public static void ConfigureClient(HttpClient client, HeimdallOptions options) => + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + + public Task ListAsync(string bearer, CancellationToken cancellationToken) => + SendAsync(HttpMethod.Get, "/v1/me/identities", bearer, cancellationToken); + + public Task UnlinkAsync(string bearer, string identityId, CancellationToken cancellationToken) => + SendAsync(HttpMethod.Post, $"/v1/me/identities/{Uri.EscapeDataString(identityId)}/unlink", bearer, cancellationToken); + + private async Task SendAsync(HttpMethod method, string path, string bearer, CancellationToken cancellationToken) + { + try + { + using var message = new HttpRequestMessage(method, path); + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode == HttpStatusCode.OK) + { + return new HeimdallMemberResult(HeimdallMemberCall.Ok, await ReadIdentitiesAsync(response, cancellationToken)); + } + + var code = await HeimdallRefusal.CodeAsync(response, cancellationToken); + var call = (response.StatusCode, code) switch + { + (HttpStatusCode.Unauthorized, _) => HeimdallMemberCall.SignInRequired, + (HttpStatusCode.Forbidden, "step_up_required") => HeimdallMemberCall.StepUpRequired, + (HttpStatusCode.NotFound, "identity_not_found") => HeimdallMemberCall.IdentityNotFound, + (HttpStatusCode.Conflict, "last_identity") => HeimdallMemberCall.LastIdentity, + _ => HeimdallMemberCall.Error + }; + return new HeimdallMemberResult(call, null); + } + catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException + && !cancellationToken.IsCancellationRequested) + { + return new HeimdallMemberResult(HeimdallMemberCall.Error, null); + } + } + + /// The identities array of a 200 answer, keeping only the fields the page shows; a malformed entry is dropped. + private static async Task> ReadIdentitiesAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + var list = new List(); + if (body.RootElement.ValueKind != JsonValueKind.Object + || !body.RootElement.TryGetProperty("identities", out var identities) + || identities.ValueKind != JsonValueKind.Array) + { + return list; + } + + foreach (var item in identities.EnumerateArray().Take(MaxIdentities)) + { + var id = Text(item, "id"); + var provider = Text(item, "provider"); + if (!Guid.TryParse(id, out _) + || provider is not { Length: > 0 and <= 32 } + || !provider.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + continue; + } + + var name = Text(item, "displayName") ?? Text(item, "username"); + if (name is { Length: > MaxDisplayName }) + { + name = name[..MaxDisplayName]; + } + + DateTimeOffset? linkedAt = DateTimeOffset.TryParse(Text(item, "linkedAt"), out var parsed) ? parsed : null; + list.Add(new LinkedIdentitySummary(id!, provider, name, linkedAt)); + } + + return list; + } + + private static string? Text(JsonElement item, string name) => + item.ValueKind == JsonValueKind.Object && item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs new file mode 100644 index 0000000..fea10aa --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace Bifrost.Web.Features.Heimdall; + +/// Reads the one thing Bifrost takes from a Heimdall refusal body: its error code, and only if it is a safe token. +public static class HeimdallRefusal +{ + private const int BodyLimit = 4096; + + /// + /// The error code in the first 4 KiB of a body when it is a short snake_case token, else null. A body that cannot be + /// read or parsed yields null: a refusal never becomes a 500, and free text or tokens never reach a log or a page. + /// + public static async Task CodeAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var head = new byte[BodyLimit]; + var length = 0; + while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read) + { + length += read; + } + + using var body = JsonDocument.Parse(head.AsMemory(0, length)); + if (body.RootElement.ValueKind == JsonValueKind.Object + && body.RootElement.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.String + && error.GetString() is { Length: > 0 and <= 64 } code + && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + return code; + } + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + } + + return null; + } +} diff --git a/src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs b/src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs new file mode 100644 index 0000000..08a21bb --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs @@ -0,0 +1,35 @@ +using System.Collections.Concurrent; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// What the Linked accounts page last read from Heimdall for an account: id, provider, display name and link date of +/// each login, nothing else, for . It lives in this process only, keyed by the Heimdall account id, +/// and is never given to the browser; it holds no bearer. The page renders from it because the list is fetched inside a +/// completion request, which redirects. After a restart it is empty and the page offers to read the list again. +/// +public sealed class LinkedAccountsSnapshots(TimeProvider timeProvider) +{ + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); + + private readonly ConcurrentDictionary Identities, DateTimeOffset ExpiresAt)> _held = new(StringComparer.Ordinal); + + public void Put(string accountId, IReadOnlyList identities) + { + var now = timeProvider.GetUtcNow(); + foreach (var (key, value) in _held) + { + if (value.ExpiresAt <= now) + { + _held.TryRemove(key, out _); + } + } + + _held[accountId] = (identities, now + Lifetime); + } + + public IReadOnlyList? Get(string accountId) => + _held.TryGetValue(accountId, out var value) && value.ExpiresAt > timeProvider.GetUtcNow() ? value.Identities : null; + + public void Remove(string accountId) => _held.TryRemove(accountId, out _); +} diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml new file mode 100644 index 0000000..c27ccc6 --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -0,0 +1,119 @@ +@page +@model Bifrost.Web.Pages.Membership.LinkedAccountsModel +@{ + ViewData["Title"] = "Linked accounts"; +} + +
+ Heimdall +

Linked accounts

+

+ The provider logins that sign in to this account. Heimdall holds them and decides what may change; Bifrost keeps none of + your Heimdall sign-in, so each action sends you through Heimdall once more. +

+ + @if (Model.ResultMessage is not null) + { +
@Model.ResultMessage
+ } + + @if (!Model.HasHeimdallAccount) + { +
+ This session is not a Heimdall account, so there are no provider logins to manage here. Sign in with Discord or Patreon + through Heimdall to see them. +
+ } + else + { +

Your logins

+ @if (Model.Identities is null) + { +

+ Bifrost does not hold the list: it asks Heimdall, which means signing in to Heimdall again, and keeps the answer for + five minutes. It also cannot tell which logins this account holds right now, so it will not choose one for you. + Pick a login you know it holds. Signing in through Discord or Patreon when the account no longer holds that login + would create a separate Heimdall account holding it. Google cannot do that: Heimdall refuses a Google sign-in for a + login no account holds. +

+
+ @foreach (var provider in Model.LinkableProviders.OrderBy(x => x == "google" ? 0 : 1)) + { +
+ + +
+ } +
+ } + else + { +
    + @foreach (var identity in Model.Identities) + { +
  • + @LinkedAccountsModel.ProviderName(identity.Provider) + @if (identity.DisplayName is not null) + { + @identity.DisplayName + } + @if (identity.LinkedAt is { } linkedAt) + { + linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC + } + @if (Model.CanProve) + { +
    + + +
    + } +
  • + } +
+ @if (Model.Identities.Count == 1) + { +

This is your only login. Heimdall will not remove the last one.

+ } + + @if (!Model.CanProve) + { +

+ Unlinking needs a fresh Google sign-in as proof. Discord and Patreon cannot give that proof, and no Google login + is linked to this account, so nothing can be unlinked from here yet. Linking Google first makes it possible + once Heimdall has Google set up. +

+ } + else + { +

Unlinking asks you to sign in with Google again as proof, then removes only the login you picked.

+ } + +
+ +
+ } + + @if (Model.Identities is not null) + { +

Link another login

+

+ You will sign in to Heimdall again, then to the provider you pick. If that login already belongs to another account, + nothing is linked and nothing is merged. +

+
+ @foreach (var provider in Model.LinkableProviders) + { +
+ + +
+ } +
+ } + } + + +
diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs new file mode 100644 index 0000000..27b6d6d --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs @@ -0,0 +1,137 @@ +using System.Security.Claims; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Pages.Membership; + +/// +/// The member's provider logins (docs/linked-accounts.md). Bifrost keeps no Heimdall token, so every action here begins a +/// fresh Heimdall sign-in for the account already signed in and finishes inside that sign-in's completion +/// (POST /auth/heimdall/complete); this page only starts them and shows what the last read found. Every handler is a +/// POST with antiforgery. Nothing from the query or the form is ever shown: the outcome is a code from a closed set. +/// +[Authorize] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public sealed class LinkedAccountsModel( + HeimdallAuthService heimdallAuthService, + LinkedAccountsSnapshots snapshots, + IOptions hostOptions, + ILogger logger) : PageModel +{ + private static readonly Dictionary ProviderNames = new(StringComparer.Ordinal) + { + ["discord"] = "Discord", + ["patreon"] = "Patreon", + ["google"] = "Google" + }; + + private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null; + + /// The wording of the outcome the page was sent back with: a closed set, null for anything else. + public string? ResultMessage { get; private set; } + + /// Whether this session is a Heimdall account. A Bifrost-native GitHub session has none, so there is nothing to list. + public bool HasHeimdallAccount => AccountId is not null; + + /// The logins the last read found, or null when there is none or it has expired. + public IReadOnlyList? Identities { get; private set; } + + /// Whether a fresh Google sign-in can be had for this account: unlinking needs one, and only Google can give it. + public bool CanProve => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true; + + public IReadOnlyList LinkableProviders => HeimdallAuthService.LinkableProviders; + + public static string ProviderName(string provider) => ProviderNames.GetValueOrDefault(provider, provider); + + public void OnGet() + { + ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString()); + Identities = AccountId is { } id ? snapshots.Get(id) : null; + } + + /// Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion. + public Task OnPostRefreshAsync(string? via, CancellationToken cancellationToken) + { + // With no current list Bifrost cannot tell which provider the account still holds, so the member names one. + if (AccountId is not { } id || (snapshots.Get(id) is null && (via is null || !HeimdallAuthService.LinkableProviders.Contains(via)))) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ProviderNeeded)); + } + + return BeginAsync( + (account, ct) => heimdallAuthService.StartListAsync(account, via, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + HeimdallLinkOutcome.Error, + cancellationToken); + } + + /// Links another provider. The provider is one Heimdall's bifrost profile admits; anything else starts nothing, and so does a missing list. + public Task OnPostLinkAsync(string? provider, CancellationToken cancellationToken) + { + if (provider is null || !HeimdallAuthService.LinkableProviders.Contains(provider)) + { + return Task.FromResult(Done(HeimdallLinkOutcome.Error)); + } + + if (AccountId is not { } id || snapshots.Get(id) is null) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired)); + } + + return BeginAsync( + (account, ct) => heimdallAuthService.StartLinkAsync(account, provider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + HeimdallLinkOutcome.Error, + cancellationToken); + } + + /// + /// Unlinks one login with fresh proof. The identity must be on the list this account was last shown and the account must + /// hold a Google login to prove with; otherwise Heimdall is not asked, and the page says so. + /// + public Task OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken) + { + var shown = AccountId is { } id ? snapshots.Get(id) : null; + if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId)) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired)); + } + + if (!shown.Any(x => x.Provider == HeimdallAuthService.StepUpProvider)) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ProofUnavailable)); + } + + return BeginAsync( + (account, ct) => heimdallAuthService.StartUnlinkAsync(account, identityId, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + HeimdallLinkOutcome.ProofUnavailable, + cancellationToken); + } + + private IActionResult Done(HeimdallLinkOutcome outcome) => + RedirectToPage(null, new Dictionary { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) }); + + private async Task BeginAsync( + Func> start, HeimdallLinkOutcome onRefusal, CancellationToken cancellationToken) + { + if (AccountId is not { } accountId) + { + return Done(HeimdallLinkOutcome.Error); + } + + try + { + var started = await start(accountId, cancellationToken); + Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(started.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Linked accounts start refused: {Reason}", error.Message); + return Done(onRefusal); + } + } +} diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml b/src/Bifrost.Web/Pages/Membership/Status.cshtml index 8f683a4..6e3110f 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml @@ -24,17 +24,12 @@

@Model.StatusMessage

- @if (Model.LinkMessage is not null) + @if (Model.CanManageLogins) { -
@Model.LinkMessage
- } - - @if (Model.CanLinkGoogle) - { -
- - You will sign in to Heimdall again, then to Google. Google links your account; it does not sign you in. -
+
+ Linked accounts + See, add and remove the provider logins that sign in to this account. +
} @if (Model.Invitation is not null) diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs index 13b47ca..ffc5a22 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs @@ -1,61 +1,21 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using System.Security.Claims; -using Bifrost.Web.Configuration; -using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; namespace Bifrost.Web.Pages.Membership; [Authorize] public sealed class StatusModel( ICurrentBifrostActorAccessor actorAccessor, - BifrostDbContext dbContext, - HeimdallAuthService heimdallAuthService, - IOptions hostOptions, - ILogger logger) : PageModel + BifrostDbContext dbContext) : PageModel { - /// The wording of the link outcome the page was sent back with: a closed set, null for anything else. - public string? LinkMessage { get; private set; } - - /// Whether this session belongs to a Heimdall account a Google login can be attached to. - public bool CanLinkGoogle => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 }; - - /// - /// Begins an explicit Google link: an ordinary Heimdall sign-in for the account already signed in here, kept as - /// an attempt that is only the proof for the link. POST with antiforgery only; nothing is started without a session. - /// - public async Task OnPostLinkGoogleAsync(CancellationToken cancellationToken) - { - var accountId = User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId); - if (string.IsNullOrEmpty(accountId)) - { - return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); - } - - try - { - var start = await heimdallAuthService.StartLinkAsync( - accountId, - User.FindFirstValue(BifrostClaimTypes.AuthProvider), - hostOptions.Value.PublicBaseUrl, - Request.Cookies, - cancellationToken); - Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); - return Redirect(start.AuthorizationUrl.ToString()); - } - catch (HeimdallAuthException error) - { - logger.LogWarning("Heimdall link start refused: {Reason}", error.Message); - return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); - } - } + /// Whether this session belongs to a Heimdall account whose logins the Linked accounts page can manage. + public bool CanManageLogins => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 }; public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; @@ -90,7 +50,6 @@ public sealed class StatusModel( public async Task OnGetAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); - LinkMessage = HeimdallLinkOutcomes.Wording(Request.Query["link"].ToString()); if (Actor.UserAccount is null) { diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index ef92be8..8fc0ee5 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -93,6 +93,7 @@ builder.Services.AddAntiforgery(options => builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); + options.Conventions.AuthorizePage("/Membership/LinkedAccounts"); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); options.Conventions.AllowAnonymousToFolder("/Mill"); }); @@ -153,6 +154,11 @@ builder.Services.AddScoped((serviceProvider, client) => HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +// Heimdall's member routes take only the member's bearer: this client carries no app secret. +builder.Services.AddHttpClient((serviceProvider, client) => + HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); @@ -823,6 +829,8 @@ app.MapPost("/auth/heimdall/complete", async ( ILogger logger, CancellationToken cancellationToken) => { + // Every answer here sets or clears an attempt cookie or signs someone in: none may be stored. + httpContext.Response.Headers.CacheControl = "no-store"; var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); var attempt = form?["attempt"].ToString(); var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs index 4ae3eae..6feea08 100644 --- a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -64,6 +64,10 @@ console.log( run("link-return-with-decoy-query-id", "?attempt=NONCE&heimdall_pending_link_id=QPEND", "#heimdall_status=success&heimdall_pending_link_id=PEND1"), run("link-error-with-pending-id", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied&heimdall_pending_link_id=PEND1"), run("link-pending-id-in-query-only", "?attempt=NONCE&heimdall_pending_link_id=QPEND", ""), + run("link-refused-elsewhere", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=link_initiator_mismatch"), + run("link-refused-elsewhere-no-attempt", "", "#heimdall_status=error&heimdall_error=link_initiator_mismatch"), + run("other-refusal-text", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=%3Cb%3Ex&heimdall_error_description=EVIL"), + run("refusal-in-query-only", "?attempt=NONCE&heimdall_error=link_initiator_mismatch", ""), run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""), ]), ); diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs index ba2b1fb..c79be6d 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs @@ -112,6 +112,28 @@ public sealed class HeimdallCompletePageScriptTests Assert.Equal(ErrorTarget, runs["link-error-with-pending-id"].Events[^1]); } + [Fact] + public async Task A_link_refused_because_the_login_belongs_to_another_account_goes_to_the_page_with_a_fixed_result_and_submits_nothing() + { + var runs = await RunScenariosAsync(); + const string Target = "replace:/Membership/LinkedAccounts?result=identity_linked_elsewhere"; + + var run = runs["link-refused-elsewhere"]; + Assert.Null(run.Submit); + Assert.Equal(Target, run.Events[^1]); + Assert.DoesNotContain("submit", run.Events); + Assert.Contains(run.Events, x => x.StartsWith("replaceState:", StringComparison.Ordinal) && !x.Contains('#')); + + // Without an attempt, from the query only, or for any other refusal text, it is the ordinary error exit. + foreach (var name in new[] { "link-refused-elsewhere-no-attempt", "other-refusal-text", "refusal-in-query-only" }) + { + Assert.Null(runs[name].Submit); + Assert.Equal(ErrorTarget, runs[name].Events[^1]); + } + + Assert.DoesNotContain("EVIL", string.Join("\n", runs["other-refusal-text"].Events)); + } + [Theory] [InlineData("error-status-with-code")] [InlineData("no-code")] diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs index 5de3eec..d45e883 100644 --- a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -16,7 +16,7 @@ namespace Bifrost.Web.Tests; ///
public sealed class HeimdallGoogleLinkTests { - private const string Status = "/Membership/Status"; + private const string LinkedPage = "/Membership/LinkedAccounts"; private const string ErrorRedirect = "/?auth=heimdall-error"; private sealed record Step(HttpResponseMessage Response, StartedAttempt Next, string Token); @@ -126,7 +126,7 @@ public sealed class HeimdallGoogleLinkTests new Dictionary { ["attempt"] = start.Nonce }, Jar(session, start)); Assert.Equal(before, host.Heimdall.StartBodies.Count); - Assert.Equal(Status + "?link=error", Location(response)); + Assert.Equal(LinkedPage + "?result=error", Location(response)); } [Fact] @@ -238,7 +238,7 @@ public sealed class HeimdallGoogleLinkTests var b = await StepB(host, session, start, HeimdallShapes.NewAccountId()); Assert.Equal(starts, host.Heimdall.StartBodies.Count); - Assert.Equal(Status + "?link=error", Location(b.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(b.Response)); AssertAttemptCleared(b.Response, start); } @@ -258,7 +258,7 @@ public sealed class HeimdallGoogleLinkTests Assert.Equal(starts, host.Heimdall.StartBodies.Count); Assert.Equal(redeems, host.Heimdall.RedeemCalls); - Assert.Equal(Status + "?link=error", Location(b.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(b.Response)); } [Fact] @@ -271,7 +271,7 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, journey.C.Next, other); Assert.Empty(host.Heimdall.ConfirmBodies); - Assert.Equal(Status + "?link=error", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(d.Response)); } private static void AssertAttemptCleared(HttpResponseMessage response, StartedAttempt attempt) @@ -295,7 +295,7 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); - Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=linked", Location(d.Response)); Assert.Single(host.Heimdall.ConfirmBodies); using var body = JsonDocument.Parse(host.Heimdall.ConfirmBodies[0]); Assert.Equal(journey.PendingLinkId, body.RootElement.GetProperty("pendingLinkId").GetString()); @@ -320,7 +320,7 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); - Assert.Equal($"{Status}?link={outcome}", Location(d.Response)); + Assert.Equal($"{LinkedPage}?result={outcome}", Location(d.Response)); Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); AssertAttemptCleared(d.Response, journey.C.Next); } @@ -334,18 +334,18 @@ public sealed class HeimdallGoogleLinkTests async Task Page(string link) { - using var request = new HttpRequestMessage(HttpMethod.Get, Status + "?link=" + Uri.EscapeDataString(link)); + using var request = new HttpRequestMessage(HttpMethod.Get, LinkedPage + "?result=" + Uri.EscapeDataString(link)); request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); return await (await client.SendAsync(request)).Content.ReadAsStringAsync(); } - Assert.Contains("Google is now linked", await Page("linked")); - Assert.Contains("already linked to a different Heimdall account", await Page("identity_linked_elsewhere")); + Assert.Contains("now linked to your Heimdall account", await Page("linked")); + Assert.Contains("belongs to another account", await Page("identity_linked_elsewhere")); Assert.Contains("expired before it was confirmed", await Page("expired")); Assert.Contains("could not be completed", await Page("error")); var hostile = await Page(""); Assert.DoesNotContain("alert(1)", hostile); - Assert.DoesNotContain("Google is now linked", hostile); + Assert.DoesNotContain("now linked to your", hostile); } // ---- 6. google stays out of sign-in and member access ---- @@ -379,7 +379,7 @@ public sealed class HeimdallGoogleLinkTests var pending = host.Heimdall.RegisterPendingLink(pendingCanary); var c = await StepC(host, session, b.Next, pending); var d = await StepD(host, session, c.Next, account); - Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=linked", Location(d.Response)); var tokens = new[] { b.Token, d.Token } .SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] }) @@ -457,7 +457,7 @@ public sealed class HeimdallGoogleLinkTests var replay = await host.PostCompleteAsync(fields, Jar(session, start)); Assert.Equal(starts, host.Heimdall.StartBodies.Count); - Assert.Equal(Status + "?link=error", Location(replay)); + Assert.Equal(LinkedPage + "?result=error", Location(replay)); } [Fact] @@ -468,12 +468,12 @@ public sealed class HeimdallGoogleLinkTests var code = host.CodeFor(journey.C.Next, journey.Account); var fields = new Dictionary { ["attempt"] = journey.C.Next.Nonce, ["code"] = code }; var first = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); - Assert.Equal(Status + "?link=linked", Location(first)); + Assert.Equal(LinkedPage + "?result=linked", Location(first)); var replay = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); Assert.Single(host.Heimdall.ConfirmBodies); - Assert.Equal(Status + "?link=error", Location(replay)); + Assert.Equal(LinkedPage + "?result=error", Location(replay)); } [Fact] @@ -487,8 +487,8 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, again.Next, journey.Account); var replayed = await StepD(host, journey.Session, journey.C.Next, journey.Account); - Assert.Equal(Status + "?link=linked", Location(d.Response)); - Assert.Equal(Status + "?link=expired", Location(replayed.Response)); + Assert.Equal(LinkedPage + "?result=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=expired", Location(replayed.Response)); } // ---- 10. pins for guards the first pass left unproven ---- @@ -521,7 +521,7 @@ public sealed class HeimdallGoogleLinkTests Assert.Equal(start.CookieName, b.Next.CookieName); Assert.Equal(start.CookieName, c.Next.CookieName); - Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=linked", Location(d.Response)); Assert.Null(HeimdallHost.SetCookieHeader(d.Response, parallel.CookieName!)); var signIn = await host.CompleteWithHeaderAsync( @@ -532,7 +532,7 @@ public sealed class HeimdallGoogleLinkTests [Theory] [InlineData("9")] [InlineData("-1")] - [InlineData("4")] + [InlineData("7")] public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose) { using var host = new HeimdallHost(realSessions: true); @@ -575,7 +575,7 @@ public sealed class HeimdallGoogleLinkTests var c = await StepC(host, session, b.Next, pendingLinkId); - Assert.Equal(Status + "?link=error", Location(c.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(c.Response)); Assert.Equal(starts, host.Heimdall.StartBodies.Count); Assert.Equal(redeems, host.Heimdall.RedeemCalls); Assert.Empty(host.Heimdall.ConfirmBodies); @@ -597,7 +597,7 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); - Assert.Equal(Status + "?link=error", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(d.Response)); Assert.Single(host.Heimdall.ConfirmBodies); AssertAttemptCleared(d.Response, journey.C.Next); } diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs new file mode 100644 index 0000000..98a8993 --- /dev/null +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -0,0 +1,911 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// +/// The Linked accounts page from the member's side, against a fake Heimdall. Bifrost keeps no Heimdall token: a list, a link +/// and an unlink each follow a fresh Heimdall sign-in begun by Bifrost, and the bearer lives inside that one completion request. +/// +public sealed class LinkedAccountsTests +{ + private const string Page = "/Membership/LinkedAccounts"; + private const string ErrorRedirect = "/?auth=heimdall-error"; + + private static string Jar(string session, StartedAttempt attempt) => + $"{BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}"; + + private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString; + + private static string Unprotect(HeimdallHost host, string cookie) => + host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1").Unprotect(cookie); + + /// The fresh sign-in's completion as the browser posts it: Heimdall minted a code for . + private static async Task Finish( + HeimdallHost host, string session, StartedAttempt attempt, string proofAccount, string? token = null, + string mode = "sign_in", string provider = "discord", string? sessionOverride = null, bool? stepUpRequested = null) + { + var code = host.CodeFor(attempt, proofAccount, token, mode: mode, provider: provider, stepUpRequested: stepUpRequested); + return await host.PostCompleteAsync( + new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = code }, Jar(sessionOverride ?? session, attempt)); + } + + private static string StepUpToken(HeimdallHost host, string account) => + host.Token(account, mutate: claims => claims["step_up_at"] = host.Clock.Now.ToUnixTimeSeconds()); + + private sealed record Member(string Account, string Session); + + /// A signed-in member whose fake Heimdall account holds these providers, and whose list has been read once. + private static async Task ListedMember(HeimdallHost host, params string[] providers) + { + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + foreach (var provider in providers) + { + host.Heimdall.AddIdentity(provider, "Name of " + provider); + } + + var start = await host.StartListAsync(session, providers[0]); + var done = await Finish(host, session, start, account); + Assert.Equal(Page + "?result=listed", Location(done)); + Assert.Empty(host.Heimdall.OrphanMints); + return new Member(account, session); + } + + // ---- who may use the page ---- + + [Fact] + public async Task An_anonymous_visitor_is_challenged_and_nothing_is_started() + { + using var host = new HeimdallHost(realSessions: true); + + var (get, _) = await host.GetLinkedAccountsAsync(session: null); + var post = await host.PostLinkedAccountsAsync(session: null, "Refresh"); + + Assert.Equal(HttpStatusCode.Redirect, get.StatusCode); + Assert.Contains("/auth/sign-in", Location(get)); + Assert.NotEqual(HttpStatusCode.OK, post.Response.StatusCode); + Assert.Empty(host.Heimdall.StartBodies); + } + + [Fact] + public async Task The_page_is_no_store_and_a_member_without_a_list_is_offered_one() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + var (response, html) = await host.GetLinkedAccountsAsync(session); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("no-store", response.Headers.CacheControl?.ToString()); + Assert.Contains("Show my logins through", html); + Assert.Contains("Show my logins through Patreon", html); + Assert.Contains("will not choose one for you", html); + Assert.DoesNotContain("Link Discord", html); + Assert.DoesNotContain("Unlink", html); + } + + [Fact] + public async Task With_a_list_the_page_offers_each_admitted_provider_to_link_and_never_twitch() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + Assert.Contains("Link Discord", html); + Assert.Contains("Link Patreon", html); + Assert.Contains("Link Google", html); + Assert.DoesNotContain("Link Twitch", html); + } + + [Fact] + public async Task After_the_list_expired_a_proof_is_never_started_through_a_provider_the_member_unlinked() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); // signed in through Discord + var discord = host.Heimdall.AddIdentity("discord", "D"); + host.Heimdall.AddIdentity("google", "G"); + var list = await host.StartListAsync(session, "discord"); + await Finish(host, session, list, account); + var unlink = await host.StartUnlinkAsync(session, discord); + var done = await Finish(host, session, unlink, account, provider: "google"); + Assert.Equal(Page + "?result=unlinked", Location(done)); + host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1)); + var starts = host.Heimdall.StartBodies.Count; + + var refresh = await host.StartListAsync(session, via: null); + var link = await host.StartLinkAsync(session, "patreon", seedList: false); + var (_, html) = await host.GetLinkedAccountsAsync(session); + + // Signing in through Discord again would mint an unrelated account holding that login: nothing is started. + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=provider_needed", Location(refresh.Response)); + Assert.Equal(Page + "?result=list_expired", Location(link.Response)); + Assert.Empty(host.Heimdall.OrphanMints); + Assert.Contains("will not choose one for you", html); + Assert.DoesNotContain("Link Patreon", html); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.OK)] + public async Task An_unlink_whose_answer_is_lost_drops_the_list_and_a_link_then_starts_nothing(HttpStatusCode lost) + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var discord = (string)host.Heimdall.Identities[0]["id"]!; + var start = await host.StartUnlinkAsync(member.Session, discord); + host.Heimdall.UnlinkApplyThenFail = lost; + + var done = await Finish(host, member.Session, start, member.Account, provider: "google"); + var starts = host.Heimdall.StartBodies.Count; + var link = await host.StartLinkAsync(member.Session, "patreon", seedList: false); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + // Heimdall did unlink Discord. Linking now through the stale list would sign in through Discord and mint an orphan. + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Single(host.Heimdall.Identities); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=list_expired", Location(link.Response)); + Assert.Empty(host.Heimdall.OrphanMints); + Assert.Contains("will not choose one for you", html); + } + + [Fact] + public async Task Without_a_list_the_picker_names_Google_first_as_unable_to_create_an_account() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + var (_, html) = await host.GetLinkedAccountsAsync(session); + + Assert.Contains("Google cannot do that", html); + Assert.Contains("through Google (cannot create an account)", html); + Assert.True(html.IndexOf("Show my logins through Google", StringComparison.Ordinal) < html.IndexOf("Show my logins through Discord", StringComparison.Ordinal)); + } + + [Fact] + public void With_no_list_and_no_choice_there_is_no_proof_provider() + { + Assert.Null(HeimdallAuthService.ProofProvider(null, null)); + Assert.Null(HeimdallAuthService.ProofProvider(null, "twitch")); + Assert.Equal("patreon", HeimdallAuthService.ProofProvider(null, "Patreon")); + Assert.Null(HeimdallAuthService.ProofProvider([], "discord")); + Assert.Null(HeimdallAuthService.ProofProvider(["twitch"], null)); + Assert.Equal("patreon", HeimdallAuthService.ProofProvider(["google", "patreon"], null)); + } + + [Fact] + public async Task The_service_refuses_to_start_a_proof_with_no_list_and_no_choice() + { + using var host = new HeimdallHost(realSessions: true); + using var scope = host.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var starts = host.Heimdall.StartBodies.Count; + + await Assert.ThrowsAsync(() => service.StartListAsync( + HeimdallShapes.NewAccountId(), null, "https://localhost", [], CancellationToken.None)); + await Assert.ThrowsAsync(() => service.StartLinkAsync( + HeimdallShapes.NewAccountId(), "patreon", "https://localhost", [], CancellationToken.None)); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Theory] + [InlineData(null)] + [InlineData("twitch")] + [InlineData("nonsense")] + public async Task Without_a_list_the_member_must_name_an_admitted_provider_or_nothing_is_started(string? via) + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.StartListAsync(session, via); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=provider_needed", Location(post.Response)); + } + + [Fact] + public async Task With_a_list_the_member_cannot_steer_the_proof_to_a_provider_the_account_does_not_hold() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "patreon", "google"); + + var post = await host.StartListAsync(member.Session, "discord"); + + Assert.Equal("/v1/oauth/patreon/start", host.Heimdall.StartPaths[^1]); + Assert.Empty(host.Heimdall.OrphanMints); + Assert.NotNull(post.Cookie); + } + + [Fact] + public async Task A_session_with_no_Heimdall_account_has_nothing_to_list_and_starts_nothing() + { + using var host = new HeimdallHost(); + + var (response, html) = await host.GetLinkedAccountsAsync(session: null); + var start = await host.StartListAsync(session: null); + + // No form, so no antiforgery token to make the response uncacheable: the page itself must say no-store. + Assert.Contains("no-store", response.Headers.CacheControl?.ToString()); + Assert.Contains("not a Heimdall account", html); + Assert.DoesNotContain("Link Discord", html); + Assert.Empty(host.Heimdall.StartBodies); + Assert.Null(start.Cookie); + } + + [Theory] + [InlineData("Refresh")] + [InlineData("Link")] + [InlineData("Unlink")] + public async Task Every_post_needs_the_antiforgery_token_and_a_get_starts_nothing(string handler) + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var form = new Dictionary { ["provider"] = "discord", ["identityId"] = Guid.NewGuid().ToString() }; + var starts = host.Heimdall.StartBodies.Count; + + var without = await host.PostLinkedAccountsAsync(session, handler, form, antiforgery: false); + var get = await host.PostLinkedAccountsAsync(session, handler, form, method: HttpMethod.Get); + + Assert.Equal(HttpStatusCode.BadRequest, without.Response.StatusCode); + Assert.Null(without.Cookie); + Assert.Null(get.Cookie); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + // ---- the list ---- + + [Fact] + public async Task The_list_is_a_fresh_ordinary_sign_in_then_GET_identities_with_its_bearer_and_the_page_shows_it() + { + using var host = new HeimdallHost(traceHttp: true, realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord", "Discord Dana"); + host.Heimdall.AddIdentity("google", "Google Gale"); + var signIns = host.Heimdall.StartBodies.Count; + + var start = await host.StartListAsync(session); + + Assert.Equal(signIns + 1, host.Heimdall.StartBodies.Count); + using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1])) + { + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + Assert.False(body.RootElement.TryGetProperty("stepUp", out _)); + } + + Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]); + Assert.Empty(host.Heimdall.ListAuthorizations); + + var token = host.Token(account); + var done = await Finish(host, session, start, account, token); + + Assert.Equal(Page + "?result=listed", Location(done)); + Assert.Equal(["Bearer " + token], host.Heimdall.ListAuthorizations); + Assert.Null(HeimdallHost.CookieValue(done, BifrostCookies.Session)); + var (_, html) = await host.GetLinkedAccountsAsync(session); + Assert.Contains("Discord Dana", html); + Assert.Contains("Google Gale", html); + Assert.Contains("13 Nov 2026", html); + // Only what the page shows was kept: nothing of the fields a login also carries. + foreach (var canary in new[] { "PUID-CANARY", "username-canary", "scope-canary" }) + { + Assert.DoesNotContain(canary, html); + } + } + + [Fact] + public async Task No_bearer_reaches_a_cookie_a_log_a_location_or_the_page() + { + using var host = new HeimdallHost(traceHttp: true, realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + host.Heimdall.AddIdentity("google"); + var listStart = await host.StartListAsync(session); + var listToken = host.Token(account); + var listed = await Finish(host, session, listStart, account, listToken); + var unlinkStart = await host.StartUnlinkAsync(session, (string)host.Heimdall.Identities[0]["id"]!); + var stepUpToken = StepUpToken(host, account); + var unlinked = await Finish(host, session, unlinkStart, account, stepUpToken); + Assert.Equal(Page + "?result=unlinked", Location(unlinked)); + var (_, html) = await host.GetLinkedAccountsAsync(session); + + var logs = string.Join("\n", host.Logs.Lines); + var secrets = new[] { listToken, stepUpToken } + .SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] }) + .ToArray(); + Assert.Contains("Bearer", string.Join("\n", host.Heimdall.ListAuthorizations)); + foreach (var secret in secrets) + { + Assert.DoesNotContain(secret, logs); + Assert.DoesNotContain(secret, html); + foreach (var response in new[] { listStart.Response, listed, unlinkStart.Response, unlinked }) + { + Assert.DoesNotContain(secret, Location(response) ?? string.Empty); + Assert.DoesNotContain(secret, string.Join("\n", response.Headers.SelectMany(x => x.Value))); + } + + foreach (var cookie in new[] { listStart.Cookie, unlinkStart.Cookie }) + { + var payload = Unprotect(host, cookie!); + Assert.DoesNotContain(secret, payload); + Assert.DoesNotContain("eyJ", payload); + } + } + + // The completion itself sets no cookie but the attempt's deletion. + foreach (var response in new[] { listed, unlinked }) + { + Assert.All( + response.Headers.TryGetValues("Set-Cookie", out var set) ? set : [], + header => Assert.StartsWith(HeimdallAttemptCookie.Prefix, header, StringComparison.Ordinal)); + } + } + + [Fact] + public async Task A_list_is_per_account_and_expires_after_five_minutes() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var stranger = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); + var (_, other) = await host.GetLinkedAccountsAsync(stranger); + Assert.DoesNotContain("Name of discord", other); + Assert.Contains("Show my logins through", other); + + host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1)); + + var (_, later) = await host.GetLinkedAccountsAsync(member.Session); + Assert.DoesNotContain("Name of discord", later); + Assert.Contains("Show my logins through", later); + } + + [Theory] + [InlineData(HttpStatusCode.Unauthorized, "sign_in_required")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + public async Task A_list_Heimdall_refuses_shows_its_fixed_outcome_and_no_list(HttpStatusCode status, string outcome) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + host.Heimdall.ListStatus = status; + var start = await host.StartListAsync(session); + + var done = await Finish(host, session, start, account); + + Assert.Equal($"{Page}?result={outcome}", Location(done)); + Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); + Assert.Contains("Show my logins through", (await host.GetLinkedAccountsAsync(session)).Html); + } + + [Fact] + public async Task A_Heimdall_that_cannot_be_reached_for_the_list_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + var start = await host.StartListAsync(session); + host.Heimdall.MemberFailure = new HttpRequestException("connection refused"); + + var done = await Finish(host, session, start, account); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Contains("Show my logins through", (await host.GetLinkedAccountsAsync(session)).Html); + } + + [Fact] + public async Task A_Heimdall_that_is_down_at_the_list_start_is_a_refusal_not_a_server_error() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + host.Heimdall.StartStatus = HttpStatusCode.ServiceUnavailable; + + var start = await host.StartListAsync(session); + + Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode); + Assert.Equal(Page + "?result=error", Location(start.Response)); + Assert.Null(start.Cookie); + } + + // ---- unlink ---- + + [Fact] + public async Task Unlink_starts_a_step_up_sign_in_with_the_app_secret_and_the_unlink_carries_that_sign_ins_bearer() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var discord = (string)host.Heimdall.Identities[0]["id"]!; + var listToken = host.Heimdall.ListAuthorizations.Single(); + var starts = host.Heimdall.StartBodies.Count; + + var start = await host.StartUnlinkAsync(member.Session, discord); + + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]); + Assert.Equal(HeimdallHost.AppSecret, host.Heimdall.StartSecrets[^1]); + Assert.Null(host.Heimdall.StartAuthorizations[^1]); + using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1])) + { + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + Assert.True(body.RootElement.GetProperty("stepUp").GetBoolean()); + } + + Assert.Empty(host.Heimdall.Unlinks); + var token = StepUpToken(host, member.Account); + var done = await Finish(host, member.Session, start, member.Account, token, provider: "google"); + + Assert.Equal(Page + "?result=unlinked", Location(done)); + var unlink = Assert.Single(host.Heimdall.Unlinks); + Assert.Equal(discord, unlink.Id); + Assert.Equal("Bearer " + token, unlink.Authorization); + Assert.NotEqual(listToken, unlink.Authorization); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.DoesNotContain("Name of discord", html); + Assert.Contains("Name of google", html); + Assert.Contains("Heimdall will not remove the last one", html); + Assert.Contains("The login was unlinked.", (await host.GetLinkedAccountsAsync(member.Session, "?result=unlinked")).Html); + } + + [Fact] + public async Task The_last_login_is_refused_by_Heimdall_and_the_page_still_shows_it() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "google"); + var only = (string)host.Heimdall.Identities[0]["id"]!; + var start = await host.StartUnlinkAsync(member.Session, only); + + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google"); + + Assert.Equal(Page + "?result=last_identity", Location(done)); + Assert.Single(host.Heimdall.Unlinks); + Assert.Single(host.Heimdall.Identities); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.Contains("Name of google", html); + Assert.Contains("will not remove it. Nothing was changed.", (await host.GetLinkedAccountsAsync(member.Session, "?result=last_identity")).Html); + } + + [Fact] + public async Task A_sign_in_Heimdall_does_not_count_as_fresh_proof_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + + // An ordinary token: no step_up_at claim, so Heimdall answers 403 step_up_required. + var done = await Finish(host, member.Session, start, member.Account, host.Token(member.Account), provider: "google"); + + Assert.Equal(Page + "?result=step_up_required", Location(done)); + Assert.Equal(2, host.Heimdall.Identities.Count); + Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); + } + + [Fact] + public async Task A_completion_that_does_not_say_a_step_up_was_asked_unlinks_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + + // The token even carries step_up_at, but the completion is not the answer to a step-up start. + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google", + stepUpRequested: false); + + Assert.Equal(Page + "?result=step_up_required", Location(done)); + Assert.Empty(host.Heimdall.Unlinks); + Assert.Equal(2, host.Heimdall.Identities.Count); + } + + [Fact] + public async Task A_proof_older_than_600_seconds_is_refused_by_Heimdall_and_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + // The sign-in happened 601 seconds before the unlink reaches Heimdall (the attempt itself lives ten minutes). + var stale = host.Token(member.Account, mutate: claims => claims["step_up_at"] = host.Clock.Now.ToUnixTimeSeconds() - 601); + + var done = await Finish(host, member.Session, start, member.Account, stale, provider: "google"); + + Assert.Equal(Page + "?result=step_up_required", Location(done)); + Assert.Single(host.Heimdall.Unlinks); + Assert.Equal(2, host.Heimdall.Identities.Count); + } + + [Fact] + public async Task Every_completion_answer_is_no_store() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + var start = await host.StartListAsync(session); + + var done = await Finish(host, session, start, account); + var refused = await host.PostCompleteAsync(new Dictionary { ["attempt"] = "nope" }, null); + + Assert.Contains("no-store", done.Headers.CacheControl?.ToString()); + Assert.Contains("no-store", refused.Headers.CacheControl?.ToString()); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound, "identity_not_found")] + [InlineData(HttpStatusCode.Unauthorized, "sign_in_required")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + public async Task Each_refused_unlink_has_its_own_fixed_outcome_and_leaks_no_Heimdall_text(HttpStatusCode status, string outcome) + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + host.Heimdall.UnlinkStatus = status; + + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google"); + + Assert.Equal($"{Page}?result={outcome}", Location(done)); + Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.DoesNotContain("HEIMDALL FREE TEXT", html); + // Only a definitive refusal (401 here) leaves the list shown; not found or an unreadable end may have changed the account, so it is dropped. + Assert.Equal(status == HttpStatusCode.Unauthorized, html.Contains("Name of discord")); + } + + [Fact] + public async Task A_Heimdall_that_cannot_be_reached_for_the_unlink_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + host.Heimdall.MemberFailure = new HttpRequestException("connection refused"); + + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google"); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.DoesNotContain("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); + } + + [Fact] + public async Task Without_a_Google_login_no_proof_can_be_had_and_the_page_says_so_without_asking_Heimdall() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "patreon"); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + + Assert.DoesNotContain(">Unlink<", html); + Assert.Contains("fresh Google sign-in", html); + Assert.Contains("no Google login", html); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=proof_unavailable", Location(post.Response)); + Assert.Null(post.Cookie); + } + + [Fact] + public async Task A_Heimdall_with_no_Google_client_refuses_the_step_up_start_and_the_page_says_proof_is_unavailable_once() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + host.Heimdall.StartStatus = HttpStatusCode.ServiceUnavailable; + host.Heimdall.StartBody = """{"error":"provider_not_configured"}"""; + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.Null(post.Cookie); + Assert.Equal(Page + "?result=proof_unavailable", Location(post.Response)); + Assert.Contains("Fresh proof could not be started", (await host.GetLinkedAccountsAsync(member.Session, "?result=proof_unavailable")).Html); + Assert.Equal(2, host.Heimdall.Identities.Count); + } + + [Theory] + [InlineData(null)] + [InlineData("not-a-guid")] + [InlineData("00000000-0000-0000-0000-000000000001")] + public async Task An_identity_that_is_not_on_the_list_the_member_was_shown_starts_nothing(string? identityId) + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.PostLinkedAccountsAsync( + member.Session, "Unlink", identityId is null ? null : new Dictionary { ["identityId"] = identityId }); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=list_expired", Location(post.Response)); + } + + [Fact] + public async Task An_unlink_after_the_list_expired_starts_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var id = (string)host.Heimdall.Identities[0]["id"]!; + host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1)); + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.StartUnlinkAsync(member.Session, id); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=list_expired", Location(post.Response)); + } + + // ---- an attempt belongs to the account and browser that began it ---- + + [Fact] + public async Task An_unlink_attempt_held_by_another_accounts_session_does_nothing_and_asks_Heimdall_for_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + var otherAccount = HeimdallShapes.NewAccountId(); + var otherSession = await host.SignInSessionAsync(otherAccount); + var redeems = host.Heimdall.RedeemCalls; + + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google", + sessionOverride: otherSession); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Empty(host.Heimdall.Unlinks); + Assert.Equal(2, host.Heimdall.Identities.Count); + } + + [Fact] + public async Task A_proof_by_another_account_unlinks_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + var stranger = HeimdallShapes.NewAccountId(); + + var done = await Finish(host, member.Session, start, stranger, StepUpToken(host, stranger), provider: "google"); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Empty(host.Heimdall.Unlinks); + } + + [Fact] + public async Task A_list_proof_by_another_account_lists_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + var start = await host.StartListAsync(session); + + var done = await Finish(host, session, start, HeimdallShapes.NewAccountId()); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Empty(host.Heimdall.ListAuthorizations); + } + + [Fact] + public async Task A_consumed_unlink_attempt_cannot_be_replayed_into_a_second_unlink() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "patreon", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + var fields = new Dictionary + { + ["attempt"] = start.Nonce, + ["code"] = host.CodeFor(start, member.Account, StepUpToken(host, member.Account), provider: "google") + }; + var first = await host.PostCompleteAsync(fields, Jar(member.Session, start)); + Assert.Equal(Page + "?result=unlinked", Location(first)); + + var replay = await host.PostCompleteAsync(fields, Jar(member.Session, start)); + + Assert.Equal(Page + "?result=error", Location(replay)); + Assert.Single(host.Heimdall.Unlinks); + } + + // ---- link another provider ---- + + [Theory] + [InlineData("discord")] + [InlineData("patreon")] + [InlineData("google")] + public async Task Linking_each_admitted_provider_proves_the_account_then_starts_that_providers_link_with_the_bearer(string provider) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var starts = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session, provider); + + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]); + Assert.Null(host.Heimdall.StartAuthorizations[^1]); + var token = host.Token(account); + var step = await Finish(host, session, start, account, token); + + Assert.Equal(starts + 2, host.Heimdall.StartBodies.Count); + Assert.Equal($"/v1/oauth/{provider}/start", host.Heimdall.StartPaths[^1]); + Assert.Equal("Bearer " + token, host.Heimdall.StartAuthorizations[^1]); + using var link = JsonDocument.Parse(host.Heimdall.StartBodies[^1]); + Assert.Equal("link", link.RootElement.GetProperty("mode").GetString()); + Assert.Equal("bifrost", link.RootElement.GetProperty("appSlug").GetString()); + Assert.False(link.RootElement.TryGetProperty("stepUp", out _)); + Assert.Equal($"https://heimdall.test/oauth/{provider}/authorize?state=link-fixture", Location(step)); + } + + [Fact] + public async Task A_proof_sign_in_goes_through_a_provider_the_account_still_holds() + { + using var host = new HeimdallHost(realSessions: true); + // Signed in through Discord, which the account has since unlinked: signing in through it again would mint another account. + var member = await ListedMember(host, "patreon", "google"); + + var start = await host.StartLinkAsync(member.Session, "discord"); + + Assert.Equal("/v1/oauth/patreon/start", host.Heimdall.StartPaths[^1]); + Assert.NotNull(start.Cookie); + } + + [Theory] + [InlineData("twitch")] + [InlineData("github")] + [InlineData("DISCORD2")] + [InlineData("")] + public async Task A_provider_Heimdalls_bifrost_profile_does_not_admit_starts_nothing(string provider) + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var starts = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session, provider); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=error", Location(start.Response)); + Assert.Null(start.Cookie); + } + + [Fact] + public async Task A_provider_that_attaches_in_the_callback_comes_back_with_a_link_completion_and_is_linked_once() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session, "patreon"); + var awaiting = host.AttemptFrom((await ContinueResponse(host, session, start, account)), host.Heimdall.StartBodies.Count - 1); + Assert.Contains("\"Purpose\":6", Unprotect(host, awaiting.Cookie!)); + Assert.Contains("\"Target\":\"patreon\"", Unprotect(host, awaiting.Cookie!)); + host.Heimdall.AddIdentity("discord"); + var redeems = host.Heimdall.RedeemCalls; + + var done = await Finish(host, session, awaiting, account, mode: "link", provider: "patreon"); + + Assert.Equal(Page + "?result=linked", Location(done)); + Assert.Equal(redeems + 1, host.Heimdall.RedeemCalls); + Assert.Contains("now linked to your Heimdall account", (await host.GetLinkedAccountsAsync(session, "?result=linked")).Html); + Assert.Null(HeimdallHost.CookieValue(done, BifrostCookies.Session)); + var replay = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = awaiting.Nonce, ["code"] = "gone" }, Jar(session, awaiting)); + Assert.Equal(Page + "?result=error", Location(replay)); + } + + private static async Task ContinueResponse(HeimdallHost host, string session, StartedAttempt start, string account) => + await Finish(host, session, start, account); + + [Theory] + [InlineData("sign_in", "patreon")] + [InlineData("link", "discord")] + public async Task A_direct_link_completion_for_another_mode_or_provider_links_nothing(string mode, string provider) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session, "patreon"); + var before = host.Heimdall.StartBodies.Count; + var awaiting = host.AttemptFrom(await ContinueResponse(host, session, start, account), before); + + var done = await Finish(host, session, awaiting, account, mode: mode, provider: provider); + + Assert.Equal(Page + "?result=error", Location(done)); + } + + [Fact] + public async Task A_direct_link_completion_proven_by_another_account_links_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session, "discord"); + var before = host.Heimdall.StartBodies.Count; + var awaiting = host.AttemptFrom(await ContinueResponse(host, session, start, account), before); + + var done = await Finish(host, session, awaiting, HeimdallShapes.NewAccountId(), mode: "link", provider: "discord"); + + Assert.Equal(Page + "?result=error", Location(done)); + } + + [Fact] + public async Task A_link_attempt_cookie_written_before_targets_existed_still_links_Google() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var nonce = "legacy-" + Guid.NewGuid().ToString("N"); + var protector = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1"); + var legacy = protector.Protect(JsonSerializer.Serialize(new Dictionary + { + ["Nonce"] = nonce, ["Provider"] = "discord", ["RequiresMemberAccess"] = false, ["ReturnPath"] = "/Membership/Status", + ["ExpiresAt"] = host.Clock.Now.AddMinutes(5), ["Purpose"] = 1, ["ExpectedAccountId"] = account + })); + var attempt = new StartedAttempt( + nonce, HeimdallAttemptCookie.SlotNames[0], legacy, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!); + + var step = await Finish(host, session, attempt, account); + + Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]); + Assert.Equal("https://heimdall.test/oauth/google/authorize?state=link-fixture", Location(step)); + } + + // ---- the page and its neighbours ---- + + [Fact] + public async Task Status_points_a_Heimdall_member_to_the_page_and_no_longer_starts_a_link_itself() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + using var client = host.NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/Membership/Status"); + request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); + + var html = await (await client.SendAsync(request)).Content.ReadAsStringAsync(); + + Assert.Contains(Page, html); + Assert.DoesNotContain("handler=LinkGoogle", html); + Assert.DoesNotContain("LinkGoogle", html); + } + + [Theory] + [InlineData("linked")] + [InlineData("identity_linked_elsewhere")] + [InlineData("expired")] + [InlineData("unlinked")] + [InlineData("last_identity")] + [InlineData("step_up_required")] + [InlineData("identity_not_found")] + [InlineData("sign_in_required")] + [InlineData("proof_unavailable")] + [InlineData("list_expired")] + [InlineData("provider_needed")] + [InlineData("error")] + public void Every_outcome_code_has_words_and_nothing_else_does(string code) + { + Assert.False(string.IsNullOrWhiteSpace(HeimdallLinkOutcomes.Wording(code))); + Assert.Null(HeimdallLinkOutcomes.Wording(code + " ")); + Assert.Null(HeimdallLinkOutcomes.Wording("" + code)); + Assert.Contains( + Enum.GetValues(), outcome => HeimdallLinkOutcomes.Code(outcome) == code); + } + + [Fact] + public async Task Link_refusals_say_the_login_belongs_to_another_account_without_naming_it() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + var (_, html) = await host.GetLinkedAccountsAsync(session, "?result=identity_linked_elsewhere"); + + Assert.Contains("That login belongs to another account. Nothing was linked.", html); + Assert.DoesNotContain("merge", html.Replace("nothing is merged", string.Empty), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs index 5deb1a8..1b8d306 100644 --- a/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs +++ b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs @@ -105,6 +105,7 @@ public sealed partial class NoThirdPartyAssetTests(TestWebApplicationFactory fac ("/auth/sign-in", null, true), ("/auth/bifrost/register", null, true), ("/auth/bifrost/consent", identity, false), + ("/Membership/LinkedAccounts", identity, false), }; foreach (var (path, who, anonymous) in pages) diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index a074ede..7299221 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -133,11 +133,13 @@ public static class HeimdallShapes string accessToken, string[]? looseCapabilities = null, string[]? looseFacts = null, - string looseName = "Loose Name") => new() + string looseName = "Loose Name", + string mode = "sign_in", + string provider = "discord") => new() { ["status"] = "success", - ["provider"] = "discord", - ["mode"] = "sign_in", + ["provider"] = provider, + ["mode"] = mode, ["appSlug"] = "bifrost", ["account"] = new Dictionary { ["id"] = accountId, ["displayName"] = looseName }, ["entitlements"] = new Dictionary @@ -220,6 +222,140 @@ public sealed class FakeHeimdall : HttpMessageHandler private readonly HashSet _pendingLinks = new(StringComparer.Ordinal); + /// The logins the fake account holds, as GET /v1/me/identities answers them (with the fields Bifrost must never keep). + public List> Identities { get; } = []; + + /// Adds a login to the fake account and returns its id. + public string AddIdentity(string provider, string? displayName = "A Name", string? id = null) + { + id ??= Guid.NewGuid().ToString(); + Identities.Add(new Dictionary + { + ["id"] = id, + ["provider"] = provider, + ["providerUserId"] = "PUID-CANARY-" + id[..8], + ["username"] = "username-canary-" + id[..8], + ["displayName"] = displayName, + ["linkedAt"] = "2026-11-13T12:00:00.000Z", + ["scopes"] = new[] { "scope-canary" } + }); + return id; + } + + public List ListAuthorizations { get; } = []; + + /// The clock the fake judges a step-up proof's age by (the host sets it to its test clock). + public Func Now { get; set; } = () => DateTimeOffset.UtcNow; + + /// The returnTo of every start that asked for a step-up and passed Heimdall's checks: its completion carries the proof. + public HashSet StepUpReturnTos { get; } = new(StringComparer.Ordinal); + + /// + /// Discord or Patreon sign-ins that came through a provider the fake account (once it holds any login) does not hold. Heimdall mints an + /// unrelated account for such a sign-in, which would then hold that login. Google never mints. + /// + public List OrphanMints { get; } = []; + + /// When set, GET /v1/me/identities answers this status with an error body. + public HttpStatusCode? ListStatus { get; set; } + + /// The Authorization header and identity id of every unlink. + public List<(string? Authorization, string Id)> Unlinks { get; } = []; + + /// When set, every unlink answers this status with Heimdall's error code for it (the fake's own rules are bypassed). + public HttpStatusCode? UnlinkStatus { get; set; } + + /// When set, an unlink is applied and then answered with this status (200 answers a body that is not JSON): a lost answer. + public HttpStatusCode? UnlinkApplyThenFail { get; set; } + + /// Throws instead of answering a member route, as an unreachable Heimdall does. + public Exception? MemberFailure { get; set; } + + private HttpResponseMessage MemberRoute(HttpRequestMessage request, string path) + { + static HttpResponseMessage Json(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + if (MemberFailure is not null) + { + throw MemberFailure; + } + + var authorization = request.Headers.Authorization?.ToString(); + object Listing() => new { accountId = "a", identities = Identities }; + if (request.Method == HttpMethod.Get && path == "/v1/me/identities") + { + ListAuthorizations.Add(authorization); + if (authorization is null) + { + return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""); + } + + return ListStatus switch + { + HttpStatusCode.Unauthorized => Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""), + { } listed => Json(listed, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}"""), + _ => Json(HttpStatusCode.OK, JsonSerializer.Serialize(Listing())) + }; + } + + var segments = path.Split('/'); + if (request.Method == HttpMethod.Post && segments is ["", "v1", "me", "identities", var id, "unlink"]) + { + Unlinks.Add((authorization, id)); + if (authorization is null) + { + return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""); + } + + if (UnlinkApplyThenFail is { } lost) + { + // The unlink happened; the answer did not arrive intact. + Identities.RemoveAll(x => (string?)x["id"] == id); + return Json(lost, lost == HttpStatusCode.OK ? "not json" : """{"error":"server_error"}"""); + } + + if (UnlinkStatus is { } forced) + { + return forced switch + { + HttpStatusCode.Unauthorized => Json(forced, """{"error":"sign_in_required"}"""), + HttpStatusCode.Forbidden => Json(forced, """{"error":"step_up_required"}"""), + HttpStatusCode.NotFound => Json(forced, """{"error":"identity_not_found"}"""), + HttpStatusCode.Conflict => Json(forced, """{"error":"last_identity"}"""), + _ => Json(forced, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}""") + }; + } + + // Heimdall's rule: the bearer must carry a step_up_at claim from the last 600 seconds, and not from the future. + var payload = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(authorization["Bearer ".Length..].Split('.')[1])); + using var claims = JsonDocument.Parse(payload); + var fresh = claims.RootElement.TryGetProperty("step_up_at", out var at) && at.TryGetInt64(out var seconds) + && seconds <= Now().ToUnixTimeSeconds() && Now().ToUnixTimeSeconds() - seconds <= 600; + if (!fresh) + { + return Json(HttpStatusCode.Forbidden, """{"error":"step_up_required"}"""); + } + + var held = Identities.FirstOrDefault(x => (string?)x["id"] == id); + if (held is null) + { + return Json(HttpStatusCode.NotFound, """{"error":"identity_not_found"}"""); + } + + if (Identities.Count == 1) + { + return Json(HttpStatusCode.Conflict, """{"error":"last_identity"}"""); + } + + Identities.Remove(held); + return Json( + HttpStatusCode.OK, + JsonSerializer.Serialize(new { unlinked = new { id, provider = held["provider"], providerUserId = held["providerUserId"] }, identities = Identities })); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + /// Registers a pending link Heimdall will confirm once for any bearer. public string RegisterPendingLink(string? id = null) { @@ -287,6 +423,28 @@ public sealed class FakeHeimdall : HttpMessageHandler StartPaths.Add(path); StartAuthorizations.Add(authorization); var root = JsonDocument.Parse(body).RootElement; + static HttpResponseMessage Refuse(HttpStatusCode status, string error) => + new(status) { Content = new StringContent($$"""{"error":"{{error}}"}""", Encoding.UTF8, "application/json") }; + var startProvider = path.Split('/')[3]; + if (root.TryGetProperty("stepUp", out var stepUp) && stepUp.ValueKind == JsonValueKind.True) + { + // Heimdall's step-up rules (service-contract.md, "Step-up"): the app's request, sign_in only, a provider that can re-authenticate. + if (secret != RequiredAppSecret) + { + return Refuse(HttpStatusCode.Unauthorized, "app_auth_required"); + } + + if (root.TryGetProperty("mode", out var stepMode) && stepMode.GetString() != "sign_in") + { + return Refuse(HttpStatusCode.BadRequest, "step_up_only_in_sign_in"); + } + + if (startProvider is not ("google" or "twitch" or "youtube")) + { + return Refuse(HttpStatusCode.BadRequest, "step_up_not_supported"); + } + } + if (root.TryGetProperty("mode", out var mode) && mode.GetString() == "link") { if (authorization is null) @@ -297,13 +455,25 @@ public sealed class FakeHeimdall : HttpMessageHandler }; } + var linkProvider = path.Split('/')[3]; return new HttpResponseMessage(HttpStatusCode.Created) { Content = new StringContent( - """{"authorizationUrl":"https://heimdall.test/oauth/google/authorize?state=link-fixture"}""", Encoding.UTF8, "application/json") + $$"""{"authorizationUrl":"https://heimdall.test/oauth/{{linkProvider}}/authorize?state=link-fixture"}""", Encoding.UTF8, "application/json") }; } + // Discord and Patreon mint an account for an identity no account holds; Google is requires_link and refuses (identity_not_linked). + if (startProvider != "google" && Identities.Count > 0 && Identities.All(x => (string?)x["provider"] != startProvider)) + { + OrphanMints.Add(startProvider); + } + + if (StartStatus == HttpStatusCode.OK && root.TryGetProperty("stepUp", out var asked) && asked.ValueKind == JsonValueKind.True) + { + StepUpReturnTos.Add(root.GetProperty("returnTo").GetString()!); + } + var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _); if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret) { @@ -318,6 +488,11 @@ public sealed class FakeHeimdall : HttpMessageHandler return new HttpResponseMessage(StartStatus) { Content = content }; } + if (path.StartsWith("/v1/me/identities", StringComparison.Ordinal)) + { + return MemberRoute(request, path); + } + if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/links/confirm") { var body = await request.Content!.ReadAsStringAsync(cancellationToken); @@ -463,6 +638,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory _publicBaseUrl = publicBaseUrl; _discordRoles = discordRoles; Heimdall.Published.Add(Key); + Heimdall.Now = () => Clock.Now; Heimdall.RequiredAppSecret = AppSecret; } @@ -512,6 +688,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory services.AddSingleton(Clock); services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); }); } @@ -561,10 +738,21 @@ public sealed class HeimdallHost : TestWebApplicationFactory /// Registers a completion code as Heimdall would mint it after authenticating this attempt. public string CodeFor(StartedAttempt attempt, string accountId, string? accessToken = null, string[]? looseCapabilities = null, - string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null) + string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null, string mode = "sign_in", string provider = "discord", + bool? stepUpRequested = null) { + // A completion of a start that asked for a step-up carries the proof the way Heimdall's does: step_up_at in the token and stepUpRequested. + var asked = Heimdall.StepUpReturnTos.Contains(returnTo ?? attempt.ReturnTo); + var token = accessToken ?? (asked + ? Token(accountId, mutate: claims => claims["step_up_at"] = Clock.Now.ToUnixTimeSeconds()) + : Token(accountId)); var completion = HeimdallShapes.Completion( - returnTo ?? attempt.ReturnTo, accountId, accessToken ?? Token(accountId), looseCapabilities, looseFacts, looseName); + returnTo ?? attempt.ReturnTo, accountId, token, looseCapabilities, looseFacts, looseName, mode, provider); + if (stepUpRequested ?? asked) + { + completion["stepUpRequested"] = true; + } + return Heimdall.RegisterCode(completion); } @@ -600,11 +788,15 @@ public sealed class HeimdallHost : TestWebApplicationFactory } /// A browser session: a normal Heimdall sign-in, answered with the session cookie it earned. + private readonly Dictionary _sessionAccounts = new(StringComparer.Ordinal); + public async Task SignInSessionAsync(string accountId, string provider = "discord") { var attempt = await StartAsync(provider: provider); var done = await CompleteAsync(attempt, CodeFor(attempt, accountId)); - return CookieValue(done.Response, BifrostCookies.Session) ?? throw new InvalidOperationException("sign-in set no session"); + var session = CookieValue(done.Response, BifrostCookies.Session) ?? throw new InvalidOperationException("sign-in set no session"); + _sessionAccounts[session] = accountId; + return session; } /// The raw POST of the complete page, whatever fields and cookies the caller names. @@ -623,18 +815,33 @@ public sealed class HeimdallHost : TestWebApplicationFactory return await client.SendAsync(request); } - public const string LinkPostUrl = "/Membership/Status?handler=LinkGoogle"; + public const string LinkedAccountsUrl = "/Membership/LinkedAccounts"; + + /// The Linked accounts page as a browser reads it, with its session when there is one. + public async Task<(HttpResponseMessage Response, string Html)> GetLinkedAccountsAsync(string? session, string query = "") + { + using var client = NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, LinkedAccountsUrl + query); + if (session is not null) + { + request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); + } + + var response = await client.SendAsync(request); + return (response, await response.Content.ReadAsStringAsync()); + } /// - /// The account page's link button as a browser posts it: the page is fetched for its antiforgery token and cookie - /// (when ), then the form is posted with the session. Without a session nothing is fetched. + /// One of the page's buttons as a browser posts it: the page is fetched for its antiforgery token and cookie (when + /// ), then the form is posted with the session. Without a session nothing is fetched. /// - public async Task StartLinkAsync( - string? session, bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null) + public async Task PostLinkedAccountsAsync( + string? session, string handler, IDictionary? form = null, bool antiforgery = true, HttpMethod? method = null, + string? extraCookies = null) { using var client = NewClient(); var cookies = new List(); - var fields = new Dictionary(); + var fields = new Dictionary(form ?? new Dictionary()); if (session is not null) { cookies.Add($"{BifrostCookies.Session}={session}"); @@ -642,7 +849,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory if (antiforgery && session is not null) { - using var get = new HttpRequestMessage(HttpMethod.Get, "/Membership/Status"); + using var get = new HttpRequestMessage(HttpMethod.Get, LinkedAccountsUrl); get.Headers.Add("Cookie", string.Join("; ", cookies)); using var page = await client.SendAsync(get); var html = await page.Content.ReadAsStringAsync(); @@ -658,7 +865,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory } var before = Heimdall.StartBodies.Count; - using var request = new HttpRequestMessage(method ?? HttpMethod.Post, LinkPostUrl); + using var request = new HttpRequestMessage(method ?? HttpMethod.Post, $"{LinkedAccountsUrl}?handler={handler}"); if (request.Method == HttpMethod.Post) { request.Content = new FormUrlEncodedContent(fields); @@ -672,6 +879,31 @@ public sealed class HeimdallHost : TestWebApplicationFactory return AttemptFrom(await client.SendAsync(request), before); } + /// + /// A link start needs the account's current list (Bifrost will not guess a provider to prove with). Link tests that are not + /// about the list get one seeded for a session of this host: the Discord login it signed in with. false leaves it. + /// + public Task StartLinkAsync( + string? session, string provider = "google", bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null, + bool seedList = true) + { + if (seedList && session is not null && _sessionAccounts.TryGetValue(session, out var account) + && Services.GetRequiredService().Get(account) is null) + { + Services.GetRequiredService().Put( + account, [new LinkedIdentitySummary(Guid.NewGuid().ToString(), "discord", "Seeded", null)]); + } + + return PostLinkedAccountsAsync(session, "Link", new Dictionary { ["provider"] = provider }, antiforgery, method, extraCookies); + } + + /// The list button. is the provider the member names, needed only when no current list is held. + public Task StartListAsync(string? session, string? via = "discord") => + PostLinkedAccountsAsync(session, "Refresh", via is null ? null : new Dictionary { ["via"] = via }); + + public Task StartUnlinkAsync(string? session, string identityId) => + PostLinkedAccountsAsync(session, "Unlink", new Dictionary { ["identityId"] = identityId }); + public Task CompleteAsync(StartedAttempt attempt, string code) => CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName);