Merge pull request #1 from GameCult/eureka/joystick-h1
Joystick.tv provider and serialized managed-credential refresh (cut H1)
This commit is contained in:
commit
d51fa6b999
30 changed files with 2757 additions and 652 deletions
|
|
@ -157,6 +157,8 @@ optional_environment = [
|
|||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
> still reports `providers=2/6`, which counts it.
|
||||
>
|
||||
> **Why it was removed anyway:** `/v1/oauth/spotify/start` is gated by
|
||||
> `supportsProvider(profile, provider)`, so a provider with no app behind it is
|
||||
> `admitsProviderForMode(profile, provider, mode)`, so a provider with no app behind it is
|
||||
> unreachable. While the profile existed, that path was publicly reachable and
|
||||
> would have run a real OAuth handshake against a real client secret, taking
|
||||
> custody of a user's Spotify refresh token on behalf of a consumer that could
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ Corroborated by `gamecult-ops/nginx/streampixels.gamecult.org.conf:14`
|
|||
The current StreamPixels architecture already makes useful splits:
|
||||
|
||||
- the web app owns the remembered viewer session
|
||||
- the service owns provider OAuth exchange and identity resolution
|
||||
- Heimdall owns provider OAuth exchange, identity resolution and refresh-token
|
||||
custody for Twitch, YouTube and Joystick; StreamPixels never calls a provider
|
||||
token endpoint and holds no provider refresh token. It asks Heimdall for a
|
||||
short-lived access token (`managed-credentials/resolve`) and treats `410
|
||||
managed_credential_revoked` as "the creator must reconnect"
|
||||
- the overlay remains auth-free
|
||||
- creator memberships and operator roles are separate from general viewer state
|
||||
|
||||
|
|
@ -38,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is:
|
|||
|
||||
- provider OAuth authority
|
||||
- identity-linking authority
|
||||
- managed Twitch/YouTube credential custody for creator connections
|
||||
- managed Twitch/YouTube/Joystick credential custody for creator connections (Joystick is a managed connection only, not a viewer sign-in provider: `startOAuthFlow` admits `sign_in` and `link` only for `identityProviders` and `connect` only for `managedConnectionProviders` or `identityProviders`, and refuses anything else before any redirect. The callback decides again from the signed state's mode: a `connect` for a managed-connection-only provider is admitted only with `connection.kind` `creator` and a `targetId`, refused otherwise at start and again at the callback before the code exchange; an admitted one stores the credential and mints a claim with no facts, no capabilities and no refresh token. Heimdall's claim is therefore never a viewer session. Heimdall cannot stop an app from minting its own viewer from a completion, so StreamPixels' redeem must treat a non-creator Joystick completion as an error, never as a sign-in. This is an S1 rule, recorded for SP)
|
||||
- entitlement refresh
|
||||
- shared claim semantics
|
||||
|
||||
|
|
|
|||
|
|
@ -305,6 +305,159 @@ Important rule:
|
|||
connection is tied to a user or account rather than a purely app-local secret
|
||||
- app-specific binding semantics and runtime behavior still stay app-owned
|
||||
|
||||
## Managed credential custody
|
||||
|
||||
Source-grounded as of branch `eureka/joystick-h1` at 6db84bf (PR
|
||||
GameCult/Heimdall#1, unmerged). Nothing here is deployed.
|
||||
|
||||
### One owner: `refreshManagedCredential`
|
||||
|
||||
`src/managed-credential.ts` owns every decision to present a stored refresh
|
||||
token to a provider. The entitlement path (`refreshAppSession`), the Bifrost
|
||||
Patreon sync and `managed-credentials/resolve` all call it; none of them reads
|
||||
`tokenExpiresAt` or a provider token endpoint itself. The earlier
|
||||
`refreshLinkedIdentityCredentialIfNeeded` is deleted, and callers no longer
|
||||
write refreshed tokens through `upsertLinkedIdentity`: a refresh reaches the row
|
||||
only through the store port below. The only other writer of credential columns
|
||||
is the OAuth callback's grant write (`app.ts`, once).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | a `StoredLinkedIdentity` snapshot, the config, the token custody (AES-256-GCM seal), the provider runtimes, an injectable clock and timeout |
|
||||
| Output | `{kind: "current" or "revoked", identity}`, or a thrown `ManagedCredentialRefreshFailed` (carries the provider and nothing the provider said) |
|
||||
| Forbidden writers | any caller writing token columns or `token_expires_at` outside `updateLinkedIdentityCredential` and the callback's grant write; any caller computing "is a refresh due" |
|
||||
|
||||
The snapshot is a hint. It may answer without a lock when the identity holds no
|
||||
access token (`revoked`) or no refresh is due (`current`; due means a known
|
||||
expiry within 5 minutes, a stored refresh token, and a runtime that refreshes).
|
||||
It never decides that a refresh happens.
|
||||
|
||||
### Three nested guards
|
||||
|
||||
1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then
|
||||
linked identity id. A caller arriving while an attempt is in flight joins it
|
||||
and receives its outcome, success or failure, so nobody queues behind a
|
||||
failed holder and re-presents the same token. The entry ends with the
|
||||
attempt; a later request retries.
|
||||
2. **Store-port lock, across processes.**
|
||||
`HeimdallStore.updateLinkedIdentityCredential(id, decide)` runs `decide`
|
||||
against the row as read under the lock. `PostgresStore` takes
|
||||
`SELECT ... FOR UPDATE` inside a transaction; `InMemoryStore` serialises per
|
||||
id on a promise chain that deletes its entry only when it is still the tail.
|
||||
`decide` re-checks `dueForRefresh` on the locked row, so a process that
|
||||
waited finds the token already rotated and returns `keep`. The provider call
|
||||
is aborted at 10 seconds (`MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS`), which
|
||||
bounds how long any waiter holds a pooled connection.
|
||||
3. **Decision, not mutation.** `decide` returns a `CredentialDecision`: `keep`,
|
||||
`revoke`, or `rotate` with the access token, refresh token and
|
||||
`tokenExpiresAt` all required. The store applies it and returns the row. The
|
||||
new pair is persisted before the access token is returned. A store that
|
||||
keeps the old expiry on rotate is a type error, not a test.
|
||||
|
||||
### Typed answers
|
||||
|
||||
- **Revoked** (`410 managed_credential_revoked`, audit
|
||||
`managed_credential_revoked {provider, reason}`, all three token columns
|
||||
cleared). Two reasons only: `provider_revoked`, an HTTP 400 whose OAuth
|
||||
`error` is exactly `invalid_grant`; and `rotation_missing`, a well-formed
|
||||
refresh answer without a refresh token from a provider whose descriptor says
|
||||
`rotatesRefreshTokens`. An identity with no access token is also `revoked`.
|
||||
`404` means no linked identity exists. On the entitlement path a revoked
|
||||
identity is skipped; on the Bifrost sync it is `404
|
||||
patreon_identity_not_linked` with Bifrost uncalled.
|
||||
- **Refresh failed** (`502 managed_credential_refresh_failed`, tokens
|
||||
untouched, retry allowed): invalid client, a 401 or 5xx whatever its body,
|
||||
timeout, a 2xx without a string `access_token` or carrying `error`, a
|
||||
`refresh_token` present but not a string, an empty or non-JSON body.
|
||||
`ProviderResponseError.oauthError` is drawn only from the closed RFC 6749 code
|
||||
set and is read only from a 400; no provider body reaches a message, stack or
|
||||
audit payload.
|
||||
- An empty-string `refresh_token` counts as missing.
|
||||
|
||||
### Provider descriptor and the one token request
|
||||
|
||||
`providerCatalog` (`src/providers.ts`) is the only place a provider's token
|
||||
behaviour is written down. Fields beyond the authorize side:
|
||||
|
||||
- `tokenEndpoint`: where both grants go.
|
||||
- `tokenEndpointAuthMethod`: `client_secret_basic` or `client_secret_post`
|
||||
(RFC 7591 names); exactly one client-auth method per request.
|
||||
- `rotatesRefreshTokens`: true only for Joystick. Drives fail-closed on
|
||||
refresh (`rotation_missing`) and a failed callback on exchange when the grant
|
||||
carries no refresh token.
|
||||
- `defaultAccessTokenLifetimeSeconds`: 3600 for all seven providers. Applied
|
||||
when an answer has no usable `expires_in`; unusable means absent, non-numeric,
|
||||
not a positive number or positive-integer string, or above 365 days. An
|
||||
unknown expiry is therefore never stored, so every credential comes due.
|
||||
|
||||
`requestProviderTokens(provider, config, grant)` in `src/oauth.ts` replaces the
|
||||
ten per-provider exchange and refresh functions. Authorization-code and refresh
|
||||
grants share it, it returns an `ExpiringTokenSet` (`expiresAt` required), and it
|
||||
rejects a malformed 2xx typed as `provider_body_not_token_set` on both grants.
|
||||
The per-provider runtimes keep only identity resolution.
|
||||
|
||||
### Mode x role at the gates
|
||||
|
||||
`src/app-profiles.ts` holds both questions, and the profile's role lists are
|
||||
their only input:
|
||||
|
||||
- `admitsProviderForMode(profile, provider, mode)`, the start gate: `sign_in`
|
||||
and `link` take `identityProviders` only; `connect` takes
|
||||
`managedConnectionProviders` or `identityProviders` (Bifrost connects its
|
||||
identity providers). `supportsProvider` is deleted.
|
||||
- `callbackMint(profile, provider, mode, connection)`, decided at the callback
|
||||
from the signed state and asked again at start, so an unbound connect never
|
||||
reaches a redirect:
|
||||
- `viewer_session`: the provider is in `identityProviders`; a normal session
|
||||
with the profile's capabilities.
|
||||
- `custody_only`: a `connect` for a provider that is only a managed
|
||||
connection (Joystick on StreamPixels) with `connection.kind = "creator"` and
|
||||
a non-empty `targetId`.
|
||||
- `refused`: everything else, before the code exchange.
|
||||
|
||||
A custody-only completion stores the credential and issues a claim with no
|
||||
facts, no capabilities and no hybrid capabilities (`withoutCapabilities` in
|
||||
`src/claims.ts`, the one owner of a capability-free claim). The completion
|
||||
payload and the `backend_callback` handoff carry no `refreshToken` or
|
||||
`refresh`, because the refresh route recomputes facts from the account and would
|
||||
turn that session into a viewer session. A session row still exists, and
|
||||
`BackendHandoffSuccessPayload` types both fields optional for this reason. The
|
||||
consumer's redeem reads only account id, provider, mode, connection and claim
|
||||
identities, so the claim form is unchanged for it.
|
||||
|
||||
### Verification layer
|
||||
|
||||
The rules above are pinned by `tests/managed-credential.test.ts` on three rigs
|
||||
(memory, Postgres with one app, Postgres with two pools),
|
||||
`tests/provider-token-request.test.ts`, `tests/app.test.ts` (a matrix over all
|
||||
profiles x providers x modes, callback mint, handoff strip) and
|
||||
`tests/recipe-secrets.test.ts`. Evidence is the Yggdrasil `ygg-verify` run on
|
||||
Linux (node:24, postgres:16, `HEIMDALL_REQUIRE_LIVE_PG=1`; 600 passed, 8
|
||||
skipped, none a live-Postgres suite), hand and StrykerJS mutants over the diff,
|
||||
and Soul's pass 4 verdict `joystick:verdict:cut-h1.s4`. The deploy path's own
|
||||
builder was not used.
|
||||
|
||||
### Open follow-ups
|
||||
|
||||
- `joystick:follow_up:cross-process-failed-refresh-marker`: single flight is per
|
||||
process, so N Heimdall processes can present one token N times after a
|
||||
transient failure. Needs a failed-attempt marker on the row, read under the
|
||||
lock. Must land before or with Idunn onboarding.
|
||||
- `joystick:follow_up:connect-start-needs-app-auth`: connect starts are
|
||||
unauthenticated, so anyone can name `{creator, targetId}`; the callback
|
||||
re-check proves a binding exists, not that the caller owns it. It also owns
|
||||
the whitespace-only `targetId` that passes both gates and the callback's
|
||||
fail-open `''` fallback for a vanished profile (finding
|
||||
`joystick:finding:cut-h1.s4.whitespace-targetid-passes-custody-gate`). Must
|
||||
land before the public Joystick bot or a second creator.
|
||||
- `joystick:follow_up:heimdall-idunn-onboarding` (gamecult-ops): live Heimdall
|
||||
is a hand-copied release under `heimdall.service`, not an Idunn-admitted
|
||||
generation; the recipe's Joystick lines are declared but unproven by a
|
||||
deploy.
|
||||
- `joystick:follow_up:joystick-viewer-signin-public-bot`: Joystick stays out of
|
||||
StreamPixels `identityProviders` until the public bot is registered; viewer
|
||||
sign-in must not rewrite a creator's credential row.
|
||||
|
||||
## Policy model
|
||||
|
||||
The shared authority should not hard-code one app's access rules. It should
|
||||
|
|
@ -514,24 +667,17 @@ each provider**. That URL cannot move on deploy.
|
|||
|
||||
### Finding: the `spotify` provider outlived its consumer
|
||||
|
||||
`spotiverse` is the only profile naming `spotify`, and Spotiverse was lost with
|
||||
the `E:` drive — it was a small daemon exposing the Spotify API to agents, and
|
||||
it no longer exists in the swarm.
|
||||
`spotiverse` was the only profile naming `spotify`, and Spotiverse was lost with
|
||||
the `E:` drive. The profile has since been removed from the registry
|
||||
(`docs/app-profiles/spotiverse.md` is the restore spec), and
|
||||
`admitsProviderForMode` refuses a provider no profile lists, so
|
||||
`/v1/oauth/spotify/start` no longer reaches a handshake.
|
||||
|
||||
What survives it: `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and
|
||||
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET` are configured in
|
||||
`/srv/heimdall/env/service.env`, the profile is advertised in the public
|
||||
discovery document, and `/v1/oauth/spotify/start` is reachable from the open
|
||||
web. That path would run a real OAuth handshake against a real client secret and
|
||||
take custody of a user's Spotify refresh token on behalf of a consumer that
|
||||
cannot use it.
|
||||
|
||||
This is not an active compromise. It is a live credential and a token-custody
|
||||
code path kept alive for nothing, which is exactly the shape that becomes an
|
||||
incident later. Retire the profile and the provider config together, or record
|
||||
why Spotiverse is expected back. Retiring is a change to `src/app-profiles.ts`
|
||||
and the provider catalog and should be decided on its own, not folded into the
|
||||
boundary change.
|
||||
What survives: the `spotify` catalog entry and runtime, and, wherever an
|
||||
operator env still holds them, `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and
|
||||
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE`. A live credential kept for a
|
||||
consumer that does not exist is the shape that becomes an incident later.
|
||||
Retire the provider config, or record why Spotiverse is expected back.
|
||||
|
||||
## Deployment modes
|
||||
|
||||
|
|
@ -588,14 +734,14 @@ Plain settings:
|
|||
- `GC_ACCESS_SIGNING_KEY_ID=...`
|
||||
- `GC_ACCESS_SESSION_TTL_SECONDS=3600`
|
||||
- `GC_ACCESS_STATE_TTL_SECONDS=600`
|
||||
- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY}_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_ID=...`
|
||||
- `GC_ACCESS_APP_{APP_SLUG}_BACKEND_CALLBACK_URLS=...`
|
||||
|
||||
Secret files:
|
||||
|
||||
- `GC_ACCESS_DATABASE_URL_FILE=/etc/gamecult/heimdall/credentials/GC_ACCESS_DATABASE_URL`
|
||||
- `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE=...`
|
||||
- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY}_CLIENT_SECRET_FILE=...`
|
||||
- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_SECRET_FILE=...`
|
||||
- `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET_FILE=...`
|
||||
- `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE=...`
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ What exists right now is:
|
|||
and managed creator connections
|
||||
- an app-authenticated managed-credential resolve endpoint so StreamPixels can
|
||||
call provider APIs without storing refresh tokens locally
|
||||
- managed Twitch/YouTube credentials refresh inside Heimdall before resolution
|
||||
- managed Twitch/YouTube/Joystick credentials refresh inside Heimdall before resolution
|
||||
when they are expired or near expiry
|
||||
- StreamPixels now starts viewer and creator Twitch/YouTube OAuth through
|
||||
Heimdall, redeems one-time completion codes server-side, and keeps local
|
||||
|
|
@ -215,7 +215,7 @@ Landed in the first pass:
|
|||
Still to do in this phase:
|
||||
|
||||
- key rotation policy instead of just stable key loading
|
||||
- token custody retrieval/refresh surfaces beyond initial storage
|
||||
- token custody retrieval and refresh have landed (see `docs/architecture.md`, Managed credential custody); the open pieces are the follow-ups listed there
|
||||
|
||||
### Phase 2: Heimdall service skeleton
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ Supported providers in the skeleton:
|
|||
- `twitch`
|
||||
- `youtube`
|
||||
- `spotify`
|
||||
- `joystick`
|
||||
|
||||
Request body:
|
||||
|
||||
|
|
@ -172,7 +173,7 @@ Failure modes:
|
|||
Launch note:
|
||||
|
||||
- Discord and Patreon are the implemented provider runtimes for Repixelizer
|
||||
- Twitch and YouTube now have generic OAuth exchange and identity runtimes for
|
||||
- Twitch, YouTube and Joystick have generic OAuth exchange and identity runtimes for
|
||||
StreamPixels viewer auth and managed creator connections
|
||||
- GitHub is still catalogued for start URLs, but its callback runtime is not
|
||||
implemented yet
|
||||
|
|
@ -430,9 +431,11 @@ Important behavior:
|
|||
|
||||
- the refresh token is signed by Heimdall and scoped to one app session
|
||||
- the access token remains short-lived; app backends still verify it locally
|
||||
- if a stored provider access token is expired or near expiry, Heimdall uses
|
||||
the stored provider refresh token and persists any rotation before
|
||||
evaluating entitlements
|
||||
- if a stored provider access token is expired or near expiry, Heimdall takes
|
||||
a row lock on the linked identity, re-reads it, uses the stored provider
|
||||
refresh token and persists any rotation before evaluating entitlements; an
|
||||
identity whose grant the provider has revoked (no stored access token)
|
||||
contributes no entitlement
|
||||
- any app authenticated via `x-heimdall-app-secret` (`resolveAppCaller`) may
|
||||
send `entitlementPolicies`; Heimdall matches each stored identity to the
|
||||
policy for that provider. Sending policy without authenticating is
|
||||
|
|
@ -484,7 +487,36 @@ Important behavior:
|
|||
- it returns access tokens only; refresh tokens remain in Heimdall custody
|
||||
- when a stored managed credential is expired or within the refresh window,
|
||||
Heimdall refreshes it before returning the access token and persists rotated
|
||||
access/refresh tokens
|
||||
access/refresh tokens. The refresh runs under a row lock on the linked
|
||||
identity (`SELECT ... FOR UPDATE`), so a given refresh token is presented to
|
||||
the provider once across concurrent requests and across two Heimdall
|
||||
processes sharing one database. Within one process, concurrent refreshes of
|
||||
one identity share a single attempt and its outcome (success or failure), so
|
||||
nobody re-presents a token behind a failed holder in that burst. Residual
|
||||
risk, tracked as follow-up `joystick:follow_up:cross-process-failed-refresh-marker`:
|
||||
the guard is one presentation per process per burst, so N
|
||||
Heimdall processes can present one token N times after a transient failure.
|
||||
The provider call is bounded at 10 seconds
|
||||
- a provider whose refresh tokens rotate (Joystick) fails closed: a refresh
|
||||
response without a new refresh token revokes the credential instead of
|
||||
keeping the old token
|
||||
- `410 {"error":"managed_credential_revoked","provider":...}`: the provider
|
||||
rejected the grant (HTTP 400 whose OAuth `error` is exactly `invalid_grant`),
|
||||
or the identity holds no access token.
|
||||
The stored tokens are cleared and an audit event `managed_credential_revoked`
|
||||
is written; the owner must reconnect. `404 managed_credential_not_found`
|
||||
means no linked identity exists
|
||||
- `502 {"error":"managed_credential_refresh_failed","provider":...}`: the
|
||||
refresh failed for any reason that is not a grant verdict (invalid client,
|
||||
a 401 or 5xx whatever its body says, timeout, a 2xx without a string
|
||||
`access_token` or carrying an `error`, a `refresh_token` that is present but
|
||||
not a string, an empty or non-JSON body). Stored tokens are untouched and a
|
||||
later request may succeed. An empty-string `refresh_token` counts as missing,
|
||||
so a rotating provider revokes (`rotation_missing`). A token answer without a
|
||||
usable `expires_in` (absent, non-numeric, or not a positive number or
|
||||
positive-integer string) stores the expiry as now plus the provider's default
|
||||
access-token lifetime (3600 seconds), so an expiry is never unknown and the
|
||||
credential always comes due for refresh
|
||||
- app-local binding, diagnostics, subscription setup, and runtime behavior stay
|
||||
app-owned
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"type": "object",
|
||||
"required": ["provider", "mode", "returnTo", "entitlementPolicy"],
|
||||
"properties": {
|
||||
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify"] },
|
||||
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] },
|
||||
"mode": { "enum": ["sign_in", "link", "connect"] },
|
||||
"returnTo": { "type": "string", "format": "uri" },
|
||||
"entitlementPolicy": {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
type CapabilityDefinition,
|
||||
type CapabilityMode,
|
||||
} from "./capability-rules.js";
|
||||
import { type AppSlug, type LinkedIdentityInput, type OAuthEntitlementPolicy, type Provider } from "./contracts.js";
|
||||
import { type AppSlug, type LinkedIdentityInput, type OAuthConnectionBinding, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js";
|
||||
import { entitlementFacts, grantFacts, identityFacts } from "./facts.js";
|
||||
|
||||
export type { CapabilityDefinition, CapabilityMode };
|
||||
|
|
@ -99,10 +99,10 @@ const repixelizerProfile: AppProfile = {
|
|||
const streampixelsProfile: AppProfile = {
|
||||
slug: "streampixels",
|
||||
displayName: "StreamPixels",
|
||||
profileVersion: "2026-04-26",
|
||||
profileVersion: "2026-10-19",
|
||||
identityProviders: ["twitch", "youtube"],
|
||||
entitlementSources: [],
|
||||
managedConnectionProviders: ["twitch", "youtube"],
|
||||
managedConnectionProviders: ["twitch", "youtube", "joystick"],
|
||||
allowedReturnOrigins: ["https://streampixels.gamecult.org"],
|
||||
capabilities: [
|
||||
{
|
||||
|
|
@ -222,12 +222,19 @@ export function serializeAppProfile(profile: AppProfile): Record<string, unknown
|
|||
};
|
||||
}
|
||||
|
||||
export function supportsProvider(profile: AppProfile, provider: Provider): boolean {
|
||||
return (
|
||||
profile.identityProviders.includes(provider) ||
|
||||
profile.managedConnectionProviders.includes(provider) ||
|
||||
profile.entitlementSources.includes(provider)
|
||||
);
|
||||
/**
|
||||
* Whether the profile admits this provider for this mode. The role lists
|
||||
* decide: `sign_in` and `link` mint viewer identity, so they take only
|
||||
* `identityProviders`; a provider that is only a managed connection (Joystick)
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
|
|
|||
152
src/app.ts
152
src/app.ts
|
|
@ -23,7 +23,7 @@ import {
|
|||
type Provider,
|
||||
} from "./contracts.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 { type HeimdallConfig, loadConfig } from "./config.js";
|
||||
import { createTokenCustody, type TokenCustody } from "./custody.js";
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
summarizePatreonMemberships,
|
||||
type OAuthRuntimeRegistry,
|
||||
} from "./oauth.js";
|
||||
import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js";
|
||||
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
|
||||
import { createStore, type HeimdallStore } from "./store/index.js";
|
||||
|
|
@ -44,8 +45,6 @@ import {
|
|||
type UpsertLinkedIdentityInput,
|
||||
} from "./store/types.js";
|
||||
|
||||
const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: HeimdallConfig;
|
||||
store?: HeimdallStore;
|
||||
|
|
@ -367,63 +366,6 @@ function centsToDecimalString(cents: number): string {
|
|||
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) {
|
||||
const resolved = {
|
||||
provider: identity.provider,
|
||||
|
|
@ -450,7 +392,6 @@ async function evaluateStoredEntitlements(options: {
|
|||
appSlug: AppSlug;
|
||||
accountId: string;
|
||||
entitlementPolicies: OAuthEntitlementPolicy[];
|
||||
now: string;
|
||||
}): Promise<string[]> {
|
||||
const profile = getAppProfile(options.appSlug);
|
||||
if (!profile) {
|
||||
|
|
@ -460,19 +401,13 @@ async function evaluateStoredEntitlements(options: {
|
|||
const facts: string[] = [];
|
||||
|
||||
for (const storedIdentity of linkedIdentities) {
|
||||
if (!profile.entitlementSources.includes(storedIdentity.provider) || !storedIdentity.accessTokenEncrypted) {
|
||||
if (!profile.entitlementSources.includes(storedIdentity.provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
|
||||
config: options.config,
|
||||
store: options.store,
|
||||
tokenCustody: options.tokenCustody,
|
||||
oauthRuntimes: options.oauthRuntimes,
|
||||
linkedIdentity: storedIdentity,
|
||||
now: options.now,
|
||||
});
|
||||
if (!linkedIdentity.accessTokenEncrypted) {
|
||||
const refreshed = await refreshManagedCredential(options, storedIdentity);
|
||||
const linkedIdentity = refreshed.identity;
|
||||
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -559,10 +494,20 @@ export async function startOAuthFlow(
|
|||
return { statusCode: 404, body: { error: "unknown_app", detail: "No app is registered under that slug." } };
|
||||
}
|
||||
|
||||
if (!supportsProvider(profile, provider)) {
|
||||
if (!admitsProviderForMode(profile, provider, input.mode)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { error: "provider_not_supported_for_app", provider, appSlug: profile.slug },
|
||||
body: { error: "provider_not_supported_for_app", provider, mode: input.mode, appSlug: profile.slug },
|
||||
};
|
||||
}
|
||||
|
||||
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: "creator_connection_required",
|
||||
detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -714,7 +659,6 @@ export async function refreshAppSession(
|
|||
appSlug,
|
||||
accountId: refreshClaim.account_id,
|
||||
entitlementPolicies,
|
||||
now: nowIso,
|
||||
});
|
||||
const issueInput: IssueAccessClaimInput = {
|
||||
appSlug,
|
||||
|
|
@ -1074,6 +1018,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
const runtime = oauthRuntimes[request.params.provider];
|
||||
|
||||
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({
|
||||
config,
|
||||
code: request.query.code,
|
||||
|
|
@ -1159,6 +1108,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
accountId: account.id,
|
||||
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
|
||||
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
|
||||
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
|
||||
};
|
||||
if (account.displayName !== undefined) {
|
||||
issueInput.displayName = account.displayName;
|
||||
|
|
@ -1170,6 +1120,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
store,
|
||||
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({
|
||||
accountId: account.id,
|
||||
|
|
@ -1209,7 +1167,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
entitlements: entitlementEvaluation,
|
||||
returnTo: statePayload.return_to,
|
||||
connection: statePayload.connection,
|
||||
...issued,
|
||||
...delivered,
|
||||
};
|
||||
if (handoff.kind === "backend_callback") {
|
||||
await maybeDeliverBackendHandoff(handoff, {
|
||||
|
|
@ -1225,7 +1183,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
connection: statePayload.connection,
|
||||
account: accountSummary,
|
||||
entitlements: entitlementEvaluation,
|
||||
...issued,
|
||||
...delivered,
|
||||
});
|
||||
|
||||
await store.createAuditEvent({
|
||||
|
|
@ -1620,21 +1578,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
}
|
||||
|
||||
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, "patreon");
|
||||
if (!storedLinkedIdentity?.accessTokenEncrypted) {
|
||||
if (!storedLinkedIdentity) {
|
||||
reply.code(404);
|
||||
return { error: "patreon_identity_not_linked" };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
|
||||
config,
|
||||
store,
|
||||
tokenCustody,
|
||||
oauthRuntimes,
|
||||
linkedIdentity: storedLinkedIdentity,
|
||||
now,
|
||||
});
|
||||
if (!linkedIdentity.accessTokenEncrypted) {
|
||||
const refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
|
||||
const linkedIdentity = refreshed.identity;
|
||||
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
|
||||
reply.code(404);
|
||||
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);
|
||||
if (!storedLinkedIdentity?.accessTokenEncrypted) {
|
||||
if (!storedLinkedIdentity) {
|
||||
reply.code(404);
|
||||
return { error: "managed_credential_not_found" };
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
|
||||
config,
|
||||
store,
|
||||
tokenCustody,
|
||||
oauthRuntimes,
|
||||
linkedIdentity: storedLinkedIdentity,
|
||||
now,
|
||||
});
|
||||
if (!linkedIdentity.accessTokenEncrypted) {
|
||||
reply.code(404);
|
||||
return { error: "managed_credential_not_found" };
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedCredentialRefreshFailed) {
|
||||
reply.code(502);
|
||||
return { error: error.code, provider: error.provider };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const linkedIdentity = refreshed.identity;
|
||||
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
|
||||
reply.code(410);
|
||||
return { error: "managed_credential_revoked", provider: linkedIdentity.provider };
|
||||
}
|
||||
|
||||
const response: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@ interface BackendHandoffBase {
|
|||
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";
|
||||
mode: OAuthMode;
|
||||
account: AccountSummary;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ export interface IssueAccessClaimInput {
|
|||
displayName?: string;
|
||||
linkedIdentities: LinkedIdentityInput[];
|
||||
facts: string[];
|
||||
/**
|
||||
* The claim names the account and its identities and carries no facts and
|
||||
* no capabilities (a managed-credential connect for a provider that is not
|
||||
* an identity provider). The facts are not even `identity.authenticated`.
|
||||
*/
|
||||
withoutCapabilities?: boolean;
|
||||
sessionId?: string;
|
||||
accessRevision?: number;
|
||||
ttlSeconds?: number;
|
||||
|
|
@ -80,11 +86,15 @@ export async function issueAccessClaim(options: {
|
|||
// would name an app no verifier can resolve a profile for.
|
||||
throw new Error(`Cannot issue an access claim for unknown app '${options.input.appSlug}'.`);
|
||||
}
|
||||
const facts = normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts);
|
||||
const factSet = new Set(facts);
|
||||
const withoutCapabilities = options.input.withoutCapabilities === true;
|
||||
const facts = withoutCapabilities
|
||||
? []
|
||||
: normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts);
|
||||
// Every shared capability needs a non-empty anyOf (capability-rules), so an
|
||||
// empty fact set yields no capabilities: facts are the one owner of that.
|
||||
const sharedCapabilities = evaluateSharedCapabilities(profile, {
|
||||
accountId: options.input.accountId,
|
||||
facts: factSet,
|
||||
facts: new Set(facts),
|
||||
identities: options.input.linkedIdentities,
|
||||
});
|
||||
|
||||
|
|
@ -176,6 +186,8 @@ export async function issueAccessClaim(options: {
|
|||
kid: options.keys.kid,
|
||||
},
|
||||
sharedCapabilities,
|
||||
hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
|
||||
hybridCapabilities: withoutCapabilities
|
||||
? []
|
||||
: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
181
src/managed-credential.ts
Normal file
181
src/managed-credential.ts
Normal 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 };
|
||||
}
|
||||
717
src/oauth.ts
717
src/oauth.ts
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "./contracts.js";
|
||||
import { type HeimdallConfig } from "./config.js";
|
||||
import { entitlementFacts } from "./facts.js";
|
||||
import { providerCatalog } from "./providers.js";
|
||||
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
|
||||
|
||||
export interface OAuthTokenSet {
|
||||
|
|
@ -40,6 +41,9 @@ export interface OAuthCallbackContext {
|
|||
entitlementPolicy: OAuthEntitlementPolicy | null;
|
||||
}
|
||||
|
||||
/** A token set whose access-token expiry is known: the provider's own, or the descriptor's default. */
|
||||
export type ExpiringTokenSet = OAuthTokenSet & { expiresAt: string };
|
||||
|
||||
export interface OAuthProviderRuntime {
|
||||
exchangeAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
|
|
@ -49,7 +53,8 @@ export interface OAuthProviderRuntime {
|
|||
refreshAccessToken?(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
}): Promise<OAuthTokenSet>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ExpiringTokenSet>;
|
||||
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
|
||||
evaluateEntitlements(options: {
|
||||
config: HeimdallConfig;
|
||||
|
|
@ -59,6 +64,25 @@ export interface OAuthProviderRuntime {
|
|||
}): 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
|
||||
* not JSON. It carries the status and a code, never the body or the parser's
|
||||
|
|
@ -68,13 +92,19 @@ export interface OAuthProviderRuntime {
|
|||
*/
|
||||
export class ProviderResponseError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: "provider_status" | "provider_body_not_json";
|
||||
readonly code: "provider_status" | "provider_body_not_json" | "provider_body_not_token_set";
|
||||
/**
|
||||
* The RFC 6749 section 5.2 error code of a refusal, when the provider sent
|
||||
* one from that closed set. The only value taken from a provider's body.
|
||||
*/
|
||||
readonly oauthError: OAuthErrorCode | undefined;
|
||||
|
||||
constructor(status: number, code: ProviderResponseError["code"], failure: string) {
|
||||
constructor(status: number, code: ProviderResponseError["code"], failure: string, oauthError?: OAuthErrorCode) {
|
||||
super(`${failure} (status ${status}, ${code}).`);
|
||||
this.name = "ProviderResponseError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.oauthError = oauthError;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +112,14 @@ export class ProviderResponseError extends Error {
|
|||
async function readProviderJson<T>(response: Response, failure: string): Promise<T> {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new ProviderResponseError(response.status, "provider_status", failure);
|
||||
// RFC 6749 section 5.2 sends a grant verdict as 400 only. The same JSON
|
||||
// behind a 401, a 5xx or a gateway page is not one.
|
||||
throw new ProviderResponseError(
|
||||
response.status,
|
||||
"provider_status",
|
||||
failure,
|
||||
response.status === 400 ? readOAuthErrorCode(text) : undefined
|
||||
);
|
||||
}
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
|
|
@ -106,512 +143,133 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
|
|||
return scope?.split(/\s+/).filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
async function exchangeDiscordAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.discord;
|
||||
export type TokenGrant =
|
||||
| { grant_type: "authorization_code"; code: string; redirect_uri: string }
|
||||
| { grant_type: "refresh_token"; refresh_token: string };
|
||||
|
||||
/**
|
||||
* The one token request: endpoint, client authentication and rotation all come
|
||||
* from the provider's descriptor. `basic` sends the client as an Authorization
|
||||
* header and nothing in the body; `post` sends it in the body. A refresh token
|
||||
* is returned only when the provider sent one; keeping or discarding the old
|
||||
* one is the caller's rule (ProviderDescriptor.rotatesRefreshTokens).
|
||||
*/
|
||||
export async function requestProviderTokens(
|
||||
provider: Provider,
|
||||
config: HeimdallConfig,
|
||||
grant: TokenGrant,
|
||||
signal?: AbortSignal
|
||||
): Promise<ExpiringTokenSet> {
|
||||
const descriptor = providerCatalog[provider];
|
||||
const providerConfig = config.providers[provider];
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Discord OAuth is not fully configured.");
|
||||
throw new Error(`${descriptor.displayName} OAuth is not fully configured.`);
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
const body = new URLSearchParams(grant);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64")}`;
|
||||
} else {
|
||||
body.set("client_id", providerConfig.clientId);
|
||||
body.set("client_secret", providerConfig.clientSecret);
|
||||
}
|
||||
|
||||
const init: RequestInit = { method: "POST", headers, body };
|
||||
if (signal) {
|
||||
init.signal = signal;
|
||||
}
|
||||
const label = grant.grant_type === "refresh_token" ? "refresh" : "exchange";
|
||||
const failure = `${descriptor.displayName} token ${label} failed`;
|
||||
const response = await fetch(descriptor.tokenEndpoint, init);
|
||||
const answer = await readProviderJson<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) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
/** The longest access token lifetime taken at its word: a year. Anything above is not a lifetime. */
|
||||
const MAX_USABLE_EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
/**
|
||||
* A positive finite number of seconds, or a string that is a positive integer,
|
||||
* no longer than a year. A larger value would overflow the Date the expiry is
|
||||
* stored as, so it is unusable and the descriptor's default lifetime applies.
|
||||
*/
|
||||
function usableExpiresIn(value: unknown): number | undefined {
|
||||
const seconds = typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : value;
|
||||
return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 && seconds <= MAX_USABLE_EXPIRES_IN_SECONDS
|
||||
? seconds
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] {
|
||||
return async (options) => {
|
||||
const tokenSet = await requestProviderTokens(provider, options.config, {
|
||||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
redirect_uri: options.redirectUri,
|
||||
});
|
||||
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://discord.com/api/v10/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
},
|
||||
"Discord token exchange failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
// 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;
|
||||
}
|
||||
|
||||
async function refreshDiscordAccessToken(options: {
|
||||
config: HeimdallConfig;
|
||||
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<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://discord.com/api/v10/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: options.refreshToken,
|
||||
}),
|
||||
},
|
||||
"Discord token refresh failed"
|
||||
);
|
||||
|
||||
const tokenSet: OAuthTokenSet = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type,
|
||||
scope: normalizeScopes(tokenResponse.scope),
|
||||
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
|
||||
raw: tokenResponse as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (tokenResponse.refresh_token) {
|
||||
tokenSet.refreshToken = tokenResponse.refresh_token;
|
||||
}
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function exchangePatreonAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
const providerConfig = options.config.providers.patreon;
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
throw new Error("Patreon OAuth is not fully configured.");
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
client_id: providerConfig.clientId,
|
||||
client_secret: providerConfig.clientSecret,
|
||||
redirect_uri: options.redirectUri,
|
||||
});
|
||||
const tokenResponse = await fetchJson<{
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string | string[];
|
||||
}>(
|
||||
"https://www.patreon.com/api/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
},
|
||||
"Patreon token exchange failed"
|
||||
function sharedRuntimeRefresh(provider: Provider): NonNullable<OAuthProviderRuntime["refreshAccessToken"]> {
|
||||
return (options) =>
|
||||
requestProviderTokens(
|
||||
provider,
|
||||
options.config,
|
||||
{ grant_type: "refresh_token", refresh_token: options.refreshToken },
|
||||
options.signal
|
||||
);
|
||||
|
||||
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> {
|
||||
const user = await fetchJson<{
|
||||
|
|
@ -910,6 +568,45 @@ async function resolveSpotifyIdentity(options: { accessToken: string }): Promise
|
|||
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: {
|
||||
config: HeimdallConfig;
|
||||
callback: OAuthCallbackContext;
|
||||
|
|
@ -1217,22 +914,22 @@ async function evaluatePatreonEntitlements(options: {
|
|||
}
|
||||
|
||||
const discordRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeDiscordAuthorizationCode,
|
||||
refreshAccessToken: refreshDiscordAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("discord"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("discord"),
|
||||
resolveIdentity: resolveDiscordIdentity,
|
||||
evaluateEntitlements: evaluateDiscordEntitlements,
|
||||
};
|
||||
|
||||
const patreonRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangePatreonAuthorizationCode,
|
||||
refreshAccessToken: refreshPatreonAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("patreon"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("patreon"),
|
||||
resolveIdentity: resolvePatreonIdentity,
|
||||
evaluateEntitlements: evaluatePatreonEntitlements,
|
||||
};
|
||||
|
||||
const twitchRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeTwitchAuthorizationCode,
|
||||
refreshAccessToken: refreshTwitchAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("twitch"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("twitch"),
|
||||
resolveIdentity: resolveTwitchIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
|
|
@ -1240,8 +937,8 @@ const twitchRuntime: OAuthProviderRuntime = {
|
|||
};
|
||||
|
||||
const youtubeRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeYouTubeAuthorizationCode,
|
||||
refreshAccessToken: refreshYouTubeAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("youtube"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("youtube"),
|
||||
resolveIdentity: resolveYouTubeIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
|
|
@ -1249,14 +946,23 @@ const youtubeRuntime: OAuthProviderRuntime = {
|
|||
};
|
||||
|
||||
const spotifyRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeSpotifyAuthorizationCode,
|
||||
refreshAccessToken: refreshSpotifyAccessToken,
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("spotify"),
|
||||
resolveIdentity: resolveSpotifyIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const joystickRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("joystick"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("joystick"),
|
||||
resolveIdentity: resolveJoystickIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const notImplementedRuntime: OAuthProviderRuntime = {
|
||||
async exchangeAuthorizationCode() {
|
||||
throw new Error("Provider callback exchange is not implemented yet.");
|
||||
|
|
@ -1279,5 +985,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
|
|||
twitch: twitchRuntime,
|
||||
youtube: youtubeRuntime,
|
||||
spotify: spotifyRuntime,
|
||||
joystick: joystickRuntime,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@ export interface ProviderDescriptor {
|
|||
key: Provider;
|
||||
displayName: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
/** RFC 7591 names: how the client authenticates at the token endpoint. */
|
||||
tokenEndpointAuthMethod: "client_secret_basic" | "client_secret_post";
|
||||
/** True when a refresh response replaces the refresh token and the old one dies. */
|
||||
rotatesRefreshTokens: boolean;
|
||||
/**
|
||||
* Seconds an access token lives when a token answer carries no usable
|
||||
* `expires_in` (RFC 6749 5.1 makes it RECOMMENDED, not required). An unknown
|
||||
* expiry is never stored: the credential would never come due for refresh.
|
||||
*/
|
||||
defaultAccessTokenLifetimeSeconds: number;
|
||||
defaultScopes: string[];
|
||||
roles: ProviderRole[];
|
||||
clientIdEnv: string;
|
||||
|
|
@ -18,6 +29,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
key: "discord",
|
||||
displayName: "Discord",
|
||||
authorizationEndpoint: "https://discord.com/oauth2/authorize",
|
||||
tokenEndpoint: "https://discord.com/api/v10/oauth2/token",
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identify", "guilds.members.read"],
|
||||
roles: ["identity", "entitlement"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
|
|
@ -27,6 +42,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
key: "patreon",
|
||||
displayName: "Patreon",
|
||||
authorizationEndpoint: "https://www.patreon.com/oauth2/authorize",
|
||||
tokenEndpoint: "https://www.patreon.com/api/oauth2/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identity", "identity[email]"],
|
||||
roles: ["identity", "entitlement"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
|
|
@ -36,6 +55,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
key: "github",
|
||||
displayName: "GitHub",
|
||||
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
||||
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["read:user", "user:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
|
||||
|
|
@ -45,6 +68,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
key: "twitch",
|
||||
displayName: "Twitch",
|
||||
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
|
||||
tokenEndpoint: "https://id.twitch.tv/oauth2/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["user:read:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
|
||||
|
|
@ -54,6 +81,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
key: "youtube",
|
||||
displayName: "YouTube",
|
||||
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: [
|
||||
"openid",
|
||||
"email",
|
||||
|
|
@ -73,6 +104,10 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
key: "spotify",
|
||||
displayName: "Spotify",
|
||||
authorizationEndpoint: "https://accounts.spotify.com/authorize",
|
||||
tokenEndpoint: "https://accounts.spotify.com/api/token",
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: [
|
||||
"user-read-currently-playing",
|
||||
"user-read-playback-state",
|
||||
|
|
@ -82,6 +117,19 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
|
||||
},
|
||||
joystick: {
|
||||
key: "joystick",
|
||||
displayName: "Joystick",
|
||||
authorizationEndpoint: "https://joystick.tv/api/oauth/authorize",
|
||||
tokenEndpoint: "https://api.joystick.tv/api/oauth/token",
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
rotatesRefreshTokens: true,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
},
|
||||
};
|
||||
|
||||
export function buildAuthorizationUrl(options: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type CreateAuthCompletionInput,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
type CreateEntitlementSnapshotInput,
|
||||
type CreateSessionInput,
|
||||
type CreatePrivateCommandReceiptInput,
|
||||
|
|
@ -37,6 +38,8 @@ function isGrantActive(grant: StoredCapabilityGrant, appSlug: AppSlug, at: strin
|
|||
}
|
||||
|
||||
export class InMemoryStore implements HeimdallStore {
|
||||
/** Tail of each linked identity's credential-decision chain; an entry is removed when it drains. */
|
||||
private readonly credentialChains = new Map<string, Promise<unknown>>();
|
||||
private readonly accounts = new Map<string, StoredAccount>();
|
||||
private readonly linkedIdentities = new Map<string, StoredLinkedIdentity>();
|
||||
private readonly grants = new Map<string, StoredCapabilityGrant>();
|
||||
|
|
@ -174,6 +177,43 @@ export class InMemoryStore implements HeimdallStore {
|
|||
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[]> {
|
||||
return [...this.linkedIdentities.values()]
|
||||
.filter((identity) => identity.accountId === accountId)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type CreateAuthCompletionInput,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
type CreateEntitlementSnapshotInput,
|
||||
type CreateSessionInput,
|
||||
type CreatePrivateCommandReceiptInput,
|
||||
|
|
@ -158,8 +159,8 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
|
|||
providerUserId: row.provider_user_id,
|
||||
scopes: parseScopes(row.scopes),
|
||||
profileJson: row.profile_json,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
updatedAt: new Date(row.updated_at).toISOString(),
|
||||
};
|
||||
|
||||
if (row.username) {
|
||||
|
|
@ -183,7 +184,8 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
|
|||
}
|
||||
|
||||
if (row.token_expires_at) {
|
||||
identity.tokenExpiresAt = row.token_expires_at;
|
||||
// pg hands back a Date for TIMESTAMPTZ whatever the row type says; the port promises ISO strings.
|
||||
identity.tokenExpiresAt = new Date(row.token_expires_at).toISOString();
|
||||
}
|
||||
|
||||
return identity;
|
||||
|
|
@ -546,7 +548,7 @@ export async function checkSchemaRequirements(pool: Pick<Pool, "query">, require
|
|||
}
|
||||
|
||||
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> {
|
||||
await this.pool.query(CREATE_SCHEMA_SQL);
|
||||
|
|
@ -722,6 +724,60 @@ export class PostgresStore implements HeimdallStore {
|
|||
return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity"));
|
||||
}
|
||||
|
||||
async updateLinkedIdentityCredential(
|
||||
id: string,
|
||||
decide: (locked: StoredLinkedIdentity) => Promise<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[]> {
|
||||
const result = await this.pool.query<LinkedIdentityRow>(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -56,6 +56,23 @@ export interface UpsertLinkedIdentityInput {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the owner of a refresh decides after reading the row under its lock.
|
||||
* `rotate` and `revoke` write token columns, scopes and updated_at only; they
|
||||
* never touch account, profile or identity fields.
|
||||
*/
|
||||
export type CredentialDecision =
|
||||
| { kind: "keep" }
|
||||
| {
|
||||
kind: "rotate";
|
||||
accessTokenEncrypted: string;
|
||||
refreshTokenEncrypted: string;
|
||||
tokenExpiresAt: string;
|
||||
scopes: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
| { kind: "revoke"; updatedAt: string };
|
||||
|
||||
export interface StoredSession {
|
||||
id: string;
|
||||
accountId: string;
|
||||
|
|
@ -213,6 +230,17 @@ export interface HeimdallStore {
|
|||
findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null>;
|
||||
listStoredLinkedIdentitiesForAccount(accountId: string): 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[]>;
|
||||
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
|
||||
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig {
|
|||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {},
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { inspect } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { buildApp, errorIdentity } from "../src/app.js";
|
||||
import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js";
|
||||
import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
|
||||
import { type HeimdallConfig, loadConfig } from "../src/config.js";
|
||||
import { entitlementFacts, identityFacts } from "../src/facts.js";
|
||||
import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { verifyJwt } from "../src/signing.js";
|
||||
import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
|
||||
function createTestConfig(): HeimdallConfig {
|
||||
|
|
@ -49,6 +53,7 @@ function createTestConfig(): HeimdallConfig {
|
|||
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
||||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -974,7 +979,8 @@ describe("Heimdall service", () => {
|
|||
expect(profileResponse.json()).toEqual(
|
||||
expect.objectContaining({
|
||||
slug: "streampixels",
|
||||
managedConnectionProviders: ["twitch", "youtube"],
|
||||
identityProviders: ["twitch", "youtube"],
|
||||
managedConnectionProviders: ["twitch", "youtube", "joystick"],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -1238,6 +1244,361 @@ describe("Heimdall service", () => {
|
|||
expect(credentialResponse.body).not.toContain("rotated-twitch-refresh-token");
|
||||
});
|
||||
|
||||
it("revocation-is-typed: a revoked identity contributes no entitlement at session refresh", async () => {
|
||||
const store = new InMemoryStore();
|
||||
let evaluations = 0;
|
||||
const base = createMockDiscordRuntime();
|
||||
const app = await buildApp({
|
||||
config: createTestConfig(),
|
||||
store,
|
||||
oauthRuntimes: {
|
||||
discord: {
|
||||
...base,
|
||||
async exchangeAuthorizationCode() {
|
||||
return {
|
||||
accessToken: "expired-discord-access-token",
|
||||
refreshToken: "discord-refresh-token",
|
||||
tokenType: "Bearer",
|
||||
scope: ["identify"],
|
||||
expiresAt: "2026-04-26T13:00:00.000Z",
|
||||
raw: {},
|
||||
};
|
||||
},
|
||||
async refreshAccessToken() {
|
||||
throw new ProviderResponseError(400, "provider_status", "Discord token refresh failed", "invalid_grant");
|
||||
},
|
||||
async evaluateEntitlements(options) {
|
||||
evaluations += 1;
|
||||
return base.evaluateEntitlements(options);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(201);
|
||||
const evaluationsAtSignIn = evaluations;
|
||||
|
||||
const refreshResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/repixelizer/sessions/refresh",
|
||||
headers: { "x-heimdall-app-secret": "repixelizer-secret" },
|
||||
payload: {
|
||||
refreshToken: callbackResponse.json().refreshToken as string,
|
||||
entitlementPolicies: [{ kind: "discord_role_access", guildId: "gamecult-guild", allowedRoleIds: ["role-repixelizer"] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshResponse.statusCode).toBe(201);
|
||||
expect(evaluations).toBe(evaluationsAtSignIn);
|
||||
const accountId = callbackResponse.json().account.id as string;
|
||||
const row = await store.findStoredLinkedIdentityForAccount(accountId, "discord");
|
||||
expect(row?.accessTokenEncrypted).toBeUndefined();
|
||||
expect(row?.refreshTokenEncrypted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revocation-is-typed: a revoked Patreon identity is not linked for Bifrost sync and Bifrost is not called", async () => {
|
||||
const baseConfig = createTestConfig();
|
||||
const store = new InMemoryStore();
|
||||
const app = await buildApp({
|
||||
config: {
|
||||
...baseConfig,
|
||||
bifrostPatronSupportEndpoint: "https://bifrost.gamecult.org/heimdall/patron-support/events",
|
||||
bifrostPatronSupportSecret: "bifrost-support-secret",
|
||||
},
|
||||
store,
|
||||
oauthRuntimes: {
|
||||
patreon: {
|
||||
...createOAuthRuntimeRegistry().patreon,
|
||||
async refreshAccessToken() {
|
||||
throw new ProviderResponseError(400, "provider_status", "Patreon token refresh failed", "invalid_grant");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
apps.push(app);
|
||||
const context = getHeimdallContext(app);
|
||||
const now = "2026-06-09T17:00:00.000Z";
|
||||
await store.createAccount({ id: "acct-p", createdAt: now, lastSeenAt: now });
|
||||
await store.upsertLinkedIdentity({
|
||||
accountId: "acct-p",
|
||||
provider: "patreon",
|
||||
providerUserId: "patreon-user-456",
|
||||
accessTokenEncrypted: context.tokenCustody.encrypt("patreon-access-token"),
|
||||
refreshTokenEncrypted: context.tokenCustody.encrypt("patreon-refresh-token"),
|
||||
tokenExpiresAt: "2026-06-09T17:01:00.000Z",
|
||||
scopes: [],
|
||||
profileJson: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = async (input) => {
|
||||
urls.push(String(input));
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/bifrost/patron-support/sync",
|
||||
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
||||
payload: { accountId: "acct-p", requiredTierTitle: "Inner Sanctum", supportedAtUtc: now },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({ error: "patreon_identity_not_linked" });
|
||||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
describe("joystick connect", () => {
|
||||
const creator = { kind: "creator", targetId: "creator-1" };
|
||||
async function connect(app: FastifyInstance, extra: Record<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 () => {
|
||||
// A per-app identity model with a global fallback is a two-app shared
|
||||
// secret in practice (R6, fork 2). GC_ACCESS_APP_SHARED_SECRET alone must
|
||||
|
|
@ -1774,3 +2135,63 @@ describe("what may be recorded about a caught error", () => {
|
|||
expect(errorIdentity(error)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth start admits a provider by the profile's role for the mode", () => {
|
||||
const allProviders = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
|
||||
const modes = ["sign_in", "link", "connect"] as const;
|
||||
|
||||
it("sign_in and link take identityProviders only; connect takes managedConnectionProviders or identityProviders, for every profile", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
try {
|
||||
for (const profile of Object.values(builtInAppProfiles)) {
|
||||
for (const provider of allProviders) {
|
||||
for (const mode of modes) {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/oauth/${provider}/start`,
|
||||
payload: { appSlug: profile.slug, mode, returnTo: `${profile.allowedReturnOrigins[0]}/x` },
|
||||
});
|
||||
const roleList =
|
||||
mode === "connect"
|
||||
? [...profile.managedConnectionProviders, ...profile.identityProviders]
|
||||
: profile.identityProviders;
|
||||
const refused = start.json().error === "provider_not_supported_for_app";
|
||||
expect([profile.slug, provider, mode, refused]).toEqual([profile.slug, provider, mode, !roleList.includes(provider)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("callbackMint", () => {
|
||||
const streampixels = builtInAppProfiles.streampixels!;
|
||||
it.each([
|
||||
["twitch", "sign_in", "viewer_session"],
|
||||
["twitch", "link", "viewer_session"],
|
||||
["youtube", "sign_in", "viewer_session"],
|
||||
["twitch", "connect", "viewer_session"],
|
||||
["joystick", "connect", "custody_only"],
|
||||
["joystick", "sign_in", "refused"],
|
||||
["joystick", "link", "refused"],
|
||||
["discord", "connect", "refused"],
|
||||
["discord", "sign_in", "refused"],
|
||||
] as const)("%s %s on streampixels is %s", (provider, mode, expected) => {
|
||||
expect(callbackMint(streampixels, provider, mode, { kind: "creator", targetId: "creator-1" })).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
["an account binding", { kind: "account", targetId: "a-1" }],
|
||||
["an empty creator targetId", { kind: "creator", targetId: "" }],
|
||||
] as const)("a joystick connect with %s is refused", (_name, connection) => {
|
||||
expect(callbackMint(streampixels, "joystick", "connect", connection)).toBe("refused");
|
||||
});
|
||||
|
||||
it("keeps Bifrost's discord connect a viewer session", () => {
|
||||
expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect", null)).toBe("viewer_session");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
1117
tests/managed-credential.test.ts
Normal file
1117
tests/managed-credential.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,7 @@ function config(): HeimdallConfig {
|
|||
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
|
||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {} },
|
||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} },
|
||||
} as HeimdallConfig;
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ it("keeps a provider's malformed 2xx refresh body out of every plaintext part of
|
|||
);
|
||||
|
||||
expect(reply.status).toBe("denied");
|
||||
expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_body_not_json)."]);
|
||||
expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ManagedCredentialRefreshFailed, managed_credential_refresh_failed)."]);
|
||||
expect(plaintext(reply)).not.toContain("CANARY");
|
||||
});
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ it("keeps a provider's refusal body out of every plaintext part of the reply", a
|
|||
const reply = await refreshAgainst(() => new Response('CANARYrefused {"error":"invalid_grant"}', { status: 400 }));
|
||||
|
||||
expect(reply.status).toBe("denied");
|
||||
expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_status)."]);
|
||||
expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ManagedCredentialRefreshFailed, managed_credential_refresh_failed)."]);
|
||||
expect(plaintext(reply)).not.toContain("CANARY");
|
||||
});
|
||||
|
||||
|
|
|
|||
307
tests/provider-token-request.test.ts
Normal file
307
tests/provider-token-request.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -326,7 +326,8 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await s.upsertLinkedIdentity(identity);
|
||||
const linked = await s.upsertLinkedIdentity(identity);
|
||||
await s.updateLinkedIdentityCredential(linked.id, async () => ({ kind: "keep" }));
|
||||
await s.upsertLinkedIdentity({ ...identity, username: "again" });
|
||||
await s.findAccountByLinkedIdentity("discord", "u1");
|
||||
await s.findStoredLinkedIdentity("discord", "u1");
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => {
|
|||
expect(bySchema.get("idunn.daemon_health")).toEqual([
|
||||
"yggdrasil-heimdall",
|
||||
"active",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/6; apps=4; healthTransport=CultNet/RUDP",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP",
|
||||
"2026-06-16T17:10:00.000Z",
|
||||
"heimdall.cultnet-rudp-provider-health",
|
||||
"daemon-published",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => {
|
|||
"twitch",
|
||||
"youtube",
|
||||
"spotify",
|
||||
"joystick",
|
||||
]);
|
||||
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
||||
"repixelizer",
|
||||
|
|
|
|||
Loading…
Reference in a new issue