Merge pull request #1 from GameCult/eureka/joystick-h1

Joystick.tv provider and serialized managed-credential refresh (cut H1)
This commit is contained in:
Meta Vens 2026-10-19 17:47:39 +02:00 • committed by GitHub
commit d51fa6b999
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2757 additions and 652 deletions

View file

@ -157,6 +157,8 @@ optional_environment = [
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
"GC_ACCESS_PROVIDER_GITHUB_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_ID",
"GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE",
"GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",

View file

@ -20,7 +20,7 @@
> still reports `providers=2/6`, which counts it. > still reports `providers=2/6`, which counts it.
> >
> **Why it was removed anyway:** `/v1/oauth/spotify/start` is gated by > **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 > unreachable. While the profile existed, that path was publicly reachable and
> would have run a real OAuth handshake against a real client secret, taking > 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 > custody of a user's Spotify refresh token on behalf of a consumer that could

View file

@ -20,7 +20,11 @@ Corroborated by `gamecult-ops/nginx/streampixels.gamecult.org.conf:14`
The current StreamPixels architecture already makes useful splits: The current StreamPixels architecture already makes useful splits:
- the web app owns the remembered viewer session - 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 - the overlay remains auth-free
- creator memberships and operator roles are separate from general viewer state - 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 - provider OAuth authority
- identity-linking 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 - entitlement refresh
- shared claim semantics - shared claim semantics

View file

@ -305,6 +305,159 @@ Important rule:
connection is tied to a user or account rather than a purely app-local secret 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 - 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 ## Policy model
The shared authority should not hard-code one app's access rules. It should 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 ### Finding: the `spotify` provider outlived its consumer
`spotiverse` is the only profile naming `spotify`, and Spotiverse was lost with `spotiverse` was 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 the `E:` drive. The profile has since been removed from the registry
it no longer exists in the swarm. (`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 What survives: the `spotify` catalog entry and runtime, and, wherever an
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET` are configured in operator env still holds them, `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and
`/srv/heimdall/env/service.env`, the profile is advertised in the public `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE`. A live credential kept for a
discovery document, and `/v1/oauth/spotify/start` is reachable from the open consumer that does not exist is the shape that becomes an incident later.
web. That path would run a real OAuth handshake against a real client secret and Retire the provider config, or record why Spotiverse is expected back.
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.
## Deployment modes ## Deployment modes
@ -588,14 +734,14 @@ Plain settings:
- `GC_ACCESS_SIGNING_KEY_ID=...` - `GC_ACCESS_SIGNING_KEY_ID=...`
- `GC_ACCESS_SESSION_TTL_SECONDS=3600` - `GC_ACCESS_SESSION_TTL_SECONDS=3600`
- `GC_ACCESS_STATE_TTL_SECONDS=600` - `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=...` - `GC_ACCESS_APP_{APP_SLUG}_BACKEND_CALLBACK_URLS=...`
Secret files: Secret files:
- `GC_ACCESS_DATABASE_URL_FILE=/etc/gamecult/heimdall/credentials/GC_ACCESS_DATABASE_URL` - `GC_ACCESS_DATABASE_URL_FILE=/etc/gamecult/heimdall/credentials/GC_ACCESS_DATABASE_URL`
- `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE=...` - `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_APP_{APP_SLUG}_SHARED_SECRET_FILE=...`
- `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE=...` - `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE=...`

View file

@ -44,7 +44,7 @@ What exists right now is:
and managed creator connections and managed creator connections
- an app-authenticated managed-credential resolve endpoint so StreamPixels can - an app-authenticated managed-credential resolve endpoint so StreamPixels can
call provider APIs without storing refresh tokens locally 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 when they are expired or near expiry
- StreamPixels now starts viewer and creator Twitch/YouTube OAuth through - StreamPixels now starts viewer and creator Twitch/YouTube OAuth through
Heimdall, redeems one-time completion codes server-side, and keeps local 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: Still to do in this phase:
- key rotation policy instead of just stable key loading - 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 ### Phase 2: Heimdall service skeleton

View file

@ -118,6 +118,7 @@ Supported providers in the skeleton:
- `twitch` - `twitch`
- `youtube` - `youtube`
- `spotify` - `spotify`
- `joystick`
Request body: Request body:
@ -172,7 +173,7 @@ Failure modes:
Launch note: Launch note:
- Discord and Patreon are the implemented provider runtimes for Repixelizer - 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 StreamPixels viewer auth and managed creator connections
- GitHub is still catalogued for start URLs, but its callback runtime is not - GitHub is still catalogued for start URLs, but its callback runtime is not
implemented yet implemented yet
@ -430,9 +431,11 @@ Important behavior:
- the refresh token is signed by Heimdall and scoped to one app session - 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 - 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 - if a stored provider access token is expired or near expiry, Heimdall takes
the stored provider refresh token and persists any rotation before a row lock on the linked identity, re-reads it, uses the stored provider
evaluating entitlements 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 - any app authenticated via `x-heimdall-app-secret` (`resolveAppCaller`) may
send `entitlementPolicies`; Heimdall matches each stored identity to the send `entitlementPolicies`; Heimdall matches each stored identity to the
policy for that provider. Sending policy without authenticating is 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 - it returns access tokens only; refresh tokens remain in Heimdall custody
- when a stored managed credential is expired or within the refresh window, - when a stored managed credential is expired or within the refresh window,
Heimdall refreshes it before returning the access token and persists rotated 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-local binding, diagnostics, subscription setup, and runtime behavior stay
app-owned app-owned

View file

@ -4,7 +4,7 @@
"type": "object", "type": "object",
"required": ["provider", "mode", "returnTo", "entitlementPolicy"], "required": ["provider", "mode", "returnTo", "entitlementPolicy"],
"properties": { "properties": {
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify"] }, "provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] },
"mode": { "enum": ["sign_in", "link", "connect"] }, "mode": { "enum": ["sign_in", "link", "connect"] },
"returnTo": { "type": "string", "format": "uri" }, "returnTo": { "type": "string", "format": "uri" },
"entitlementPolicy": { "entitlementPolicy": {

View file

@ -3,7 +3,7 @@ import {
type CapabilityDefinition, type CapabilityDefinition,
type CapabilityMode, type CapabilityMode,
} from "./capability-rules.js"; } 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"; import { entitlementFacts, grantFacts, identityFacts } from "./facts.js";
export type { CapabilityDefinition, CapabilityMode }; export type { CapabilityDefinition, CapabilityMode };
@ -99,10 +99,10 @@ const repixelizerProfile: AppProfile = {
const streampixelsProfile: AppProfile = { const streampixelsProfile: AppProfile = {
slug: "streampixels", slug: "streampixels",
displayName: "StreamPixels", displayName: "StreamPixels",
profileVersion: "2026-04-26", profileVersion: "2026-10-19",
identityProviders: ["twitch", "youtube"], identityProviders: ["twitch", "youtube"],
entitlementSources: [], entitlementSources: [],
managedConnectionProviders: ["twitch", "youtube"], managedConnectionProviders: ["twitch", "youtube", "joystick"],
allowedReturnOrigins: ["https://streampixels.gamecult.org"], allowedReturnOrigins: ["https://streampixels.gamecult.org"],
capabilities: [ capabilities: [
{ {
@ -222,12 +222,19 @@ export function serializeAppProfile(profile: AppProfile): Record<string, unknown
}; };
} }
export function supportsProvider(profile: AppProfile, provider: Provider): boolean { /**
return ( * Whether the profile admits this provider for this mode. The role lists
profile.identityProviders.includes(provider) || * decide: `sign_in` and `link` mint viewer identity, so they take only
profile.managedConnectionProviders.includes(provider) || * `identityProviders`; a provider that is only a managed connection (Joystick)
profile.entitlementSources.includes(provider) * can never sign anyone in or link. `connect` takes either list:
); * `managedConnectionProviders` for creator credentials, and
* `identityProviders` because Bifrost and its siblings already connect their
* identity providers. A provider in neither list is refused before any redirect.
*/
export function admitsProviderForMode(profile: AppProfile, provider: Provider, mode: OAuthMode): boolean {
return mode === "connect"
? profile.managedConnectionProviders.includes(provider) || profile.identityProviders.includes(provider)
: profile.identityProviders.includes(provider);
} }
/** /**
@ -243,3 +250,34 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo
return false; return false;
} }
} }
/**
* What a completed OAuth callback may mint, decided at the callback from the
* signed state's mode and the provider's role in the profile (the start gate
* is only an early refusal). `viewer_session`: the provider is one of the
* profile's `identityProviders`, so the completion signs the account in with
* the profile's capabilities. `custody_only`: a `connect` for a provider that
* is only a managed connection (Joystick on StreamPixels), bound to a creator
* target, stores the creator credential and mints a claim with no capabilities
* and no refresh token. Without a creator binding it is `refused`, as is any
* other combination. The start gate asks this same question, so an unbound
* connect never reaches a redirect.
*/
export type CallbackMint = "viewer_session" | "custody_only" | "refused";
export function callbackMint(
profile: AppProfile,
provider: Provider,
mode: OAuthMode,
connection: OAuthConnectionBinding | null | undefined
): CallbackMint {
if (profile.identityProviders.includes(provider)) {
return "viewer_session";
}
return mode === "connect" &&
profile.managedConnectionProviders.includes(provider) &&
connection?.kind === "creator" &&
connection.targetId !== ""
? "custody_only"
: "refused";
}

View file

@ -23,7 +23,7 @@ import {
type Provider, type Provider,
} from "./contracts.js"; } from "./contracts.js";
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js"; import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, supportsProvider } from "./app-profiles.js"; import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.js";
import { renderBrowserHandoffPage } from "./browser-handoff.js"; import { renderBrowserHandoffPage } from "./browser-handoff.js";
import { type HeimdallConfig, loadConfig } from "./config.js"; import { type HeimdallConfig, loadConfig } from "./config.js";
import { createTokenCustody, type TokenCustody } from "./custody.js"; import { createTokenCustody, type TokenCustody } from "./custody.js";
@ -34,6 +34,7 @@ import {
summarizePatreonMemberships, summarizePatreonMemberships,
type OAuthRuntimeRegistry, type OAuthRuntimeRegistry,
} from "./oauth.js"; } from "./oauth.js";
import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js";
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
import { createStore, type HeimdallStore } from "./store/index.js"; import { createStore, type HeimdallStore } from "./store/index.js";
@ -44,8 +45,6 @@ import {
type UpsertLinkedIdentityInput, type UpsertLinkedIdentityInput,
} from "./store/types.js"; } from "./store/types.js";
const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000;
export interface BuildAppOptions { export interface BuildAppOptions {
config?: HeimdallConfig; config?: HeimdallConfig;
store?: HeimdallStore; store?: HeimdallStore;
@ -367,63 +366,6 @@ function centsToDecimalString(cents: number): string {
return (cents / 100).toFixed(2); return (cents / 100).toFixed(2);
} }
async function refreshLinkedIdentityCredentialIfNeeded(options: {
config: HeimdallConfig;
store: HeimdallStore;
tokenCustody: TokenCustody;
oauthRuntimes: OAuthRuntimeRegistry;
linkedIdentity: StoredLinkedIdentity;
now: string;
}): Promise<StoredLinkedIdentity> {
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) { function storedIdentityToResolvedIdentity(identity: StoredLinkedIdentity) {
const resolved = { const resolved = {
provider: identity.provider, provider: identity.provider,
@ -450,7 +392,6 @@ async function evaluateStoredEntitlements(options: {
appSlug: AppSlug; appSlug: AppSlug;
accountId: string; accountId: string;
entitlementPolicies: OAuthEntitlementPolicy[]; entitlementPolicies: OAuthEntitlementPolicy[];
now: string;
}): Promise<string[]> { }): Promise<string[]> {
const profile = getAppProfile(options.appSlug); const profile = getAppProfile(options.appSlug);
if (!profile) { if (!profile) {
@ -460,19 +401,13 @@ async function evaluateStoredEntitlements(options: {
const facts: string[] = []; const facts: string[] = [];
for (const storedIdentity of linkedIdentities) { for (const storedIdentity of linkedIdentities) {
if (!profile.entitlementSources.includes(storedIdentity.provider) || !storedIdentity.accessTokenEncrypted) { if (!profile.entitlementSources.includes(storedIdentity.provider)) {
continue; continue;
} }
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({ const refreshed = await refreshManagedCredential(options, storedIdentity);
config: options.config, const linkedIdentity = refreshed.identity;
store: options.store, if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
tokenCustody: options.tokenCustody,
oauthRuntimes: options.oauthRuntimes,
linkedIdentity: storedIdentity,
now: options.now,
});
if (!linkedIdentity.accessTokenEncrypted) {
continue; continue;
} }
@ -559,10 +494,20 @@ export async function startOAuthFlow(
return { statusCode: 404, body: { error: "unknown_app", detail: "No app is registered under that slug." } }; 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 { return {
statusCode: 400, 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, appSlug,
accountId: refreshClaim.account_id, accountId: refreshClaim.account_id,
entitlementPolicies, entitlementPolicies,
now: nowIso,
}); });
const issueInput: IssueAccessClaimInput = { const issueInput: IssueAccessClaimInput = {
appSlug, appSlug,
@ -1074,6 +1018,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
const runtime = oauthRuntimes[request.params.provider]; const runtime = oauthRuntimes[request.params.provider];
try { try {
const callbackProfile = getAppProfile(statePayload.app_slug);
const mint = callbackProfile ? callbackMint(callbackProfile, request.params.provider, statePayload.mode, statePayload.connection) : "refused";
if (mint === "refused") {
throw new Error("The signed state names a mode the provider has no role for in this app.");
}
const tokenSet = await runtime.exchangeAuthorizationCode({ const tokenSet = await runtime.exchangeAuthorizationCode({
config, config,
code: request.query.code, code: request.query.code,
@ -1159,6 +1108,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
accountId: account.id, accountId: account.id,
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id), linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)], facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
}; };
if (account.displayName !== undefined) { if (account.displayName !== undefined) {
issueInput.displayName = account.displayName; issueInput.displayName = account.displayName;
@ -1170,6 +1120,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
store, store,
input: issueInput, input: issueInput,
}); });
// A custody-only completion carries no refresh token: the refresh
// route recomputes capabilities from the account's facts, so one
// would turn the capability-free session into a viewer session.
const { refreshToken: issuedRefreshToken, refresh: issuedRefresh, ...issuedWithoutRefresh } = issued;
const delivered =
mint === "custody_only"
? issuedWithoutRefresh
: { ...issuedWithoutRefresh, refreshToken: issuedRefreshToken, refresh: issuedRefresh };
await store.createAuditEvent({ await store.createAuditEvent({
accountId: account.id, accountId: account.id,
@ -1209,7 +1167,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
entitlements: entitlementEvaluation, entitlements: entitlementEvaluation,
returnTo: statePayload.return_to, returnTo: statePayload.return_to,
connection: statePayload.connection, connection: statePayload.connection,
...issued, ...delivered,
}; };
if (handoff.kind === "backend_callback") { if (handoff.kind === "backend_callback") {
await maybeDeliverBackendHandoff(handoff, { await maybeDeliverBackendHandoff(handoff, {
@ -1225,7 +1183,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
connection: statePayload.connection, connection: statePayload.connection,
account: accountSummary, account: accountSummary,
entitlements: entitlementEvaluation, entitlements: entitlementEvaluation,
...issued, ...delivered,
}); });
await store.createAuditEvent({ await store.createAuditEvent({
@ -1620,21 +1578,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
} }
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, "patreon"); const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, "patreon");
if (!storedLinkedIdentity?.accessTokenEncrypted) { if (!storedLinkedIdentity) {
reply.code(404); reply.code(404);
return { error: "patreon_identity_not_linked" }; return { error: "patreon_identity_not_linked" };
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({ const refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
config, const linkedIdentity = refreshed.identity;
store, if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
tokenCustody,
oauthRuntimes,
linkedIdentity: storedLinkedIdentity,
now,
});
if (!linkedIdentity.accessTokenEncrypted) {
reply.code(404); reply.code(404);
return { error: "patreon_identity_not_linked" }; return { error: "patreon_identity_not_linked" };
} }
@ -1772,22 +1724,24 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
} }
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, request.body.provider); const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, request.body.provider);
if (!storedLinkedIdentity?.accessTokenEncrypted) { if (!storedLinkedIdentity) {
reply.code(404); reply.code(404);
return { error: "managed_credential_not_found" }; return { error: "managed_credential_not_found" };
} }
const now = new Date().toISOString(); let refreshed;
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({ try {
config, refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
store, } catch (error) {
tokenCustody, if (error instanceof ManagedCredentialRefreshFailed) {
oauthRuntimes, reply.code(502);
linkedIdentity: storedLinkedIdentity, return { error: error.code, provider: error.provider };
now, }
}); throw error;
if (!linkedIdentity.accessTokenEncrypted) { }
reply.code(404); const linkedIdentity = refreshed.identity;
return { error: "managed_credential_not_found" }; if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
reply.code(410);
return { error: "managed_credential_revoked", provider: linkedIdentity.provider };
} }
const response: { const response: {

View file

@ -19,7 +19,13 @@ interface BackendHandoffBase {
connection: OAuthConnectionBinding | null; connection: OAuthConnectionBinding | null;
} }
export interface BackendHandoffSuccessPayload extends BackendHandoffBase, IssuedAccessClaimResult { /** A custody-only completion (callbackMint) carries no refresh token. */
type RefreshFields = "refreshToken" | "refresh";
export interface BackendHandoffSuccessPayload
extends BackendHandoffBase,
Omit<IssuedAccessClaimResult, RefreshFields>,
Partial<Pick<IssuedAccessClaimResult, RefreshFields>> {
status: "success"; status: "success";
mode: OAuthMode; mode: OAuthMode;
account: AccountSummary; account: AccountSummary;

View file

@ -39,6 +39,12 @@ export interface IssueAccessClaimInput {
displayName?: string; displayName?: string;
linkedIdentities: LinkedIdentityInput[]; linkedIdentities: LinkedIdentityInput[];
facts: string[]; 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; sessionId?: string;
accessRevision?: number; accessRevision?: number;
ttlSeconds?: number; ttlSeconds?: number;
@ -80,11 +86,15 @@ export async function issueAccessClaim(options: {
// would name an app no verifier can resolve a profile for. // 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}'.`); 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 withoutCapabilities = options.input.withoutCapabilities === true;
const factSet = new Set(facts); 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, { const sharedCapabilities = evaluateSharedCapabilities(profile, {
accountId: options.input.accountId, accountId: options.input.accountId,
facts: factSet, facts: new Set(facts),
identities: options.input.linkedIdentities, identities: options.input.linkedIdentities,
}); });
@ -176,6 +186,8 @@ export async function issueAccessClaim(options: {
kid: options.keys.kid, kid: options.keys.kid,
}, },
sharedCapabilities, sharedCapabilities,
hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"), hybridCapabilities: withoutCapabilities
? []
: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
}; };
} }

