diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml index 8524451..f520258 100644 --- a/deployment/idunn/recipe.toml +++ b/deployment/idunn/recipe.toml @@ -157,6 +157,8 @@ optional_environment = [ "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE", + "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID", + "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE", diff --git a/docs/app-profiles/spotiverse.md b/docs/app-profiles/spotiverse.md index a10c72a..a847fba 100644 --- a/docs/app-profiles/spotiverse.md +++ b/docs/app-profiles/spotiverse.md @@ -20,7 +20,7 @@ > still reports `providers=2/6`, which counts it. > > **Why it was removed anyway:** `/v1/oauth/spotify/start` is gated by -> `supportsProvider(profile, provider)`, so a provider with no app behind it is +> `admitsProviderForMode(profile, provider, mode)`, so a provider with no app behind it is > unreachable. While the profile existed, that path was publicly reachable and > would have run a real OAuth handshake against a real client secret, taking > custody of a user's Spotify refresh token on behalf of a consumer that could diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md index f28b40a..dbd91f1 100644 --- a/docs/app-profiles/streampixels.md +++ b/docs/app-profiles/streampixels.md @@ -20,7 +20,11 @@ Corroborated by `gamecult-ops/nginx/streampixels.gamecult.org.conf:14` The current StreamPixels architecture already makes useful splits: - the web app owns the remembered viewer session -- the service owns provider OAuth exchange and identity resolution +- Heimdall owns provider OAuth exchange, identity resolution and refresh-token + custody for Twitch, YouTube and Joystick; StreamPixels never calls a provider + token endpoint and holds no provider refresh token. It asks Heimdall for a + short-lived access token (`managed-credentials/resolve`) and treats `410 + managed_credential_revoked` as "the creator must reconnect" - the overlay remains auth-free - creator memberships and operator roles are separate from general viewer state @@ -38,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is: - provider OAuth authority - identity-linking authority -- managed Twitch/YouTube credential custody for creator connections +- managed Twitch/YouTube/Joystick credential custody for creator connections (Joystick is a managed connection only, not a viewer sign-in provider: `startOAuthFlow` admits `sign_in` and `link` only for `identityProviders` and `connect` only for `managedConnectionProviders` or `identityProviders`, and refuses anything else before any redirect. The callback decides again from the signed state's mode: a `connect` for a managed-connection-only provider is admitted only with `connection.kind` `creator` and a `targetId`, refused otherwise at start and again at the callback before the code exchange; an admitted one stores the credential and mints a claim with no facts, no capabilities and no refresh token. Heimdall's claim is therefore never a viewer session. Heimdall cannot stop an app from minting its own viewer from a completion, so StreamPixels' redeem must treat a non-creator Joystick completion as an error, never as a sign-in. This is an S1 rule, recorded for SP) - entitlement refresh - shared claim semantics diff --git a/docs/architecture.md b/docs/architecture.md index 88bfeaa..8ae5c19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -305,6 +305,159 @@ Important rule: connection is tied to a user or account rather than a purely app-local secret - app-specific binding semantics and runtime behavior still stay app-owned +## Managed credential custody + +Source-grounded as of branch `eureka/joystick-h1` at 6db84bf (PR +GameCult/Heimdall#1, unmerged). Nothing here is deployed. + +### One owner: `refreshManagedCredential` + +`src/managed-credential.ts` owns every decision to present a stored refresh +token to a provider. The entitlement path (`refreshAppSession`), the Bifrost +Patreon sync and `managed-credentials/resolve` all call it; none of them reads +`tokenExpiresAt` or a provider token endpoint itself. The earlier +`refreshLinkedIdentityCredentialIfNeeded` is deleted, and callers no longer +write refreshed tokens through `upsertLinkedIdentity`: a refresh reaches the row +only through the store port below. The only other writer of credential columns +is the OAuth callback's grant write (`app.ts`, once). + +| | | +|---|---| +| Input | a `StoredLinkedIdentity` snapshot, the config, the token custody (AES-256-GCM seal), the provider runtimes, an injectable clock and timeout | +| Output | `{kind: "current" or "revoked", identity}`, or a thrown `ManagedCredentialRefreshFailed` (carries the provider and nothing the provider said) | +| Forbidden writers | any caller writing token columns or `token_expires_at` outside `updateLinkedIdentityCredential` and the callback's grant write; any caller computing "is a refresh due" | + +The snapshot is a hint. It may answer without a lock when the identity holds no +access token (`revoked`) or no refresh is due (`current`; due means a known +expiry within 5 minutes, a stored refresh token, and a runtime that refreshes). +It never decides that a refresh happens. + +### Three nested guards + +1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then + linked identity id. A caller arriving while an attempt is in flight joins it + and receives its outcome, success or failure, so nobody queues behind a + failed holder and re-presents the same token. The entry ends with the + attempt; a later request retries. +2. **Store-port lock, across processes.** + `HeimdallStore.updateLinkedIdentityCredential(id, decide)` runs `decide` + against the row as read under the lock. `PostgresStore` takes + `SELECT ... FOR UPDATE` inside a transaction; `InMemoryStore` serialises per + id on a promise chain that deletes its entry only when it is still the tail. + `decide` re-checks `dueForRefresh` on the locked row, so a process that + waited finds the token already rotated and returns `keep`. The provider call + is aborted at 10 seconds (`MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS`), which + bounds how long any waiter holds a pooled connection. +3. **Decision, not mutation.** `decide` returns a `CredentialDecision`: `keep`, + `revoke`, or `rotate` with the access token, refresh token and + `tokenExpiresAt` all required. The store applies it and returns the row. The + new pair is persisted before the access token is returned. A store that + keeps the old expiry on rotate is a type error, not a test. + +### Typed answers + +- **Revoked** (`410 managed_credential_revoked`, audit + `managed_credential_revoked {provider, reason}`, all three token columns + cleared). Two reasons only: `provider_revoked`, an HTTP 400 whose OAuth + `error` is exactly `invalid_grant`; and `rotation_missing`, a well-formed + refresh answer without a refresh token from a provider whose descriptor says + `rotatesRefreshTokens`. An identity with no access token is also `revoked`. + `404` means no linked identity exists. On the entitlement path a revoked + identity is skipped; on the Bifrost sync it is `404 + patreon_identity_not_linked` with Bifrost uncalled. +- **Refresh failed** (`502 managed_credential_refresh_failed`, tokens + untouched, retry allowed): invalid client, a 401 or 5xx whatever its body, + timeout, a 2xx without a string `access_token` or carrying `error`, a + `refresh_token` present but not a string, an empty or non-JSON body. + `ProviderResponseError.oauthError` is drawn only from the closed RFC 6749 code + set and is read only from a 400; no provider body reaches a message, stack or + audit payload. +- An empty-string `refresh_token` counts as missing. + +### Provider descriptor and the one token request + +`providerCatalog` (`src/providers.ts`) is the only place a provider's token +behaviour is written down. Fields beyond the authorize side: + +- `tokenEndpoint`: where both grants go. +- `tokenEndpointAuthMethod`: `client_secret_basic` or `client_secret_post` + (RFC 7591 names); exactly one client-auth method per request. +- `rotatesRefreshTokens`: true only for Joystick. Drives fail-closed on + refresh (`rotation_missing`) and a failed callback on exchange when the grant + carries no refresh token. +- `defaultAccessTokenLifetimeSeconds`: 3600 for all seven providers. Applied + when an answer has no usable `expires_in`; unusable means absent, non-numeric, + not a positive number or positive-integer string, or above 365 days. An + unknown expiry is therefore never stored, so every credential comes due. + +`requestProviderTokens(provider, config, grant)` in `src/oauth.ts` replaces the +ten per-provider exchange and refresh functions. Authorization-code and refresh +grants share it, it returns an `ExpiringTokenSet` (`expiresAt` required), and it +rejects a malformed 2xx typed as `provider_body_not_token_set` on both grants. +The per-provider runtimes keep only identity resolution. + +### Mode x role at the gates + +`src/app-profiles.ts` holds both questions, and the profile's role lists are +their only input: + +- `admitsProviderForMode(profile, provider, mode)`, the start gate: `sign_in` + and `link` take `identityProviders` only; `connect` takes + `managedConnectionProviders` or `identityProviders` (Bifrost connects its + identity providers). `supportsProvider` is deleted. +- `callbackMint(profile, provider, mode, connection)`, decided at the callback + from the signed state and asked again at start, so an unbound connect never + reaches a redirect: + - `viewer_session`: the provider is in `identityProviders`; a normal session + with the profile's capabilities. + - `custody_only`: a `connect` for a provider that is only a managed + connection (Joystick on StreamPixels) with `connection.kind = "creator"` and + a non-empty `targetId`. + - `refused`: everything else, before the code exchange. + +A custody-only completion stores the credential and issues a claim with no +facts, no capabilities and no hybrid capabilities (`withoutCapabilities` in +`src/claims.ts`, the one owner of a capability-free claim). The completion +payload and the `backend_callback` handoff carry no `refreshToken` or +`refresh`, because the refresh route recomputes facts from the account and would +turn that session into a viewer session. A session row still exists, and +`BackendHandoffSuccessPayload` types both fields optional for this reason. The +consumer's redeem reads only account id, provider, mode, connection and claim +identities, so the claim form is unchanged for it. + +### Verification layer + +The rules above are pinned by `tests/managed-credential.test.ts` on three rigs +(memory, Postgres with one app, Postgres with two pools), +`tests/provider-token-request.test.ts`, `tests/app.test.ts` (a matrix over all +profiles x providers x modes, callback mint, handoff strip) and +`tests/recipe-secrets.test.ts`. Evidence is the Yggdrasil `ygg-verify` run on +Linux (node:24, postgres:16, `HEIMDALL_REQUIRE_LIVE_PG=1`; 600 passed, 8 +skipped, none a live-Postgres suite), hand and StrykerJS mutants over the diff, +and Soul's pass 4 verdict `joystick:verdict:cut-h1.s4`. The deploy path's own +builder was not used. + +### Open follow-ups + +- `joystick:follow_up:cross-process-failed-refresh-marker`: single flight is per + process, so N Heimdall processes can present one token N times after a + transient failure. Needs a failed-attempt marker on the row, read under the + lock. Must land before or with Idunn onboarding. +- `joystick:follow_up:connect-start-needs-app-auth`: connect starts are + unauthenticated, so anyone can name `{creator, targetId}`; the callback + re-check proves a binding exists, not that the caller owns it. It also owns + the whitespace-only `targetId` that passes both gates and the callback's + fail-open `''` fallback for a vanished profile (finding + `joystick:finding:cut-h1.s4.whitespace-targetid-passes-custody-gate`). Must + land before the public Joystick bot or a second creator. +- `joystick:follow_up:heimdall-idunn-onboarding` (gamecult-ops): live Heimdall + is a hand-copied release under `heimdall.service`, not an Idunn-admitted + generation; the recipe's Joystick lines are declared but unproven by a + deploy. +- `joystick:follow_up:joystick-viewer-signin-public-bot`: Joystick stays out of + StreamPixels `identityProviders` until the public bot is registered; viewer + sign-in must not rewrite a creator's credential row. + ## Policy model The shared authority should not hard-code one app's access rules. It should @@ -514,24 +667,17 @@ each provider**. That URL cannot move on deploy. ### Finding: the `spotify` provider outlived its consumer -`spotiverse` is the only profile naming `spotify`, and Spotiverse was lost with -the `E:` drive — it was a small daemon exposing the Spotify API to agents, and -it no longer exists in the swarm. +`spotiverse` was the only profile naming `spotify`, and Spotiverse was lost with +the `E:` drive. The profile has since been removed from the registry +(`docs/app-profiles/spotiverse.md` is the restore spec), and +`admitsProviderForMode` refuses a provider no profile lists, so +`/v1/oauth/spotify/start` no longer reaches a handshake. -What survives it: `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and -`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET` are configured in -`/srv/heimdall/env/service.env`, the profile is advertised in the public -discovery document, and `/v1/oauth/spotify/start` is reachable from the open -web. That path would run a real OAuth handshake against a real client secret and -take custody of a user's Spotify refresh token on behalf of a consumer that -cannot use it. - -This is not an active compromise. It is a live credential and a token-custody -code path kept alive for nothing, which is exactly the shape that becomes an -incident later. Retire the profile and the provider config together, or record -why Spotiverse is expected back. Retiring is a change to `src/app-profiles.ts` -and the provider catalog and should be decided on its own, not folded into the -boundary change. +What survives: the `spotify` catalog entry and runtime, and, wherever an +operator env still holds them, `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and +`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE`. A live credential kept for a +consumer that does not exist is the shape that becomes an incident later. +Retire the provider config, or record why Spotiverse is expected back. ## Deployment modes @@ -588,14 +734,14 @@ Plain settings: - `GC_ACCESS_SIGNING_KEY_ID=...` - `GC_ACCESS_SESSION_TTL_SECONDS=3600` - `GC_ACCESS_STATE_TTL_SECONDS=600` -- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY}_CLIENT_ID=...` +- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_ID=...` - `GC_ACCESS_APP_{APP_SLUG}_BACKEND_CALLBACK_URLS=...` Secret files: - `GC_ACCESS_DATABASE_URL_FILE=/etc/gamecult/heimdall/credentials/GC_ACCESS_DATABASE_URL` - `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE=...` -- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY}_CLIENT_SECRET_FILE=...` +- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_SECRET_FILE=...` - `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET_FILE=...` - `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE=...` diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 9a1f84d..44bcbc7 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -44,7 +44,7 @@ What exists right now is: and managed creator connections - an app-authenticated managed-credential resolve endpoint so StreamPixels can call provider APIs without storing refresh tokens locally -- managed Twitch/YouTube credentials refresh inside Heimdall before resolution +- managed Twitch/YouTube/Joystick credentials refresh inside Heimdall before resolution when they are expired or near expiry - StreamPixels now starts viewer and creator Twitch/YouTube OAuth through Heimdall, redeems one-time completion codes server-side, and keeps local @@ -215,7 +215,7 @@ Landed in the first pass: Still to do in this phase: - key rotation policy instead of just stable key loading -- token custody retrieval/refresh surfaces beyond initial storage +- token custody retrieval and refresh have landed (see `docs/architecture.md`, Managed credential custody); the open pieces are the follow-ups listed there ### Phase 2: Heimdall service skeleton diff --git a/docs/service-contract.md b/docs/service-contract.md index c639178..1567ae5 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -118,6 +118,7 @@ Supported providers in the skeleton: - `twitch` - `youtube` - `spotify` +- `joystick` Request body: @@ -172,7 +173,7 @@ Failure modes: Launch note: - Discord and Patreon are the implemented provider runtimes for Repixelizer -- Twitch and YouTube now have generic OAuth exchange and identity runtimes for +- Twitch, YouTube and Joystick have generic OAuth exchange and identity runtimes for StreamPixels viewer auth and managed creator connections - GitHub is still catalogued for start URLs, but its callback runtime is not implemented yet @@ -430,9 +431,11 @@ Important behavior: - the refresh token is signed by Heimdall and scoped to one app session - the access token remains short-lived; app backends still verify it locally -- if a stored provider access token is expired or near expiry, Heimdall uses - the stored provider refresh token and persists any rotation before - evaluating entitlements +- if a stored provider access token is expired or near expiry, Heimdall takes + a row lock on the linked identity, re-reads it, uses the stored provider + refresh token and persists any rotation before evaluating entitlements; an + identity whose grant the provider has revoked (no stored access token) + contributes no entitlement - any app authenticated via `x-heimdall-app-secret` (`resolveAppCaller`) may send `entitlementPolicies`; Heimdall matches each stored identity to the policy for that provider. Sending policy without authenticating is @@ -484,7 +487,36 @@ Important behavior: - it returns access tokens only; refresh tokens remain in Heimdall custody - when a stored managed credential is expired or within the refresh window, Heimdall refreshes it before returning the access token and persists rotated - access/refresh tokens + access/refresh tokens. The refresh runs under a row lock on the linked + identity (`SELECT ... FOR UPDATE`), so a given refresh token is presented to + the provider once across concurrent requests and across two Heimdall + processes sharing one database. Within one process, concurrent refreshes of + one identity share a single attempt and its outcome (success or failure), so + nobody re-presents a token behind a failed holder in that burst. Residual + risk, tracked as follow-up `joystick:follow_up:cross-process-failed-refresh-marker`: + the guard is one presentation per process per burst, so N + Heimdall processes can present one token N times after a transient failure. + The provider call is bounded at 10 seconds +- a provider whose refresh tokens rotate (Joystick) fails closed: a refresh + response without a new refresh token revokes the credential instead of + keeping the old token +- `410 {"error":"managed_credential_revoked","provider":...}`: the provider + rejected the grant (HTTP 400 whose OAuth `error` is exactly `invalid_grant`), + or the identity holds no access token. + The stored tokens are cleared and an audit event `managed_credential_revoked` + is written; the owner must reconnect. `404 managed_credential_not_found` + means no linked identity exists +- `502 {"error":"managed_credential_refresh_failed","provider":...}`: the + refresh failed for any reason that is not a grant verdict (invalid client, + a 401 or 5xx whatever its body says, timeout, a 2xx without a string + `access_token` or carrying an `error`, a `refresh_token` that is present but + not a string, an empty or non-JSON body). Stored tokens are untouched and a + later request may succeed. An empty-string `refresh_token` counts as missing, + so a rotating provider revokes (`rotation_missing`). A token answer without a + usable `expires_in` (absent, non-numeric, or not a positive number or + positive-integer string) stores the expiry as now plus the provider's default + access-token lifetime (3600 seconds), so an expiry is never unknown and the + credential always comes due for refresh - app-local binding, diagnostics, subscription setup, and runtime behavior stay app-owned diff --git a/schemas/heimdall.auth_begin_command.v1.schema.json b/schemas/heimdall.auth_begin_command.v1.schema.json index 81fdaf8..ca22ccf 100644 --- a/schemas/heimdall.auth_begin_command.v1.schema.json +++ b/schemas/heimdall.auth_begin_command.v1.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["provider", "mode", "returnTo", "entitlementPolicy"], "properties": { - "provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify"] }, + "provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] }, "mode": { "enum": ["sign_in", "link", "connect"] }, "returnTo": { "type": "string", "format": "uri" }, "entitlementPolicy": { diff --git a/src/app-profiles.ts b/src/app-profiles.ts index bede115..3489a48 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -3,7 +3,7 @@ import { type CapabilityDefinition, type CapabilityMode, } from "./capability-rules.js"; -import { type AppSlug, type LinkedIdentityInput, type OAuthEntitlementPolicy, type Provider } from "./contracts.js"; +import { type AppSlug, type LinkedIdentityInput, type OAuthConnectionBinding, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js"; import { entitlementFacts, grantFacts, identityFacts } from "./facts.js"; export type { CapabilityDefinition, CapabilityMode }; @@ -99,10 +99,10 @@ const repixelizerProfile: AppProfile = { const streampixelsProfile: AppProfile = { slug: "streampixels", displayName: "StreamPixels", - profileVersion: "2026-04-26", + profileVersion: "2026-10-19", identityProviders: ["twitch", "youtube"], entitlementSources: [], - managedConnectionProviders: ["twitch", "youtube"], + managedConnectionProviders: ["twitch", "youtube", "joystick"], allowedReturnOrigins: ["https://streampixels.gamecult.org"], capabilities: [ { @@ -222,12 +222,19 @@ export function serializeAppProfile(profile: AppProfile): Record { - const expiresAt = options.linkedIdentity.tokenExpiresAt ? Date.parse(options.linkedIdentity.tokenExpiresAt) : undefined; - if ( - !expiresAt || - expiresAt - Date.now() > MANAGED_CREDENTIAL_REFRESH_SKEW_MS || - !options.linkedIdentity.refreshTokenEncrypted - ) { - return options.linkedIdentity; - } - - const runtime = options.oauthRuntimes[options.linkedIdentity.provider]; - if (!runtime.refreshAccessToken) { - return options.linkedIdentity; - } - - const refreshToken = options.tokenCustody.decrypt(options.linkedIdentity.refreshTokenEncrypted); - const refreshed = await runtime.refreshAccessToken({ - config: options.config, - refreshToken, - }); - - const upsertInput: UpsertLinkedIdentityInput = { - id: options.linkedIdentity.id, - accountId: options.linkedIdentity.accountId, - provider: options.linkedIdentity.provider, - providerUserId: options.linkedIdentity.providerUserId, - accessTokenEncrypted: options.tokenCustody.encrypt(refreshed.accessToken), - refreshTokenEncrypted: options.tokenCustody.encrypt(refreshed.refreshToken ?? refreshToken), - scopes: refreshed.scope.length ? refreshed.scope : options.linkedIdentity.scopes, - profileJson: options.linkedIdentity.profileJson, - createdAt: options.linkedIdentity.createdAt, - updatedAt: options.now, - }; - const tokenExpiresAt = refreshed.expiresAt ?? options.linkedIdentity.tokenExpiresAt; - if (tokenExpiresAt !== undefined) { - upsertInput.tokenExpiresAt = tokenExpiresAt; - } - if (options.linkedIdentity.username !== undefined) { - upsertInput.username = options.linkedIdentity.username; - } - if (options.linkedIdentity.displayName !== undefined) { - upsertInput.displayName = options.linkedIdentity.displayName; - } - if (options.linkedIdentity.primaryEmail !== undefined) { - upsertInput.primaryEmail = options.linkedIdentity.primaryEmail; - } - - return options.store.upsertLinkedIdentity(upsertInput); -} - function storedIdentityToResolvedIdentity(identity: StoredLinkedIdentity) { const resolved = { provider: identity.provider, @@ -450,7 +392,6 @@ async function evaluateStoredEntitlements(options: { appSlug: AppSlug; accountId: string; entitlementPolicies: OAuthEntitlementPolicy[]; - now: string; }): Promise { const profile = getAppProfile(options.appSlug); if (!profile) { @@ -460,19 +401,13 @@ async function evaluateStoredEntitlements(options: { const facts: string[] = []; for (const storedIdentity of linkedIdentities) { - if (!profile.entitlementSources.includes(storedIdentity.provider) || !storedIdentity.accessTokenEncrypted) { + if (!profile.entitlementSources.includes(storedIdentity.provider)) { continue; } - const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({ - config: options.config, - store: options.store, - tokenCustody: options.tokenCustody, - oauthRuntimes: options.oauthRuntimes, - linkedIdentity: storedIdentity, - now: options.now, - }); - if (!linkedIdentity.accessTokenEncrypted) { + const refreshed = await refreshManagedCredential(options, storedIdentity); + const linkedIdentity = refreshed.identity; + if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) { continue; } @@ -559,10 +494,20 @@ export async function startOAuthFlow( return { statusCode: 404, body: { error: "unknown_app", detail: "No app is registered under that slug." } }; } - if (!supportsProvider(profile, provider)) { + if (!admitsProviderForMode(profile, provider, input.mode)) { return { statusCode: 400, - body: { error: "provider_not_supported_for_app", provider, appSlug: profile.slug }, + body: { error: "provider_not_supported_for_app", provider, mode: input.mode, appSlug: profile.slug }, + }; + } + + if (callbackMint(profile, provider, input.mode, input.connection) === "refused") { + return { + statusCode: 400, + body: { + error: "creator_connection_required", + detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`, + }, }; } @@ -714,7 +659,6 @@ export async function refreshAppSession( appSlug, accountId: refreshClaim.account_id, entitlementPolicies, - now: nowIso, }); const issueInput: IssueAccessClaimInput = { appSlug, @@ -1074,6 +1018,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, + Partial> { status: "success"; mode: OAuthMode; account: AccountSummary; diff --git a/src/claims.ts b/src/claims.ts index 11c42ea..4440ed3 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -39,6 +39,12 @@ export interface IssueAccessClaimInput { displayName?: string; linkedIdentities: LinkedIdentityInput[]; facts: string[]; + /** + * The claim names the account and its identities and carries no facts and + * no capabilities (a managed-credential connect for a provider that is not + * an identity provider). The facts are not even `identity.authenticated`. + */ + withoutCapabilities?: boolean; sessionId?: string; accessRevision?: number; ttlSeconds?: number; @@ -80,11 +86,15 @@ export async function issueAccessClaim(options: { // would name an app no verifier can resolve a profile for. throw new Error(`Cannot issue an access claim for unknown app '${options.input.appSlug}'.`); } - const facts = normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts); - const factSet = new Set(facts); + const withoutCapabilities = options.input.withoutCapabilities === true; + const facts = withoutCapabilities + ? [] + : normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts); + // Every shared capability needs a non-empty anyOf (capability-rules), so an + // empty fact set yields no capabilities: facts are the one owner of that. const sharedCapabilities = evaluateSharedCapabilities(profile, { accountId: options.input.accountId, - facts: factSet, + facts: new Set(facts), identities: options.input.linkedIdentities, }); @@ -176,6 +186,8 @@ export async function issueAccessClaim(options: { kid: options.keys.kid, }, sharedCapabilities, - hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"), + hybridCapabilities: withoutCapabilities + ? [] + : profile.capabilities.filter((capability) => capability.mode === "hybrid"), }; } diff --git a/src/contracts.ts b/src/contracts.ts index 743bfdc..d6cab2a 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,4 +1,4 @@ -export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify"] as const; +export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const; export type Provider = (typeof providers)[number]; diff --git a/src/managed-credential.ts b/src/managed-credential.ts new file mode 100644 index 0000000..32e9f51 --- /dev/null +++ b/src/managed-credential.ts @@ -0,0 +1,181 @@ +import { type HeimdallConfig } from "./config.js"; +import { type Provider } from "./contracts.js"; +import { type TokenCustody } from "./custody.js"; +import { type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js"; +import { providerCatalog } from "./providers.js"; +import { type HeimdallStore } from "./store/index.js"; +import { type CredentialDecision, type StoredLinkedIdentity } from "./store/types.js"; + +/** A stored access token this close to expiry (or past it) is refreshed before use. */ +export const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; + +/** + * The longest a refresh may hold the row lock: the provider call is aborted + * at this bound, and every waiter holds a pooled connection for at most this + * long behind the holder. + */ +export const MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS = 10_000; + +export interface ManagedCredentialContext { + config: HeimdallConfig; + store: HeimdallStore; + tokenCustody: TokenCustody; + oauthRuntimes: OAuthRuntimeRegistry; + clock?: () => number; + timeoutMs?: number; +} + +export type ManagedCredentialRevocationReason = "provider_revoked" | "rotation_missing"; + +/** `revoked` means the identity holds no access token: the provider took the grant back, or it was never held. */ +export type ManagedCredentialOutcome = + | { kind: "current"; identity: StoredLinkedIdentity } + | { kind: "revoked"; identity: StoredLinkedIdentity }; + +/** + * The provider could not be asked, or answered with something that is not a + * grant verdict (invalid_client, 5xx, timeout, malformed body). The stored + * tokens are untouched. Carries the provider and nothing the provider said. + */ +export class ManagedCredentialRefreshFailed extends Error { + readonly code = "managed_credential_refresh_failed"; + readonly provider: Provider; + + constructor(provider: Provider) { + super(`${providerCatalog[provider].displayName} managed credential refresh failed.`); + this.name = "ManagedCredentialRefreshFailed"; + this.provider = provider; + } +} + +function dueForRefresh(identity: StoredLinkedIdentity, runtime: OAuthProviderRuntime, now: number): boolean { + if (!identity.tokenExpiresAt || !identity.refreshTokenEncrypted || !runtime.refreshAccessToken) { + return false; + } + return Date.parse(identity.tokenExpiresAt) - now <= MANAGED_CREDENTIAL_REFRESH_SKEW_MS; +} + +function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity { + const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = identity; + return rest; +} + +/** + * Refreshes in flight in this process, per store and linked identity. The row + * lock serialises processes; it cannot tell a waiter that queued behind a + * failed holder from a caller that arrived after the failure, so each waiter + * would present the same refresh token again. Callers that arrive while an + * attempt is in flight join it and receive its outcome, success or failure. + * The entry lives exactly as long as the attempt: a later request retries. + * + * Known residual risk (docs/service-contract.md): one presentation per + * process per burst, so N processes may present one token N times. The + * cross-process failed-refresh marker that would close it is + * joystick:follow_up:cross-process-failed-refresh-marker. + */ +const refreshesInFlight = new WeakMap>>(); + +/** + * The one refresh policy. `snapshot` is a hint: it may skip the lock when no + * refresh is due and never decides one. The row's lock owns when a refresh + * token is presented across processes; this process makes one attempt per + * identity at a time; the decision is made on the row as read under the lock. + */ +export async function refreshManagedCredential( + ctx: ManagedCredentialContext, + snapshot: StoredLinkedIdentity +): Promise { + const clock = ctx.clock ?? Date.now; + const runtime = ctx.oauthRuntimes[snapshot.provider]; + + if (!snapshot.accessTokenEncrypted) { + return { kind: "revoked", identity: snapshot }; + } + if (!dueForRefresh(snapshot, runtime, clock())) { + return { kind: "current", identity: snapshot }; + } + + let inFlight = refreshesInFlight.get(ctx.store); + if (!inFlight) { + inFlight = new Map(); + refreshesInFlight.set(ctx.store, inFlight); + } + const joined = inFlight.get(snapshot.id); + if (joined) { + return joined; + } + const attempt = refreshUnderLock(ctx, snapshot).finally(() => { + inFlight.delete(snapshot.id); + }); + inFlight.set(snapshot.id, attempt); + return attempt; +} + +async function refreshUnderLock( + ctx: ManagedCredentialContext, + snapshot: StoredLinkedIdentity +): Promise { + const clock = ctx.clock ?? Date.now; + const provider = snapshot.provider; + const runtime = ctx.oauthRuntimes[provider]; + + let revocation: ManagedCredentialRevocationReason | undefined; + const decide = async (locked: StoredLinkedIdentity): Promise => { + if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) { + return { kind: "keep" }; + } + const updatedAt = new Date(clock()).toISOString(); + const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!); + + let refreshed; + try { + refreshed = await runtime.refreshAccessToken!({ + config: ctx.config, + refreshToken, + signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS), + }); + } catch (error) { + if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") { + revocation = "provider_revoked"; + return { kind: "revoke", updatedAt }; + } + throw new ManagedCredentialRefreshFailed(provider); + } + + let refreshTokenEncrypted: string; + if (refreshed.refreshToken) { + refreshTokenEncrypted = ctx.tokenCustody.encrypt(refreshed.refreshToken); + } else if (providerCatalog[provider].rotatesRefreshTokens) { + revocation = "rotation_missing"; + return { kind: "revoke", updatedAt }; + } else { + refreshTokenEncrypted = locked.refreshTokenEncrypted!; + } + + // The refreshed set always carries an expiry (the provider's, or the + // descriptor's default lifetime): the old one is already due, and an + // unknown one would never come due (dueForRefresh). + return { + kind: "rotate", + accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken), + refreshTokenEncrypted, + tokenExpiresAt: refreshed.expiresAt, + scopes: refreshed.scope.length ? refreshed.scope : locked.scopes, + updatedAt, + }; + }; + + const committed = await ctx.store.updateLinkedIdentityCredential(snapshot.id, decide); + if (revocation) { + await ctx.store.createAuditEvent({ + accountId: snapshot.accountId, + eventType: "managed_credential_revoked", + eventPayloadJson: { provider, reason: revocation }, + createdAt: new Date(clock()).toISOString(), + }); + } + if (!committed) { + return { kind: "revoked", identity: withoutTokens(snapshot) }; + } + return { kind: committed.accessTokenEncrypted ? "current" : "revoked", identity: committed }; +} diff --git a/src/oauth.ts b/src/oauth.ts index 33ae495..b6658e8 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -7,6 +7,7 @@ import { } from "./contracts.js"; import { type HeimdallConfig } from "./config.js"; import { entitlementFacts } from "./facts.js"; +import { providerCatalog } from "./providers.js"; import { type CreateEntitlementSnapshotInput } from "./store/types.js"; export interface OAuthTokenSet { @@ -40,6 +41,9 @@ export interface OAuthCallbackContext { entitlementPolicy: OAuthEntitlementPolicy | null; } +/** A token set whose access-token expiry is known: the provider's own, or the descriptor's default. */ +export type ExpiringTokenSet = OAuthTokenSet & { expiresAt: string }; + export interface OAuthProviderRuntime { exchangeAuthorizationCode(options: { config: HeimdallConfig; @@ -49,7 +53,8 @@ export interface OAuthProviderRuntime { refreshAccessToken?(options: { config: HeimdallConfig; refreshToken: string; - }): Promise; + signal?: AbortSignal; + }): Promise; resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; evaluateEntitlements(options: { config: HeimdallConfig; @@ -59,6 +64,25 @@ export interface OAuthProviderRuntime { }): Promise; } +const oauthErrorCodes = [ + "invalid_request", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "invalid_scope", +] as const; +export type OAuthErrorCode = (typeof oauthErrorCodes)[number]; + +function readOAuthErrorCode(text: string): OAuthErrorCode | undefined { + try { + const error = (JSON.parse(text) as { error?: unknown } | null)?.error; + return oauthErrorCodes.find((code) => code === error); + } catch { + return undefined; + } +} + /** * A provider answer Heimdall cannot use: a non-2xx status, or a body that is * not JSON. It carries the status and a code, never the body or the parser's @@ -68,13 +92,19 @@ export interface OAuthProviderRuntime { */ export class ProviderResponseError extends Error { readonly status: number; - readonly code: "provider_status" | "provider_body_not_json"; + readonly code: "provider_status" | "provider_body_not_json" | "provider_body_not_token_set"; + /** + * The RFC 6749 section 5.2 error code of a refusal, when the provider sent + * one from that closed set. The only value taken from a provider's body. + */ + readonly oauthError: OAuthErrorCode | undefined; - constructor(status: number, code: ProviderResponseError["code"], failure: string) { + constructor(status: number, code: ProviderResponseError["code"], failure: string, oauthError?: OAuthErrorCode) { super(`${failure} (status ${status}, ${code}).`); this.name = "ProviderResponseError"; this.status = status; this.code = code; + this.oauthError = oauthError; } } @@ -82,7 +112,14 @@ export class ProviderResponseError extends Error { async function readProviderJson(response: Response, failure: string): Promise { const text = await response.text(); if (!response.ok) { - throw new ProviderResponseError(response.status, "provider_status", failure); + // RFC 6749 section 5.2 sends a grant verdict as 400 only. The same JSON + // behind a 401, a 5xx or a gateway page is not one. + throw new ProviderResponseError( + response.status, + "provider_status", + failure, + response.status === 400 ? readOAuthErrorCode(text) : undefined + ); } if (!text) { return {} as T; @@ -106,512 +143,133 @@ function normalizeScopes(scope: string | string[] | undefined): string[] { return scope?.split(/\s+/).filter(Boolean) ?? []; } -async function exchangeDiscordAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.discord; +export type TokenGrant = + | { grant_type: "authorization_code"; code: string; redirect_uri: string } + | { grant_type: "refresh_token"; refresh_token: string }; + +/** + * The one token request: endpoint, client authentication and rotation all come + * from the provider's descriptor. `basic` sends the client as an Authorization + * header and nothing in the body; `post` sends it in the body. A refresh token + * is returned only when the provider sent one; keeping or discarding the old + * one is the caller's rule (ProviderDescriptor.rotatesRefreshTokens). + */ +export async function requestProviderTokens( + provider: Provider, + config: HeimdallConfig, + grant: TokenGrant, + signal?: AbortSignal +): Promise { + const descriptor = providerCatalog[provider]; + const providerConfig = config.providers[provider]; if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Discord OAuth is not fully configured."); + throw new Error(`${descriptor.displayName} OAuth is not fully configured.`); } - const body = new URLSearchParams({ - grant_type: "authorization_code", - code: options.code, - redirect_uri: options.redirectUri, - }); - const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - scope?: string | string[]; - }>( - "https://discord.com/api/v10/oauth2/token", - { - method: "POST", - headers: { - Authorization: `Basic ${basicAuth}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }, - "Discord token exchange failed" - ); + const body = new URLSearchParams(grant); + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") { + headers.Authorization = `Basic ${Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64")}`; + } else { + body.set("client_id", providerConfig.clientId); + body.set("client_secret", providerConfig.clientSecret); + } - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), - raw: tokenResponse as unknown as Record, + const init: RequestInit = { method: "POST", headers, body }; + if (signal) { + init.signal = signal; + } + const label = grant.grant_type === "refresh_token" ? "refresh" : "exchange"; + const failure = `${descriptor.displayName} token ${label} failed`; + const response = await fetch(descriptor.tokenEndpoint, init); + const answer = await readProviderJson(response, failure); + // A 2xx is a token set only when it is an object holding a string access + // token and no error. + if ( + typeof answer !== "object" || + answer === null || + typeof (answer as { access_token?: unknown }).access_token !== "string" || + !(answer as { access_token: string }).access_token || + (answer as { error?: unknown }).error !== undefined + ) { + throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure); + } + const tokenResponse = answer as { + access_token: string; + refresh_token?: unknown; + token_type?: string; + expires_in?: unknown; + scope?: string | string[]; }; + // A refresh token that is present must be a string: anything else is a + // malformed grant answer, refused here as a typed failure rather than + // reaching custody as a TypeError. + if (tokenResponse.refresh_token !== undefined && typeof tokenResponse.refresh_token !== "string") { + throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure); + } + + // Nothing issued here has an unknown expiry: an answer without a usable + // expires_in takes the descriptor's default lifetime. + const lifetimeSeconds = usableExpiresIn(tokenResponse.expires_in) ?? descriptor.defaultAccessTokenLifetimeSeconds; + const tokenSet: ExpiringTokenSet = { + accessToken: tokenResponse.access_token, + tokenType: tokenResponse.token_type ?? "Bearer", + scope: normalizeScopes(tokenResponse.scope), + expiresAt: new Date(Date.now() + lifetimeSeconds * 1000).toISOString(), + raw: tokenResponse as unknown as Record, + }; if (tokenResponse.refresh_token) { tokenSet.refreshToken = tokenResponse.refresh_token; } - return tokenSet; } -async function refreshDiscordAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.discord; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Discord OAuth is not fully configured."); - } +/** The longest access token lifetime taken at its word: a year. Anything above is not a lifetime. */ +const MAX_USABLE_EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60; - const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - scope?: string | string[]; - }>( - "https://discord.com/api/v10/oauth2/token", - { - method: "POST", - headers: { - Authorization: `Basic ${basicAuth}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: options.refreshToken, - }), - }, - "Discord token refresh failed" - ); +/** + * A positive finite number of seconds, or a string that is a positive integer, + * no longer than a year. A larger value would overflow the Date the expiry is + * stored as, so it is unusable and the descriptor's default lifetime applies. + */ +function usableExpiresIn(value: unknown): number | undefined { + const seconds = typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : value; + return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 && seconds <= MAX_USABLE_EXPIRES_IN_SECONDS + ? seconds + : undefined; +} - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), - raw: tokenResponse as unknown as Record, +function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] { + return async (options) => { + const tokenSet = await requestProviderTokens(provider, options.config, { + grant_type: "authorization_code", + code: options.code, + redirect_uri: options.redirectUri, + }); + // A rotating provider's refresh token is the only way back to the + // credential: a grant that arrives without one would be stored as a row + // the first refresh revokes, so the callback fails before it writes. + if (providerCatalog[provider].rotatesRefreshTokens && !tokenSet.refreshToken) { + throw new ProviderResponseError(200, "provider_body_not_token_set", `${providerCatalog[provider].displayName} token exchange failed`); + } + return tokenSet; }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - return tokenSet; } -async function exchangePatreonAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.patreon; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Patreon OAuth is not fully configured."); - } - - const body = new URLSearchParams({ - grant_type: "authorization_code", - code: options.code, - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - redirect_uri: options.redirectUri, - }); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://www.patreon.com/api/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }, - "Patreon token exchange failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; +function sharedRuntimeRefresh(provider: Provider): NonNullable { + return (options) => + requestProviderTokens( + provider, + options.config, + { grant_type: "refresh_token", refresh_token: options.refreshToken }, + options.signal + ); } -async function refreshPatreonAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.patreon; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Patreon OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://www.patreon.com/api/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: options.refreshToken, - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - }), - }, - "Patreon token refresh failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function exchangeTwitchAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.twitch; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Twitch OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://id.twitch.tv/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - code: options.code, - grant_type: "authorization_code", - redirect_uri: options.redirectUri, - }), - }, - "Twitch token exchange failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function refreshTwitchAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.twitch; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Twitch OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://id.twitch.tv/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - grant_type: "refresh_token", - refresh_token: options.refreshToken, - }), - }, - "Twitch token refresh failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function exchangeYouTubeAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.youtube; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("YouTube OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - id_token?: string; - }>( - "https://oauth2.googleapis.com/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - code: options.code, - grant_type: "authorization_code", - redirect_uri: options.redirectUri, - }), - }, - "YouTube token exchange failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function refreshYouTubeAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.youtube; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("YouTube OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://oauth2.googleapis.com/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - grant_type: "refresh_token", - refresh_token: options.refreshToken, - }), - }, - "YouTube token refresh failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function exchangeSpotifyAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.spotify; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Spotify OAuth is not fully configured."); - } - - const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://accounts.spotify.com/api/token", - { - method: "POST", - headers: { - Authorization: `Basic ${basicAuth}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code: options.code, - redirect_uri: options.redirectUri, - }), - }, - "Spotify token exchange failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function refreshSpotifyAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.spotify; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Spotify OAuth is not fully configured."); - } - - const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://accounts.spotify.com/api/token", - { - method: "POST", - headers: { - Authorization: `Basic ${basicAuth}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: options.refreshToken, - }), - }, - "Spotify token refresh failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} async function resolveDiscordIdentity(options: { accessToken: string }): Promise { const user = await fetchJson<{ @@ -910,6 +568,45 @@ async function resolveSpotifyIdentity(options: { accessToken: string }): Promise return identity; } +async function resolveJoystickIdentity(options: { accessToken: string }): Promise { + const profile = await fetchJson<{ + channel_id?: string; + username?: string; + nickname?: string; + photo_url?: string; + }>( + "https://api.joystick.tv/api/v1/me/identity", + { + headers: { + Authorization: `Bearer ${options.accessToken}`, + }, + }, + "Joystick identity lookup failed" + ); + + if (!profile.channel_id) { + throw new Error("Joystick identity response did not include a channel id."); + } + + const identity: ResolvedIdentity = { + provider: "joystick", + providerUserId: profile.channel_id, + profile: profile as unknown as Record, + }; + const displayName = profile.nickname ?? profile.username; + if (profile.username) { + identity.username = profile.username; + } + if (displayName) { + identity.displayName = displayName; + } + if (profile.photo_url) { + identity.avatarUrl = profile.photo_url; + } + + return identity; +} + async function evaluateDiscordEntitlements(options: { config: HeimdallConfig; callback: OAuthCallbackContext; @@ -1217,22 +914,22 @@ async function evaluatePatreonEntitlements(options: { } const discordRuntime: OAuthProviderRuntime = { - exchangeAuthorizationCode: exchangeDiscordAuthorizationCode, - refreshAccessToken: refreshDiscordAccessToken, + exchangeAuthorizationCode: sharedRuntimeExchange("discord"), + refreshAccessToken: sharedRuntimeRefresh("discord"), resolveIdentity: resolveDiscordIdentity, evaluateEntitlements: evaluateDiscordEntitlements, }; const patreonRuntime: OAuthProviderRuntime = { - exchangeAuthorizationCode: exchangePatreonAuthorizationCode, - refreshAccessToken: refreshPatreonAccessToken, + exchangeAuthorizationCode: sharedRuntimeExchange("patreon"), + refreshAccessToken: sharedRuntimeRefresh("patreon"), resolveIdentity: resolvePatreonIdentity, evaluateEntitlements: evaluatePatreonEntitlements, }; const twitchRuntime: OAuthProviderRuntime = { - exchangeAuthorizationCode: exchangeTwitchAuthorizationCode, - refreshAccessToken: refreshTwitchAccessToken, + exchangeAuthorizationCode: sharedRuntimeExchange("twitch"), + refreshAccessToken: sharedRuntimeRefresh("twitch"), resolveIdentity: resolveTwitchIdentity, async evaluateEntitlements() { return { facts: [], snapshots: [] }; @@ -1240,8 +937,8 @@ const twitchRuntime: OAuthProviderRuntime = { }; const youtubeRuntime: OAuthProviderRuntime = { - exchangeAuthorizationCode: exchangeYouTubeAuthorizationCode, - refreshAccessToken: refreshYouTubeAccessToken, + exchangeAuthorizationCode: sharedRuntimeExchange("youtube"), + refreshAccessToken: sharedRuntimeRefresh("youtube"), resolveIdentity: resolveYouTubeIdentity, async evaluateEntitlements() { return { facts: [], snapshots: [] }; @@ -1249,14 +946,23 @@ const youtubeRuntime: OAuthProviderRuntime = { }; const spotifyRuntime: OAuthProviderRuntime = { - exchangeAuthorizationCode: exchangeSpotifyAuthorizationCode, - refreshAccessToken: refreshSpotifyAccessToken, + exchangeAuthorizationCode: sharedRuntimeExchange("spotify"), + refreshAccessToken: sharedRuntimeRefresh("spotify"), resolveIdentity: resolveSpotifyIdentity, async evaluateEntitlements() { return { facts: [], snapshots: [] }; }, }; +const joystickRuntime: OAuthProviderRuntime = { + exchangeAuthorizationCode: sharedRuntimeExchange("joystick"), + refreshAccessToken: sharedRuntimeRefresh("joystick"), + resolveIdentity: resolveJoystickIdentity, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, +}; + const notImplementedRuntime: OAuthProviderRuntime = { async exchangeAuthorizationCode() { throw new Error("Provider callback exchange is not implemented yet."); @@ -1279,5 +985,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry { twitch: twitchRuntime, youtube: youtubeRuntime, spotify: spotifyRuntime, + joystick: joystickRuntime, }; } diff --git a/src/providers.ts b/src/providers.ts index 506e084..77eebf4 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -6,6 +6,17 @@ export interface ProviderDescriptor { key: Provider; displayName: string; authorizationEndpoint: string; + tokenEndpoint: string; + /** RFC 7591 names: how the client authenticates at the token endpoint. */ + tokenEndpointAuthMethod: "client_secret_basic" | "client_secret_post"; + /** True when a refresh response replaces the refresh token and the old one dies. */ + rotatesRefreshTokens: boolean; + /** + * Seconds an access token lives when a token answer carries no usable + * `expires_in` (RFC 6749 5.1 makes it RECOMMENDED, not required). An unknown + * expiry is never stored: the credential would never come due for refresh. + */ + defaultAccessTokenLifetimeSeconds: number; defaultScopes: string[]; roles: ProviderRole[]; clientIdEnv: string; @@ -18,6 +29,10 @@ export const providerCatalog: Record = { key: "discord", displayName: "Discord", authorizationEndpoint: "https://discord.com/oauth2/authorize", + tokenEndpoint: "https://discord.com/api/v10/oauth2/token", + tokenEndpointAuthMethod: "client_secret_basic", + rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["identify", "guilds.members.read"], roles: ["identity", "entitlement"], clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", @@ -27,6 +42,10 @@ export const providerCatalog: Record = { key: "patreon", displayName: "Patreon", authorizationEndpoint: "https://www.patreon.com/oauth2/authorize", + tokenEndpoint: "https://www.patreon.com/api/oauth2/token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["identity", "identity[email]"], roles: ["identity", "entitlement"], clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", @@ -36,6 +55,10 @@ export const providerCatalog: Record = { key: "github", displayName: "GitHub", authorizationEndpoint: "https://github.com/login/oauth/authorize", + tokenEndpoint: "https://github.com/login/oauth/access_token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["read:user", "user:email"], roles: ["identity", "managed_credential"], clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID", @@ -45,6 +68,10 @@ export const providerCatalog: Record = { key: "twitch", displayName: "Twitch", authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize", + tokenEndpoint: "https://id.twitch.tv/oauth2/token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["user:read:email"], roles: ["identity", "managed_credential"], clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID", @@ -54,6 +81,10 @@ export const providerCatalog: Record = { key: "youtube", displayName: "YouTube", authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint: "https://oauth2.googleapis.com/token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: [ "openid", "email", @@ -73,6 +104,10 @@ export const providerCatalog: Record = { key: "spotify", displayName: "Spotify", authorizationEndpoint: "https://accounts.spotify.com/authorize", + tokenEndpoint: "https://accounts.spotify.com/api/token", + tokenEndpointAuthMethod: "client_secret_basic", + rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: [ "user-read-currently-playing", "user-read-playback-state", @@ -82,6 +117,19 @@ export const providerCatalog: Record = { clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE", }, + joystick: { + key: "joystick", + displayName: "Joystick", + authorizationEndpoint: "https://joystick.tv/api/oauth/authorize", + tokenEndpoint: "https://api.joystick.tv/api/oauth/token", + tokenEndpointAuthMethod: "client_secret_basic", + rotatesRefreshTokens: true, + defaultAccessTokenLifetimeSeconds: 3600, + defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"], + roles: ["identity", "managed_credential"], + clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE", + }, }; export function buildAuthorizationUrl(options: { diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 5583ac4..6a53dad 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -6,6 +6,7 @@ import { type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, + type CredentialDecision, type CreateEntitlementSnapshotInput, type CreateSessionInput, type CreatePrivateCommandReceiptInput, @@ -37,6 +38,8 @@ function isGrantActive(grant: StoredCapabilityGrant, appSlug: AppSlug, at: strin } export class InMemoryStore implements HeimdallStore { + /** Tail of each linked identity's credential-decision chain; an entry is removed when it drains. */ + private readonly credentialChains = new Map>(); private readonly accounts = new Map(); private readonly linkedIdentities = new Map(); private readonly grants = new Map(); @@ -174,6 +177,43 @@ export class InMemoryStore implements HeimdallStore { return clone(linkedIdentity); } + async updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise { + const previous = this.credentialChains.get(id) ?? Promise.resolve(); + const run = previous.then(async () => { + const current = this.linkedIdentities.get(id); + if (!current) { + return null; + } + const decision = await decide(clone(current)); + if (decision.kind === "keep") { + return clone(current); + } + const next: StoredLinkedIdentity = { ...current, updatedAt: decision.updatedAt }; + delete next.accessTokenEncrypted; + delete next.refreshTokenEncrypted; + delete next.tokenExpiresAt; + if (decision.kind === "rotate") { + next.accessTokenEncrypted = decision.accessTokenEncrypted; + next.refreshTokenEncrypted = decision.refreshTokenEncrypted; + next.tokenExpiresAt = decision.tokenExpiresAt; + next.scopes = [...decision.scopes]; + } + this.linkedIdentities.set(id, clone(next)); + return clone(next); + }); + const tail = run.catch(() => undefined); + this.credentialChains.set(id, tail); + void tail.then(() => { + if (this.credentialChains.get(id) === tail) { + this.credentialChains.delete(id); + } + }); + return run; + } + async listLinkedIdentitiesForAccount(accountId: string): Promise { return [...this.linkedIdentities.values()] .filter((identity) => identity.accountId === accountId) diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 64f013d..487f375 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -8,6 +8,7 @@ import { type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, + type CredentialDecision, type CreateEntitlementSnapshotInput, type CreateSessionInput, type CreatePrivateCommandReceiptInput, @@ -158,8 +159,8 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity { providerUserId: row.provider_user_id, scopes: parseScopes(row.scopes), profileJson: row.profile_json, - createdAt: row.created_at, - updatedAt: row.updated_at, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), }; if (row.username) { @@ -183,7 +184,8 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity { } if (row.token_expires_at) { - identity.tokenExpiresAt = row.token_expires_at; + // pg hands back a Date for TIMESTAMPTZ whatever the row type says; the port promises ISO strings. + identity.tokenExpiresAt = new Date(row.token_expires_at).toISOString(); } return identity; @@ -546,7 +548,7 @@ export async function checkSchemaRequirements(pool: Pick, require } export class PostgresStore implements HeimdallStore { - constructor(private readonly pool: Pick) {} + constructor(private readonly pool: Pick) {} async ensureSchema(): Promise { await this.pool.query(CREATE_SCHEMA_SQL); @@ -722,6 +724,60 @@ export class PostgresStore implements HeimdallStore { return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity")); } + async updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + try { + const locked = await client.query( + "SELECT * FROM linked_identities WHERE id = $1 FOR UPDATE", + [id] + ); + if (!locked.rowCount) { + await client.query("COMMIT"); + return null; + } + const row = mapLinkedIdentityRow(expectRow(locked.rows[0], "updateLinkedIdentityCredential")); + const decision = await decide(row); + let committed = row; + if (decision.kind !== "keep") { + const rotated = decision.kind === "rotate" ? decision : undefined; + const written = await client.query( + ` + UPDATE linked_identities + SET access_token_encrypted = $2, + refresh_token_encrypted = $3, + token_expires_at = $4, + scopes = $5, + updated_at = $6 + WHERE id = $1 + RETURNING * + `, + [ + id, + nullable(rotated?.accessTokenEncrypted), + nullable(rotated?.refreshTokenEncrypted), + nullable(rotated?.tokenExpiresAt), + (rotated?.scopes ?? row.scopes).join(" "), + decision.updatedAt, + ] + ); + committed = mapLinkedIdentityRow(expectRow(written.rows[0], "updateLinkedIdentityCredential")); + } + await client.query("COMMIT"); + return committed; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + } finally { + client.release(); + } + } + async listLinkedIdentitiesForAccount(accountId: string): Promise { const result = await this.pool.query( ` diff --git a/src/store/types.ts b/src/store/types.ts index d1fabcf..5307e1f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -56,6 +56,23 @@ export interface UpsertLinkedIdentityInput { updatedAt: string; } +/** + * What the owner of a refresh decides after reading the row under its lock. + * `rotate` and `revoke` write token columns, scopes and updated_at only; they + * never touch account, profile or identity fields. + */ +export type CredentialDecision = + | { kind: "keep" } + | { + kind: "rotate"; + accessTokenEncrypted: string; + refreshTokenEncrypted: string; + tokenExpiresAt: string; + scopes: string[]; + updatedAt: string; + } + | { kind: "revoke"; updatedAt: string }; + export interface StoredSession { id: string; accountId: string; @@ -213,6 +230,17 @@ export interface HeimdallStore { findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise; listStoredLinkedIdentitiesForAccount(accountId: string): Promise; upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise; + /** + * Serialise decisions about one row's credential. `decide` receives the row + * as read under the lock (never a caller's snapshot) and the decision is + * committed before this resolves, so a rotated pair is persisted before any + * caller sees the new access token. Returns the committed row, or null when + * no row has that id. A throw from `decide` writes nothing. + */ + updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise; listLinkedIdentitiesForAccount(accountId: string): Promise; createCapabilityGrant(input: CreateCapabilityGrantInput): Promise; listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise; diff --git a/tests/access-plugin.test.ts b/tests/access-plugin.test.ts index b5027d0..4789bf8 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig { storage: { backend: "memory", applySchemaOnStartup: true }, providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, - patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, + patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, }, }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index 7f9a72c..7f4d96d 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1,12 +1,16 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { inspect } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp, errorIdentity } from "../src/app.js"; +import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js"; import { entitlementFacts, identityFacts } from "../src/facts.js"; -import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js"; -import { verifyJwt } from "../src/signing.js"; +import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; +import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; import { InMemoryStore } from "../src/store/index.js"; function createTestConfig(): HeimdallConfig { @@ -49,6 +53,7 @@ function createTestConfig(): HeimdallConfig { twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" }, youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" }, + joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" }, }, }; } @@ -974,7 +979,8 @@ describe("Heimdall service", () => { expect(profileResponse.json()).toEqual( expect.objectContaining({ slug: "streampixels", - managedConnectionProviders: ["twitch", "youtube"], + identityProviders: ["twitch", "youtube"], + managedConnectionProviders: ["twitch", "youtube", "joystick"], }) ); }); @@ -1238,6 +1244,361 @@ describe("Heimdall service", () => { expect(credentialResponse.body).not.toContain("rotated-twitch-refresh-token"); }); + it("revocation-is-typed: a revoked identity contributes no entitlement at session refresh", async () => { + const store = new InMemoryStore(); + let evaluations = 0; + const base = createMockDiscordRuntime(); + const app = await buildApp({ + config: createTestConfig(), + store, + oauthRuntimes: { + discord: { + ...base, + async exchangeAuthorizationCode() { + return { + accessToken: "expired-discord-access-token", + refreshToken: "discord-refresh-token", + tokenType: "Bearer", + scope: ["identify"], + expiresAt: "2026-04-26T13:00:00.000Z", + raw: {}, + }; + }, + async refreshAccessToken() { + throw new ProviderResponseError(400, "provider_status", "Discord token refresh failed", "invalid_grant"); + }, + async evaluateEntitlements(options) { + evaluations += 1; + return base.evaluateEntitlements(options); + }, + }, + }, + }); + apps.push(app); + + const stateToken = await startDiscordSignIn(app); + const callbackResponse = await app.inject({ + method: "GET", + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + }); + expect(callbackResponse.statusCode).toBe(201); + const evaluationsAtSignIn = evaluations; + + const refreshResponse = await app.inject({ + method: "POST", + url: "/v1/apps/repixelizer/sessions/refresh", + headers: { "x-heimdall-app-secret": "repixelizer-secret" }, + payload: { + refreshToken: callbackResponse.json().refreshToken as string, + entitlementPolicies: [{ kind: "discord_role_access", guildId: "gamecult-guild", allowedRoleIds: ["role-repixelizer"] }], + }, + }); + + expect(refreshResponse.statusCode).toBe(201); + expect(evaluations).toBe(evaluationsAtSignIn); + const accountId = callbackResponse.json().account.id as string; + const row = await store.findStoredLinkedIdentityForAccount(accountId, "discord"); + expect(row?.accessTokenEncrypted).toBeUndefined(); + expect(row?.refreshTokenEncrypted).toBeUndefined(); + }); + + it("revocation-is-typed: a revoked Patreon identity is not linked for Bifrost sync and Bifrost is not called", async () => { + const baseConfig = createTestConfig(); + const store = new InMemoryStore(); + const app = await buildApp({ + config: { + ...baseConfig, + bifrostPatronSupportEndpoint: "https://bifrost.gamecult.org/heimdall/patron-support/events", + bifrostPatronSupportSecret: "bifrost-support-secret", + }, + store, + oauthRuntimes: { + patreon: { + ...createOAuthRuntimeRegistry().patreon, + async refreshAccessToken() { + throw new ProviderResponseError(400, "provider_status", "Patreon token refresh failed", "invalid_grant"); + }, + }, + }, + }); + apps.push(app); + const context = getHeimdallContext(app); + const now = "2026-06-09T17:00:00.000Z"; + await store.createAccount({ id: "acct-p", createdAt: now, lastSeenAt: now }); + await store.upsertLinkedIdentity({ + accountId: "acct-p", + provider: "patreon", + providerUserId: "patreon-user-456", + accessTokenEncrypted: context.tokenCustody.encrypt("patreon-access-token"), + refreshTokenEncrypted: context.tokenCustody.encrypt("patreon-refresh-token"), + tokenExpiresAt: "2026-06-09T17:01:00.000Z", + scopes: [], + profileJson: {}, + createdAt: now, + updatedAt: now, + }); + const urls: string[] = []; + globalThis.fetch = async (input) => { + urls.push(String(input)); + return new Response("unexpected", { status: 500 }); + }; + + const response = await app.inject({ + method: "POST", + url: "/v1/apps/bifrost/patron-support/sync", + headers: { "x-heimdall-app-secret": "bifrost-secret" }, + payload: { accountId: "acct-p", requiredTierTitle: "Inner Sanctum", supportedAtUtc: now }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: "patreon_identity_not_linked" }); + expect(urls).toEqual([]); + }); + + describe("joystick connect", () => { + const creator = { kind: "creator", targetId: "creator-1" }; + async function connect(app: FastifyInstance, extra: Record = { connection: creator }) { + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra }, + }); + return start; + } + + it.each([ + ["no connection", {}, "creator_connection_required"], + ["an account connection", { connection: { kind: "account", targetId: "victim-1" } }, "creator_connection_required"], + ["an empty targetId", { connection: { kind: "creator", targetId: "" } }, "invalid_request"], + ])("refuses a joystick connect with %s at start, before any redirect", async (_name, extra, error) => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + const start = await connect(app, extra); + expect(start.statusCode).toBe(400); + expect(start.json().error).toBe(error); + expect(start.json().authorizationUrl).toBeUndefined(); + expect(start.json().stateToken).toBeUndefined(); + }); + + it.each([ + ["no connection", null], + ["an account connection", { kind: "account", targetId: "victim-1" }], + ["an empty targetId", { kind: "creator", targetId: "" }], + ])("the callback refuses a signed joystick connect with %s before the code exchange, and nothing is written", async (_name, connection) => { + const dir = mkdtempSync(join(tmpdir(), "heimdall-forge-")); + try { + const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; + const store = new InMemoryStore(); + const app = await buildApp({ config, store }); + apps.push(app); + const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); + const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, keys.publicKey); + expect(verified.valid).toBe(true); + const forged = signJwt({ ...(verified as { payload: Record }).payload, connection }, keys); + + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(calls).toEqual([]); + expect(await store.findStoredLinkedIdentity("joystick", "chan-forged")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("a backend_callback delivery of a joystick connect carries no refresh token and no capabilities", async () => { + const callbackUrl = "https://streampixels.gamecult.org/api/heimdall/callback"; + const app = await buildApp({ + config: { ...createTestConfig(), appBackendCallbacks: { streampixels: [callbackUrl] } }, + }); + apps.push(app); + stubJoystick({ channel_id: "chan-hand", username: "deru" }); + const providerFetch = globalThis.fetch; + const deliveries: Array> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + if (String(input) === callbackUrl) { + deliveries.push(JSON.parse(String(init?.body)) as Record); + return new Response(null, { status: 204 }); + } + return providerFetch(input, init); + }) as typeof fetch; + + const start = await connect(app, { + connection: creator, + handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl }, + }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + }); + expect(callback.statusCode).toBeLessThan(300); + + expect(deliveries).toHaveLength(1); + const delivered = deliveries[0]!; + expect(delivered).toMatchObject({ status: "success", handoffKind: "backend_callback", mode: "connect", attemptId: "attempt-js-1" }); + expect(typeof delivered.accessToken).toBe("string"); + expect(delivered.refreshToken).toBeUndefined(); + expect(delivered.refresh).toBeUndefined(); + expect(delivered.claimSet.capabilities).toEqual([]); + expect(JSON.stringify(delivered)).not.toContain("js-refresh"); + }); + + it("starts a streampixels connect at joystick.tv with the five scopes and the callback redirect", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + const start = await connect(app); + expect(start.statusCode).toBe(201); + const url = new URL(start.json().authorizationUrl as string); + expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize"); + expect(url.searchParams.get("client_id")).toBe("joystick-client"); + expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read"); + expect(url.searchParams.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback"); + }); + + it.each(["sign_in", "link"] as const)( + "refuses %s for joystick on streampixels before any redirect, and Deru's managed row is untouched", + async (mode) => { + const store = new InMemoryStore(); + const app = await buildApp({ config: createTestConfig(), store }); + apps.push(app); + stubJoystick({ channel_id: "deru-chan", username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const connected = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`, + }); + const accountId = connected.json().account.id as string; + const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); + + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + payload: { appSlug: "streampixels", mode, returnTo: "https://streampixels.gamecult.org/auth/connect" }, + }); + expect(start.statusCode).toBe(400); + expect(start.json()).toMatchObject({ error: "provider_not_supported_for_app", provider: "joystick", mode }); + expect(start.json().authorizationUrl).toBeUndefined(); + expect(await store.findStoredLinkedIdentityForAccount(accountId, "joystick")).toEqual(before); + } + ); + + function stubJoystick(identity: Record) { + const calls: Array<{ url: string; headers: Record; body?: URLSearchParams | undefined }> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, headers: (init?.headers ?? {}) as Record, body: init?.body as URLSearchParams | undefined }); + if (url === "https://api.joystick.tv/api/oauth/token") { + return new Response( + JSON.stringify({ access_token: "js-access", refresh_token: "js-refresh", token_type: "Bearer", expires_in: 864000 }), + { status: 200 } + ); + } + if (url === "https://api.joystick.tv/api/v1/me/identity") { + return new Response(JSON.stringify(identity), { status: 200 }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + return calls; + } + + it.each(["sign_in", "link"] as const)( + "the callback refuses a signed %s state for joystick on streampixels before the code exchange, and nothing is written", + async (mode) => { + // The start gate already refuses these words, so the state is re-signed with the app's own key. + const dir = mkdtempSync(join(tmpdir(), "heimdall-forge-")); + try { + const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; + const store = new InMemoryStore(); + const app = await buildApp({ config, store }); + apps.push(app); + const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); + const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, keys.publicKey); + expect(verified.valid).toBe(true); + const forged = signJwt({ ...(verified as { payload: Record }).payload, mode }, keys); + + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(callback.body).not.toContain("viewer_access"); + expect(calls).toEqual([]); + expect(await store.findStoredLinkedIdentity("joystick", "chan-forged")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + ); + + it("exchanges the code with Basic client auth and links the identity by channel id", async () => { + const store = new InMemoryStore(); + const app = await buildApp({ config: createTestConfig(), store }); + apps.push(app); + const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" }); + const stateToken = (await connect(app)).json().stateToken as string; + + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`, + }); + expect(callback.statusCode).toBeLessThan(300); + + const exchange = calls.find((c) => c.url === "https://api.joystick.tv/api/oauth/token")!; + expect(exchange.headers.Authorization).toBe(`Basic ${Buffer.from("joystick-client:joystick-secret").toString("base64")}`); + expect(exchange.body!.get("grant_type")).toBe("authorization_code"); + expect(exchange.body!.get("code")).toBe("the-code"); + expect(exchange.body!.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback"); + expect(exchange.body!.has("client_secret")).toBe(false); + const identityCall = calls.find((c) => c.url === "https://api.joystick.tv/api/v1/me/identity")!; + expect(identityCall.headers.Authorization).toBe("Bearer js-access"); + + const accountId = callback.json().account.id as string; + const row = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); + expect(row).toMatchObject({ providerUserId: "chan-9", username: "deru", displayName: "Deru!" }); + expect(row?.accessTokenEncrypted).toEqual(expect.any(String)); + expect(row?.accessTokenEncrypted).not.toContain("js-access"); + expect(row?.refreshTokenEncrypted).toEqual(expect.any(String)); + const left = Date.parse(row!.tokenExpiresAt!) - Date.now(); + expect(left).toBeGreaterThan(863_000 * 1000); + expect(left).toBeLessThanOrEqual(864_000 * 1000); + }); + + it("falls back to the username for the display name", async () => { + const store = new InMemoryStore(); + const app = await buildApp({ config: createTestConfig(), store }); + apps.push(app); + stubJoystick({ channel_id: "chan-9", username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, + }); + const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick"); + expect(row?.displayName).toBe("deru"); + }); + + it("fails the callback when the identity has no channel id", async () => { + const store = new InMemoryStore(); + const app = await buildApp({ config: createTestConfig(), store }); + apps.push(app); + stubJoystick({ username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull(); + }); + }); + it("no longer honors a global shared secret across apps", async () => { // A per-app identity model with a global fallback is a two-app shared // secret in practice (R6, fork 2). GC_ACCESS_APP_SHARED_SECRET alone must @@ -1774,3 +2135,63 @@ describe("what may be recorded about a caught error", () => { expect(errorIdentity(error)).toEqual(expected); }); }); + +describe("OAuth start admits a provider by the profile's role for the mode", () => { + const allProviders = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const; + const modes = ["sign_in", "link", "connect"] as const; + + it("sign_in and link take identityProviders only; connect takes managedConnectionProviders or identityProviders, for every profile", async () => { + const app = await buildApp({ config: createTestConfig() }); + try { + for (const profile of Object.values(builtInAppProfiles)) { + for (const provider of allProviders) { + for (const mode of modes) { + const start = await app.inject({ + method: "POST", + url: `/v1/oauth/${provider}/start`, + payload: { appSlug: profile.slug, mode, returnTo: `${profile.allowedReturnOrigins[0]}/x` }, + }); + const roleList = + mode === "connect" + ? [...profile.managedConnectionProviders, ...profile.identityProviders] + : profile.identityProviders; + const refused = start.json().error === "provider_not_supported_for_app"; + expect([profile.slug, provider, mode, refused]).toEqual([profile.slug, provider, mode, !roleList.includes(provider)]); + } + } + } + } finally { + await app.close(); + } + }); +}); + +describe("callbackMint", () => { + const streampixels = builtInAppProfiles.streampixels!; + it.each([ + ["twitch", "sign_in", "viewer_session"], + ["twitch", "link", "viewer_session"], + ["youtube", "sign_in", "viewer_session"], + ["twitch", "connect", "viewer_session"], + ["joystick", "connect", "custody_only"], + ["joystick", "sign_in", "refused"], + ["joystick", "link", "refused"], + ["discord", "connect", "refused"], + ["discord", "sign_in", "refused"], + ] as const)("%s %s on streampixels is %s", (provider, mode, expected) => { + expect(callbackMint(streampixels, provider, mode, { kind: "creator", targetId: "creator-1" })).toBe(expected); + }); + + it.each([ + ["null", null], + ["undefined", undefined], + ["an account binding", { kind: "account", targetId: "a-1" }], + ["an empty creator targetId", { kind: "creator", targetId: "" }], + ] as const)("a joystick connect with %s is refused", (_name, connection) => { + expect(callbackMint(streampixels, "joystick", "connect", connection)).toBe("refused"); + }); + + it("keeps Bifrost's discord connect a viewer session", () => { + expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect", null)).toBe("viewer_session"); + }); +}); diff --git a/tests/caller-identity-attack-soul-r21.test.ts b/tests/caller-identity-attack-soul-r21.test.ts index 4c3df32..de959a8 100644 --- a/tests/caller-identity-attack-soul-r21.test.ts +++ b/tests/caller-identity-attack-soul-r21.test.ts @@ -65,6 +65,7 @@ function testConfig(overrides: Partial = {}): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, ...overrides, }; diff --git a/tests/caller-identity-attack-soul.test.ts b/tests/caller-identity-attack-soul.test.ts index 9fb8e0a..8f77801 100644 --- a/tests/caller-identity-attack-soul.test.ts +++ b/tests/caller-identity-attack-soul.test.ts @@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, }; } diff --git a/tests/caller-identity-soul.test.ts b/tests/caller-identity-soul.test.ts index 3ebc000..b5562a7 100644 --- a/tests/caller-identity-soul.test.ts +++ b/tests/caller-identity-soul.test.ts @@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, }; } diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts new file mode 100644 index 0000000..50ca9ba --- /dev/null +++ b/tests/managed-credential.test.ts @@ -0,0 +1,1117 @@ +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { type HeimdallConfig } from "../src/config.js"; +import { type Provider } from "../src/contracts.js"; +import { createTokenCustody } from "../src/custody.js"; +import { + MANAGED_CREDENTIAL_REFRESH_SKEW_MS, + ManagedCredentialRefreshFailed, + refreshManagedCredential, +} from "../src/managed-credential.js"; +import { createOAuthRuntimeRegistry } from "../src/oauth.js"; +import { providerCatalog } from "../src/providers.js"; +import { InMemoryStore, PostgresStore, type HeimdallStore } from "../src/store/index.js"; +import { type StoredLinkedIdentity } from "../src/store/types.js"; + +const originalFetch = globalThis.fetch; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function testConfig(): HeimdallConfig { + const clients = Object.fromEntries( + ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"].map((p) => [ + p, + { clientId: `${p}-client`, clientSecret: `${p}-secret` }, + ]) + ); + return { + serviceName: "heimdall", + host: "127.0.0.1", + port: 4100, + workspaceRoot: "E:/Projects/Heimdall", + dataRoot: "E:/Projects/Heimdall/.heimdall-data", + cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc", + publicBaseUrl: "https://heimdall.gamecult.org", + issuer: "https://heimdall.gamecult.org", + daemonId: "yggdrasil-heimdall", + idunnRudpHealth: undefined, + idunnHealthContract: "heimdall.cultnet-rudp-provider-health", + providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc", + sessionTtlSeconds: 3600, + refreshTtlSeconds: 60 * 60 * 24 * 30, + stateTtlSeconds: 600, + completionTtlSeconds: 300, + bootstrapSigningPrivateKeyOnMissing: false, + tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), + appSharedSecrets: { streampixels: "streampixels-secret", bifrost: "bifrost-secret" }, + appBackendCallbacks: {}, + storage: { backend: "memory", applySchemaOnStartup: true }, + bifrostPatronSupportEndpoint: "https://bifrost.gamecult.org/heimdall/patron-support/events", + bifrostPatronSupportSecret: "bifrost-support-secret", + providers: clients, + } as unknown as HeimdallConfig; +} + +type Mode = + | "normal" + | "invalid_grant" + | "invalid_client" + | "server_error" + | "not_json" + | "no_refresh_token" + | "empty_refresh_token" + | "bad_refresh_token" + | "no_expires_in" + | "unusable_expires_in" + | "string_expires_in" + | "huge_expires_in" + | "huge_string_expires_in" + | "hang"; + +function expiresInFor(mode: Mode): unknown { + switch (mode) { + case "unusable_expires_in": + return "soon"; + case "string_expires_in": + return "7200"; + case "huge_expires_in": + return 3e11; + case "huge_string_expires_in": + return "300000000000"; + default: + return 3600; + } +} + +/** + * A provider token endpoint with single-use refresh tokens. Presenting a + * consumed refresh token revokes the whole installation, as Joystick does: + * every later refresh answers 400 invalid_grant. + */ +function installTokenEndpoint(provider: Provider, firstRefreshToken: string) { + const state = { + calls: 0, + mode: "normal" as Mode, + live: firstRefreshToken, + issued: 0, + installationRevoked: false, + otherCalls: [] as string[], + }; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + if (url !== providerCatalog[provider].tokenEndpoint) { + state.otherCalls.push(url); + return new Response("unexpected", { status: 500 }); + } + state.calls += 1; + const presented = (init!.body as URLSearchParams).get("refresh_token"); + await sleep(25); + switch (state.mode) { + case "hang": + return new Promise((_resolve, reject) => { + init!.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }); + case "invalid_grant": + return new Response(JSON.stringify({ error: "invalid_grant", error_description: "CANARY" }), { status: 400 }); + case "invalid_client": + return new Response(JSON.stringify({ error: "invalid_client", error_description: "CANARY" }), { status: 401 }); + case "server_error": + return new Response("CANARY upstream exploded", { status: 500 }); + case "not_json": + return new Response("CANARY ", { status: 200 }); + case "no_refresh_token": + return new Response(JSON.stringify({ access_token: "access-norefresh", token_type: "Bearer", expires_in: 3600 }), { + status: 200, + }); + case "empty_refresh_token": + return new Response(JSON.stringify({ access_token: "access-empty", refresh_token: "", token_type: "Bearer", expires_in: 3600 }), { + status: 200, + }); + case "bad_refresh_token": + return new Response(JSON.stringify({ access_token: "access-bad", refresh_token: 42, token_type: "Bearer", expires_in: 3600 }), { + status: 200, + }); + case "normal": + case "no_expires_in": + case "unusable_expires_in": + case "string_expires_in": + case "huge_expires_in": + case "huge_string_expires_in": { + if (state.installationRevoked || presented !== state.live) { + state.installationRevoked = true; + return new Response(JSON.stringify({ error: "invalid_grant", error_description: "reused CANARY" }), { status: 400 }); + } + state.issued += 1; + state.live = `refresh-${state.issued}`; + return new Response( + JSON.stringify({ + access_token: `access-${state.issued}`, + refresh_token: state.live, + token_type: "Bearer", + ...(state.mode === "no_expires_in" ? {} : { expires_in: expiresInFor(state.mode) }), + }), + { status: 200 } + ); + } + } + }) as typeof fetch; + return state; +} + +interface Rig { + stores: HeimdallStore[]; + apps: Awaited>[]; + accountId: string; + identityId: string; + custody: ReturnType; + config: HeimdallConfig; + provider: Provider; + databaseUrl: string | undefined; + row(): Promise; + audit(): Promise>; + resolve(appIndex?: number): Promise<{ statusCode: number; json: Record; body: string }>; + close(): Promise; +} + +const adminUrl = process.env.HEIMDALL_TEST_PG_ADMIN_URL; +const rigKinds = [ + { kind: "memory", live: false }, + { kind: "postgres one app", live: true }, + { kind: "postgres two apps", live: true }, +] as const; +// Providers whose refresh goes through the real shared runtime; rotation differs by descriptor. +const refreshProviders: Provider[] = ["twitch", "joystick"]; + +for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`managed credential refresh, ${kind}`, () => { + const admin = live && adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined; + const suffix = randomUUID().replace(/-/g, "").slice(0, 12); + const template = `heimdall_mc_template_${suffix}`; + const cleanup: Array<() => Promise> = []; + + function urlFor(database: string): string { + const url = new URL(adminUrl!); + url.pathname = `/${database}`; + return url.toString(); + } + + beforeAll(async () => { + if (!live) return; + await admin!.query(`CREATE DATABASE ${template}`); + const pool = new Pool({ connectionString: urlFor(template), max: 1 }); + await new PostgresStore(pool).ensureSchema(); + await pool.end(); + }, 120_000); + + afterEach(async () => { + globalThis.fetch = originalFetch; + for (const step of cleanup.splice(0).reverse()) await step(); + }); + + afterAll(async () => { + if (!admin) return; + await admin.query(`DROP DATABASE IF EXISTS ${template}`); + await admin.end(); + }, 120_000); + + async function makeRig( + provider: Provider, + options: { expiresInMs?: number; refreshToken?: string | null; accessToken?: string | null } = {} + ): Promise { + const config = testConfig(); + const custody = createTokenCustody(config); + const stores: HeimdallStore[] = []; + let adminForDb: Pool | undefined; + let databaseUrl: string | undefined; + if (!live) { + stores.push(new InMemoryStore()); + } else { + const name = `heimdall_mc_${randomUUID().replace(/-/g, "")}`; + await admin!.query(`CREATE DATABASE ${name} TEMPLATE ${template}`); + const count = kind === "postgres two apps" ? 2 : 1; + for (let i = 0; i < count; i += 1) { + const pool = new Pool({ connectionString: urlFor(name), max: 12 }); + pool.on("error", () => undefined); + stores.push(new PostgresStore(pool)); + } + databaseUrl = urlFor(name); + adminForDb = new Pool({ connectionString: urlFor(name), max: 1 }); + adminForDb.on("error", () => undefined); + cleanup.push(async () => { + await adminForDb!.end(); + await admin!.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); + }); + } + + const now = new Date().toISOString(); + const account = await stores[0]!.createAccount({ createdAt: now, lastSeenAt: now }); + const expiresInMs = options.expiresInMs ?? 60_000; + const input = { + accountId: account.id, + provider, + providerUserId: "channel-1", + username: "deru", + displayName: "Deru", + scopes: ["identity:read"], + profileJson: { keep: "me" }, + tokenExpiresAt: new Date(Date.now() + expiresInMs).toISOString(), + createdAt: now, + updatedAt: now, + }; + const seeded = await stores[0]!.upsertLinkedIdentity({ + ...input, + ...(options.accessToken === null ? {} : { accessTokenEncrypted: custody.encrypt(options.accessToken ?? "access-0") }), + ...(options.refreshToken === null ? {} : { refreshTokenEncrypted: custody.encrypt(options.refreshToken ?? "refresh-0") }), + }); + + const apps: Rig["apps"] = []; + for (const store of stores) { + apps.push(await buildApp({ config, store, tokenCustody: custody, oauthRuntimes: createOAuthRuntimeRegistry() })); + } + cleanup.push(async () => { + for (const app of apps) await app.close().catch(() => undefined); + }); + + return { + stores, + apps, + accountId: account.id, + identityId: seeded.id, + custody, + config, + provider, + databaseUrl, + async row() { + const row = await stores[0]!.findStoredLinkedIdentityForAccount(account.id, provider); + return row!; + }, + async audit() { + if (!live) { + const events = (stores[0] as unknown as { auditEvents: Map }).auditEvents; + return [...events.values()].map((e) => ({ eventType: e.eventType, payload: e.eventPayloadJson })); + } + const result = await adminForDb!.query<{ event_type: string; event_payload_json: unknown }>( + "SELECT event_type, event_payload_json FROM audit_events ORDER BY created_at" + ); + return result.rows.map((r) => ({ eventType: r.event_type, payload: r.event_payload_json })); + }, + async resolve(appIndex = 0) { + const response = await apps[appIndex]!.inject({ + method: "POST", + url: "/v1/apps/streampixels/managed-credentials/resolve", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, + payload: { accountId: account.id, provider }, + }); + return { statusCode: response.statusCode, json: response.json() as Record, body: response.body }; + }, + async close() {}, + }; + } + + const timeout = { timeout: 60_000 }; + + describe.each(refreshProviders)("%s", (provider) => { + it("refresh-once-per-token: 20 concurrent resolves present the refresh token once", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + + const answers = await Promise.all(Array.from({ length: 20 }, (_, i) => rig.resolve(i % rig.apps.length))); + + expect(endpoint.calls).toBe(1); + expect(answers.map((a) => a.statusCode)).toEqual(Array(20).fill(200)); + expect(new Set(answers.map((a) => a.json.accessToken))).toEqual(new Set(["access-1"])); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe("access-1"); + expect(rig.custody.decrypt(row.refreshTokenEncrypted!)).toBe("refresh-1"); + expect(await rig.audit()).toEqual([]); + }); + + it("rotated-pair-persisted-before-use: the stored pair is the one the caller was given", timeout, async () => { + const rig = await makeRig(provider); + installTokenEndpoint(provider, "refresh-0"); + const answer = await rig.resolve(); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe(answer.json.accessToken); + expect(Date.parse(row.tokenExpiresAt!)).toBeGreaterThan(Date.now() + MANAGED_CREDENTIAL_REFRESH_SKEW_MS); + }); + + it("outside the skew: no token call, the stored token is returned", timeout, async () => { + const rig = await makeRig(provider, { expiresInMs: 60 * 60 * 1000 }); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const answer = await rig.resolve(); + expect(endpoint.calls).toBe(0); + expect(answer.statusCode).toBe(200); + expect(answer.json.accessToken).toBe("access-0"); + }); + + it("revocation-is-typed: invalid_grant clears the tokens, answers 410, audits once, and stops calling the provider", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "invalid_grant"; + + const first = await rig.resolve(); + expect(first.statusCode).toBe(410); + expect(first.json).toEqual({ error: "managed_credential_revoked", provider }); + const row = await rig.row(); + expect(row.accessTokenEncrypted).toBeUndefined(); + expect(row.refreshTokenEncrypted).toBeUndefined(); + expect(row.tokenExpiresAt).toBeUndefined(); + expect(row.username).toBe("deru"); + expect(row.profileJson).toEqual({ keep: "me" }); + expect(row.scopes).toEqual(["identity:read"]); + + const second = await rig.resolve(); + expect(second.statusCode).toBe(410); + expect(endpoint.calls).toBe(1); + + const events = await rig.audit(); + expect(events.filter((e) => e.eventType === "managed_credential_revoked")).toEqual([ + { eventType: "managed_credential_revoked", payload: { provider, reason: "provider_revoked" } }, + ]); + expect(first.body + second.body + JSON.stringify(events)).not.toContain("CANARY"); + }); + + it.each(["invalid_client", "server_error", "not_json", "bad_refresh_token", "hang"] as const)( + "transient failure (%s) keeps custody: 502, tokens byte-identical, nothing leaked", + timeout, + async (mode) => { + const rig = await makeRig(provider); + const before = await rig.row(); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = mode; + + let answer; + if (mode === "hang") { + // The route's own bound is 10 s; the primitive takes an injected one. + const failure = await refreshManagedCredential( + { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 100 }, + before + ).then( + () => undefined, + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(ManagedCredentialRefreshFailed); + expect((failure as ManagedCredentialRefreshFailed).provider).toBe(provider); + expect(String(failure) + JSON.stringify(failure)).not.toContain("CANARY"); + } else { + answer = await rig.resolve(); + expect(answer.statusCode).toBe(502); + expect(answer.json).toEqual({ error: "managed_credential_refresh_failed", provider }); + expect(answer.body).not.toContain("CANARY"); + } + + const after = await rig.row(); + expect(after.accessTokenEncrypted).toBe(before.accessTokenEncrypted); + expect(after.refreshTokenEncrypted).toBe(before.refreshTokenEncrypted); + expect(after.tokenExpiresAt).toBe(before.tokenExpiresAt); + expect(await rig.audit()).toEqual([]); + } + ); + + it("bounded lock: a refresh that never answers is aborted and the next resolve takes the lock", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "hang"; + const ctx = { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 100 }; + const started = Date.now(); + await expect(refreshManagedCredential(ctx, await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed); + expect(Date.now() - started).toBeLessThan(5_000); + + endpoint.mode = "normal"; + const outcome = await refreshManagedCredential(ctx, await rig.row()); + expect(outcome.kind).toBe("current"); + expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1"); + }); + + it("single flight: concurrent refreshes against a never-answering endpoint make one provider call per process, and every caller gets the typed failure", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "hang"; + const snapshot = await rig.row(); + const ctxFor = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 150 }); + + const results = await Promise.allSettled( + Array.from({ length: 12 }, (_, i) => refreshManagedCredential(ctxFor(rig.stores[i % rig.stores.length]!), snapshot)) + ); + + expect(results.map((r) => r.status)).toEqual(Array(12).fill("rejected")); + for (const result of results) { + expect((result as PromiseRejectedResult).reason).toBeInstanceOf(ManagedCredentialRefreshFailed); + } + expect(endpoint.calls).toBe(rig.stores.length); + expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0"); + + // The burst is over: a later independent request tries again. + endpoint.mode = "normal"; + const later = await refreshManagedCredential(ctxFor(rig.stores[0]!), await rig.row()); + expect(later.kind).toBe("current"); + expect(endpoint.calls).toBe(rig.stores.length + 1); + }); + + it("a snapshot taken before another caller's refresh does not decide: the locked row does", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const stale = await rig.row(); + await rig.resolve(); + expect(endpoint.calls).toBe(1); + const outcome = await refreshManagedCredential( + { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry() }, + stale + ); + expect(endpoint.calls).toBe(1); + expect(outcome.kind).toBe("current"); + expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1"); + }); + + if (providerCatalog[provider].rotatesRefreshTokens) { + it.each(["no_refresh_token", "empty_refresh_token"] as const)("rotation-fails-closed: a response with %s revokes, 410, and the old token is never presented again", timeout, async (mode) => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = mode; + + const first = await rig.resolve(); + expect(first.statusCode).toBe(410); + expect(first.json).toEqual({ error: "managed_credential_revoked", provider }); + expect(first.body).not.toContain("access-norefresh"); + expect(first.body).not.toContain("access-empty"); + const row = await rig.row(); + expect(row.accessTokenEncrypted).toBeUndefined(); + expect(row.refreshTokenEncrypted).toBeUndefined(); + expect(row.tokenExpiresAt).toBeUndefined(); + + const second = await rig.resolve(); + expect(second.statusCode).toBe(410); + expect(endpoint.calls).toBe(1); + expect((await rig.audit()).filter((e) => e.eventType === "managed_credential_revoked")).toEqual([ + { eventType: "managed_credential_revoked", payload: { provider, reason: "rotation_missing" } }, + ]); + }); + } else { + it.each(["no_refresh_token", "empty_refresh_token"] as const)("a response with %s keeps the old one for a non-rotating provider", timeout, async (mode) => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = mode; + const answer = await rig.resolve(); + expect(answer.statusCode).toBe(200); + expect(answer.json.accessToken).toBe(mode === "no_refresh_token" ? "access-norefresh" : "access-empty"); + expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0"); + }); + } + + it.each([ + ["no expires_in", "no_expires_in", 3600], + ["an unusable expires_in", "unusable_expires_in", 3600], + ["a numeric-string expires_in", "string_expires_in", 7200], + ["a numeric expires_in beyond a year", "huge_expires_in", 3600], + ["a numeric-string expires_in beyond a year", "huge_string_expires_in", 3600], + ] as const)( + "refresh-once-per-token: an answer with %s stores now plus %s s, not the old due expiry, and resolve refreshes again only once it passes", + timeout, + async (_name, mode, seconds) => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = mode; + + const before = Date.now(); + expect((await rig.resolve()).statusCode).toBe(200); + expect(endpoint.calls).toBe(1); + const stored = Date.parse((await rig.row()).tokenExpiresAt!); + expect(stored - before).toBeGreaterThanOrEqual(seconds * 1000 - 1000); + expect(stored - before).toBeLessThan(seconds * 1000 + 10_000); + + expect((await rig.resolve()).statusCode).toBe(200); + expect(endpoint.calls).toBe(1); + + // The stored expiry passes: the next resolve refreshes. + const past = new Date(Date.now() - 1000).toISOString(); + await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async (locked) => ({ + kind: "rotate", + accessTokenEncrypted: locked.accessTokenEncrypted!, + refreshTokenEncrypted: locked.refreshTokenEncrypted!, + tokenExpiresAt: past, + scopes: locked.scopes, + updatedAt: past, + })); + expect((await rig.resolve()).statusCode).toBe(200); + expect(endpoint.calls).toBe(2); + const again = Date.parse((await rig.row()).tokenExpiresAt!); + expect(again).toBeGreaterThan(Date.now() + MANAGED_CREDENTIAL_REFRESH_SKEW_MS); + } + ); + + it("a row with no access token resolves as revoked (410), a missing row as not found (404)", timeout, async () => { + const rig = await makeRig(provider, { accessToken: null, refreshToken: null }); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const revoked = await rig.resolve(); + expect(revoked.statusCode).toBe(410); + expect(revoked.json).toEqual({ error: "managed_credential_revoked", provider }); + expect(endpoint.calls).toBe(0); + + const missing = await rig.apps[0]!.inject({ + method: "POST", + url: "/v1/apps/streampixels/managed-credentials/resolve", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, + payload: { accountId: "no-such-account", provider }, + }); + expect(missing.statusCode).toBe(404); + expect(missing.json()).toEqual({ error: "managed_credential_not_found" }); + }); + }); + + describe("what the callback mints is decided by mode and provider role", () => { + const returnTo = "https://streampixels.gamecult.org/auth/connect"; + + function stubProviders(identity: Record, tokenAnswer: Record = { refresh_token: "tok-refresh-CANARY" }) { + globalThis.fetch = (async (input: string | URL) => { + const url = String(input); + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); + if (url.startsWith("https://api.joystick.tv/api/oauth/token") || Object.values(providerCatalog).some((d) => d.tokenEndpoint === url)) { + return json({ access_token: "tok-access", token_type: "Bearer", expires_in: 3600, ...tokenAnswer }); + } + if (url === "https://api.joystick.tv/api/v1/me/identity") return json({ channel_id: identity.joystick, username: "deru" }); + if (url === "https://api.twitch.tv/helix/users") return json({ data: [{ id: identity.twitch, login: "deru", display_name: "Deru" }] }); + if (url.startsWith("https://www.googleapis.com/youtube/v3/channels")) { + return json({ items: [{ id: identity.youtube, snippet: { title: "Deru" } }] }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + } + + async function flow(rig: Rig, provider: "joystick" | "twitch" | "youtube", mode: "connect" | "sign_in") { + const app = rig.apps[0]!; + const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + }); + expect(callback.statusCode).toBe(201); + const redeem = await app.inject({ + method: "POST", + url: "/v1/apps/streampixels/auth-completions/redeem", + payload: { completionCode: callback.json().completion.code as string }, + }); + expect(redeem.statusCode).toBe(201); + return { callback, redeem }; + } + + it("a joystick connect on streampixels stores custody and yields no viewer_access, no capability and no refresh token", timeout, async () => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { callback, redeem } = await flow(rig, "joystick", "connect"); + + for (const payload of [callback.json(), redeem.json()] as Array>) { + expect(payload.mode).toBe("connect"); + expect(payload.claimSet.capabilities).toEqual([]); + expect(payload.claimSet.facts).toEqual([]); + expect(payload.sharedCapabilities).toEqual([]); + expect(payload.hybridCapabilities).toEqual([]); + expect(payload.claimSet.identities).toEqual([expect.objectContaining({ provider: "joystick", providerUserId: "channel-1" })]); + expect(payload.account.id).toBe(rig.accountId); + expect(payload.refreshToken).toBeUndefined(); + expect(payload.refresh).toBeUndefined(); + } + expect(callback.body + redeem.body).not.toContain("viewer_access"); + expect(callback.body + redeem.body).not.toContain("tok-refresh-CANARY"); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe("tok-access"); + expect(rig.custody.decrypt(row.refreshTokenEncrypted!)).toBe("tok-refresh-CANARY"); + }); + + it.each([ + ["no refresh_token", {}], + ["an empty refresh_token", { refresh_token: "" }], + ])("a rotating provider's code exchange with %s fails the callback before anything is written", timeout, async (_name, tokenAnswer) => { + const rig = await makeRig("joystick"); + const before = await rig.row(); + const auditBefore = (await rig.audit()).length; + stubProviders({ joystick: "channel-new", twitch: "t-1", youtube: "y-1" }, tokenAnswer); + const app = rig.apps[0]!; + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + payload: { appSlug: "streampixels", mode: "connect", returnTo, connection: { kind: "creator", targetId: "creator-1" } }, + }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(callback.body).not.toContain("tok-access"); + expect(await rig.row()).toEqual(before); + expect(await rig.stores[0]!.findStoredLinkedIdentity("joystick", "channel-new")).toBeNull(); + const failures = (await rig.audit()).slice(auditBefore); + expect(failures).toEqual([ + { eventType: "oauth_callback_failed", payload: expect.objectContaining({ errorClass: "ProviderResponseError", providerStatus: 200 }) }, + ]); + }); + + it.each(["twitch", "youtube"] as const)("a %s sign_in on streampixels still yields viewer_access and a refresh token", timeout, async (provider) => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { callback, redeem } = await flow(rig, provider, "sign_in"); + for (const payload of [callback.json(), redeem.json()] as Array>) { + expect(payload.claimSet.capabilities).toContain("viewer_access"); + expect(payload.refreshToken).toEqual(expect.any(String)); + expect(payload.hybridCapabilities.map((c: { key: string }) => c.key)).toEqual(["creator_access", "creator_admin"]); + } + }); + + it("a twitch connect on streampixels keeps its current behaviour: an identity provider still signs the viewer in", timeout, async () => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { redeem } = await flow(rig, "twitch", "connect"); + expect(redeem.json().claimSet.capabilities).toContain("viewer_access"); + }); + }); + + describe("updateLinkedIdentityCredential", () => { + it("returns null for a missing id and does not call decide", async () => { + const rig = await makeRig("twitch"); + let called = false; + const result = await rig.stores[0]!.updateLinkedIdentityCredential(randomUUID(), async () => { + called = true; + return { kind: "keep" }; + }); + expect(result).toBeNull(); + expect(called).toBe(false); + }); + + it("hands decide the committed row, and a throw writes nothing", async () => { + const rig = await makeRig("twitch"); + const before = await rig.row(); + let seen: StoredLinkedIdentity | undefined; + await expect( + rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async (locked) => { + seen = locked; + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + expect(seen).toEqual(before); + expect(await rig.row()).toEqual(before); + }); + + it("rotate writes token columns, scopes and updated_at and nothing else; keep writes nothing", async () => { + const rig = await makeRig("twitch"); + const before = await rig.row(); + const kept = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ kind: "keep" })); + expect(kept).toEqual(before); + + const rotated = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ + kind: "rotate", + accessTokenEncrypted: "A2", + refreshTokenEncrypted: "R2", + tokenExpiresAt: "2999-01-01T00:00:00.000Z", + scopes: ["a", "b"], + updatedAt: "2999-01-02T00:00:00.000Z", + })); + expect(rotated).toEqual({ + ...before, + accessTokenEncrypted: "A2", + refreshTokenEncrypted: "R2", + tokenExpiresAt: "2999-01-01T00:00:00.000Z", + scopes: ["a", "b"], + updatedAt: "2999-01-02T00:00:00.000Z", + }); + expect(await rig.row()).toEqual(rotated); + + const revoked = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ + kind: "revoke", + updatedAt: "2999-01-03T00:00:00.000Z", + })); + const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = rotated!; + expect(revoked).toEqual({ ...rest, updatedAt: "2999-01-03T00:00:00.000Z" }); + expect(await rig.row()).toEqual(revoked); + }); + + it.runIf(live)("leaves no transaction open after a missing row or a throwing decide", timeout, async () => { + const rig = await makeRig("twitch"); + const pool = new Pool({ connectionString: rig.databaseUrl, max: 1 }); + pool.on("error", () => undefined); + try { + const store = new PostgresStore(pool); + const idle = async () => { + await sleep(10); + const result = await pool.query<{ fresh: boolean }>("SELECT now() = statement_timestamp() AS fresh"); + return result.rows[0]!.fresh; + }; + expect(await store.updateLinkedIdentityCredential(randomUUID(), async () => ({ kind: "keep" }))).toBeNull(); + expect(await idle()).toBe(true); + await expect( + store.updateLinkedIdentityCredential(rig.identityId, async () => { + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + expect(await idle()).toBe(true); + } finally { + await pool.end(); + } + }); + + it.runIf(!live)("a decider arriving after the first drains still waits for the one queued behind it", async () => { + const rig = await makeRig("twitch"); + const events: string[] = []; + const decider = (name: string, ms: number) => async (locked: StoredLinkedIdentity) => { + events.push(`start ${name}`); + await sleep(ms); + events.push(`end ${name}`); + return { kind: "keep" as const, seen: locked }; + }; + const a = rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, decider("A", 20)); + const b = rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, decider("B", 120)); + await a; + const c = rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, decider("C", 1)); + await Promise.all([b, c]); + expect(events).toEqual(["start A", "end A", "start B", "end B", "start C", "end C"]); + }); + + it.runIf(!live)("drops a drained row's chain entry", async () => { + const rig = await makeRig("twitch"); + await Promise.all([1, 2, 3].map(() => rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ kind: "keep" })))); + await sleep(5); + expect((rig.stores[0] as unknown as { credentialChains: Map }).credentialChains.size).toBe(0); + }); + + it("serialises deciders on one row: each sees the previous commit", timeout, async () => { + const rig = await makeRig("twitch"); + const order: string[] = []; + await Promise.all( + [1, 2, 3, 4, 5].map((n) => + rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async (locked) => { + order.push(`${n}:${locked.accessTokenEncrypted}`); + await sleep(20); + return { + kind: "rotate", + accessTokenEncrypted: `A${n}`, + refreshTokenEncrypted: `R${n}`, + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), + scopes: locked.scopes, + updatedAt: new Date().toISOString(), + }; + }) + ) + ); + const seenValues = order.map((entry) => entry.split(":")[1]); + // Each decider saw the value the one before it wrote, so their reads form a chain. + expect(new Set(seenValues).size).toBe(5); + const wrote = order.map((entry) => `A${entry.split(":")[0]}`); + for (let i = 1; i < seenValues.length; i += 1) expect(seenValues[i]).toBe(wrote[i - 1]); + }); + }); +}); + +describe("refreshManagedCredential without the lock", () => { + const config = testConfig(); + const custody = createTokenCustody(config); + const now = Date.parse("2026-10-19T12:00:00.000Z"); + + function identity(overrides: Partial = {}, provider: Provider = "twitch"): StoredLinkedIdentity { + return { + id: "id-1", + accountId: "acct-1", + provider, + providerUserId: "u1", + scopes: ["s"], + profileJson: {}, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + accessTokenEncrypted: custody.encrypt("access-0"), + refreshTokenEncrypted: custody.encrypt("refresh-0"), + tokenExpiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + ...overrides, + }; + } + + function countingStore(locked: StoredLinkedIdentity | null) { + const log = { locks: 0, decisions: [] as unknown[], audits: [] as unknown[] }; + const store = { + async updateLinkedIdentityCredential(_id: string, decide: (row: StoredLinkedIdentity) => Promise) { + log.locks += 1; + if (locked === null) return null; + log.decisions.push(await decide(locked)); + return locked; + }, + async createAuditEvent(event: unknown) { + log.audits.push(event); + }, + }; + return { store: store as unknown as HeimdallStore, log }; + } + + const ctxFor = (store: HeimdallStore) => ({ config, store, tokenCustody: custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => now }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("a snapshot with no access token is revoked without a lock or a provider call", async () => { + const { store, log } = countingStore(null); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), identity({ accessTokenEncrypted: undefined as never })); + expect(outcome.kind).toBe("revoked"); + expect(log.locks).toBe(0); + expect(endpoint.calls).toBe(0); + }); + + it.each([ + ["outside the skew", {}, "twitch"], + ["without an expiry", { tokenExpiresAt: undefined as never }, "twitch"], + ["without a refresh token", { refreshTokenEncrypted: undefined as never, tokenExpiresAt: new Date(now).toISOString() }, "twitch"], + ["for a provider whose runtime cannot refresh", { tokenExpiresAt: new Date(now).toISOString() }, "github"], + ] as const)("a credential %s is current with no lock and no provider call", async (_name, overrides, provider) => { + const { store, log } = countingStore(null); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const snapshot = identity(overrides as Partial, provider); + const outcome = await refreshManagedCredential(ctxFor(store), snapshot); + expect(outcome).toEqual({ kind: "current", identity: snapshot }); + expect(log.locks).toBe(0); + expect(endpoint.calls).toBe(0); + }); + + it("an expiry exactly one skew away is due; one millisecond further is not", async () => { + const due = identity({ tokenExpiresAt: new Date(now + MANAGED_CREDENTIAL_REFRESH_SKEW_MS).toISOString() }); + const notDue = identity({ tokenExpiresAt: new Date(now + MANAGED_CREDENTIAL_REFRESH_SKEW_MS + 1).toISOString() }); + const a = countingStore(due); + installTokenEndpoint("twitch", "refresh-0"); + await refreshManagedCredential(ctxFor(a.store), due); + expect(a.log.locks).toBe(1); + const b = countingStore(notDue); + await refreshManagedCredential(ctxFor(b.store), notDue); + expect(b.log.locks).toBe(0); + }); + + it("decides revoke for a grant verdict and for a missing rotated token, rotate otherwise", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const grant = countingStore(due); + installTokenEndpoint("twitch", "refresh-0").mode = "invalid_grant"; + await refreshManagedCredential(ctxFor(grant.store), due); + expect(grant.log.decisions).toEqual([{ kind: "revoke", updatedAt: new Date(now).toISOString() }]); + expect(grant.log.audits).toEqual([ + expect.objectContaining({ eventType: "managed_credential_revoked", eventPayloadJson: { provider: "twitch", reason: "provider_revoked" } }), + ]); + + const rotating = identity({ tokenExpiresAt: new Date(now).toISOString() }, "joystick"); + const missing = countingStore(rotating); + installTokenEndpoint("joystick", "refresh-0").mode = "no_refresh_token"; + await refreshManagedCredential(ctxFor(missing.store), rotating); + expect(missing.log.decisions).toEqual([{ kind: "revoke", updatedAt: new Date(now).toISOString() }]); + + const fine = countingStore(due); + installTokenEndpoint("twitch", "refresh-0"); + await refreshManagedCredential(ctxFor(fine.store), due); + expect(fine.log.decisions).toEqual([expect.objectContaining({ kind: "rotate", refreshTokenEncrypted: expect.any(String), scopes: ["s"] })]); + expect(fine.log.audits).toEqual([]); + }); + + it("a row that vanished is revoked with its tokens cleared", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const { store } = countingStore(null); + installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), due); + expect(outcome.kind).toBe("revoked"); + expect(outcome.identity.accessTokenEncrypted).toBeUndefined(); + expect(outcome.identity.refreshTokenEncrypted).toBeUndefined(); + expect(outcome.identity.tokenExpiresAt).toBeUndefined(); + expect(outcome.identity.providerUserId).toBe("u1"); + }); + + it("a row revoked by someone else between the snapshot and the lock is revoked, with no provider call", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const gone = identity({ accessTokenEncrypted: undefined as never, refreshTokenEncrypted: undefined as never, tokenExpiresAt: undefined as never }); + const { store } = countingStore(gone); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), due); + expect(outcome.kind).toBe("revoked"); + expect(endpoint.calls).toBe(0); + }); + + it("names the provider in the failure and carries nothing the provider said", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const { store } = countingStore(due); + installTokenEndpoint("twitch", "refresh-0").mode = "server_error"; + const failure = await refreshManagedCredential(ctxFor(store), due).catch((error: unknown) => error as ManagedCredentialRefreshFailed); + expect(failure).toBeInstanceOf(ManagedCredentialRefreshFailed); + expect((failure as Error).message).toBe("Twitch managed credential refresh failed."); + expect((failure as ManagedCredentialRefreshFailed).code).toBe("managed_credential_refresh_failed"); + }); +}); + +describe("refresh answer classification", () => { + const config = testConfig(); + const custody = createTokenCustody(config); + const now = Date.parse("2026-10-19T12:00:00.000Z"); + const dueAt = new Date(now).toISOString(); + + async function seedRow(store: InMemoryStore, provider: Provider) { + const created = new Date(now).toISOString(); + const account = await store.createAccount({ createdAt: created, lastSeenAt: created }); + return store.upsertLinkedIdentity({ + accountId: account.id, + provider, + providerUserId: `u-${randomUUID()}`, + scopes: ["s"], + profileJson: {}, + accessTokenEncrypted: custody.encrypt("access-0"), + refreshTokenEncrypted: custody.encrypt("refresh-0"), + tokenExpiresAt: dueAt, + createdAt: created, + updatedAt: created, + }); + } + + const ctxFor = (store: HeimdallStore, extra: { timeoutMs?: number } = {}) => ({ + config, + store, + tokenCustody: custody, + oauthRuntimes: createOAuthRuntimeRegistry(), + clock: () => now, + ...extra, + }); + + function answer(body: string, status: number) { + const calls: string[] = []; + globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { + calls.push(String((init!.body as URLSearchParams).get("refresh_token"))); + return new Response(body, { status }); + }) as typeof fetch; + return calls; + } + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const grantVerdict = JSON.stringify({ error: "invalid_grant" }); + const keepsTokens: Array<[string, string, number]> = [ + ["an error code that only contains invalid_grant", JSON.stringify({ error: "invalid_grant_extra" }), 400], + ["an error code that is contained in a longer one", JSON.stringify({ error: "not_invalid_grant" }), 400], + ["a different error code", JSON.stringify({ error: "invalid_request" }), 400], + ["a 401 carrying invalid_grant", grantVerdict, 401], + ["a 500 carrying invalid_grant", grantVerdict, 500], + ["a 503 carrying invalid_grant", grantVerdict, 503], + ["a 2xx carrying invalid_grant", grantVerdict, 200], + ["a 2xx carrying an error beside an access token", JSON.stringify({ access_token: "a", error: "server_error" }), 200], + ["a 2xx with an empty object", "{}", 200], + ["a 2xx with an empty body", "", 200], + ["a 2xx with JSON null", "null", 200], + ["a 2xx with a JSON array", "[]", 200], + ["a 2xx with a non-string access token", JSON.stringify({ access_token: 7, refresh_token: "r" }), 200], + ["a 2xx with a numeric refresh token", JSON.stringify({ access_token: "a", refresh_token: 42 }), 200], + ["a 2xx with an object refresh token", JSON.stringify({ access_token: "a", refresh_token: { r: 1 } }), 200], + ["a 2xx with an empty access token", JSON.stringify({ access_token: "", refresh_token: "r" }), 200], + ["a 2xx that is not JSON", "", 200], + ["a 5xx page", "upstream exploded", 502], + ]; + + describe.each(["twitch", "joystick"] as const)("%s", (provider) => { + it.each(keepsTokens)("keeps the tokens and fails typed for %s", async (_name, body, status) => { + const store = new InMemoryStore(); + const row = await seedRow(store, provider); + const calls = answer(body, status); + await expect(refreshManagedCredential(ctxFor(store), row)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed); + expect(calls).toEqual(["refresh-0"]); + const after = (await store.findStoredLinkedIdentity(provider, row.providerUserId))!; + expect(after.accessTokenEncrypted).toBe(row.accessTokenEncrypted); + expect(after.refreshTokenEncrypted).toBe(row.refreshTokenEncrypted); + expect(after.tokenExpiresAt).toBe(dueAt); + }); + + it("keeps the tokens and fails typed when the provider never answers", async () => { + const store = new InMemoryStore(); + const row = await seedRow(store, provider); + globalThis.fetch = ((_input: string | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init!.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + })) as typeof fetch; + await expect(refreshManagedCredential(ctxFor(store, { timeoutMs: 30 }), row)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed); + const after = (await store.findStoredLinkedIdentity(provider, row.providerUserId))!; + expect(after.refreshTokenEncrypted).toBe(row.refreshTokenEncrypted); + }); + + it("revokes and clears the tokens on a 400 whose error is exactly invalid_grant", async () => { + const store = new InMemoryStore(); + const row = await seedRow(store, provider); + answer(grantVerdict, 400); + const outcome = await refreshManagedCredential(ctxFor(store), row); + expect(outcome.kind).toBe("revoked"); + const after = (await store.findStoredLinkedIdentity(provider, row.providerUserId))!; + expect(after.accessTokenEncrypted).toBeUndefined(); + expect(after.refreshTokenEncrypted).toBeUndefined(); + }); + }); + + it.each([ + ["without a refresh token", { access_token: "a", expires_in: 3600 }], + ["with an empty refresh token", { access_token: "a", refresh_token: "", expires_in: 3600 }], + ])("a rotating provider's well-formed answer %s still revokes (rotation_missing)", async (_n, body) => { + const store = new InMemoryStore(); + const row = await seedRow(store, "joystick"); + answer(JSON.stringify(body), 200); + expect((await refreshManagedCredential(ctxFor(store), row)).kind).toBe("revoked"); + expect((await store.findStoredLinkedIdentity("joystick", row.providerUserId))!.refreshTokenEncrypted).toBeUndefined(); + }); + + it.each([ + ["no expires_in", {}], + ["a non-numeric string expires_in", { expires_in: "soon" }], + ])("an answer with %s stores now plus the default lifetime; resolve refreshes once it passes", async (_n, extra) => { + const store = new InMemoryStore(); + const seeded = await seedRow(store, "twitch"); + // Due against the real clock, which the token answer's expiry is computed from. + const row = (await store.updateLinkedIdentityCredential(seeded.id, async (locked) => ({ + kind: "rotate", + accessTokenEncrypted: locked.accessTokenEncrypted!, + refreshTokenEncrypted: locked.refreshTokenEncrypted!, + tokenExpiresAt: new Date().toISOString(), + scopes: locked.scopes, + updatedAt: new Date().toISOString(), + })))!; + const calls = answer(JSON.stringify({ access_token: "access-1", refresh_token: "refresh-1", ...extra }), 200); + let skew = 0; + const ctx = { ...ctxFor(store), clock: () => Date.now() + skew }; + const before = Date.now(); + const first = await refreshManagedCredential(ctx, row); + expect(first.kind).toBe("current"); + const stored = (await store.findStoredLinkedIdentity("twitch", row.providerUserId))!; + const expiry = Date.parse(stored.tokenExpiresAt!); + expect(expiry - before).toBeGreaterThanOrEqual(providerCatalog.twitch.defaultAccessTokenLifetimeSeconds * 1000); + expect(expiry - before).toBeLessThan(providerCatalog.twitch.defaultAccessTokenLifetimeSeconds * 1000 + 5_000); + expect(custody.decrypt(stored.accessTokenEncrypted!)).toBe("access-1"); + + await refreshManagedCredential(ctx, stored); + expect(calls).toEqual(["refresh-0"]); + + skew = providerCatalog.twitch.defaultAccessTokenLifetimeSeconds * 1000; + await refreshManagedCredential(ctx, stored); + expect(calls).toEqual(["refresh-0", "refresh-1"]); + }); + + it("a non-rotating provider keeps the locked row's refresh token, not the snapshot's", async () => { + const store = new InMemoryStore(); + const snapshot = await seedRow(store, "twitch"); + // Another caller rotated the pair after the snapshot; its expiry is still due. + await store.updateLinkedIdentityCredential(snapshot.id, async () => ({ + kind: "rotate", + accessTokenEncrypted: custody.encrypt("access-1"), + refreshTokenEncrypted: custody.encrypt("refresh-1"), + tokenExpiresAt: dueAt, + scopes: ["s"], + updatedAt: dueAt, + })); + const calls = answer(JSON.stringify({ access_token: "access-2", expires_in: 3600 }), 200); + await refreshManagedCredential(ctxFor(store), snapshot); + expect(calls).toEqual(["refresh-1"]); + const after = (await store.findStoredLinkedIdentity("twitch", snapshot.providerUserId))!; + expect(custody.decrypt(after.refreshTokenEncrypted!)).toBe("refresh-1"); + expect(custody.decrypt(after.accessTokenEncrypted!)).toBe("access-2"); + }); +}); + +describe("live Postgres availability", () => { + it.skipIf(!!adminUrl)("is present whenever the job requires it", () => { + // Local workstation runs skip the live suites; the verification job sets + // HEIMDALL_REQUIRE_LIVE_PG=1 and fails here when they would have been skipped. + expect(process.env.HEIMDALL_REQUIRE_LIVE_PG).toBeUndefined(); + }); +}); diff --git a/tests/private-command-diagnostics.test.ts b/tests/private-command-diagnostics.test.ts index 41f16f1..d1801a5 100644 --- a/tests/private-command-diagnostics.test.ts +++ b/tests/private-command-diagnostics.test.ts @@ -30,7 +30,7 @@ function config(): HeimdallConfig { bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {}, storage: { backend: "memory", applySchemaOnStartup: true }, - providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {} }, + providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} }, } as HeimdallConfig; } @@ -98,7 +98,7 @@ it("keeps a provider's malformed 2xx refresh body out of every plaintext part of ); expect(reply.status).toBe("denied"); - expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_body_not_json)."]); + expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ManagedCredentialRefreshFailed, managed_credential_refresh_failed)."]); expect(plaintext(reply)).not.toContain("CANARY"); }); @@ -106,7 +106,7 @@ it("keeps a provider's refusal body out of every plaintext part of the reply", a const reply = await refreshAgainst(() => new Response('CANARYrefused {"error":"invalid_grant"}', { status: 400 })); expect(reply.status).toBe("denied"); - expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_status)."]); + expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ManagedCredentialRefreshFailed, managed_credential_refresh_failed)."]); expect(plaintext(reply)).not.toContain("CANARY"); }); diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts new file mode 100644 index 0000000..6a778f5 --- /dev/null +++ b/tests/provider-token-request.test.ts @@ -0,0 +1,307 @@ +import { inspect } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { type HeimdallConfig } from "../src/config.js"; +import { providers, type Provider } from "../src/contracts.js"; +import { createOAuthRuntimeRegistry, requestProviderTokens } from "../src/oauth.js"; +import { providerCatalog } from "../src/providers.js"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function configFor(): HeimdallConfig { + return { + providers: Object.fromEntries(providers.map((p) => [p, { clientId: `${p}-client`, clientSecret: `${p}-secret` }])), + } as unknown as HeimdallConfig; +} + +interface Captured { + url: string; + method: string | undefined; + headers: Record; + body: URLSearchParams; +} + +function capture(answer: () => Response): { calls: Captured[] } { + const calls: Captured[] = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + calls.push({ + url: String(input), + method: init?.method, + headers: init?.headers as Record, + body: init?.body as URLSearchParams, + }); + return answer(); + }) as typeof fetch; + return { calls }; +} + +const ok = () => + new Response(JSON.stringify({ access_token: "a", refresh_token: "r", token_type: "Bearer", expires_in: 3600, scope: "x y" }), { + status: 200, + }); + +const requestable: Provider[] = [...providers]; + +describe("requestProviderTokens follows the provider descriptor", () => { + it.each(requestable.flatMap((p) => [[p, "authorization_code"] as const, [p, "refresh_token"] as const]))( + "%s %s hits its token endpoint with its client-auth method only", + async (provider, grantType) => { + const { calls } = capture(ok); + const descriptor = providerCatalog[provider]; + const grant = + grantType === "authorization_code" + ? ({ grant_type: "authorization_code", code: "the-code", redirect_uri: "https://h/cb" } as const) + : ({ grant_type: "refresh_token", refresh_token: "the-refresh" } as const); + + const tokens = await requestProviderTokens(provider, configFor(), grant); + + expect(calls).toHaveLength(1); + const call = calls[0]!; + expect(call.url).toBe(descriptor.tokenEndpoint); + expect(call.method).toBe("POST"); + expect(call.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + expect(call.headers.Accept).toBe("application/json"); + expect(call.body.get("grant_type")).toBe(grantType); + if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") { + expect(call.headers.Authorization).toBe(`Basic ${Buffer.from(`${provider}-client:${provider}-secret`).toString("base64")}`); + expect(call.body.has("client_id")).toBe(false); + expect(call.body.has("client_secret")).toBe(false); + } else { + expect(call.headers.Authorization).toBeUndefined(); + expect(call.body.get("client_id")).toBe(`${provider}-client`); + expect(call.body.get("client_secret")).toBe(`${provider}-secret`); + } + expect(tokens.accessToken).toBe("a"); + expect(tokens.refreshToken).toBe("r"); + expect(tokens.scope).toEqual(["x", "y"]); + expect(Date.parse(tokens.expiresAt ?? "")).toBeGreaterThan(Date.now()); + } + ); + + it("sends the code and redirect uri on exchange and the refresh token on refresh", async () => { + const { calls } = capture(ok); + await requestProviderTokens("spotify", configFor(), { grant_type: "authorization_code", code: "C", redirect_uri: "https://h/cb" }); + await requestProviderTokens("spotify", configFor(), { grant_type: "refresh_token", refresh_token: "R" }); + expect(calls[0]!.body.get("code")).toBe("C"); + expect(calls[0]!.body.get("redirect_uri")).toBe("https://h/cb"); + expect(calls[1]!.body.get("refresh_token")).toBe("R"); + expect(calls[1]!.body.has("code")).toBe(false); + }); + + const everyProvider = Object.keys(providerCatalog) as Provider[]; + + it("gives every provider a default access-token lifetime of 3600 seconds", () => { + for (const provider of everyProvider) { + expect(providerCatalog[provider].defaultAccessTokenLifetimeSeconds).toBe(3600); + } + }); + + // expires_in is RECOMMENDED (RFC 6749 5.1): unusable means the default, never an unknown expiry. + it.each([ + ["no expires_in", undefined, 3600], + ["a zero expires_in", 0, 3600], + ["a negative expires_in", -5, 3600], + ["a null expires_in", null, 3600], + ["an object expires_in", {}, 3600], + ["a non-numeric string", "abc", 3600], + ["an empty string", "", 3600], + ["a negative string", "-1", 3600], + ["a zero string", "0", 3600], + ["a fractional string", "12.5", 3600], + ["an exponent string", "1e3", 3600], + ["a numeric string", "7200", 7200], + ["a padded numeric string", " 7200 ", 7200], + ["a number", 7200, 7200], + ])("stores now plus the right lifetime for %s, on every provider and grant", async (_n, expiresIn, seconds) => { + for (const provider of everyProvider) { + for (const grant of [ + { grant_type: "authorization_code", code: "c", redirect_uri: "u" } as const, + { grant_type: "refresh_token", refresh_token: "R" } as const, + ]) { + capture(() => new Response(JSON.stringify({ access_token: "a", ...(expiresIn === undefined ? {} : { expires_in: expiresIn }) }), { status: 200 })); + const before = Date.now(); + const tokens = await requestProviderTokens(provider, configFor(), grant); + const stored = Date.parse(tokens.expiresAt ?? ""); + expect(stored - before).toBeGreaterThanOrEqual(seconds * 1000); + expect(stored - before).toBeLessThan(seconds * 1000 + 5_000); + expect(tokens.refreshToken).toBeUndefined(); + } + } + }); + + it("takes the default lifetime for an expires_in too large to be a date", async () => { + capture(() => new Response('{"access_token":"a","expires_in":1e999}', { status: 200 })); + const before = Date.now(); + const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" }); + expect(Date.parse(tokens.expiresAt ?? "") - before).toBeLessThan(3600 * 1000 + 5_000); + }); + + it.each([ + ["exactly a year", 31_536_000, 31_536_000], + ["a second over a year", 31_536_001, 3600], + ["3e11", 3e11, 3600], + ["3e11 as a string", "300000000000", 3600], + ])("takes an expires_in of %s as %i seconds' worth of lifetime", async (_n, expiresIn, seconds) => { + capture(() => new Response(JSON.stringify({ access_token: "a", expires_in: expiresIn }), { status: 200 })); + const before = Date.now(); + const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" }); + expect(Date.parse(tokens.expiresAt ?? "") - before).toBeGreaterThanOrEqual(seconds * 1000 - 1000); + expect(Date.parse(tokens.expiresAt ?? "") - before).toBeLessThan(seconds * 1000 + 5_000); + }); + + it.each([ + ["no refresh_token", {}], + ["an empty refresh_token", { refresh_token: "" }], + ])("a rotating provider's code exchange with %s is a typed malformed token set; a non-rotating one is not", async (_n, extra) => { + capture(() => new Response(JSON.stringify({ access_token: "a", expires_in: 3600, ...extra }), { status: 200 })); + const runtimes = createOAuthRuntimeRegistry(); + const exchange = { config: configFor(), code: "c", redirectUri: "https://h/cb" }; + await expect(runtimes.joystick.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({ + name: "ProviderResponseError", + code: "provider_body_not_token_set", + }); + expect((await runtimes.twitch.exchangeAuthorizationCode(exchange)).refreshToken).toBeUndefined(); + }); + + it.each([ + ["a number", 42], + ["an object", { a: 1 }], + ["an array", ["r"]], + ["true", true], + ["null", null], + ])("refuses a 2xx whose refresh_token is %s as a malformed token set", async (_n, refreshToken) => { + capture(() => new Response(JSON.stringify({ access_token: "a", refresh_token: refreshToken, expires_in: 3600 }), { status: 200 })); + await expect( + requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" }) + ).rejects.toMatchObject({ code: "provider_body_not_token_set" }); + }); + + it("reads an empty-string refresh_token as no refresh token", async () => { + capture(() => new Response(JSON.stringify({ access_token: "a", refresh_token: "", expires_in: 3600 }), { status: 200 })); + const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" }); + expect(tokens.refreshToken).toBeUndefined(); + }); + + it.each([ + ["authorization_code", { grant_type: "authorization_code", code: "c", redirect_uri: "u" } as const, "exchange"], + ["refresh_token", { grant_type: "refresh_token", refresh_token: "r" } as const, "refresh"], + ])("keeps the failure label for %s", async (_g, grant, word) => { + capture(() => new Response("nope", { status: 500 })); + await expect(requestProviderTokens("youtube", configFor(), grant)).rejects.toThrow( + `YouTube token ${word} failed (status 500, provider_status).` + ); + }); + + it("passes the abort signal to the provider call", async () => { + let seen: AbortSignal | null | undefined; + globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { + seen = init?.signal; + return ok(); + }) as typeof fetch; + const controller = new AbortController(); + await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }, controller.signal); + expect(seen).toBe(controller.signal); + }); + + it("keeps the provider's token type and defaults a missing one to Bearer", async () => { + capture(() => new Response(JSON.stringify({ access_token: "a", token_type: "bearer" }), { status: 200 })); + expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).tokenType).toBe("bearer"); + capture(() => new Response(JSON.stringify({ access_token: "a" }), { status: 200 })); + expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).tokenType).toBe("Bearer"); + }); + + it("trims scope entries and drops empty ones", async () => { + capture(() => new Response(JSON.stringify({ access_token: "a", scope: [" a ", "", "b"] }), { status: 200 })); + expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).scope).toEqual(["a", "b"]); + }); + + it.each([ + ["an empty body", ""], + ["an empty object", "{}"], + ["JSON null", "null"], + ["a JSON array", "[]"], + ["an error beside an access token", JSON.stringify({ access_token: "a", error: "invalid_grant" })], + ["an empty access token", JSON.stringify({ access_token: "" })], + ["a numeric access token", JSON.stringify({ access_token: 5 })], + ])("refuses a 2xx with %s as not a token set, carrying nothing of the body", async (_name, body) => { + capture(() => new Response(body, { status: 200 })); + const error = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }).then( + () => { + throw new Error("expected a refusal"); + }, + (e: unknown) => e as Error & { code?: string; status?: number; oauthError?: unknown } + ); + expect(error.name).toBe("ProviderResponseError"); + expect(error.code).toBe("provider_body_not_token_set"); + expect(error.status).toBe(200); + expect(error.oauthError).toBeUndefined(); + }); + + it("sets no refreshToken key when the provider sent none", async () => { + capture(() => new Response(JSON.stringify({ access_token: "a" }), { status: 200 })); + const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }); + expect("refreshToken" in tokens).toBe(false); + }); + + it.each([ + ["only the secret missing", { clientId: "id" }], + ["only the id missing", { clientSecret: "secret" }], + ])("refuses a provider with %s", async (_n, partial) => { + const config = { providers: { twitch: partial } } as unknown as HeimdallConfig; + await expect(requestProviderTokens("twitch", config, { grant_type: "refresh_token", refresh_token: "r" })).rejects.toThrow( + "Twitch OAuth is not fully configured." + ); + }); + + it("refuses an unconfigured provider by display name", async () => { + const config = { providers: { patreon: {} } } as unknown as HeimdallConfig; + await expect( + requestProviderTokens("patreon", config, { grant_type: "refresh_token", refresh_token: "r" }) + ).rejects.toThrow("Patreon OAuth is not fully configured."); + }); +}); + +describe("oauthError is a closed set", () => { + const refresh = { grant_type: "refresh_token", refresh_token: "r" } as const; + async function failure(body: string, status = 400) { + capture(() => new Response(body, { status })); + return requestProviderTokens("twitch", configFor(), refresh).then( + () => { + throw new Error("expected a refusal"); + }, + (error: unknown) => error as Error & { oauthError?: string } + ); + } + + it("takes invalid_grant from the body and carries nothing else of it", async () => { + const error = await failure(JSON.stringify({ error: "invalid_grant", error_description: "CANARY" })); + expect(error.oauthError).toBe("invalid_grant"); + expect(`${error.message} ${error.stack} ${inspect(error, { depth: 5, showHidden: true })} ${JSON.stringify(error)}`).not.toContain("CANARY"); + }); + + it.each([401, 403, 429, 500, 502, 503])("takes no oauthError from a %i, whatever its body says", async (status) => { + const error = await failure(JSON.stringify({ error: "invalid_grant" }), status); + expect(error.oauthError).toBeUndefined(); + }); + + it.each(["invalid_grant_extra", "not_invalid_grant", "INVALID_GRANT", " invalid_grant", "invalid_grant "])( + "takes the code by exact match only: %j is no code", + async (code) => { + const error = await failure(JSON.stringify({ error: code })); + expect(error.oauthError).toBeUndefined(); + } + ); + + it.each([ + ["an error outside the RFC set", JSON.stringify({ error: "CANARY_unknown" })], + ["a non-string error", JSON.stringify({ error: { code: "invalid_grant" } })], + ["a body that is not JSON", "CANARY "], + ["JSON null", "null"], + ])("leaves oauthError undefined for %s", async (_name, body) => { + const error = await failure(body); + expect(error.oauthError).toBeUndefined(); + expect(`${error.message} ${inspect(error, { depth: 5, showHidden: true })}`).not.toContain("CANARY"); + }); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts index 5b07c6d..4f3f39d 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial = {}): HeimdallConf twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, ...overrides, }; diff --git a/tests/store.test.ts b/tests/store.test.ts index 8eeaeb1..c5f6445 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -326,7 +326,8 @@ async function exerciseStore(store: PostgresStore): Promise> { createdAt: now, updatedAt: now, }; - await s.upsertLinkedIdentity(identity); + const linked = await s.upsertLinkedIdentity(identity); + await s.updateLinkedIdentityCredential(linked.id, async () => ({ kind: "keep" })); await s.upsertLinkedIdentity({ ...identity, username: "again" }); await s.findAccountByLinkedIdentity("discord", "u1"); await s.findStoredLinkedIdentity("discord", "u1"); diff --git a/tests/verse-runtime.test.ts b/tests/verse-runtime.test.ts index 85182dc..3196cfa 100644 --- a/tests/verse-runtime.test.ts +++ b/tests/verse-runtime.test.ts @@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => { expect(bySchema.get("idunn.daemon_health")).toEqual([ "yggdrasil-heimdall", "active", - "Heimdall auth runtime active; storage=postgres; providers=2/6; apps=4; healthTransport=CultNet/RUDP", + "Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP", "2026-06-16T17:10:00.000Z", "heimdall.cultnet-rudp-provider-health", "daemon-published", diff --git a/tests/verse-witness.test.ts b/tests/verse-witness.test.ts index acd9b89..07c141f 100644 --- a/tests/verse-witness.test.ts +++ b/tests/verse-witness.test.ts @@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => { "twitch", "youtube", "spotify", + "joystick", ]); expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([ "repixelizer",