View file

@ -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]; export type Provider = (typeof providers)[number];

181
src/managed-credential.ts Normal file
View file

@ -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<HeimdallStore, Map<string, Promise<ManagedCredentialOutcome>>>();
/**
* 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<ManagedCredentialOutcome> {
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<ManagedCredentialOutcome> {
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<CredentialDecision> => {
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 };
}

View file

@ -7,6 +7,7 @@ import {
} from "./contracts.js"; } from "./contracts.js";
import { type HeimdallConfig } from "./config.js"; import { type HeimdallConfig } from "./config.js";
import { entitlementFacts } from "./facts.js"; import { entitlementFacts } from "./facts.js";
import { providerCatalog } from "./providers.js";
import { type CreateEntitlementSnapshotInput } from "./store/types.js"; import { type CreateEntitlementSnapshotInput } from "./store/types.js";
export interface OAuthTokenSet { export interface OAuthTokenSet {
@ -40,6 +41,9 @@ export interface OAuthCallbackContext {
entitlementPolicy: OAuthEntitlementPolicy | null; 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 { export interface OAuthProviderRuntime {
exchangeAuthorizationCode(options: { exchangeAuthorizationCode(options: {
config: HeimdallConfig; config: HeimdallConfig;
@ -49,7 +53,8 @@ export interface OAuthProviderRuntime {
refreshAccessToken?(options: { refreshAccessToken?(options: {
config: HeimdallConfig; config: HeimdallConfig;
refreshToken: string; refreshToken: string;
}): Promise<OAuthTokenSet>; signal?: AbortSignal;
}): Promise<ExpiringTokenSet>;
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>; resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
evaluateEntitlements(options: { evaluateEntitlements(options: {
config: HeimdallConfig; config: HeimdallConfig;
@ -59,6 +64,25 @@ export interface OAuthProviderRuntime {
}): Promise<EntitlementEvaluation>; }): Promise<EntitlementEvaluation>;
} }
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 * 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 * 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 { export class ProviderResponseError extends Error {
readonly status: number; 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}).`); super(`${failure} (status ${status}, ${code}).`);
this.name = "ProviderResponseError"; this.name = "ProviderResponseError";
this.status = status; this.status = status;
this.code = code; this.code = code;
this.oauthError = oauthError;
} }
} }
@ -82,7 +112,14 @@ export class ProviderResponseError extends Error {
async function readProviderJson<T>(response: Response, failure: string): Promise<T> { async function readProviderJson<T>(response: Response, failure: string): Promise<T> {
const text = await response.text(); const text = await response.text();
if (!response.ok) { 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) { if (!text) {
return {} as T; return {} as T;
@ -106,512 +143,133 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
return scope?.split(/\s+/).filter(Boolean) ?? []; return scope?.split(/\s+/).filter(Boolean) ?? [];
} }
async function exchangeDiscordAuthorizationCode(options: { export type TokenGrant =
config: HeimdallConfig; | { grant_type: "authorization_code"; code: string; redirect_uri: string }
code: string; | { grant_type: "refresh_token"; refresh_token: string };
redirectUri: string;
}): Promise<OAuthTokenSet> { /**
const providerConfig = options.config.providers.discord; * 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<ExpiringTokenSet> {
const descriptor = providerCatalog[provider];
const providerConfig = config.providers[provider];
if (!providerConfig.clientId || !providerConfig.clientSecret) { 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({ const body = new URLSearchParams(grant);
grant_type: "authorization_code", const headers: Record<string, string> = {
code: options.code, "Content-Type": "application/x-www-form-urlencoded",
redirect_uri: options.redirectUri, Accept: "application/json",
}); };
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") {
const tokenResponse = await fetchJson<{ headers.Authorization = `Basic ${Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64")}`;
access_token: string; } else {
refresh_token?: string; body.set("client_id", providerConfig.clientId);
token_type: string; body.set("client_secret", providerConfig.clientSecret);
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 tokenSet: OAuthTokenSet = { const init: RequestInit = { method: "POST", headers, body };
accessToken: tokenResponse.access_token, if (signal) {
tokenType: tokenResponse.token_type, init.signal = signal;
scope: normalizeScopes(tokenResponse.scope), }
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), const label = grant.grant_type === "refresh_token" ? "refresh" : "exchange";
raw: tokenResponse as unknown as Record<string, unknown>, const failure = `${descriptor.displayName} token ${label} failed`;
const response = await fetch(descriptor.tokenEndpoint, init);
const answer = await readProviderJson<unknown>(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<string, unknown>,
};
if (tokenResponse.refresh_token) { if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token; tokenSet.refreshToken = tokenResponse.refresh_token;
} }
return tokenSet; return tokenSet;
} }
async function refreshDiscordAccessToken(options: { /** The longest access token lifetime taken at its word: a year. Anything above is not a lifetime. */
config: HeimdallConfig; const MAX_USABLE_EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60;
refreshToken: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.discord;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Discord OAuth is not fully configured.");
}
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); /**
const tokenResponse = await fetchJson<{ * A positive finite number of seconds, or a string that is a positive integer,
access_token: string; * no longer than a year. A larger value would overflow the Date the expiry is
refresh_token?: string; * stored as, so it is unusable and the descriptor's default lifetime applies.
token_type: string; */
expires_in: number; function usableExpiresIn(value: unknown): number | undefined {
scope?: string | string[]; 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
"https://discord.com/api/v10/oauth2/token", ? seconds
{ : undefined;
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"
);
const tokenSet: OAuthTokenSet = { function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] {
accessToken: tokenResponse.access_token, return async (options) => {
tokenType: tokenResponse.token_type, const tokenSet = await requestProviderTokens(provider, options.config, {
scope: normalizeScopes(tokenResponse.scope), grant_type: "authorization_code",
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), code: options.code,
raw: tokenResponse as unknown as Record<string, unknown>, 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: { function sharedRuntimeRefresh(provider: Provider): NonNullable<OAuthProviderRuntime["refreshAccessToken"]> {
config: HeimdallConfig; return (options) =>
code: string; requestProviderTokens(
redirectUri: string; provider,
}): Promise<OAuthTokenSet> { options.config,
const providerConfig = options.config.providers.patreon; { grant_type: "refresh_token", refresh_token: options.refreshToken },
if (!providerConfig.clientId || !providerConfig.clientSecret) { options.signal
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<string, unknown>,
};
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 refreshPatreonAccessToken(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet> {
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<string, unknown>,
};
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<OAuthTokenSet> {
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<string, unknown>,
};
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<OAuthTokenSet> {
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<string, unknown>,
};
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<OAuthTokenSet> {
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<string, unknown>,
};
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<OAuthTokenSet> {
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<string, unknown>,
};
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<OAuthTokenSet> {
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<string, unknown>,
};
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<OAuthTokenSet> {
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<string, unknown>,
};
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<ResolvedIdentity> { async function resolveDiscordIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
const user = await fetchJson<{ const user = await fetchJson<{
@ -910,6 +568,45 @@ async function resolveSpotifyIdentity(options: { accessToken: string }): Promise
return identity; return identity;
} }
async function resolveJoystickIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
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<string, unknown>,
};
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: { async function evaluateDiscordEntitlements(options: {
config: HeimdallConfig; config: HeimdallConfig;
callback: OAuthCallbackContext; callback: OAuthCallbackContext;
@ -1217,22 +914,22 @@ async function evaluatePatreonEntitlements(options: {
} }
const discordRuntime: OAuthProviderRuntime = { const discordRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeDiscordAuthorizationCode, exchangeAuthorizationCode: sharedRuntimeExchange("discord"),
refreshAccessToken: refreshDiscordAccessToken, refreshAccessToken: sharedRuntimeRefresh("discord"),
resolveIdentity: resolveDiscordIdentity, resolveIdentity: resolveDiscordIdentity,
evaluateEntitlements: evaluateDiscordEntitlements, evaluateEntitlements: evaluateDiscordEntitlements,
}; };
const patreonRuntime: OAuthProviderRuntime = { const patreonRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangePatreonAuthorizationCode, exchangeAuthorizationCode: sharedRuntimeExchange("patreon"),
refreshAccessToken: refreshPatreonAccessToken, refreshAccessToken: sharedRuntimeRefresh("patreon"),
resolveIdentity: resolvePatreonIdentity, resolveIdentity: resolvePatreonIdentity,
evaluateEntitlements: evaluatePatreonEntitlements, evaluateEntitlements: evaluatePatreonEntitlements,
}; };
const twitchRuntime: OAuthProviderRuntime = { const twitchRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeTwitchAuthorizationCode, exchangeAuthorizationCode: sharedRuntimeExchange("twitch"),
refreshAccessToken: refreshTwitchAccessToken, refreshAccessToken: sharedRuntimeRefresh("twitch"),
resolveIdentity: resolveTwitchIdentity, resolveIdentity: resolveTwitchIdentity,
async evaluateEntitlements() { async evaluateEntitlements() {
return { facts: [], snapshots: [] }; return { facts: [], snapshots: [] };
@ -1240,8 +937,8 @@ const twitchRuntime: OAuthProviderRuntime = {
}; };
const youtubeRuntime: OAuthProviderRuntime = { const youtubeRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeYouTubeAuthorizationCode, exchangeAuthorizationCode: sharedRuntimeExchange("youtube"),
refreshAccessToken: refreshYouTubeAccessToken, refreshAccessToken: sharedRuntimeRefresh("youtube"),
resolveIdentity: resolveYouTubeIdentity, resolveIdentity: resolveYouTubeIdentity,
async evaluateEntitlements() { async evaluateEntitlements() {
return { facts: [], snapshots: [] }; return { facts: [], snapshots: [] };
@ -1249,14 +946,23 @@ const youtubeRuntime: OAuthProviderRuntime = {
}; };
const spotifyRuntime: OAuthProviderRuntime = { const spotifyRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeSpotifyAuthorizationCode, exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
refreshAccessToken: refreshSpotifyAccessToken, refreshAccessToken: sharedRuntimeRefresh("spotify"),
resolveIdentity: resolveSpotifyIdentity, resolveIdentity: resolveSpotifyIdentity,
async evaluateEntitlements() { async evaluateEntitlements() {
return { facts: [], snapshots: [] }; return { facts: [], snapshots: [] };
}, },
}; };
const joystickRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: sharedRuntimeExchange("joystick"),
refreshAccessToken: sharedRuntimeRefresh("joystick"),
resolveIdentity: resolveJoystickIdentity,
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
const notImplementedRuntime: OAuthProviderRuntime = { const notImplementedRuntime: OAuthProviderRuntime = {
async exchangeAuthorizationCode() { async exchangeAuthorizationCode() {
throw new Error("Provider callback exchange is not implemented yet."); throw new Error("Provider callback exchange is not implemented yet.");
@ -1279,5 +985,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
twitch: twitchRuntime, twitch: twitchRuntime,
youtube: youtubeRuntime, youtube: youtubeRuntime,
spotify: spotifyRuntime, spotify: spotifyRuntime,
joystick: joystickRuntime,
}; };
} }

View file

@ -6,6 +6,17 @@ export interface ProviderDescriptor {
key: Provider; key: Provider;
displayName: string; displayName: string;
authorizationEndpoint: 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[]; defaultScopes: string[];
roles: ProviderRole[]; roles: ProviderRole[];
clientIdEnv: string; clientIdEnv: string;
@ -18,6 +29,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "discord", key: "discord",
displayName: "Discord", displayName: "Discord",
authorizationEndpoint: "https://discord.com/oauth2/authorize", 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"], defaultScopes: ["identify", "guilds.members.read"],
roles: ["identity", "entitlement"], roles: ["identity", "entitlement"],
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
@ -27,6 +42,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "patreon", key: "patreon",
displayName: "Patreon", displayName: "Patreon",
authorizationEndpoint: "https://www.patreon.com/oauth2/authorize", 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]"], defaultScopes: ["identity", "identity[email]"],
roles: ["identity", "entitlement"], roles: ["identity", "entitlement"],
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
@ -36,6 +55,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "github", key: "github",
displayName: "GitHub", displayName: "GitHub",
authorizationEndpoint: "https://github.com/login/oauth/authorize", 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"], defaultScopes: ["read:user", "user:email"],
roles: ["identity", "managed_credential"], roles: ["identity", "managed_credential"],
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID", clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
@ -45,6 +68,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "twitch", key: "twitch",
displayName: "Twitch", displayName: "Twitch",
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize", 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"], defaultScopes: ["user:read:email"],
roles: ["identity", "managed_credential"], roles: ["identity", "managed_credential"],
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID", clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
@ -54,6 +81,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "youtube", key: "youtube",
displayName: "YouTube", displayName: "YouTube",
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
tokenEndpointAuthMethod: "client_secret_post",
rotatesRefreshTokens: false,
defaultAccessTokenLifetimeSeconds: 3600,
defaultScopes: [ defaultScopes: [
"openid", "openid",
"email", "email",
@ -73,6 +104,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "spotify", key: "spotify",
displayName: "Spotify", displayName: "Spotify",
authorizationEndpoint: "https://accounts.spotify.com/authorize", authorizationEndpoint: "https://accounts.spotify.com/authorize",
tokenEndpoint: "https://accounts.spotify.com/api/token",
tokenEndpointAuthMethod: "client_secret_basic",
rotatesRefreshTokens: false,
defaultAccessTokenLifetimeSeconds: 3600,
defaultScopes: [ defaultScopes: [
"user-read-currently-playing", "user-read-currently-playing",
"user-read-playback-state", "user-read-playback-state",
@ -82,6 +117,19 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID", clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE", 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: { export function buildAuthorizationUrl(options: {

View file

@ -6,6 +6,7 @@ import {
type CreateAuthCompletionInput, type CreateAuthCompletionInput,
type CreateAuditEventInput, type CreateAuditEventInput,
type CreateCapabilityGrantInput, type CreateCapabilityGrantInput,
type CredentialDecision,
type CreateEntitlementSnapshotInput, type CreateEntitlementSnapshotInput,
type CreateSessionInput, type CreateSessionInput,
type CreatePrivateCommandReceiptInput, type CreatePrivateCommandReceiptInput,
@ -37,6 +38,8 @@ function isGrantActive(grant: StoredCapabilityGrant, appSlug: AppSlug, at: strin
} }
export class InMemoryStore implements HeimdallStore { 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<string, Promise<unknown>>();
private readonly accounts = new Map<string, StoredAccount>(); private readonly accounts = new Map<string, StoredAccount>();
private readonly linkedIdentities = new Map<string, StoredLinkedIdentity>(); private readonly linkedIdentities = new Map<string, StoredLinkedIdentity>();
private readonly grants = new Map<string, StoredCapabilityGrant>(); private readonly grants = new Map<string, StoredCapabilityGrant>();
@ -174,6 +177,43 @@ export class InMemoryStore implements HeimdallStore {
return clone(linkedIdentity); return clone(linkedIdentity);
} }
async updateLinkedIdentityCredential(
id: string,
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,
): Promise<StoredLinkedIdentity | null> {
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<LinkedIdentityInput[]> { async listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]> {
return [...this.linkedIdentities.values()] return [...this.linkedIdentities.values()]
.filter((identity) => identity.accountId === accountId) .filter((identity) => identity.accountId === accountId)

View file

@ -8,6 +8,7 @@ import {
type CreateAuthCompletionInput, type CreateAuthCompletionInput,
type CreateAuditEventInput, type CreateAuditEventInput,
type CreateCapabilityGrantInput, type CreateCapabilityGrantInput,
type CredentialDecision,
type CreateEntitlementSnapshotInput, type CreateEntitlementSnapshotInput,
type CreateSessionInput, type CreateSessionInput,
type CreatePrivateCommandReceiptInput, type CreatePrivateCommandReceiptInput,
@ -158,8 +159,8 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
providerUserId: row.provider_user_id, providerUserId: row.provider_user_id,
scopes: parseScopes(row.scopes), scopes: parseScopes(row.scopes),
profileJson: row.profile_json, profileJson: row.profile_json,
createdAt: row.created_at, createdAt: new Date(row.created_at).toISOString(),
updatedAt: row.updated_at, updatedAt: new Date(row.updated_at).toISOString(),
}; };
if (row.username) { if (row.username) {
@ -183,7 +184,8 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
} }
if (row.token_expires_at) { 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; return identity;
@ -546,7 +548,7 @@ export async function checkSchemaRequirements(pool: Pick<Pool, "query">, require
} }
export class PostgresStore implements HeimdallStore { export class PostgresStore implements HeimdallStore {
constructor(private readonly pool: Pick<Pool, "query" | "end">) {} constructor(private readonly pool: Pick<Pool, "query" | "end" | "connect">) {}
async ensureSchema(): Promise<void> { async ensureSchema(): Promise<void> {
await this.pool.query(CREATE_SCHEMA_SQL); await this.pool.query(CREATE_SCHEMA_SQL);
@ -722,6 +724,60 @@ export class PostgresStore implements HeimdallStore {
return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity")); return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity"));
} }
async updateLinkedIdentityCredential(
id: string,
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,
): Promise<StoredLinkedIdentity | null> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
try {
const locked = await client.query<LinkedIdentityRow>(
"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<LinkedIdentityRow>(
`
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<LinkedIdentityInput[]> { async listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]> {
const result = await this.pool.query<LinkedIdentityRow>( const result = await this.pool.query<LinkedIdentityRow>(
` `

View file

@ -56,6 +56,23 @@ export interface UpsertLinkedIdentityInput {
updatedAt: string; 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 { export interface StoredSession {
id: string; id: string;
accountId: string; accountId: string;
@ -213,6 +230,17 @@ export interface HeimdallStore {
findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null>; findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null>;
listStoredLinkedIdentitiesForAccount(accountId: string): Promise<StoredLinkedIdentity[]>; listStoredLinkedIdentitiesForAccount(accountId: string): Promise<StoredLinkedIdentity[]>;
upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity>; upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity>;
/**
* 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<CredentialDecision>,
): Promise<StoredLinkedIdentity | null>;
listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]>; listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]>;
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>; createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>; listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;

View file

@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig {
storage: { backend: "memory", applySchemaOnStartup: true }, storage: { backend: "memory", applySchemaOnStartup: true },
providers: { providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" }, discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
}, },
}; };
} }

View file

@ -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 { inspect } from "node:util";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { type FastifyInstance } from "fastify"; import { type FastifyInstance } from "fastify";
import { buildApp, errorIdentity } from "../src/app.js"; 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 { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
import { type HeimdallConfig, loadConfig } from "../src/config.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js";
import { entitlementFacts, identityFacts } from "../src/facts.js"; import { entitlementFacts, identityFacts } from "../src/facts.js";
import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js"; import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js";
import { verifyJwt } from "../src/signing.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
import { InMemoryStore } from "../src/store/index.js"; import { InMemoryStore } from "../src/store/index.js";
function createTestConfig(): HeimdallConfig { function createTestConfig(): HeimdallConfig {
@ -49,6 +53,7 @@ function createTestConfig(): HeimdallConfig {
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" }, twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-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(profileResponse.json()).toEqual(
expect.objectContaining({ expect.objectContaining({
slug: "streampixels", 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"); 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<string, unknown> = { 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<string, unknown> }).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<Record<string, any>> = [];
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
if (String(input) === callbackUrl) {
deliveries.push(JSON.parse(String(init?.body)) as Record<string, any>);
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<string, unknown>) {
const calls: Array<{ url: string; headers: Record<string, string>; body?: URLSearchParams | undefined }> = [];
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, headers: (init?.headers ?? {}) as Record<string, string>, 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<string, unknown> }).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 () => { 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 // 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 // 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); 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");
});
});

View file

@ -65,6 +65,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
twitch: {}, twitch: {},
youtube: {}, youtube: {},
spotify: {}, spotify: {},
joystick: {},
}, },
...overrides, ...overrides,
}; };

View file

@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig {
twitch: {}, twitch: {},
youtube: {}, youtube: {},
spotify: {}, spotify: {},
joystick: {},
}, },
}; };
} }

View file

@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig {
twitch: {}, twitch: {},
youtube: {}, youtube: {},
spotify: {}, spotify: {},
joystick: {},
}, },
}; };
} }

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,7 @@ function config(): HeimdallConfig {
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {}, appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
storage: { backend: "memory", applySchemaOnStartup: true }, 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; } 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.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"); 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 })); const reply = await refreshAgainst(() => new Response('CANARYrefused {"error":"invalid_grant"}', { status: 400 }));
expect(reply.status).toBe("denied"); 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"); expect(plaintext(reply)).not.toContain("CANARY");
}); });

View file

@ -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<string, string>;
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<string, string>,
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 <html>"],
["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");
});
});

View file

@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
twitch: {}, twitch: {},
youtube: {}, youtube: {},
spotify: {}, spotify: {},
joystick: {},
}, },
...overrides, ...overrides,
}; };

View file

@ -326,7 +326,8 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
createdAt: now, createdAt: now,
updatedAt: 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.upsertLinkedIdentity({ ...identity, username: "again" });
await s.findAccountByLinkedIdentity("discord", "u1"); await s.findAccountByLinkedIdentity("discord", "u1");
await s.findStoredLinkedIdentity("discord", "u1"); await s.findStoredLinkedIdentity("discord", "u1");

View file

@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => {
expect(bySchema.get("idunn.daemon_health")).toEqual([ expect(bySchema.get("idunn.daemon_health")).toEqual([
"yggdrasil-heimdall", "yggdrasil-heimdall",
"active", "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", "2026-06-16T17:10:00.000Z",
"heimdall.cultnet-rudp-provider-health", "heimdall.cultnet-rudp-provider-health",
"daemon-published", "daemon-published",

View file

@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => {
"twitch", "twitch",
"youtube", "youtube",
"spotify", "spotify",
"joystick",
]); ]);
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([ expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
"repixelizer", "repixelizer",