From 0dbcd98019eba61b320938d06bb90e6b410cccf0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:06:32 +0200 Subject: [PATCH 01/27] Collapse ten per-provider token functions into one descriptor-driven request Endpoint, client-auth method (RFC 7591 names) and rotation now live on ProviderDescriptor; requestProviderTokens is the only code that talks to a token endpoint. Failure labels are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/oauth.ts | 550 ++++----------------------- src/providers.ts | 23 ++ tests/provider-token-request.test.ts | 116 ++++++ 3 files changed, 203 insertions(+), 486 deletions(-) create mode 100644 tests/provider-token-request.test.ts diff --git a/src/oauth.ts b/src/oauth.ts index 33ae495..2d0020c 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -7,6 +7,7 @@ import { } from "./contracts.js"; import { type HeimdallConfig } from "./config.js"; import { entitlementFacts } from "./facts.js"; +import { providerCatalog } from "./providers.js"; import { type CreateEntitlementSnapshotInput } from "./store/types.js"; export interface OAuthTokenSet { @@ -49,6 +50,7 @@ export interface OAuthProviderRuntime { refreshAccessToken?(options: { config: HeimdallConfig; refreshToken: string; + signal?: AbortSignal; }): Promise; resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; evaluateEntitlements(options: { @@ -106,512 +108,88 @@ function normalizeScopes(scope: string | string[] | undefined): string[] { return scope?.split(/\s+/).filter(Boolean) ?? []; } -async function exchangeDiscordAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.discord; +export type TokenGrant = + | { grant_type: "authorization_code"; code: string; redirect_uri: string } + | { grant_type: "refresh_token"; refresh_token: string }; + +/** + * The one token request: endpoint, client authentication and rotation all come + * from the provider's descriptor. `basic` sends the client as an Authorization + * header and nothing in the body; `post` sends it in the body. A refresh token + * is returned only when the provider sent one; keeping or discarding the old + * one is the caller's rule (ProviderDescriptor.rotatesRefreshTokens). + */ +export async function requestProviderTokens( + provider: Provider, + config: HeimdallConfig, + grant: TokenGrant, + signal?: AbortSignal +): Promise { + const descriptor = providerCatalog[provider]; + const providerConfig = config.providers[provider]; if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Discord OAuth is not fully configured."); + throw new Error(`${descriptor.displayName} OAuth is not fully configured.`); } - const body = new URLSearchParams({ - grant_type: "authorization_code", - code: options.code, - redirect_uri: options.redirectUri, - }); - const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - scope?: string | string[]; - }>( - "https://discord.com/api/v10/oauth2/token", - { - method: "POST", - headers: { - Authorization: `Basic ${basicAuth}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }, - "Discord token exchange failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), - raw: tokenResponse as unknown as Record, + const body = new URLSearchParams(grant); + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; + 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); } - return tokenSet; -} - -async function refreshDiscordAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.discord; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Discord OAuth is not fully configured."); + const init: RequestInit = { method: "POST", headers, body }; + if (signal) { + init.signal = signal; } - - const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); + const label = grant.grant_type === "refresh_token" ? "refresh" : "exchange"; 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, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - return tokenSet; -} - -async function exchangePatreonAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.patreon; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Patreon OAuth is not fully configured."); - } - - const body = new URLSearchParams({ - grant_type: "authorization_code", - code: options.code, - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - redirect_uri: options.redirectUri, - }); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; + 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" - ); + }>(descriptor.tokenEndpoint, init, `${descriptor.displayName} token ${label} failed`); const tokenSet: OAuthTokenSet = { accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, + tokenType: tokenResponse.token_type ?? "Bearer", scope: normalizeScopes(tokenResponse.scope), raw: tokenResponse as unknown as Record, }; - if (tokenResponse.refresh_token) { tokenSet.refreshToken = tokenResponse.refresh_token; } - - if (tokenResponse.expires_in) { + if (typeof tokenResponse.expires_in === "number" && tokenResponse.expires_in > 0) { tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); } - return tokenSet; } -async function refreshPatreonAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.patreon; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Patreon OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://www.patreon.com/api/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: options.refreshToken, - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - }), - }, - "Patreon token refresh failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; +function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] { + return (options) => + requestProviderTokens(provider, options.config, { + grant_type: "authorization_code", + code: options.code, + redirect_uri: options.redirectUri, + }); } -async function exchangeTwitchAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.twitch; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Twitch OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://id.twitch.tv/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - code: options.code, - grant_type: "authorization_code", - redirect_uri: options.redirectUri, - }), - }, - "Twitch token exchange failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; +function sharedRuntimeRefresh(provider: Provider): NonNullable { + return (options) => + requestProviderTokens( + provider, + options.config, + { grant_type: "refresh_token", refresh_token: options.refreshToken }, + options.signal + ); } -async function refreshTwitchAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.twitch; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Twitch OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://id.twitch.tv/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - grant_type: "refresh_token", - refresh_token: options.refreshToken, - }), - }, - "Twitch token refresh failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function exchangeYouTubeAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.youtube; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("YouTube OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - id_token?: string; - }>( - "https://oauth2.googleapis.com/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - code: options.code, - grant_type: "authorization_code", - redirect_uri: options.redirectUri, - }), - }, - "YouTube token exchange failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function refreshYouTubeAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.youtube; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("YouTube OAuth is not fully configured."); - } - - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://oauth2.googleapis.com/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: providerConfig.clientId, - client_secret: providerConfig.clientSecret, - grant_type: "refresh_token", - refresh_token: options.refreshToken, - }), - }, - "YouTube token refresh failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function exchangeSpotifyAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - const providerConfig = options.config.providers.spotify; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Spotify OAuth is not fully configured."); - } - - const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://accounts.spotify.com/api/token", - { - method: "POST", - headers: { - Authorization: `Basic ${basicAuth}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code: options.code, - redirect_uri: options.redirectUri, - }), - }, - "Spotify token exchange failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} - -async function refreshSpotifyAccessToken(options: { - config: HeimdallConfig; - refreshToken: string; -}): Promise { - const providerConfig = options.config.providers.spotify; - if (!providerConfig.clientId || !providerConfig.clientSecret) { - throw new Error("Spotify OAuth is not fully configured."); - } - - const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); - const tokenResponse = await fetchJson<{ - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - scope?: string | string[]; - }>( - "https://accounts.spotify.com/api/token", - { - method: "POST", - headers: { - Authorization: `Basic ${basicAuth}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: options.refreshToken, - }), - }, - "Spotify token refresh failed" - ); - - const tokenSet: OAuthTokenSet = { - accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, - scope: normalizeScopes(tokenResponse.scope), - raw: tokenResponse as unknown as Record, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - if (tokenResponse.expires_in) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } - - return tokenSet; -} async function resolveDiscordIdentity(options: { accessToken: string }): Promise { const user = await fetchJson<{ @@ -1217,22 +795,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 +818,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,8 +827,8 @@ const youtubeRuntime: OAuthProviderRuntime = { }; const spotifyRuntime: OAuthProviderRuntime = { - exchangeAuthorizationCode: exchangeSpotifyAuthorizationCode, - refreshAccessToken: refreshSpotifyAccessToken, + exchangeAuthorizationCode: sharedRuntimeExchange("spotify"), + refreshAccessToken: sharedRuntimeRefresh("spotify"), resolveIdentity: resolveSpotifyIdentity, async evaluateEntitlements() { return { facts: [], snapshots: [] }; diff --git a/src/providers.ts b/src/providers.ts index 506e084..2998e4d 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -6,6 +6,11 @@ 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; defaultScopes: string[]; roles: ProviderRole[]; clientIdEnv: string; @@ -18,6 +23,9 @@ export const providerCatalog: Record = { key: "discord", displayName: "Discord", authorizationEndpoint: "https://discord.com/oauth2/authorize", + tokenEndpoint: "https://discord.com/api/v10/oauth2/token", + tokenEndpointAuthMethod: "client_secret_basic", + rotatesRefreshTokens: false, defaultScopes: ["identify", "guilds.members.read"], roles: ["identity", "entitlement"], clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", @@ -27,6 +35,9 @@ export const providerCatalog: Record = { key: "patreon", displayName: "Patreon", authorizationEndpoint: "https://www.patreon.com/oauth2/authorize", + tokenEndpoint: "https://www.patreon.com/api/oauth2/token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, defaultScopes: ["identity", "identity[email]"], roles: ["identity", "entitlement"], clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", @@ -36,6 +47,9 @@ export const providerCatalog: Record = { key: "github", displayName: "GitHub", authorizationEndpoint: "https://github.com/login/oauth/authorize", + tokenEndpoint: "https://github.com/login/oauth/access_token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, defaultScopes: ["read:user", "user:email"], roles: ["identity", "managed_credential"], clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID", @@ -45,6 +59,9 @@ export const providerCatalog: Record = { key: "twitch", displayName: "Twitch", authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize", + tokenEndpoint: "https://id.twitch.tv/oauth2/token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, defaultScopes: ["user:read:email"], roles: ["identity", "managed_credential"], clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID", @@ -54,6 +71,9 @@ export const providerCatalog: Record = { key: "youtube", displayName: "YouTube", authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint: "https://oauth2.googleapis.com/token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, defaultScopes: [ "openid", "email", @@ -73,6 +93,9 @@ export const providerCatalog: Record = { key: "spotify", displayName: "Spotify", authorizationEndpoint: "https://accounts.spotify.com/authorize", + tokenEndpoint: "https://accounts.spotify.com/api/token", + tokenEndpointAuthMethod: "client_secret_basic", + rotatesRefreshTokens: false, defaultScopes: [ "user-read-currently-playing", "user-read-playback-state", diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts new file mode 100644 index 0000000..2d13a30 --- /dev/null +++ b/tests/provider-token-request.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { type HeimdallConfig } from "../src/config.js"; +import { providers, type Provider } from "../src/contracts.js"; +import { 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; + headers: Record; + body: URLSearchParams; +} + +function capture(answer: () => Response): { calls: Captured[] } { + const calls: Captured[] = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + calls.push({ + url: String(input), + headers: init?.headers as Record, + body: init?.body as URLSearchParams, + }); + return answer(); + }) as typeof fetch; + return { calls }; +} + +const ok = () => + new Response(JSON.stringify({ access_token: "a", refresh_token: "r", token_type: "Bearer", expires_in: 3600, scope: "x y" }), { + status: 200, + }); + +const requestable: Provider[] = [...providers]; + +describe("requestProviderTokens follows the provider descriptor", () => { + it.each(requestable.flatMap((p) => [[p, "authorization_code"] as const, [p, "refresh_token"] as const]))( + "%s %s hits its token endpoint with its client-auth method only", + async (provider, grantType) => { + const { calls } = capture(ok); + const descriptor = providerCatalog[provider]; + const grant = + grantType === "authorization_code" + ? ({ grant_type: "authorization_code", code: "the-code", redirect_uri: "https://h/cb" } as const) + : ({ grant_type: "refresh_token", refresh_token: "the-refresh" } as const); + + const tokens = await requestProviderTokens(provider, configFor(), grant); + + expect(calls).toHaveLength(1); + const call = calls[0]!; + expect(call.url).toBe(descriptor.tokenEndpoint); + expect(call.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); + }); + + it.each([ + ["no expires_in", { access_token: "a" }], + ["a zero expires_in", { access_token: "a", expires_in: 0 }], + ["a string expires_in", { access_token: "a", expires_in: "3600" }], + ])("sets no expiry and no refresh token for %s", async (_n, body) => { + capture(() => new Response(JSON.stringify(body), { status: 200 })); + const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" }); + expect(tokens.expiresAt).toBeUndefined(); + 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("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."); + }); +}); From c81f9238907fa34f7f53d83c93c9facdf89dd82f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:11:34 +0200 Subject: [PATCH 02/27] Refresh managed credentials under a row lock, with typed revocation refreshLinkedIdentityCredentialIfNeeded is deleted. refreshManagedCredential owns the policy; HeimdallStore.updateLinkedIdentityCredential owns the row (SELECT ... FOR UPDATE in Postgres, a per-id chain in memory), so a refresh token is presented once across concurrent requests and processes. invalid_grant clears the tokens (410 managed_credential_revoked); any other failure keeps them (502 managed_credential_refresh_failed). The "?? refreshToken" keep-old fallback is gone; keeping it is an explicit branch for non-rotating providers. Co-Authored-By: Claude Opus 5.5 --- src/app.ts | 118 ++---- src/managed-credential.ts | 140 ++++++++ src/oauth.ts | 29 +- src/store/in-memory.ts | 42 +++ src/store/postgres.ts | 57 ++- src/store/types.ts | 28 ++ tests/app.test.ts | 113 +++++- tests/managed-credential.test.ts | 517 +++++++++++++++++++++++++++ tests/provider-token-request.test.ts | 31 ++ tests/store.test.ts | 3 +- 10 files changed, 979 insertions(+), 99 deletions(-) create mode 100644 src/managed-credential.ts create mode 100644 tests/managed-credential.test.ts diff --git a/src/app.ts b/src/app.ts index 9621856..5345c40 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 { - const expiresAt = options.linkedIdentity.tokenExpiresAt ? Date.parse(options.linkedIdentity.tokenExpiresAt) : undefined; - if ( - !expiresAt || - expiresAt - Date.now() > MANAGED_CREDENTIAL_REFRESH_SKEW_MS || - !options.linkedIdentity.refreshTokenEncrypted - ) { - return options.linkedIdentity; - } - - const runtime = options.oauthRuntimes[options.linkedIdentity.provider]; - if (!runtime.refreshAccessToken) { - return options.linkedIdentity; - } - - const refreshToken = options.tokenCustody.decrypt(options.linkedIdentity.refreshTokenEncrypted); - const refreshed = await runtime.refreshAccessToken({ - config: options.config, - refreshToken, - }); - - const upsertInput: UpsertLinkedIdentityInput = { - id: options.linkedIdentity.id, - accountId: options.linkedIdentity.accountId, - provider: options.linkedIdentity.provider, - providerUserId: options.linkedIdentity.providerUserId, - accessTokenEncrypted: options.tokenCustody.encrypt(refreshed.accessToken), - refreshTokenEncrypted: options.tokenCustody.encrypt(refreshed.refreshToken ?? refreshToken), - scopes: refreshed.scope.length ? refreshed.scope : options.linkedIdentity.scopes, - profileJson: options.linkedIdentity.profileJson, - createdAt: options.linkedIdentity.createdAt, - updatedAt: options.now, - }; - const tokenExpiresAt = refreshed.expiresAt ?? options.linkedIdentity.tokenExpiresAt; - if (tokenExpiresAt !== undefined) { - upsertInput.tokenExpiresAt = tokenExpiresAt; - } - if (options.linkedIdentity.username !== undefined) { - upsertInput.username = options.linkedIdentity.username; - } - if (options.linkedIdentity.displayName !== undefined) { - upsertInput.displayName = options.linkedIdentity.displayName; - } - if (options.linkedIdentity.primaryEmail !== undefined) { - upsertInput.primaryEmail = options.linkedIdentity.primaryEmail; - } - - return options.store.upsertLinkedIdentity(upsertInput); -} - function storedIdentityToResolvedIdentity(identity: StoredLinkedIdentity) { const resolved = { provider: identity.provider, @@ -450,7 +392,6 @@ async function evaluateStoredEntitlements(options: { appSlug: AppSlug; accountId: string; entitlementPolicies: OAuthEntitlementPolicy[]; - now: string; }): Promise { const profile = getAppProfile(options.appSlug); if (!profile) { @@ -460,19 +401,13 @@ async function evaluateStoredEntitlements(options: { const facts: string[] = []; for (const storedIdentity of linkedIdentities) { - if (!profile.entitlementSources.includes(storedIdentity.provider) || !storedIdentity.accessTokenEncrypted) { + if (!profile.entitlementSources.includes(storedIdentity.provider)) { continue; } - const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({ - config: options.config, - store: options.store, - tokenCustody: options.tokenCustody, - oauthRuntimes: options.oauthRuntimes, - linkedIdentity: storedIdentity, - now: options.now, - }); - if (!linkedIdentity.accessTokenEncrypted) { + const refreshed = await refreshManagedCredential(options, storedIdentity); + const linkedIdentity = refreshed.identity; + if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) { continue; } @@ -714,7 +649,6 @@ export async function refreshAppSession( appSlug, accountId: refreshClaim.account_id, entitlementPolicies, - now: nowIso, }); const issueInput: IssueAccessClaimInput = { appSlug, @@ -1620,21 +1554,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise 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; +} + +/** + * 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; the decision is made on the row as read under it. + */ +export async function refreshManagedCredential( + ctx: ManagedCredentialContext, + snapshot: StoredLinkedIdentity +): Promise { + const clock = ctx.clock ?? Date.now; + const provider = snapshot.provider; + const runtime = ctx.oauthRuntimes[provider]; + + if (!snapshot.accessTokenEncrypted) { + return { kind: "revoked", identity: snapshot }; + } + if (!dueForRefresh(snapshot, runtime, clock())) { + return { kind: "current", identity: snapshot }; + } + + let revocation: ManagedCredentialRevocationReason | undefined; + const decide = async (locked: StoredLinkedIdentity): Promise => { + if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) { + return { kind: "keep" }; + } + const updatedAt = new Date(clock()).toISOString(); + const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!); + + let refreshed; + try { + refreshed = await runtime.refreshAccessToken!({ + config: ctx.config, + refreshToken, + signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS), + }); + } catch (error) { + if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") { + revocation = "provider_revoked"; + return { kind: "revoke", updatedAt }; + } + throw new ManagedCredentialRefreshFailed(provider); + } + + let refreshTokenEncrypted: string; + if (refreshed.refreshToken) { + refreshTokenEncrypted = ctx.tokenCustody.encrypt(refreshed.refreshToken); + } else if (providerCatalog[provider].rotatesRefreshTokens) { + revocation = "rotation_missing"; + return { kind: "revoke", updatedAt }; + } else { + refreshTokenEncrypted = locked.refreshTokenEncrypted!; + } + + const tokenExpiresAt = refreshed.expiresAt ?? locked.tokenExpiresAt; + return { + kind: "rotate", + accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken), + refreshTokenEncrypted, + ...(tokenExpiresAt !== undefined ? { tokenExpiresAt } : {}), + scopes: refreshed.scope.length ? refreshed.scope : locked.scopes, + updatedAt, + }; + }; + + const committed = await ctx.store.updateLinkedIdentityCredential(snapshot.id, decide); + if (revocation) { + await ctx.store.createAuditEvent({ + accountId: snapshot.accountId, + eventType: "managed_credential_revoked", + eventPayloadJson: { provider, reason: revocation }, + createdAt: new Date(clock()).toISOString(), + }); + } + if (!committed) { + return { kind: "revoked", identity: withoutTokens(snapshot) }; + } + return { kind: committed.accessTokenEncrypted ? "current" : "revoked", identity: committed }; +} diff --git a/src/oauth.ts b/src/oauth.ts index 2d0020c..187df6c 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -61,6 +61,25 @@ export interface OAuthProviderRuntime { }): Promise; } +const oauthErrorCodes = [ + "invalid_request", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "invalid_scope", +] as const; +export type OAuthErrorCode = (typeof oauthErrorCodes)[number]; + +function readOAuthErrorCode(text: string): OAuthErrorCode | undefined { + try { + const error = (JSON.parse(text) as { error?: unknown } | null)?.error; + return oauthErrorCodes.find((code) => code === error); + } catch { + return undefined; + } +} + /** * A provider answer Heimdall cannot use: a non-2xx status, or a body that is * not JSON. It carries the status and a code, never the body or the parser's @@ -71,12 +90,18 @@ export interface OAuthProviderRuntime { export class ProviderResponseError extends Error { readonly status: number; readonly code: "provider_status" | "provider_body_not_json"; + /** + * 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; } } @@ -84,7 +109,7 @@ export class ProviderResponseError extends Error { async function readProviderJson(response: Response, failure: string): Promise { const text = await response.text(); if (!response.ok) { - throw new ProviderResponseError(response.status, "provider_status", failure); + throw new ProviderResponseError(response.status, "provider_status", failure, readOAuthErrorCode(text)); } if (!text) { return {} as T; diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 5583ac4..0dea560 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -6,6 +6,7 @@ import { type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, + type CredentialDecision, type CreateEntitlementSnapshotInput, type CreateSessionInput, type CreatePrivateCommandReceiptInput, @@ -37,6 +38,8 @@ function isGrantActive(grant: StoredCapabilityGrant, appSlug: AppSlug, at: strin } export class InMemoryStore implements HeimdallStore { + /** Tail of each linked identity's credential-decision chain; an entry is removed when it drains. */ + private readonly credentialChains = new Map>(); private readonly accounts = new Map(); private readonly linkedIdentities = new Map(); private readonly grants = new Map(); @@ -174,6 +177,45 @@ export class InMemoryStore implements HeimdallStore { return clone(linkedIdentity); } + async updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise { + const previous = this.credentialChains.get(id) ?? Promise.resolve(); + const run = previous.then(async () => { + const current = this.linkedIdentities.get(id); + if (!current) { + return null; + } + const decision = await decide(clone(current)); + if (decision.kind === "keep") { + return clone(current); + } + const next: StoredLinkedIdentity = { ...current, updatedAt: decision.updatedAt }; + delete next.accessTokenEncrypted; + delete next.refreshTokenEncrypted; + delete next.tokenExpiresAt; + if (decision.kind === "rotate") { + next.accessTokenEncrypted = decision.accessTokenEncrypted; + next.refreshTokenEncrypted = decision.refreshTokenEncrypted; + if (decision.tokenExpiresAt !== undefined) { + next.tokenExpiresAt = decision.tokenExpiresAt; + } + next.scopes = [...decision.scopes]; + } + this.linkedIdentities.set(id, clone(next)); + return clone(next); + }); + const tail = run.catch(() => undefined); + this.credentialChains.set(id, tail); + void tail.then(() => { + if (this.credentialChains.get(id) === tail) { + this.credentialChains.delete(id); + } + }); + return run; + } + async listLinkedIdentitiesForAccount(accountId: string): Promise { return [...this.linkedIdentities.values()] .filter((identity) => identity.accountId === accountId) diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 64f013d..f138f06 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -8,6 +8,7 @@ import { type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, + type CredentialDecision, type CreateEntitlementSnapshotInput, type CreateSessionInput, type CreatePrivateCommandReceiptInput, @@ -546,7 +547,7 @@ export async function checkSchemaRequirements(pool: Pick, require } export class PostgresStore implements HeimdallStore { - constructor(private readonly pool: Pick) {} + constructor(private readonly pool: Pick) {} async ensureSchema(): Promise { await this.pool.query(CREATE_SCHEMA_SQL); @@ -722,6 +723,60 @@ export class PostgresStore implements HeimdallStore { return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity")); } + async updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + try { + const locked = await client.query( + "SELECT * FROM linked_identities WHERE id = $1 FOR UPDATE", + [id] + ); + if (!locked.rowCount) { + await client.query("COMMIT"); + return null; + } + const row = mapLinkedIdentityRow(expectRow(locked.rows[0], "updateLinkedIdentityCredential")); + const decision = await decide(row); + let committed = row; + if (decision.kind !== "keep") { + const rotated = decision.kind === "rotate" ? decision : undefined; + const written = await client.query( + ` + UPDATE linked_identities + SET access_token_encrypted = $2, + refresh_token_encrypted = $3, + token_expires_at = $4, + scopes = $5, + updated_at = $6 + WHERE id = $1 + RETURNING * + `, + [ + id, + nullable(rotated?.accessTokenEncrypted), + nullable(rotated?.refreshTokenEncrypted), + nullable(rotated?.tokenExpiresAt), + (rotated?.scopes ?? row.scopes).join(" "), + decision.updatedAt, + ] + ); + committed = mapLinkedIdentityRow(expectRow(written.rows[0], "updateLinkedIdentityCredential")); + } + await client.query("COMMIT"); + return committed; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + } finally { + client.release(); + } + } + async listLinkedIdentitiesForAccount(accountId: string): Promise { const result = await this.pool.query( ` diff --git a/src/store/types.ts b/src/store/types.ts index d1fabcf..0145626 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -56,6 +56,23 @@ export interface UpsertLinkedIdentityInput { updatedAt: string; } +/** + * What the owner of a refresh decides after reading the row under its lock. + * `rotate` and `revoke` write token columns, scopes and updated_at only; they + * never touch account, profile or identity fields. + */ +export type CredentialDecision = + | { kind: "keep" } + | { + kind: "rotate"; + accessTokenEncrypted: string; + refreshTokenEncrypted: string; + tokenExpiresAt?: string; + scopes: string[]; + updatedAt: string; + } + | { kind: "revoke"; updatedAt: string }; + export interface StoredSession { id: string; accountId: string; @@ -213,6 +230,17 @@ export interface HeimdallStore { findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise; listStoredLinkedIdentitiesForAccount(accountId: string): Promise; upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise; + /** + * Serialise decisions about one row's credential. `decide` receives the row + * as read under the lock (never a caller's snapshot) and the decision is + * committed before this resolves, so a rotated pair is persisted before any + * caller sees the new access token. Returns the committed row, or null when + * no row has that id. A throw from `decide` writes nothing. + */ + updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise; listLinkedIdentitiesForAccount(accountId: string): Promise; createCapabilityGrant(input: CreateCapabilityGrantInput): Promise; listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise; diff --git a/tests/app.test.ts b/tests/app.test.ts index 7f9a72c..d938b9c 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -5,7 +5,7 @@ import { buildApp, errorIdentity } from "../src/app.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 { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; import { verifyJwt } from "../src/signing.js"; import { InMemoryStore } from "../src/store/index.js"; @@ -1238,6 +1238,117 @@ 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([]); + }); + 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 diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts new file mode 100644 index 0000000..b0dc2a2 --- /dev/null +++ b/tests/managed-credential.test.ts @@ -0,0 +1,517 @@ +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { type HeimdallConfig } from "../src/config.js"; +import { type Provider } from "../src/contracts.js"; +import { createTokenCustody } from "../src/custody.js"; +import { + MANAGED_CREDENTIAL_REFRESH_SKEW_MS, + ManagedCredentialRefreshFailed, + refreshManagedCredential, +} from "../src/managed-credential.js"; +import { createOAuthRuntimeRegistry } from "../src/oauth.js"; +import { providerCatalog } from "../src/providers.js"; +import { InMemoryStore, PostgresStore, type HeimdallStore } from "../src/store/index.js"; +import { type StoredLinkedIdentity } from "../src/store/types.js"; + +const originalFetch = globalThis.fetch; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function testConfig(): HeimdallConfig { + const clients = Object.fromEntries( + ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"].map((p) => [ + p, + { clientId: `${p}-client`, clientSecret: `${p}-secret` }, + ]) + ); + return { + serviceName: "heimdall", + host: "127.0.0.1", + port: 4100, + workspaceRoot: "E:/Projects/Heimdall", + dataRoot: "E:/Projects/Heimdall/.heimdall-data", + cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc", + publicBaseUrl: "https://heimdall.gamecult.org", + issuer: "https://heimdall.gamecult.org", + daemonId: "yggdrasil-heimdall", + idunnRudpHealth: undefined, + idunnHealthContract: "heimdall.cultnet-rudp-provider-health", + providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc", + sessionTtlSeconds: 3600, + refreshTtlSeconds: 60 * 60 * 24 * 30, + stateTtlSeconds: 600, + completionTtlSeconds: 300, + bootstrapSigningPrivateKeyOnMissing: false, + tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), + appSharedSecrets: { streampixels: "streampixels-secret", bifrost: "bifrost-secret" }, + appBackendCallbacks: {}, + storage: { backend: "memory", applySchemaOnStartup: true }, + bifrostPatronSupportEndpoint: "https://bifrost.gamecult.org/heimdall/patron-support/events", + bifrostPatronSupportSecret: "bifrost-support-secret", + providers: clients, + } as unknown as HeimdallConfig; +} + +type Mode = "normal" | "invalid_grant" | "invalid_client" | "server_error" | "not_json" | "no_refresh_token" | "hang"; + +/** + * A provider token endpoint with single-use refresh tokens. Presenting a + * consumed refresh token revokes the whole installation, as Joystick does: + * every later refresh answers 400 invalid_grant. + */ +function installTokenEndpoint(provider: Provider, firstRefreshToken: string) { + const state = { + calls: 0, + mode: "normal" as Mode, + live: firstRefreshToken, + issued: 0, + installationRevoked: false, + otherCalls: [] as string[], + }; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + if (url !== providerCatalog[provider].tokenEndpoint) { + state.otherCalls.push(url); + return new Response("unexpected", { status: 500 }); + } + state.calls += 1; + const presented = (init!.body as URLSearchParams).get("refresh_token"); + await sleep(25); + switch (state.mode) { + case "hang": + return new Promise((_resolve, reject) => { + init!.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }); + case "invalid_grant": + return new Response(JSON.stringify({ error: "invalid_grant", error_description: "CANARY" }), { status: 400 }); + case "invalid_client": + return new Response(JSON.stringify({ error: "invalid_client", error_description: "CANARY" }), { status: 401 }); + case "server_error": + return new Response("CANARY upstream exploded", { status: 500 }); + case "not_json": + return new Response("CANARY ", { status: 200 }); + case "no_refresh_token": + return new Response(JSON.stringify({ access_token: "access-norefresh", token_type: "Bearer", expires_in: 3600 }), { + status: 200, + }); + case "normal": { + if (state.installationRevoked || presented !== state.live) { + state.installationRevoked = true; + return new Response(JSON.stringify({ error: "invalid_grant", error_description: "reused CANARY" }), { status: 400 }); + } + state.issued += 1; + state.live = `refresh-${state.issued}`; + return new Response( + JSON.stringify({ + access_token: `access-${state.issued}`, + refresh_token: state.live, + token_type: "Bearer", + expires_in: 3600, + }), + { status: 200 } + ); + } + } + }) as typeof fetch; + return state; +} + +interface Rig { + stores: HeimdallStore[]; + apps: Awaited>[]; + accountId: string; + identityId: string; + custody: ReturnType; + config: HeimdallConfig; + provider: Provider; + row(): Promise; + audit(): Promise>; + resolve(appIndex?: number): Promise<{ statusCode: number; json: Record; body: string }>; + close(): Promise; +} + +const adminUrl = process.env.HEIMDALL_TEST_PG_ADMIN_URL; +const rigKinds = [ + { kind: "memory", live: false }, + { kind: "postgres one app", live: true }, + { kind: "postgres two apps", live: true }, +] as const; +// Providers whose refresh goes through the real shared runtime; rotation differs by descriptor. +const refreshProviders: Provider[] = ["twitch"]; + +for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`managed credential refresh, ${kind}`, () => { + const admin = live && adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined; + const suffix = randomUUID().replace(/-/g, "").slice(0, 12); + const template = `heimdall_mc_template_${suffix}`; + const cleanup: Array<() => Promise> = []; + + function urlFor(database: string): string { + const url = new URL(adminUrl!); + url.pathname = `/${database}`; + return url.toString(); + } + + beforeAll(async () => { + if (!live) return; + await admin!.query(`CREATE DATABASE ${template}`); + const pool = new Pool({ connectionString: urlFor(template), max: 1 }); + await new PostgresStore(pool).ensureSchema(); + await pool.end(); + }, 120_000); + + afterEach(async () => { + globalThis.fetch = originalFetch; + for (const step of cleanup.splice(0).reverse()) await step(); + }); + + afterAll(async () => { + if (!admin) return; + await admin.query(`DROP DATABASE IF EXISTS ${template}`); + await admin.end(); + }, 120_000); + + async function makeRig( + provider: Provider, + options: { expiresInMs?: number; refreshToken?: string | null; accessToken?: string | null } = {} + ): Promise { + const config = testConfig(); + const custody = createTokenCustody(config); + const stores: HeimdallStore[] = []; + let adminForDb: Pool | undefined; + if (!live) { + stores.push(new InMemoryStore()); + } else { + const name = `heimdall_mc_${randomUUID().replace(/-/g, "")}`; + await admin!.query(`CREATE DATABASE ${name} TEMPLATE ${template}`); + const count = kind === "postgres two apps" ? 2 : 1; + for (let i = 0; i < count; i += 1) { + const pool = new Pool({ connectionString: urlFor(name), max: 12 }); + pool.on("error", () => undefined); + stores.push(new PostgresStore(pool)); + } + adminForDb = new Pool({ connectionString: urlFor(name), max: 1 }); + adminForDb.on("error", () => undefined); + cleanup.push(async () => { + await adminForDb!.end(); + await admin!.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); + }); + } + + const now = new Date().toISOString(); + const account = await stores[0]!.createAccount({ createdAt: now, lastSeenAt: now }); + const expiresInMs = options.expiresInMs ?? 60_000; + const input = { + accountId: account.id, + provider, + providerUserId: "channel-1", + username: "deru", + displayName: "Deru", + scopes: ["identity:read"], + profileJson: { keep: "me" }, + tokenExpiresAt: new Date(Date.now() + expiresInMs).toISOString(), + createdAt: now, + updatedAt: now, + }; + const seeded = await stores[0]!.upsertLinkedIdentity({ + ...input, + ...(options.accessToken === null ? {} : { accessTokenEncrypted: custody.encrypt(options.accessToken ?? "access-0") }), + ...(options.refreshToken === null ? {} : { refreshTokenEncrypted: custody.encrypt(options.refreshToken ?? "refresh-0") }), + }); + + const apps: Rig["apps"] = []; + for (const store of stores) { + apps.push(await buildApp({ config, store, tokenCustody: custody, oauthRuntimes: createOAuthRuntimeRegistry() })); + } + cleanup.push(async () => { + for (const app of apps) await app.close().catch(() => undefined); + }); + + return { + stores, + apps, + accountId: account.id, + identityId: seeded.id, + custody, + config, + provider, + async row() { + const row = await stores[0]!.findStoredLinkedIdentityForAccount(account.id, provider); + return row!; + }, + async audit() { + if (!live) { + const events = (stores[0] as unknown as { auditEvents: Map }).auditEvents; + return [...events.values()].map((e) => ({ eventType: e.eventType, payload: e.eventPayloadJson })); + } + const result = await adminForDb!.query<{ event_type: string; event_payload_json: unknown }>( + "SELECT event_type, event_payload_json FROM audit_events ORDER BY created_at" + ); + return result.rows.map((r) => ({ eventType: r.event_type, payload: r.event_payload_json })); + }, + async resolve(appIndex = 0) { + const response = await apps[appIndex]!.inject({ + method: "POST", + url: "/v1/apps/streampixels/managed-credentials/resolve", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, + payload: { accountId: account.id, provider }, + }); + return { statusCode: response.statusCode, json: response.json() as Record, body: response.body }; + }, + async close() {}, + }; + } + + const timeout = { timeout: 60_000 }; + + describe.each(refreshProviders)("%s", (provider) => { + it("refresh-once-per-token: 20 concurrent resolves present the refresh token once", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + + const answers = await Promise.all(Array.from({ length: 20 }, (_, i) => rig.resolve(i % rig.apps.length))); + + expect(endpoint.calls).toBe(1); + expect(answers.map((a) => a.statusCode)).toEqual(Array(20).fill(200)); + expect(new Set(answers.map((a) => a.json.accessToken))).toEqual(new Set(["access-1"])); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe("access-1"); + expect(rig.custody.decrypt(row.refreshTokenEncrypted!)).toBe("refresh-1"); + expect(await rig.audit()).toEqual([]); + }); + + it("rotated-pair-persisted-before-use: the stored pair is the one the caller was given", timeout, async () => { + const rig = await makeRig(provider); + installTokenEndpoint(provider, "refresh-0"); + const answer = await rig.resolve(); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe(answer.json.accessToken); + expect(Date.parse(row.tokenExpiresAt!)).toBeGreaterThan(Date.now() + MANAGED_CREDENTIAL_REFRESH_SKEW_MS); + }); + + it("outside the skew: no token call, the stored token is returned", timeout, async () => { + const rig = await makeRig(provider, { expiresInMs: 60 * 60 * 1000 }); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const answer = await rig.resolve(); + expect(endpoint.calls).toBe(0); + expect(answer.statusCode).toBe(200); + expect(answer.json.accessToken).toBe("access-0"); + }); + + it("revocation-is-typed: invalid_grant clears the tokens, answers 410, audits once, and stops calling the provider", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "invalid_grant"; + + const first = await rig.resolve(); + expect(first.statusCode).toBe(410); + expect(first.json).toEqual({ error: "managed_credential_revoked", provider }); + const row = await rig.row(); + expect(row.accessTokenEncrypted).toBeUndefined(); + expect(row.refreshTokenEncrypted).toBeUndefined(); + expect(row.tokenExpiresAt).toBeUndefined(); + expect(row.username).toBe("deru"); + expect(row.profileJson).toEqual({ keep: "me" }); + expect(row.scopes).toEqual(["identity:read"]); + + const second = await rig.resolve(); + expect(second.statusCode).toBe(410); + expect(endpoint.calls).toBe(1); + + const events = await rig.audit(); + expect(events.filter((e) => e.eventType === "managed_credential_revoked")).toEqual([ + { eventType: "managed_credential_revoked", payload: { provider, reason: "provider_revoked" } }, + ]); + expect(first.body + second.body + JSON.stringify(events)).not.toContain("CANARY"); + }); + + it.each(["invalid_client", "server_error", "not_json", "hang"] as const)( + "transient failure (%s) keeps custody: 502, tokens byte-identical, nothing leaked", + timeout, + async (mode) => { + const rig = await makeRig(provider); + const before = await rig.row(); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = mode; + + let answer; + if (mode === "hang") { + // The route's own bound is 10 s; the primitive takes an injected one. + const failure = await refreshManagedCredential( + { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 100 }, + before + ).then( + () => undefined, + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(ManagedCredentialRefreshFailed); + expect((failure as ManagedCredentialRefreshFailed).provider).toBe(provider); + expect(String(failure) + JSON.stringify(failure)).not.toContain("CANARY"); + } else { + answer = await rig.resolve(); + expect(answer.statusCode).toBe(502); + expect(answer.json).toEqual({ error: "managed_credential_refresh_failed", provider }); + expect(answer.body).not.toContain("CANARY"); + } + + const after = await rig.row(); + expect(after.accessTokenEncrypted).toBe(before.accessTokenEncrypted); + expect(after.refreshTokenEncrypted).toBe(before.refreshTokenEncrypted); + expect(after.tokenExpiresAt).toBe(before.tokenExpiresAt); + expect(await rig.audit()).toEqual([]); + } + ); + + it("bounded lock: a refresh that never answers is aborted and the next resolve takes the lock", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "hang"; + const ctx = { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 100 }; + const started = Date.now(); + await expect(refreshManagedCredential(ctx, await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed); + expect(Date.now() - started).toBeLessThan(5_000); + + endpoint.mode = "normal"; + const outcome = await refreshManagedCredential(ctx, await rig.row()); + expect(outcome.kind).toBe("current"); + expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1"); + }); + + it("a snapshot taken before another caller's refresh does not decide: the locked row does", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const stale = await rig.row(); + await rig.resolve(); + expect(endpoint.calls).toBe(1); + const outcome = await refreshManagedCredential( + { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry() }, + stale + ); + expect(endpoint.calls).toBe(1); + expect(outcome.kind).toBe("current"); + expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1"); + }); + + it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => { + expect(providerCatalog[provider].rotatesRefreshTokens).toBe(false); + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "no_refresh_token"; + const answer = await rig.resolve(); + expect(answer.statusCode).toBe(200); + expect(answer.json.accessToken).toBe("access-norefresh"); + expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0"); + }); + + it("a row with no access token resolves as revoked (410), a missing row as not found (404)", timeout, async () => { + const rig = await makeRig(provider, { accessToken: null, refreshToken: null }); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const revoked = await rig.resolve(); + expect(revoked.statusCode).toBe(410); + expect(revoked.json).toEqual({ error: "managed_credential_revoked", provider }); + expect(endpoint.calls).toBe(0); + + const missing = await rig.apps[0]!.inject({ + method: "POST", + url: "/v1/apps/streampixels/managed-credentials/resolve", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, + payload: { accountId: "no-such-account", provider }, + }); + expect(missing.statusCode).toBe(404); + expect(missing.json()).toEqual({ error: "managed_credential_not_found" }); + }); + }); + + describe("updateLinkedIdentityCredential", () => { + it("returns null for a missing id and does not call decide", async () => { + const rig = await makeRig("twitch"); + let called = false; + const result = await rig.stores[0]!.updateLinkedIdentityCredential(randomUUID(), async () => { + called = true; + return { kind: "keep" }; + }); + expect(result).toBeNull(); + expect(called).toBe(false); + }); + + it("hands decide the committed row, and a throw writes nothing", async () => { + const rig = await makeRig("twitch"); + const before = await rig.row(); + let seen: StoredLinkedIdentity | undefined; + await expect( + rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async (locked) => { + seen = locked; + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + expect(seen).toEqual(before); + expect(await rig.row()).toEqual(before); + }); + + it("rotate writes token columns, scopes and updated_at and nothing else; keep writes nothing", async () => { + const rig = await makeRig("twitch"); + const before = await rig.row(); + const kept = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ kind: "keep" })); + expect(kept).toEqual(before); + + const rotated = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ + kind: "rotate", + accessTokenEncrypted: "A2", + refreshTokenEncrypted: "R2", + tokenExpiresAt: "2999-01-01T00:00:00.000Z", + scopes: ["a", "b"], + updatedAt: "2999-01-02T00:00:00.000Z", + })); + expect(rotated).toEqual({ + ...before, + accessTokenEncrypted: "A2", + refreshTokenEncrypted: "R2", + tokenExpiresAt: "2999-01-01T00:00:00.000Z", + scopes: ["a", "b"], + updatedAt: "2999-01-02T00:00:00.000Z", + }); + expect(await rig.row()).toEqual(rotated); + + const revoked = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ + kind: "revoke", + updatedAt: "2999-01-03T00:00:00.000Z", + })); + const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = rotated!; + expect(revoked).toEqual({ ...rest, updatedAt: "2999-01-03T00:00:00.000Z" }); + expect(await rig.row()).toEqual(revoked); + }); + + it("serialises deciders on one row: each sees the previous commit", timeout, async () => { + const rig = await makeRig("twitch"); + const order: string[] = []; + await Promise.all( + [1, 2, 3, 4, 5].map((n) => + rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async (locked) => { + order.push(`${n}:${locked.accessTokenEncrypted}`); + await sleep(20); + return { + kind: "rotate", + accessTokenEncrypted: `A${n}`, + refreshTokenEncrypted: `R${n}`, + scopes: locked.scopes, + updatedAt: new Date().toISOString(), + }; + }) + ) + ); + const seenValues = order.map((entry) => entry.split(":")[1]); + // Each decider saw the value the one before it wrote, so their reads form a chain. + expect(new Set(seenValues).size).toBe(5); + const wrote = order.map((entry) => `A${entry.split(":")[0]}`); + for (let i = 1; i < seenValues.length; i += 1) expect(seenValues[i]).toBe(wrote[i - 1]); + }); + }); +}); + +describe("live Postgres availability", () => { + it.skipIf(!!adminUrl)("is present whenever the job requires it", () => { + // Local workstation runs skip the live suites; the verification job sets + // HEIMDALL_REQUIRE_LIVE_PG=1 and fails here when they would have been skipped. + expect(process.env.HEIMDALL_REQUIRE_LIVE_PG).toBeUndefined(); + }); +}); diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts index 2d13a30..ff13d72 100644 --- a/tests/provider-token-request.test.ts +++ b/tests/provider-token-request.test.ts @@ -1,3 +1,4 @@ +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"; @@ -114,3 +115,33 @@ describe("requestProviderTokens follows the provider descriptor", () => { ).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([ + ["an error outside the RFC set", JSON.stringify({ error: "CANARY_unknown" })], + ["a non-string error", JSON.stringify({ error: { code: "invalid_grant" } })], + ["a body that is not JSON", "CANARY "], + ["JSON null", "null"], + ])("leaves oauthError undefined for %s", async (_name, body) => { + const error = await failure(body); + expect(error.oauthError).toBeUndefined(); + expect(`${error.message} ${inspect(error, { depth: 5, showHidden: true })}`).not.toContain("CANARY"); + }); +}); diff --git a/tests/store.test.ts b/tests/store.test.ts index 8eeaeb1..c5f6445 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -326,7 +326,8 @@ async function exerciseStore(store: PostgresStore): Promise> { createdAt: now, updatedAt: now, }; - await s.upsertLinkedIdentity(identity); + const linked = await s.upsertLinkedIdentity(identity); + await s.updateLinkedIdentityCredential(linked.id, async () => ({ kind: "keep" })); await s.upsertLinkedIdentity({ ...identity, username: "again" }); await s.findAccountByLinkedIdentity("discord", "u1"); await s.findStoredLinkedIdentity("discord", "u1"); From 1c38d12050c295d2739d39d0aa8fa49b9075fba3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:13:40 +0200 Subject: [PATCH 03/27] Add Joystick as a confidential, rotating managed-connection provider Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves identity by channel id, and is a streampixels managed-connection provider only: it is not an identity provider for the profile (ruling signin-waits-for-public-bot). Co-Authored-By: Claude Opus 5.5 --- ...heimdall.auth_begin_command.v1.schema.json | 2 +- src/app-profiles.ts | 4 +- src/contracts.ts | 2 +- src/oauth.ts | 49 ++++++++ src/providers.ts | 12 ++ tests/access-plugin.test.ts | 2 +- tests/app.test.ts | 107 +++++++++++++++++- tests/caller-identity-attack-soul-r21.test.ts | 1 + tests/caller-identity-attack-soul.test.ts | 1 + tests/caller-identity-soul.test.ts | 1 + tests/managed-credential.test.ts | 45 ++++++-- tests/private-command-diagnostics.test.ts | 2 +- tests/security.test.ts | 1 + tests/verse-witness.test.ts | 1 + 14 files changed, 212 insertions(+), 18 deletions(-) diff --git a/schemas/heimdall.auth_begin_command.v1.schema.json b/schemas/heimdall.auth_begin_command.v1.schema.json index 81fdaf8..ca22ccf 100644 --- a/schemas/heimdall.auth_begin_command.v1.schema.json +++ b/schemas/heimdall.auth_begin_command.v1.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["provider", "mode", "returnTo", "entitlementPolicy"], "properties": { - "provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify"] }, + "provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] }, "mode": { "enum": ["sign_in", "link", "connect"] }, "returnTo": { "type": "string", "format": "uri" }, "entitlementPolicy": { diff --git a/src/app-profiles.ts b/src/app-profiles.ts index bede115..88466d9 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -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: [ { diff --git a/src/contracts.ts b/src/contracts.ts index 743bfdc..d6cab2a 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,4 +1,4 @@ -export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify"] as const; +export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const; export type Provider = (typeof providers)[number]; diff --git a/src/oauth.ts b/src/oauth.ts index 187df6c..c46976e 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -513,6 +513,45 @@ async function resolveSpotifyIdentity(options: { accessToken: string }): Promise return identity; } +async function resolveJoystickIdentity(options: { accessToken: string }): Promise { + const profile = await fetchJson<{ + channel_id?: string; + username?: string; + nickname?: string; + photo_url?: string; + }>( + "https://api.joystick.tv/api/v1/me/identity", + { + headers: { + Authorization: `Bearer ${options.accessToken}`, + }, + }, + "Joystick identity lookup failed" + ); + + if (!profile.channel_id) { + throw new Error("Joystick identity response did not include a channel id."); + } + + const identity: ResolvedIdentity = { + provider: "joystick", + providerUserId: profile.channel_id, + profile: profile as unknown as Record, + }; + const displayName = profile.nickname ?? profile.username; + if (profile.username) { + identity.username = profile.username; + } + if (displayName) { + identity.displayName = displayName; + } + if (profile.photo_url) { + identity.avatarUrl = profile.photo_url; + } + + return identity; +} + async function evaluateDiscordEntitlements(options: { config: HeimdallConfig; callback: OAuthCallbackContext; @@ -860,6 +899,15 @@ const spotifyRuntime: OAuthProviderRuntime = { }, }; +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."); @@ -882,5 +930,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry { twitch: twitchRuntime, youtube: youtubeRuntime, spotify: spotifyRuntime, + joystick: joystickRuntime, }; } diff --git a/src/providers.ts b/src/providers.ts index 2998e4d..9b139ab 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -105,6 +105,18 @@ export const providerCatalog: Record = { clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE", }, + joystick: { + key: "joystick", + displayName: "Joystick", + authorizationEndpoint: "https://joystick.tv/api/oauth/authorize", + tokenEndpoint: "https://api.joystick.tv/api/oauth/token", + tokenEndpointAuthMethod: "client_secret_basic", + rotatesRefreshTokens: true, + defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"], + roles: ["identity", "managed_credential"], + clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE", + }, }; export function buildAuthorizationUrl(options: { diff --git a/tests/access-plugin.test.ts b/tests/access-plugin.test.ts index b5027d0..4789bf8 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig { storage: { backend: "memory", applySchemaOnStartup: true }, providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, - patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, + patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, }, }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index d938b9c..b2960fc 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -49,6 +49,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 +975,8 @@ describe("Heimdall service", () => { expect(profileResponse.json()).toEqual( expect.objectContaining({ slug: "streampixels", - managedConnectionProviders: ["twitch", "youtube"], + identityProviders: ["twitch", "youtube"], + managedConnectionProviders: ["twitch", "youtube", "joystick"], }) ); }); @@ -1349,6 +1351,109 @@ describe("Heimdall service", () => { expect(urls).toEqual([]); }); + describe("joystick connect", () => { + async function connect(app: FastifyInstance) { + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect" }, + }); + return start; + } + + 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"); + }); + + function stubJoystick(identity: Record) { + const calls: Array<{ url: string; headers: Record; body?: URLSearchParams }> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, headers: (init?.headers ?? {}) as Record, body: init?.body as URLSearchParams | undefined }); + if (url === "https://api.joystick.tv/api/oauth/token") { + return new Response( + JSON.stringify({ access_token: "js-access", refresh_token: "js-refresh", token_type: "Bearer", expires_in: 864000 }), + { status: 200 } + ); + } + if (url === "https://api.joystick.tv/api/v1/me/identity") { + return new Response(JSON.stringify(identity), { status: 200 }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + return calls; + } + + it("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 diff --git a/tests/caller-identity-attack-soul-r21.test.ts b/tests/caller-identity-attack-soul-r21.test.ts index 4c3df32..de959a8 100644 --- a/tests/caller-identity-attack-soul-r21.test.ts +++ b/tests/caller-identity-attack-soul-r21.test.ts @@ -65,6 +65,7 @@ function testConfig(overrides: Partial = {}): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, ...overrides, }; diff --git a/tests/caller-identity-attack-soul.test.ts b/tests/caller-identity-attack-soul.test.ts index 9fb8e0a..8f77801 100644 --- a/tests/caller-identity-attack-soul.test.ts +++ b/tests/caller-identity-attack-soul.test.ts @@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, }; } diff --git a/tests/caller-identity-soul.test.ts b/tests/caller-identity-soul.test.ts index 3ebc000..b5562a7 100644 --- a/tests/caller-identity-soul.test.ts +++ b/tests/caller-identity-soul.test.ts @@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, }; } diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index b0dc2a2..427b2c8 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -138,7 +138,7 @@ const rigKinds = [ { kind: "postgres two apps", live: true }, ] as const; // Providers whose refresh goes through the real shared runtime; rotation differs by descriptor. -const refreshProviders: Provider[] = ["twitch"]; +const refreshProviders: Provider[] = ["twitch", "joystick"]; for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`managed credential refresh, ${kind}`, () => { const admin = live && adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined; @@ -392,16 +392,39 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1"); }); - it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => { - expect(providerCatalog[provider].rotatesRefreshTokens).toBe(false); - const rig = await makeRig(provider); - const endpoint = installTokenEndpoint(provider, "refresh-0"); - endpoint.mode = "no_refresh_token"; - const answer = await rig.resolve(); - expect(answer.statusCode).toBe(200); - expect(answer.json.accessToken).toBe("access-norefresh"); - expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0"); - }); + if (providerCatalog[provider].rotatesRefreshTokens) { + it("rotation-fails-closed: a response without a refresh token revokes, 410, and the old token is never presented again", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "no_refresh_token"; + + const first = await rig.resolve(); + expect(first.statusCode).toBe(410); + expect(first.json).toEqual({ error: "managed_credential_revoked", provider }); + expect(first.body).not.toContain("access-norefresh"); + const row = await rig.row(); + expect(row.accessTokenEncrypted).toBeUndefined(); + expect(row.refreshTokenEncrypted).toBeUndefined(); + expect(row.tokenExpiresAt).toBeUndefined(); + + const second = await rig.resolve(); + expect(second.statusCode).toBe(410); + expect(endpoint.calls).toBe(1); + expect((await rig.audit()).filter((e) => e.eventType === "managed_credential_revoked")).toEqual([ + { eventType: "managed_credential_revoked", payload: { provider, reason: "rotation_missing" } }, + ]); + }); + } else { + it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "no_refresh_token"; + const answer = await rig.resolve(); + expect(answer.statusCode).toBe(200); + expect(answer.json.accessToken).toBe("access-norefresh"); + expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0"); + }); + } it("a row with no access token resolves as revoked (410), a missing row as not found (404)", timeout, async () => { const rig = await makeRig(provider, { accessToken: null, refreshToken: null }); diff --git a/tests/private-command-diagnostics.test.ts b/tests/private-command-diagnostics.test.ts index 41f16f1..d9d92ed 100644 --- a/tests/private-command-diagnostics.test.ts +++ b/tests/private-command-diagnostics.test.ts @@ -30,7 +30,7 @@ function config(): HeimdallConfig { bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {}, storage: { backend: "memory", applySchemaOnStartup: true }, - providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {} }, + providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} }, } as HeimdallConfig; } diff --git a/tests/security.test.ts b/tests/security.test.ts index 5b07c6d..4f3f39d 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial = {}): HeimdallConf twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, ...overrides, }; diff --git a/tests/verse-witness.test.ts b/tests/verse-witness.test.ts index acd9b89..07c141f 100644 --- a/tests/verse-witness.test.ts +++ b/tests/verse-witness.test.ts @@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => { "twitch", "youtube", "spotify", + "joystick", ]); expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([ "repixelizer", From 43b7072dfbc046ce571ca5f09c75d1fea58cd580 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:15:12 +0200 Subject: [PATCH 04/27] Postgres store: return token_expires_at as the ISO string the port promises pg returns a Date for TIMESTAMPTZ; a byte-identity check on a kept credential saw a Date where the port types a string. Co-Authored-By: Claude Opus 5.5 --- src/store/postgres.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/store/postgres.ts b/src/store/postgres.ts index f138f06..5f0a0a1 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -184,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 an ISO string. + identity.tokenExpiresAt = new Date(row.token_expires_at).toISOString(); } return identity; From 3eaa9ef2654d77b7e3e2d007c34cc46614a5db68 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:15:42 +0200 Subject: [PATCH 05/27] Declare Joystick in the Idunn recipe and document the locked refresh Recipe optional_environment gains the Joystick client id and secret file; service-contract documents the row lock, rotation fail-closed and the 410/502 answers; the StreamPixels profile names Heimdall as sole holder of provider refresh tokens (F5). Co-Authored-By: Claude Opus 5.5 --- deployment/idunn/recipe.toml | 2 ++ docs/app-profiles/streampixels.md | 8 ++++++-- docs/service-contract.md | 26 ++++++++++++++++++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml index 8524451..f520258 100644 --- a/deployment/idunn/recipe.toml +++ b/deployment/idunn/recipe.toml @@ -157,6 +157,8 @@ optional_environment = [ "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE", + "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID", + "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE", diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md index f28b40a..703dde7 100644 --- a/docs/app-profiles/streampixels.md +++ b/docs/app-profiles/streampixels.md @@ -20,7 +20,11 @@ Corroborated by `gamecult-ops/nginx/streampixels.gamecult.org.conf:14` The current StreamPixels architecture already makes useful splits: - the web app owns the remembered viewer session -- the service owns provider OAuth exchange and identity resolution +- Heimdall owns provider OAuth exchange, identity resolution and refresh-token + custody for Twitch, YouTube and Joystick; StreamPixels never calls a provider + token endpoint and holds no provider refresh token. It asks Heimdall for a + short-lived access token (`managed-credentials/resolve`) and treats `410 + managed_credential_revoked` as "the creator must reconnect" - the overlay remains auth-free - creator memberships and operator roles are separate from general viewer state @@ -38,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is: - provider OAuth authority - identity-linking authority -- managed Twitch/YouTube credential custody for creator connections +- managed Twitch/YouTube/Joystick credential custody for creator connections (Joystick is a managed connection only, not a viewer sign-in provider) - entitlement refresh - shared claim semantics diff --git a/docs/service-contract.md b/docs/service-contract.md index c639178..47fa7fa 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -118,6 +118,7 @@ Supported providers in the skeleton: - `twitch` - `youtube` - `spotify` +- `joystick` Request body: @@ -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,22 @@ 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. 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 (`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, + provider 5xx, timeout, malformed body). Stored tokens are untouched and a + later request may succeed - app-local binding, diagnostics, subscription setup, and runtime behavior stay app-owned From 7ce9588503c2a07e92d1bc74ac9a25402812bbe4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:18:12 +0200 Subject: [PATCH 06/27] Postgres store: return created_at and updated_at as ISO strings too The credential write returns updated_at; a Date there broke the equality the port promises against the in-memory store. Co-Authored-By: Claude Opus 5.5 --- src/store/postgres.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 5f0a0a1..487f375 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -159,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) { @@ -184,7 +184,7 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity { } if (row.token_expires_at) { - // pg hands back a Date for TIMESTAMPTZ whatever the row type says; the port promises an ISO string. + // 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(); } From f6baadec0c8cf1c0b10116740d174268e2219925 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:20:18 +0200 Subject: [PATCH 07/27] Tests: refresh failures surface as the typed ManagedCredentialRefreshFailed; seven providers Co-Authored-By: Claude Opus 5.5 --- tests/private-command-diagnostics.test.ts | 4 ++-- tests/verse-runtime.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/private-command-diagnostics.test.ts b/tests/private-command-diagnostics.test.ts index d9d92ed..d1801a5 100644 --- a/tests/private-command-diagnostics.test.ts +++ b/tests/private-command-diagnostics.test.ts @@ -98,7 +98,7 @@ it("keeps a provider's malformed 2xx refresh body out of every plaintext part of ); expect(reply.status).toBe("denied"); - expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_body_not_json)."]); + expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ManagedCredentialRefreshFailed, managed_credential_refresh_failed)."]); expect(plaintext(reply)).not.toContain("CANARY"); }); @@ -106,7 +106,7 @@ it("keeps a provider's refusal body out of every plaintext part of the reply", a const reply = await refreshAgainst(() => new Response('CANARYrefused {"error":"invalid_grant"}', { status: 400 })); expect(reply.status).toBe("denied"); - expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_status)."]); + expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ManagedCredentialRefreshFailed, managed_credential_refresh_failed)."]); expect(plaintext(reply)).not.toContain("CANARY"); }); diff --git a/tests/verse-runtime.test.ts b/tests/verse-runtime.test.ts index 85182dc..3196cfa 100644 --- a/tests/verse-runtime.test.ts +++ b/tests/verse-runtime.test.ts @@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => { expect(bySchema.get("idunn.daemon_health")).toEqual([ "yggdrasil-heimdall", "active", - "Heimdall auth runtime active; storage=postgres; providers=2/6; apps=4; healthTransport=CultNet/RUDP", + "Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP", "2026-06-16T17:10:00.000Z", "heimdall.cultnet-rudp-provider-health", "daemon-published", From 9b3d44f56a677bff28be23543a5cba4eb5685668 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:25:01 +0200 Subject: [PATCH 08/27] Tests: satisfy exactOptionalPropertyTypes in the Joystick stub Co-Authored-By: Claude Opus 5.5 --- tests/app.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/app.test.ts b/tests/app.test.ts index b2960fc..9677466 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1374,7 +1374,7 @@ describe("Heimdall service", () => { }); function stubJoystick(identity: Record) { - const calls: Array<{ url: string; headers: Record; body?: URLSearchParams }> = []; + const calls: Array<{ url: string; headers: Record; body?: URLSearchParams | undefined }> = []; globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { const url = String(input); calls.push({ url, headers: (init?.headers ?? {}) as Record, body: init?.body as URLSearchParams | undefined }); From 6b8c4a9c63540775d4aa3a4cfba1f4f2f592554c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:47:37 +0200 Subject: [PATCH 09/27] Tests: pin the refresh fast paths, boundaries and connection hygiene Added after a diff-scoped StrykerJS run left survivors: no-lock paths, the skew boundary, decision kinds, a vanished row, open-transaction hygiene on the live store, and the token request's method, signal, token type and scope handling. Co-Authored-By: Claude Opus 5.5 --- tests/managed-credential.test.ts | 171 +++++++++++++++++++++++++++ tests/provider-token-request.test.ts | 48 ++++++++ 2 files changed, 219 insertions(+) diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 427b2c8..a3b6c75 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -125,6 +125,7 @@ interface Rig { custody: ReturnType; config: HeimdallConfig; provider: Provider; + databaseUrl: string | undefined; row(): Promise; audit(): Promise>; resolve(appIndex?: number): Promise<{ statusCode: number; json: Record; body: string }>; @@ -179,6 +180,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag const custody = createTokenCustody(config); const stores: HeimdallStore[] = []; let adminForDb: Pool | undefined; + let databaseUrl: string | undefined; if (!live) { stores.push(new InMemoryStore()); } else { @@ -190,6 +192,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag pool.on("error", () => undefined); stores.push(new PostgresStore(pool)); } + databaseUrl = urlFor(name); adminForDb = new Pool({ connectionString: urlFor(name), max: 1 }); adminForDb.on("error", () => undefined); cleanup.push(async () => { @@ -235,6 +238,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag custody, config, provider, + databaseUrl, async row() { const row = await stores[0]!.findStoredLinkedIdentityForAccount(account.id, provider); return row!; @@ -504,6 +508,37 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag expect(await rig.row()).toEqual(revoked); }); + it.runIf(live)("leaves no transaction open after a missing row or a throwing decide", timeout, async () => { + const rig = await makeRig("twitch"); + const pool = new Pool({ connectionString: rig.databaseUrl, max: 1 }); + pool.on("error", () => undefined); + try { + const store = new PostgresStore(pool); + const idle = async () => { + await sleep(10); + const result = await pool.query<{ fresh: boolean }>("SELECT now() = statement_timestamp() AS fresh"); + return result.rows[0]!.fresh; + }; + expect(await store.updateLinkedIdentityCredential(randomUUID(), async () => ({ kind: "keep" }))).toBeNull(); + expect(await idle()).toBe(true); + await expect( + store.updateLinkedIdentityCredential(rig.identityId, async () => { + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + expect(await idle()).toBe(true); + } finally { + await pool.end(); + } + }); + + it.runIf(!live)("drops a drained row's chain entry", async () => { + const rig = await makeRig("twitch"); + await Promise.all([1, 2, 3].map(() => rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ kind: "keep" })))); + await sleep(5); + expect((rig.stores[0] as unknown as { credentialChains: Map }).credentialChains.size).toBe(0); + }); + it("serialises deciders on one row: each sees the previous commit", timeout, async () => { const rig = await makeRig("twitch"); const order: string[] = []; @@ -531,6 +566,142 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag }); }); +describe("refreshManagedCredential without the lock", () => { + const config = testConfig(); + const custody = createTokenCustody(config); + const now = Date.parse("2026-10-19T12:00:00.000Z"); + + function identity(overrides: Partial = {}, provider: Provider = "twitch"): StoredLinkedIdentity { + return { + id: "id-1", + accountId: "acct-1", + provider, + providerUserId: "u1", + scopes: ["s"], + profileJson: {}, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + accessTokenEncrypted: custody.encrypt("access-0"), + refreshTokenEncrypted: custody.encrypt("refresh-0"), + tokenExpiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + ...overrides, + }; + } + + function countingStore(locked: StoredLinkedIdentity | null) { + const log = { locks: 0, decisions: [] as unknown[], audits: [] as unknown[] }; + const store = { + async updateLinkedIdentityCredential(_id: string, decide: (row: StoredLinkedIdentity) => Promise) { + log.locks += 1; + if (locked === null) return null; + log.decisions.push(await decide(locked)); + return locked; + }, + async createAuditEvent(event: unknown) { + log.audits.push(event); + }, + }; + return { store: store as unknown as HeimdallStore, log }; + } + + const ctxFor = (store: HeimdallStore) => ({ config, store, tokenCustody: custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => now }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("a snapshot with no access token is revoked without a lock or a provider call", async () => { + const { store, log } = countingStore(null); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), identity({ accessTokenEncrypted: undefined as never })); + expect(outcome.kind).toBe("revoked"); + expect(log.locks).toBe(0); + expect(endpoint.calls).toBe(0); + }); + + it.each([ + ["outside the skew", {}, "twitch"], + ["without an expiry", { tokenExpiresAt: undefined as never }, "twitch"], + ["without a refresh token", { refreshTokenEncrypted: undefined as never, tokenExpiresAt: new Date(now).toISOString() }, "twitch"], + ["for a provider whose runtime cannot refresh", { tokenExpiresAt: new Date(now).toISOString() }, "github"], + ] as const)("a credential %s is current with no lock and no provider call", async (_name, overrides, provider) => { + const { store, log } = countingStore(null); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const snapshot = identity(overrides as Partial, provider); + const outcome = await refreshManagedCredential(ctxFor(store), snapshot); + expect(outcome).toEqual({ kind: "current", identity: snapshot }); + expect(log.locks).toBe(0); + expect(endpoint.calls).toBe(0); + }); + + it("an expiry exactly one skew away is due; one millisecond further is not", async () => { + const due = identity({ tokenExpiresAt: new Date(now + MANAGED_CREDENTIAL_REFRESH_SKEW_MS).toISOString() }); + const notDue = identity({ tokenExpiresAt: new Date(now + MANAGED_CREDENTIAL_REFRESH_SKEW_MS + 1).toISOString() }); + const a = countingStore(due); + installTokenEndpoint("twitch", "refresh-0"); + await refreshManagedCredential(ctxFor(a.store), due); + expect(a.log.locks).toBe(1); + const b = countingStore(notDue); + await refreshManagedCredential(ctxFor(b.store), notDue); + expect(b.log.locks).toBe(0); + }); + + it("decides revoke for a grant verdict and for a missing rotated token, rotate otherwise", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const grant = countingStore(due); + installTokenEndpoint("twitch", "refresh-0").mode = "invalid_grant"; + await refreshManagedCredential(ctxFor(grant.store), due); + expect(grant.log.decisions).toEqual([{ kind: "revoke", updatedAt: new Date(now).toISOString() }]); + expect(grant.log.audits).toEqual([ + expect.objectContaining({ eventType: "managed_credential_revoked", eventPayloadJson: { provider: "twitch", reason: "provider_revoked" } }), + ]); + + const rotating = identity({ tokenExpiresAt: new Date(now).toISOString() }, "joystick"); + const missing = countingStore(rotating); + installTokenEndpoint("joystick", "refresh-0").mode = "no_refresh_token"; + await refreshManagedCredential(ctxFor(missing.store), rotating); + expect(missing.log.decisions).toEqual([{ kind: "revoke", updatedAt: new Date(now).toISOString() }]); + + const fine = countingStore(due); + installTokenEndpoint("twitch", "refresh-0"); + await refreshManagedCredential(ctxFor(fine.store), due); + expect(fine.log.decisions).toEqual([expect.objectContaining({ kind: "rotate", refreshTokenEncrypted: expect.any(String), scopes: ["s"] })]); + expect(fine.log.audits).toEqual([]); + }); + + it("a row that vanished is revoked with its tokens cleared", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const { store } = countingStore(null); + installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), due); + expect(outcome.kind).toBe("revoked"); + expect(outcome.identity.accessTokenEncrypted).toBeUndefined(); + expect(outcome.identity.refreshTokenEncrypted).toBeUndefined(); + expect(outcome.identity.tokenExpiresAt).toBeUndefined(); + expect(outcome.identity.providerUserId).toBe("u1"); + }); + + it("a row revoked by someone else between the snapshot and the lock is revoked, with no provider call", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const gone = identity({ accessTokenEncrypted: undefined as never, refreshTokenEncrypted: undefined as never, tokenExpiresAt: undefined as never }); + const { store } = countingStore(gone); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), due); + expect(outcome.kind).toBe("revoked"); + expect(endpoint.calls).toBe(0); + }); + + it("names the provider in the failure and carries nothing the provider said", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const { store } = countingStore(due); + installTokenEndpoint("twitch", "refresh-0").mode = "server_error"; + const failure = await refreshManagedCredential(ctxFor(store), due).catch((error: unknown) => error as ManagedCredentialRefreshFailed); + expect(failure).toBeInstanceOf(ManagedCredentialRefreshFailed); + expect((failure as Error).message).toBe("Twitch managed credential refresh failed."); + expect((failure as ManagedCredentialRefreshFailed).code).toBe("managed_credential_refresh_failed"); + }); +}); + describe("live Postgres availability", () => { it.skipIf(!!adminUrl)("is present whenever the job requires it", () => { // Local workstation runs skip the live suites; the verification job sets diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts index ff13d72..875598e 100644 --- a/tests/provider-token-request.test.ts +++ b/tests/provider-token-request.test.ts @@ -18,6 +18,7 @@ function configFor(): HeimdallConfig { interface Captured { url: string; + method: string | undefined; headers: Record; body: URLSearchParams; } @@ -27,6 +28,7 @@ function capture(answer: () => Response): { calls: Captured[] } { globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { calls.push({ url: String(input), + method: init?.method, headers: init?.headers as Record, body: init?.body as URLSearchParams, }); @@ -58,6 +60,7 @@ describe("requestProviderTokens follows the provider descriptor", () => { 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); @@ -108,6 +111,51 @@ describe("requestProviderTokens follows the provider descriptor", () => { ); }); + 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("treats an empty 2xx body as an empty answer, not a malformed one", async () => { + capture(() => new Response("", { status: 200 })); + const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }); + expect(tokens.accessToken).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( From 34a8c6a81d23f7468152f1ed6398ababa2254764 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:52:27 +0200 Subject: [PATCH 10/27] Tests: a late decider still queues behind the one waiting on the in-memory chain Co-Authored-By: Claude Opus 5.5 --- tests/managed-credential.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index a3b6c75..f9a9c88 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -532,6 +532,23 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag } }); + it.runIf(!live)("a decider arriving after the first drains still waits for the one queued behind it", async () => { + const rig = await makeRig("twitch"); + const events: string[] = []; + const decider = (name: string, ms: number) => async (locked: StoredLinkedIdentity) => { + events.push(`start ${name}`); + await sleep(ms); + events.push(`end ${name}`); + return { kind: "keep" as const, seen: locked }; + }; + const a = rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, decider("A", 20)); + const b = rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, decider("B", 120)); + await a; + const c = rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, decider("C", 1)); + await Promise.all([b, c]); + expect(events).toEqual(["start A", "end A", "start B", "end B", "start C", "end C"]); + }); + it.runIf(!live)("drops a drained row's chain entry", async () => { const rig = await makeRig("twitch"); await Promise.all([1, 2, 3].map(() => rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ kind: "keep" })))); From c554c0069728bcfb11393830a93b301649651e8c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 12:31:59 +0200 Subject: [PATCH 11/27] OAuth start: the profile's role lists decide which provider a mode may use sign_in and link now take only identityProviders; connect takes only managedConnectionProviders. supportsProvider(), which unioned the role lists with entitlementSources and never looked at the mode, is deleted: a Joystick sign_in for streampixels no longer starts, so a Deru sign-in cannot rewrite his managed credential row. No built-in profile had a provider reachable only through the union. Co-Authored-By: Claude Opus 5.5 --- docs/app-profiles/spotiverse.md | 2 +- docs/app-profiles/streampixels.md | 2 +- src/app-profiles.ts | 19 +++++++---- src/app.ts | 6 ++-- tests/app.test.ts | 55 +++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/docs/app-profiles/spotiverse.md b/docs/app-profiles/spotiverse.md index a10c72a..a847fba 100644 --- a/docs/app-profiles/spotiverse.md +++ b/docs/app-profiles/spotiverse.md @@ -20,7 +20,7 @@ > still reports `providers=2/6`, which counts it. > > **Why it was removed anyway:** `/v1/oauth/spotify/start` is gated by -> `supportsProvider(profile, provider)`, so a provider with no app behind it is +> `admitsProviderForMode(profile, provider, mode)`, so a provider with no app behind it is > unreachable. While the profile existed, that path was publicly reachable and > would have run a real OAuth handshake against a real client secret, taking > custody of a user's Spotify refresh token on behalf of a consumer that could diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md index 703dde7..9d1e23d 100644 --- a/docs/app-profiles/streampixels.md +++ b/docs/app-profiles/streampixels.md @@ -42,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is: - provider OAuth authority - identity-linking authority -- managed Twitch/YouTube/Joystick credential custody for creator connections (Joystick is a managed connection only, not a viewer sign-in provider) +- 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`, and refuses anything else before any redirect) - entitlement refresh - shared claim semantics diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 88466d9..8ac57dd 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -3,7 +3,7 @@ import { type CapabilityDefinition, type CapabilityMode, } from "./capability-rules.js"; -import { type AppSlug, type LinkedIdentityInput, type OAuthEntitlementPolicy, type Provider } from "./contracts.js"; +import { type AppSlug, type LinkedIdentityInput, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js"; import { entitlementFacts, grantFacts, identityFacts } from "./facts.js"; export type { CapabilityDefinition, CapabilityMode }; @@ -222,12 +222,17 @@ export function serializeAppProfile(profile: AppProfile): Record { expect(url.searchParams.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback"); }); + it.each(["sign_in", "link"] as const)( + "refuses %s for joystick on streampixels before any redirect, and Deru's managed row is untouched", + async (mode) => { + const store = new InMemoryStore(); + const app = await buildApp({ config: createTestConfig(), store }); + apps.push(app); + stubJoystick({ channel_id: "deru-chan", username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const connected = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`, + }); + const accountId = connected.json().account.id as string; + const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); + + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + payload: { appSlug: "streampixels", mode, returnTo: "https://streampixels.gamecult.org/auth/connect" }, + }); + expect(start.statusCode).toBe(400); + expect(start.json()).toMatchObject({ error: "provider_not_supported_for_app", provider: "joystick", mode }); + expect(start.json().authorizationUrl).toBeUndefined(); + expect(await store.findStoredLinkedIdentityForAccount(accountId, "joystick")).toEqual(before); + } + ); + function stubJoystick(identity: Record) { const calls: Array<{ url: string; headers: Record; body?: URLSearchParams | undefined }> = []; globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { @@ -1990,3 +2018,30 @@ 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 only, 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; + 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(); + } + }); +}); From d989860163ff1e94e68fbe5a360fee2477070aba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 12:34:17 +0200 Subject: [PATCH 12/27] Refresh classification: revoke only on a 400 invalid_grant; any other answer keeps the tokens A grant verdict is read from a 400 body by exact code, never from a 401, a 5xx or a 2xx. A 2xx that is not an object with a non-empty string access_token and no error is refused as provider_body_not_token_set, so a malformed success keeps the tokens and surfaces as the typed 502 for every provider, rotating or not. A refresh answer without expires_in stores the expiry as unknown rather than keeping the already-due one; an unknown expiry is never due, so the next resolve does not refresh again. Pins Soul's three surviving mutants: substring code match, the dropped expiry rule, and the snapshot refresh token on a non-rotating provider. Co-Authored-By: Claude Opus 5.5 --- docs/service-contract.md | 10 +- src/managed-credential.ts | 5 +- src/oauth.ts | 30 +++++- tests/managed-credential.test.ts | 146 +++++++++++++++++++++++++++ tests/provider-token-request.test.ts | 37 ++++++- 5 files changed, 216 insertions(+), 12 deletions(-) diff --git a/docs/service-contract.md b/docs/service-contract.md index 47fa7fa..001b34b 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -495,14 +495,18 @@ Important behavior: 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 (`invalid_grant`), or the identity holds no access token. + 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, - provider 5xx, timeout, malformed body). Stored tokens are untouched and a - later request may succeed + a 401 or 5xx whatever its body says, timeout, a 2xx without a string + `access_token` or carrying an `error`, an empty or non-JSON body). Stored + tokens are untouched and a later request may succeed. An answer without + `expires_in` stores the expiry as unknown, and an unknown expiry is never + due, so it does not trigger another refresh - app-local binding, diagnostics, subscription setup, and runtime behavior stay app-owned diff --git a/src/managed-credential.ts b/src/managed-credential.ts index 4da4b5a..6a06727 100644 --- a/src/managed-credential.ts +++ b/src/managed-credential.ts @@ -113,7 +113,10 @@ export async function refreshManagedCredential( refreshTokenEncrypted = locked.refreshTokenEncrypted!; } - const tokenExpiresAt = refreshed.expiresAt ?? locked.tokenExpiresAt; + // An answer without expires_in leaves the expiry unknown. The old expiry is + // already due, so keeping it would refresh again on every resolve; an + // unknown expiry is never due (dueForRefresh). + const tokenExpiresAt = refreshed.expiresAt; return { kind: "rotate", accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken), diff --git a/src/oauth.ts b/src/oauth.ts index c46976e..9593b54 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -89,7 +89,7 @@ function readOAuthErrorCode(text: string): OAuthErrorCode | undefined { */ 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. @@ -109,7 +109,14 @@ export class ProviderResponseError extends Error { async function readProviderJson(response: Response, failure: string): Promise { const text = await response.text(); if (!response.ok) { - throw new ProviderResponseError(response.status, "provider_status", failure, readOAuthErrorCode(text)); + // 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; @@ -173,13 +180,28 @@ export async function requestProviderTokens( init.signal = signal; } const label = grant.grant_type === "refresh_token" ? "refresh" : "exchange"; - const tokenResponse = await fetchJson<{ + const failure = `${descriptor.displayName} token ${label} failed`; + const response = await fetch(descriptor.tokenEndpoint, init); + const answer = await readProviderJson(response, failure); + // A 2xx is a token set only when it is an object holding a string access + // token and no error. + if ( + typeof answer !== "object" || + answer === null || + Array.isArray(answer) || + 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?: string; token_type?: string; expires_in?: number; scope?: string | string[]; - }>(descriptor.tokenEndpoint, init, `${descriptor.displayName} token ${label} failed`); + }; const tokenSet: OAuthTokenSet = { accessToken: tokenResponse.access_token, diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index f9a9c88..7758534 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -719,6 +719,152 @@ describe("refreshManagedCredential without the lock", () => { }); }); +describe("refresh answer classification", () => { + const config = testConfig(); + const custody = createTokenCustody(config); + const now = Date.parse("2026-10-19T12:00:00.000Z"); + const dueAt = new Date(now).toISOString(); + + async function seedRow(store: InMemoryStore, provider: Provider) { + const created = new Date(now).toISOString(); + const account = await store.createAccount({ createdAt: created, lastSeenAt: created }); + return store.upsertLinkedIdentity({ + accountId: account.id, + provider, + providerUserId: `u-${randomUUID()}`, + scopes: ["s"], + profileJson: {}, + accessTokenEncrypted: custody.encrypt("access-0"), + refreshTokenEncrypted: custody.encrypt("refresh-0"), + tokenExpiresAt: dueAt, + createdAt: created, + updatedAt: created, + }); + } + + const ctxFor = (store: HeimdallStore, extra: { timeoutMs?: number } = {}) => ({ + config, + store, + tokenCustody: custody, + oauthRuntimes: createOAuthRuntimeRegistry(), + clock: () => now, + ...extra, + }); + + function answer(body: string, status: number) { + const calls: string[] = []; + globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { + calls.push(String((init!.body as URLSearchParams).get("refresh_token"))); + return new Response(body, { status }); + }) as typeof fetch; + return calls; + } + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const grantVerdict = JSON.stringify({ error: "invalid_grant" }); + const keepsTokens: Array<[string, string, number]> = [ + ["an error code that only contains invalid_grant", JSON.stringify({ error: "invalid_grant_extra" }), 400], + ["an error code that is contained in a longer one", JSON.stringify({ error: "not_invalid_grant" }), 400], + ["a different error code", JSON.stringify({ error: "invalid_request" }), 400], + ["a 401 carrying invalid_grant", grantVerdict, 401], + ["a 500 carrying invalid_grant", grantVerdict, 500], + ["a 503 carrying invalid_grant", grantVerdict, 503], + ["a 2xx carrying invalid_grant", grantVerdict, 200], + ["a 2xx carrying an error beside an access token", JSON.stringify({ access_token: "a", error: "server_error" }), 200], + ["a 2xx with an empty object", "{}", 200], + ["a 2xx with an empty body", "", 200], + ["a 2xx with JSON null", "null", 200], + ["a 2xx with a JSON array", "[]", 200], + ["a 2xx with a non-string access token", JSON.stringify({ access_token: 7, refresh_token: "r" }), 200], + ["a 2xx with an empty access token", JSON.stringify({ access_token: "", refresh_token: "r" }), 200], + ["a 2xx that is not JSON", "", 200], + ["a 5xx page", "upstream exploded", 502], + ]; + + describe.each(["twitch", "joystick"] as const)("%s", (provider) => { + it.each(keepsTokens)("keeps the tokens and fails typed for %s", async (_name, body, status) => { + const store = new InMemoryStore(); + const row = await seedRow(store, provider); + const calls = answer(body, status); + await expect(refreshManagedCredential(ctxFor(store), row)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed); + expect(calls).toEqual(["refresh-0"]); + const after = (await store.findStoredLinkedIdentity(provider, row.providerUserId))!; + expect(after.accessTokenEncrypted).toBe(row.accessTokenEncrypted); + expect(after.refreshTokenEncrypted).toBe(row.refreshTokenEncrypted); + expect(after.tokenExpiresAt).toBe(dueAt); + }); + + it("keeps the tokens and fails typed when the provider never answers", async () => { + const store = new InMemoryStore(); + const row = await seedRow(store, provider); + globalThis.fetch = ((_input: string | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init!.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + })) as typeof fetch; + await expect(refreshManagedCredential(ctxFor(store, { timeoutMs: 30 }), row)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed); + const after = (await store.findStoredLinkedIdentity(provider, row.providerUserId))!; + expect(after.refreshTokenEncrypted).toBe(row.refreshTokenEncrypted); + }); + + it("revokes and clears the tokens on a 400 whose error is exactly invalid_grant", async () => { + const store = new InMemoryStore(); + const row = await seedRow(store, provider); + answer(grantVerdict, 400); + const outcome = await refreshManagedCredential(ctxFor(store), row); + expect(outcome.kind).toBe("revoked"); + const after = (await store.findStoredLinkedIdentity(provider, row.providerUserId))!; + expect(after.accessTokenEncrypted).toBeUndefined(); + expect(after.refreshTokenEncrypted).toBeUndefined(); + }); + }); + + it("a rotating provider's well-formed answer without a refresh token still revokes (rotation_missing)", async () => { + const store = new InMemoryStore(); + const row = await seedRow(store, "joystick"); + answer(JSON.stringify({ access_token: "a", expires_in: 3600 }), 200); + expect((await refreshManagedCredential(ctxFor(store), row)).kind).toBe("revoked"); + expect((await store.findStoredLinkedIdentity("joystick", row.providerUserId))!.refreshTokenEncrypted).toBeUndefined(); + }); + + it("an answer without expires_in stores the expiry as unknown, and the next resolve does not refresh again", async () => { + const store = new InMemoryStore(); + const row = await seedRow(store, "twitch"); + const calls = answer(JSON.stringify({ access_token: "access-1", refresh_token: "refresh-1" }), 200); + const first = await refreshManagedCredential(ctxFor(store), row); + expect(first.kind).toBe("current"); + expect(first.identity.tokenExpiresAt).toBeUndefined(); + const stored = (await store.findStoredLinkedIdentity("twitch", row.providerUserId))!; + expect(stored.tokenExpiresAt).toBeUndefined(); + expect(custody.decrypt(stored.accessTokenEncrypted!)).toBe("access-1"); + + await refreshManagedCredential(ctxFor(store), stored); + expect(calls).toEqual(["refresh-0"]); + }); + + it("a non-rotating provider keeps the locked row's refresh token, not the snapshot's", async () => { + const store = new InMemoryStore(); + const snapshot = await seedRow(store, "twitch"); + // Another caller rotated the pair after the snapshot; its expiry is still due. + await store.updateLinkedIdentityCredential(snapshot.id, async () => ({ + kind: "rotate", + accessTokenEncrypted: custody.encrypt("access-1"), + refreshTokenEncrypted: custody.encrypt("refresh-1"), + tokenExpiresAt: dueAt, + scopes: ["s"], + updatedAt: dueAt, + })); + const calls = answer(JSON.stringify({ access_token: "access-2", expires_in: 3600 }), 200); + await refreshManagedCredential(ctxFor(store), snapshot); + expect(calls).toEqual(["refresh-1"]); + const after = (await store.findStoredLinkedIdentity("twitch", snapshot.providerUserId))!; + expect(custody.decrypt(after.refreshTokenEncrypted!)).toBe("refresh-1"); + expect(custody.decrypt(after.accessTokenEncrypted!)).toBe("access-2"); + }); +}); + describe("live Postgres availability", () => { it.skipIf(!!adminUrl)("is present whenever the job requires it", () => { // Local workstation runs skip the live suites; the verification job sets diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts index 875598e..501a6e3 100644 --- a/tests/provider-token-request.test.ts +++ b/tests/provider-token-request.test.ts @@ -134,10 +134,26 @@ describe("requestProviderTokens follows the provider descriptor", () => { expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).scope).toEqual(["a", "b"]); }); - it("treats an empty 2xx body as an empty answer, not a malformed one", async () => { - capture(() => new Response("", { status: 200 })); - const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }); - expect(tokens.accessToken).toBeUndefined(); + 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 () => { @@ -182,6 +198,19 @@ describe("oauthError is a closed set", () => { 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" } })], From b15bf33b21b548dda18c803fef6b2758d6cc204f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 12:34:39 +0200 Subject: [PATCH 13/27] Refresh: one attempt per identity per process; waiters share its outcome Callers that arrive while a refresh of one linked identity is in flight in this process join it and receive its success or failure, instead of queuing on the row lock and presenting the same refresh token again behind a failed holder. The entry lives as long as the attempt, so a later request retries. The store lock stays the cross-process guard; the residual risk (one presentation per process per burst) is written into the service contract. Co-Authored-By: Claude Opus 5.5 --- docs/service-contract.md | 7 +++++- src/managed-credential.ts | 43 +++++++++++++++++++++++++++++--- tests/managed-credential.test.ts | 25 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/service-contract.md b/docs/service-contract.md index 001b34b..2a8a47f 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -490,7 +490,12 @@ Important behavior: 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. The provider call is bounded at 10 seconds + 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, accepted: 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 diff --git a/src/managed-credential.ts b/src/managed-credential.ts index 6a06727..617a897 100644 --- a/src/managed-credential.ts +++ b/src/managed-credential.ts @@ -60,18 +60,31 @@ function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity { 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. + * + * Accepted residual risk (docs/service-contract.md): one presentation per + * process per burst, so N processes may present one token N times. + */ +const refreshesInFlight = new WeakMap>>(); + /** * The one refresh policy. `snapshot` is a hint: it may skip the lock when no * refresh is due and never decides one. The row's lock owns when a refresh - * token is presented; the decision is made on the row as read under it. + * token is presented across processes; this process makes one attempt per + * identity at a time; the decision is made on the row as read under the lock. */ export async function refreshManagedCredential( ctx: ManagedCredentialContext, snapshot: StoredLinkedIdentity ): Promise { const clock = ctx.clock ?? Date.now; - const provider = snapshot.provider; - const runtime = ctx.oauthRuntimes[provider]; + const runtime = ctx.oauthRuntimes[snapshot.provider]; if (!snapshot.accessTokenEncrypted) { return { kind: "revoked", identity: snapshot }; @@ -80,6 +93,30 @@ export async function refreshManagedCredential( return { kind: "current", identity: snapshot }; } + let inFlight = refreshesInFlight.get(ctx.store); + if (!inFlight) { + inFlight = new Map(); + refreshesInFlight.set(ctx.store, inFlight); + } + const joined = inFlight.get(snapshot.id); + if (joined) { + return joined; + } + const attempt = refreshUnderLock(ctx, snapshot).finally(() => { + inFlight.delete(snapshot.id); + }); + inFlight.set(snapshot.id, attempt); + return attempt; +} + +async function refreshUnderLock( + ctx: ManagedCredentialContext, + snapshot: StoredLinkedIdentity +): Promise { + const clock = ctx.clock ?? Date.now; + const provider = snapshot.provider; + const runtime = ctx.oauthRuntimes[provider]; + let revocation: ManagedCredentialRevocationReason | undefined; const decide = async (locked: StoredLinkedIdentity): Promise => { if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) { diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 7758534..36cc187 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -381,6 +381,31 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1"); }); + it("single flight: concurrent refreshes against a never-answering endpoint make one provider call per process, and every caller gets the typed failure", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "hang"; + const snapshot = await rig.row(); + const ctxFor = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 150 }); + + const results = await Promise.allSettled( + Array.from({ length: 12 }, (_, i) => refreshManagedCredential(ctxFor(rig.stores[i % rig.stores.length]!), snapshot)) + ); + + expect(results.map((r) => r.status)).toEqual(Array(12).fill("rejected")); + for (const result of results) { + expect((result as PromiseRejectedResult).reason).toBeInstanceOf(ManagedCredentialRefreshFailed); + } + expect(endpoint.calls).toBe(rig.stores.length); + expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0"); + + // The burst is over: a later independent request tries again. + endpoint.mode = "normal"; + const later = await refreshManagedCredential(ctxFor(rig.stores[0]!), await rig.row()); + expect(later.kind).toBe("current"); + expect(endpoint.calls).toBe(rig.stores.length + 1); + }); + it("a snapshot taken before another caller's refresh does not decide: the locked row does", timeout, async () => { const rig = await makeRig(provider); const endpoint = installTokenEndpoint(provider, "refresh-0"); From c1fbc1261a02ac57b334fafe90c909c5387a4eb9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 12:38:13 +0200 Subject: [PATCH 14/27] OAuth start: connect keeps admitting identity providers, as Bifrost uses it The first gate refused connect for a provider outside managedConnectionProviders, which broke Bifrost's existing Discord connect. sign_in and link still take identityProviders only, so a managed-only provider (Joystick) can never sign anyone in or link; connect takes either list. Co-Authored-By: Claude Opus 5.5 --- docs/app-profiles/streampixels.md | 2 +- src/app-profiles.ts | 10 ++++++---- tests/app.test.ts | 7 +++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md index 9d1e23d..cf7b602 100644 --- a/docs/app-profiles/streampixels.md +++ b/docs/app-profiles/streampixels.md @@ -42,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is: - provider OAuth authority - identity-linking authority -- 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`, and refuses anything else before any redirect) +- 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) - entitlement refresh - shared claim semantics diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 8ac57dd..128b27a 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -225,13 +225,15 @@ export function serializeAppProfile(profile: AppProfile): Record { + 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)) { @@ -2034,7 +2034,10 @@ describe("OAuth start admits a provider by the profile's role for the mode", () url: `/v1/oauth/${provider}/start`, payload: { appSlug: profile.slug, mode, returnTo: `${profile.allowedReturnOrigins[0]}/x` }, }); - const roleList = mode === "connect" ? profile.managedConnectionProviders : profile.identityProviders; + 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)]); } From 6bdaed2a9b13a0765aabe37ecbe925034e51f02f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 12:44:20 +0200 Subject: [PATCH 15/27] Token set check: drop the array clause the access_token check already decides A JSON array has no string access_token, so the typeof check refuses it; the separate Array.isArray clause was an equivalent mutant's worth of code. Co-Authored-By: Claude Opus 5.5 --- src/oauth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/oauth.ts b/src/oauth.ts index 9593b54..39dd2c0 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -188,7 +188,6 @@ export async function requestProviderTokens( if ( typeof answer !== "object" || answer === null || - Array.isArray(answer) || typeof (answer as { access_token?: unknown }).access_token !== "string" || !(answer as { access_token: string }).access_token || (answer as { error?: unknown }).error !== undefined From 79ea0007d3d6be9b19ce6a8cf192518061eba2d9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 13:23:53 +0200 Subject: [PATCH 16/27] OAuth callback: mode and provider role decide what a completion mints The start gate only refused early. The callback ignored the signed state's mode, so a Joystick connect for StreamPixels redeemed into a viewer_access session. callbackMint(profile, provider, mode) now decides at the callback: identity providers keep a viewer session (Bifrost discord connect unchanged); a connect for a managed-connection-only provider stores custody and mints a claim with no facts, no capabilities and no refresh token (the refresh route would recompute viewer_access from the account); anything else is refused. StreamPixels' redeem reads only account, provider, mode, connection and claimSet.identities, so it accepts the capability-free claim unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/app-profiles/streampixels.md | 2 +- src/app-profiles.ts | 19 ++++++++ src/app.ts | 20 ++++++-- src/backend-handoff.ts | 8 +++- src/claims.ts | 28 +++++++---- tests/app.test.ts | 23 ++++++++- tests/managed-credential.test.ts | 78 +++++++++++++++++++++++++++++++ 7 files changed, 164 insertions(+), 14 deletions(-) diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md index cf7b602..fe101eb 100644 --- a/docs/app-profiles/streampixels.md +++ b/docs/app-profiles/streampixels.md @@ -42,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is: - provider OAuth authority - identity-linking authority -- 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) +- 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 stores the credential and mints a claim with no facts, no capabilities and no refresh token, so it can never be a viewer session) - entitlement refresh - shared claim semantics diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 128b27a..621f3cb 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -250,3 +250,22 @@ 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) stores the creator + * credential and mints a claim with no capabilities and no refresh token. + * `refused`: any other combination, which the start gate never admits. + */ +export type CallbackMint = "viewer_session" | "custody_only" | "refused"; + +export function callbackMint(profile: AppProfile, provider: Provider, mode: OAuthMode): CallbackMint { + if (profile.identityProviders.includes(provider)) { + return "viewer_session"; + } + return mode === "connect" && profile.managedConnectionProviders.includes(provider) ? "custody_only" : "refused"; +} diff --git a/src/app.ts b/src/app.ts index 0a7dd60..a60e2d6 100644 --- a/src/app.ts +++ b/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, admitsProviderForMode } 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"; @@ -1008,6 +1008,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, + Partial> { status: "success"; mode: OAuthMode; account: AccountSummary; diff --git a/src/claims.ts b/src/claims.ts index 11c42ea..b762cbd 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -39,6 +39,12 @@ export interface IssueAccessClaimInput { displayName?: string; linkedIdentities: LinkedIdentityInput[]; facts: string[]; + /** + * The claim names the account and its identities and carries no facts and + * no capabilities (a managed-credential connect for a provider that is not + * an identity provider). The facts are not even `identity.authenticated`. + */ + withoutCapabilities?: boolean; sessionId?: string; accessRevision?: number; ttlSeconds?: number; @@ -80,13 +86,17 @@ 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 sharedCapabilities = evaluateSharedCapabilities(profile, { - accountId: options.input.accountId, - facts: factSet, - identities: options.input.linkedIdentities, - }); + const withoutCapabilities = options.input.withoutCapabilities === true; + const facts = withoutCapabilities + ? [] + : normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts); + const sharedCapabilities = withoutCapabilities + ? [] + : evaluateSharedCapabilities(profile, { + accountId: options.input.accountId, + facts: new Set(facts), + identities: options.input.linkedIdentities, + }); const issuedAt = nowEpochSeconds(); const ttlSeconds = clampTtlSeconds( @@ -176,6 +186,8 @@ export async function issueAccessClaim(options: { kid: options.keys.kid, }, sharedCapabilities, - hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"), + hybridCapabilities: withoutCapabilities + ? [] + : profile.capabilities.filter((capability) => capability.mode === "hybrid"), }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index 70ed754..b086853 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -2,7 +2,7 @@ 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 } from "../src/app-profiles.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"; @@ -2048,3 +2048,24 @@ describe("OAuth start admits a provider by the profile's role for the mode", () } }); }); + +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)).toBe(expected); + }); + + it("keeps Bifrost's discord connect a viewer session", () => { + expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect")).toBe("viewer_session"); + }); +}); diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 36cc187..8a0d4d2 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -474,6 +474,84 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag }); }); + describe("what the callback mints is decided by mode and provider role", () => { + const returnTo = "https://streampixels.gamecult.org/auth/connect"; + + function stubProviders(identity: Record) { + globalThis.fetch = (async (input: string | URL) => { + const url = String(input); + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); + if (url.startsWith("https://api.joystick.tv/api/oauth/token") || Object.values(providerCatalog).some((d) => d.tokenEndpoint === url)) { + return json({ access_token: "tok-access", refresh_token: "tok-refresh-CANARY", token_type: "Bearer", expires_in: 3600 }); + } + if (url === "https://api.joystick.tv/api/v1/me/identity") return json({ channel_id: identity.joystick, username: "deru" }); + if (url === "https://api.twitch.tv/helix/users") return json({ data: [{ id: identity.twitch, login: "deru", display_name: "Deru" }] }); + if (url.startsWith("https://www.googleapis.com/youtube/v3/channels")) { + return json({ items: [{ id: identity.youtube, snippet: { title: "Deru" } }] }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + } + + async function flow(rig: Rig, provider: "joystick" | "twitch" | "youtube", mode: "connect" | "sign_in") { + const app = rig.apps[0]!; + const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo } }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + }); + expect(callback.statusCode).toBe(201); + const redeem = await app.inject({ + method: "POST", + url: "/v1/apps/streampixels/auth-completions/redeem", + payload: { completionCode: callback.json().completion.code as string }, + }); + expect(redeem.statusCode).toBe(201); + return { callback, redeem }; + } + + it("a joystick connect on streampixels stores custody and yields no viewer_access, no capability and no refresh token", timeout, async () => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { callback, redeem } = await flow(rig, "joystick", "connect"); + + for (const payload of [callback.json(), redeem.json()] as Array>) { + expect(payload.mode).toBe("connect"); + expect(payload.claimSet.capabilities).toEqual([]); + expect(payload.claimSet.facts).toEqual([]); + expect(payload.sharedCapabilities).toEqual([]); + expect(payload.hybridCapabilities).toEqual([]); + expect(payload.claimSet.identities).toEqual([expect.objectContaining({ provider: "joystick", providerUserId: "channel-1" })]); + expect(payload.account.id).toBe(rig.accountId); + expect(payload.refreshToken).toBeUndefined(); + expect(payload.refresh).toBeUndefined(); + } + expect(callback.body + redeem.body).not.toContain("viewer_access"); + expect(callback.body + redeem.body).not.toContain("tok-refresh-CANARY"); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe("tok-access"); + expect(rig.custody.decrypt(row.refreshTokenEncrypted!)).toBe("tok-refresh-CANARY"); + }); + + it.each(["twitch", "youtube"] as const)("a %s sign_in on streampixels still yields viewer_access and a refresh token", timeout, async (provider) => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { callback, redeem } = await flow(rig, provider, "sign_in"); + for (const payload of [callback.json(), redeem.json()] as Array>) { + expect(payload.claimSet.capabilities).toContain("viewer_access"); + expect(payload.refreshToken).toEqual(expect.any(String)); + } + }); + + it("a twitch connect on streampixels keeps its current behaviour: an identity provider still signs the viewer in", timeout, async () => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { redeem } = await flow(rig, "twitch", "connect"); + expect(redeem.json().claimSet.capabilities).toContain("viewer_access"); + }); + }); + describe("updateLinkedIdentityCredential", () => { it("returns null for a missing id and does not call decide", async () => { const rig = await makeRig("twitch"); From a32be41878cee2a967f7caf7b856dc38a188e9eb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 13:24:21 +0200 Subject: [PATCH 17/27] Token answers: a default lifetime replaces an unknown expiry; a malformed refresh_token is typed A token answer without a usable expires_in (absent, non-numeric, a string that is not a positive integer) now takes the descriptor's defaultAccessTokenLifetimeSeconds (3600 for every provider), so no issued token has a NULL expiry that dueForRefresh would never refresh. A 2xx whose refresh_token is present but not a string is the typed provider_body_not_token_set (502 managed_credential_refresh_failed, tokens kept); an empty string stays a missing token, so a rotating provider still fails closed. The expiry rule is now pinned on memory and both live Postgres rigs. Co-Authored-By: Claude Opus 5.5 --- docs/service-contract.md | 15 ++-- src/managed-credential.ts | 6 +- src/oauth.ts | 24 +++-- src/providers.ts | 13 +++ tests/managed-credential.test.ts | 128 +++++++++++++++++++++++---- tests/provider-token-request.test.ts | 68 ++++++++++++-- 6 files changed, 217 insertions(+), 37 deletions(-) diff --git a/docs/service-contract.md b/docs/service-contract.md index 2a8a47f..fdcc48f 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -493,7 +493,8 @@ Important behavior: 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, accepted: the guard is one presentation per process per burst, so N + 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 @@ -508,10 +509,14 @@ Important behavior: - `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`, an empty or non-JSON body). Stored - tokens are untouched and a later request may succeed. An answer without - `expires_in` stores the expiry as unknown, and an unknown expiry is never - due, so it does not trigger another refresh + `access_token` or carrying an `error`, a `refresh_token` that is present but + not a string, an empty or non-JSON body). Stored tokens are untouched and a + later request may succeed. An empty-string `refresh_token` counts as missing, + so a rotating provider revokes (`rotation_missing`). A token answer without a + usable `expires_in` (absent, non-numeric, or not a positive number or + positive-integer string) stores the expiry as now plus the provider's default + access-token lifetime (3600 seconds), so an expiry is never unknown and the + credential always comes due for refresh - app-local binding, diagnostics, subscription setup, and runtime behavior stay app-owned diff --git a/src/managed-credential.ts b/src/managed-credential.ts index 617a897..7d15e2c 100644 --- a/src/managed-credential.ts +++ b/src/managed-credential.ts @@ -150,9 +150,9 @@ async function refreshUnderLock( refreshTokenEncrypted = locked.refreshTokenEncrypted!; } - // An answer without expires_in leaves the expiry unknown. The old expiry is - // already due, so keeping it would refresh again on every resolve; an - // unknown expiry is never due (dueForRefresh). + // The token 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). const tokenExpiresAt = refreshed.expiresAt; return { kind: "rotate", diff --git a/src/oauth.ts b/src/oauth.ts index 39dd2c0..53f0f85 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -196,12 +196,19 @@ export async function requestProviderTokens( } const tokenResponse = answer as { access_token: string; - refresh_token?: string; + refresh_token?: unknown; token_type?: string; - expires_in?: number; + 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); + } + const tokenSet: OAuthTokenSet = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type ?? "Bearer", @@ -211,12 +218,19 @@ export async function requestProviderTokens( if (tokenResponse.refresh_token) { tokenSet.refreshToken = tokenResponse.refresh_token; } - if (typeof tokenResponse.expires_in === "number" && tokenResponse.expires_in > 0) { - tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); - } + // 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; + tokenSet.expiresAt = new Date(Date.now() + lifetimeSeconds * 1000).toISOString(); return tokenSet; } +/** A positive finite number, or a string that is a positive integer. */ +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 : undefined; +} + function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] { return (options) => requestProviderTokens(provider, options.config, { diff --git a/src/providers.ts b/src/providers.ts index 9b139ab..77eebf4 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -11,6 +11,12 @@ export interface ProviderDescriptor { 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; @@ -26,6 +32,7 @@ export const providerCatalog: Record = { 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", @@ -38,6 +45,7 @@ export const providerCatalog: Record = { 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", @@ -50,6 +58,7 @@ export const providerCatalog: Record = { 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", @@ -62,6 +71,7 @@ export const providerCatalog: Record = { 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", @@ -74,6 +84,7 @@ export const providerCatalog: Record = { tokenEndpoint: "https://oauth2.googleapis.com/token", tokenEndpointAuthMethod: "client_secret_post", rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: [ "openid", "email", @@ -96,6 +107,7 @@ export const providerCatalog: Record = { tokenEndpoint: "https://accounts.spotify.com/api/token", tokenEndpointAuthMethod: "client_secret_basic", rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: [ "user-read-currently-playing", "user-read-playback-state", @@ -112,6 +124,7 @@ export const providerCatalog: Record = { 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", diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 8a0d4d2..931f7ef 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -53,7 +53,23 @@ function testConfig(): HeimdallConfig { } as unknown as HeimdallConfig; } -type Mode = "normal" | "invalid_grant" | "invalid_client" | "server_error" | "not_json" | "no_refresh_token" | "hang"; +type Mode = + | "normal" + | "invalid_grant" + | "invalid_client" + | "server_error" + | "not_json" + | "no_refresh_token" + | "empty_refresh_token" + | "bad_refresh_token" + | "no_expires_in" + | "unusable_expires_in" + | "string_expires_in" + | "hang"; + +function expiresInFor(mode: Mode): unknown { + return mode === "unusable_expires_in" ? "soon" : mode === "string_expires_in" ? "7200" : 3600; +} /** * A provider token endpoint with single-use refresh tokens. Presenting a @@ -95,7 +111,18 @@ function installTokenEndpoint(provider: Provider, firstRefreshToken: string) { return new Response(JSON.stringify({ access_token: "access-norefresh", token_type: "Bearer", expires_in: 3600 }), { status: 200, }); - case "normal": { + case "empty_refresh_token": + return new Response(JSON.stringify({ access_token: "access-empty", refresh_token: "", token_type: "Bearer", expires_in: 3600 }), { + status: 200, + }); + case "bad_refresh_token": + return new Response(JSON.stringify({ access_token: "access-bad", refresh_token: 42, token_type: "Bearer", expires_in: 3600 }), { + status: 200, + }); + case "normal": + case "no_expires_in": + case "unusable_expires_in": + case "string_expires_in": { if (state.installationRevoked || presented !== state.live) { state.installationRevoked = true; return new Response(JSON.stringify({ error: "invalid_grant", error_description: "reused CANARY" }), { status: 400 }); @@ -107,7 +134,7 @@ function installTokenEndpoint(provider: Provider, firstRefreshToken: string) { access_token: `access-${state.issued}`, refresh_token: state.live, token_type: "Bearer", - expires_in: 3600, + ...(state.mode === "no_expires_in" ? {} : { expires_in: expiresInFor(state.mode) }), }), { status: 200 } ); @@ -329,7 +356,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag expect(first.body + second.body + JSON.stringify(events)).not.toContain("CANARY"); }); - it.each(["invalid_client", "server_error", "not_json", "hang"] as const)( + it.each(["invalid_client", "server_error", "not_json", "bad_refresh_token", "hang"] as const)( "transient failure (%s) keeps custody: 502, tokens byte-identical, nothing leaked", timeout, async (mode) => { @@ -422,15 +449,16 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag }); if (providerCatalog[provider].rotatesRefreshTokens) { - it("rotation-fails-closed: a response without a refresh token revokes, 410, and the old token is never presented again", timeout, async () => { + it.each(["no_refresh_token", "empty_refresh_token"] as const)("rotation-fails-closed: a response with %s revokes, 410, and the old token is never presented again", timeout, async (mode) => { const rig = await makeRig(provider); const endpoint = installTokenEndpoint(provider, "refresh-0"); - endpoint.mode = "no_refresh_token"; + endpoint.mode = mode; const first = await rig.resolve(); expect(first.statusCode).toBe(410); expect(first.json).toEqual({ error: "managed_credential_revoked", provider }); expect(first.body).not.toContain("access-norefresh"); + expect(first.body).not.toContain("access-empty"); const row = await rig.row(); expect(row.accessTokenEncrypted).toBeUndefined(); expect(row.refreshTokenEncrypted).toBeUndefined(); @@ -444,17 +472,56 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag ]); }); } else { - it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => { + it.each(["no_refresh_token", "empty_refresh_token"] as const)("a response with %s keeps the old one for a non-rotating provider", timeout, async (mode) => { const rig = await makeRig(provider); const endpoint = installTokenEndpoint(provider, "refresh-0"); - endpoint.mode = "no_refresh_token"; + endpoint.mode = mode; const answer = await rig.resolve(); expect(answer.statusCode).toBe(200); - expect(answer.json.accessToken).toBe("access-norefresh"); + expect(answer.json.accessToken).toBe(mode === "no_refresh_token" ? "access-norefresh" : "access-empty"); expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0"); }); } + it.each([ + ["no expires_in", "no_expires_in", 3600], + ["an unusable expires_in", "unusable_expires_in", 3600], + ["a numeric-string expires_in", "string_expires_in", 7200], + ] as const)( + "refresh-once-per-token: an answer with %s stores now plus %s s, not the old due expiry, and resolve refreshes again only once it passes", + timeout, + async (_name, mode, seconds) => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = mode; + + const before = Date.now(); + expect((await rig.resolve()).statusCode).toBe(200); + expect(endpoint.calls).toBe(1); + const stored = Date.parse((await rig.row()).tokenExpiresAt!); + expect(stored - before).toBeGreaterThanOrEqual(seconds * 1000 - 1000); + expect(stored - before).toBeLessThan(seconds * 1000 + 10_000); + + expect((await rig.resolve()).statusCode).toBe(200); + expect(endpoint.calls).toBe(1); + + // The stored expiry passes: the next resolve refreshes. + const past = new Date(Date.now() - 1000).toISOString(); + await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async (locked) => ({ + kind: "rotate", + accessTokenEncrypted: locked.accessTokenEncrypted!, + refreshTokenEncrypted: locked.refreshTokenEncrypted!, + tokenExpiresAt: past, + scopes: locked.scopes, + updatedAt: past, + })); + expect((await rig.resolve()).statusCode).toBe(200); + expect(endpoint.calls).toBe(2); + const again = Date.parse((await rig.row()).tokenExpiresAt!); + expect(again).toBeGreaterThan(Date.now() + MANAGED_CREDENTIAL_REFRESH_SKEW_MS); + } + ); + it("a row with no access token resolves as revoked (410), a missing row as not found (404)", timeout, async () => { const rig = await makeRig(provider, { accessToken: null, refreshToken: null }); const endpoint = installTokenEndpoint(provider, "refresh-0"); @@ -882,6 +949,8 @@ describe("refresh answer classification", () => { ["a 2xx with JSON null", "null", 200], ["a 2xx with a JSON array", "[]", 200], ["a 2xx with a non-string access token", JSON.stringify({ access_token: 7, refresh_token: "r" }), 200], + ["a 2xx with a numeric refresh token", JSON.stringify({ access_token: "a", refresh_token: 42 }), 200], + ["a 2xx with an object refresh token", JSON.stringify({ access_token: "a", refresh_token: { r: 1 } }), 200], ["a 2xx with an empty access token", JSON.stringify({ access_token: "", refresh_token: "r" }), 200], ["a 2xx that is not JSON", "", 200], ["a 5xx page", "upstream exploded", 502], @@ -924,27 +993,50 @@ describe("refresh answer classification", () => { }); }); - it("a rotating provider's well-formed answer without a refresh token still revokes (rotation_missing)", async () => { + it.each([ + ["without a refresh token", { access_token: "a", expires_in: 3600 }], + ["with an empty refresh token", { access_token: "a", refresh_token: "", expires_in: 3600 }], + ])("a rotating provider's well-formed answer %s still revokes (rotation_missing)", async (_n, body) => { const store = new InMemoryStore(); const row = await seedRow(store, "joystick"); - answer(JSON.stringify({ access_token: "a", expires_in: 3600 }), 200); + answer(JSON.stringify(body), 200); expect((await refreshManagedCredential(ctxFor(store), row)).kind).toBe("revoked"); expect((await store.findStoredLinkedIdentity("joystick", row.providerUserId))!.refreshTokenEncrypted).toBeUndefined(); }); - it("an answer without expires_in stores the expiry as unknown, and the next resolve does not refresh again", async () => { + it.each([ + ["no expires_in", {}], + ["a non-numeric string expires_in", { expires_in: "soon" }], + ])("an answer with %s stores now plus the default lifetime; resolve refreshes once it passes", async (_n, extra) => { const store = new InMemoryStore(); - const row = await seedRow(store, "twitch"); - const calls = answer(JSON.stringify({ access_token: "access-1", refresh_token: "refresh-1" }), 200); - const first = await refreshManagedCredential(ctxFor(store), row); + const seeded = await seedRow(store, "twitch"); + // Due against the real clock, which the token answer's expiry is computed from. + const row = (await store.updateLinkedIdentityCredential(seeded.id, async (locked) => ({ + kind: "rotate", + accessTokenEncrypted: locked.accessTokenEncrypted!, + refreshTokenEncrypted: locked.refreshTokenEncrypted!, + tokenExpiresAt: new Date().toISOString(), + scopes: locked.scopes, + updatedAt: new Date().toISOString(), + })))!; + const calls = answer(JSON.stringify({ access_token: "access-1", refresh_token: "refresh-1", ...extra }), 200); + let skew = 0; + const ctx = { ...ctxFor(store), clock: () => Date.now() + skew }; + const before = Date.now(); + const first = await refreshManagedCredential(ctx, row); expect(first.kind).toBe("current"); - expect(first.identity.tokenExpiresAt).toBeUndefined(); const stored = (await store.findStoredLinkedIdentity("twitch", row.providerUserId))!; - expect(stored.tokenExpiresAt).toBeUndefined(); + const expiry = Date.parse(stored.tokenExpiresAt!); + expect(expiry - before).toBeGreaterThanOrEqual(providerCatalog.twitch.defaultAccessTokenLifetimeSeconds * 1000); + expect(expiry - before).toBeLessThan(providerCatalog.twitch.defaultAccessTokenLifetimeSeconds * 1000 + 5_000); expect(custody.decrypt(stored.accessTokenEncrypted!)).toBe("access-1"); - await refreshManagedCredential(ctxFor(store), stored); + await refreshManagedCredential(ctx, stored); expect(calls).toEqual(["refresh-0"]); + + skew = providerCatalog.twitch.defaultAccessTokenLifetimeSeconds * 1000; + await refreshManagedCredential(ctx, stored); + expect(calls).toEqual(["refresh-0", "refresh-1"]); }); it("a non-rotating provider keeps the locked row's refresh token, not the snapshot's", async () => { diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts index 501a6e3..ff415fd 100644 --- a/tests/provider-token-request.test.ts +++ b/tests/provider-token-request.test.ts @@ -90,14 +90,70 @@ describe("requestProviderTokens follows the provider descriptor", () => { 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", { access_token: "a" }], - ["a zero expires_in", { access_token: "a", expires_in: 0 }], - ["a string expires_in", { access_token: "a", expires_in: "3600" }], - ])("sets no expiry and no refresh token for %s", async (_n, body) => { - capture(() => new Response(JSON.stringify(body), { status: 200 })); + ["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([ + ["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.expiresAt).toBeUndefined(); expect(tokens.refreshToken).toBeUndefined(); }); From 32208cc8e2b2e6cf946448b1af6d46212494eed6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 13:27:28 +0200 Subject: [PATCH 18/27] Pin the callback's refusal of a signed state the provider has no role for Re-signs a joystick connect state as sign_in and link with the app's own key and shows the callback refuses before the code exchange and writes nothing. Co-Authored-By: Claude Opus 5.5 --- tests/app.test.ts | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/app.test.ts b/tests/app.test.ts index b086853..3afe0a6 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1,3 +1,6 @@ +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"; @@ -7,7 +10,7 @@ import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backen import { type HeimdallConfig, loadConfig } from "../src/config.js"; import { entitlementFacts, identityFacts } from "../src/facts.js"; import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; -import { verifyJwt } from "../src/signing.js"; +import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; import { InMemoryStore } from "../src/store/index.js"; function createTestConfig(): HeimdallConfig { @@ -1420,6 +1423,37 @@ describe("Heimdall service", () => { return calls; } + it.each(["sign_in", "link"] as const)( + "the callback refuses a signed %s state for joystick on streampixels before the code exchange, and nothing is written", + async (mode) => { + // The start gate already refuses these words, so the state is re-signed with the app's own key. + const dir = mkdtempSync(join(tmpdir(), "heimdall-forge-")); + try { + const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; + const store = new InMemoryStore(); + const app = await buildApp({ config, store }); + apps.push(app); + const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); + const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, keys.publicKey); + expect(verified.valid).toBe(true); + const forged = signJwt({ ...(verified as { payload: Record }).payload, mode }, keys); + + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(callback.body).not.toContain("viewer_access"); + expect(calls).toEqual([]); + expect(await store.findStoredLinkedIdentity("joystick", "chan-forged")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + ); + it("exchanges the code with Basic client auth and links the identity by channel id", async () => { const store = new InMemoryStore(); const app = await buildApp({ config: createTestConfig(), store }); From 655fcc7d228b29c7ab55561c9bb1abcc606735a4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 13:29:10 +0200 Subject: [PATCH 19/27] Credential rotate decisions carry a required expiry Refresh and exchange answers now type as ExpiringTokenSet, and a rotate decision requires tokenExpiresAt, so neither store has a no-expiry rule left to mutate: a NULL expiry can only be written by a revoke. Co-Authored-By: Claude Opus 5.5 --- src/managed-credential.ts | 5 ++--- src/oauth.ts | 17 ++++++++++------- src/store/in-memory.ts | 4 +--- src/store/types.ts | 2 +- tests/managed-credential.test.ts | 1 + 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/managed-credential.ts b/src/managed-credential.ts index 7d15e2c..e2fd124 100644 --- a/src/managed-credential.ts +++ b/src/managed-credential.ts @@ -150,15 +150,14 @@ async function refreshUnderLock( refreshTokenEncrypted = locked.refreshTokenEncrypted!; } - // The token set always carries an expiry (the provider's, or the + // 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). - const tokenExpiresAt = refreshed.expiresAt; return { kind: "rotate", accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken), refreshTokenEncrypted, - ...(tokenExpiresAt !== undefined ? { tokenExpiresAt } : {}), + tokenExpiresAt: refreshed.expiresAt, scopes: refreshed.scope.length ? refreshed.scope : locked.scopes, updatedAt, }; diff --git a/src/oauth.ts b/src/oauth.ts index 53f0f85..2ad2c76 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -41,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; @@ -51,7 +54,7 @@ export interface OAuthProviderRuntime { config: HeimdallConfig; refreshToken: string; signal?: AbortSignal; - }): Promise; + }): Promise; resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; evaluateEntitlements(options: { config: HeimdallConfig; @@ -156,7 +159,7 @@ export async function requestProviderTokens( config: HeimdallConfig, grant: TokenGrant, signal?: AbortSignal -): Promise { +): Promise { const descriptor = providerCatalog[provider]; const providerConfig = config.providers[provider]; if (!providerConfig.clientId || !providerConfig.clientSecret) { @@ -209,19 +212,19 @@ export async function requestProviderTokens( throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure); } - const tokenSet: OAuthTokenSet = { + // Nothing issued here has an unknown expiry: an answer without a usable + // expires_in takes the descriptor's default lifetime. + const lifetimeSeconds = usableExpiresIn(tokenResponse.expires_in) ?? descriptor.defaultAccessTokenLifetimeSeconds; + const tokenSet: ExpiringTokenSet = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type ?? "Bearer", scope: normalizeScopes(tokenResponse.scope), + expiresAt: new Date(Date.now() + lifetimeSeconds * 1000).toISOString(), raw: tokenResponse as unknown as Record, }; if (tokenResponse.refresh_token) { tokenSet.refreshToken = tokenResponse.refresh_token; } - // 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; - tokenSet.expiresAt = new Date(Date.now() + lifetimeSeconds * 1000).toISOString(); return tokenSet; } diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 0dea560..6a53dad 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -198,9 +198,7 @@ export class InMemoryStore implements HeimdallStore { if (decision.kind === "rotate") { next.accessTokenEncrypted = decision.accessTokenEncrypted; next.refreshTokenEncrypted = decision.refreshTokenEncrypted; - if (decision.tokenExpiresAt !== undefined) { - next.tokenExpiresAt = decision.tokenExpiresAt; - } + next.tokenExpiresAt = decision.tokenExpiresAt; next.scopes = [...decision.scopes]; } this.linkedIdentities.set(id, clone(next)); diff --git a/src/store/types.ts b/src/store/types.ts index 0145626..5307e1f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -67,7 +67,7 @@ export type CredentialDecision = kind: "rotate"; accessTokenEncrypted: string; refreshTokenEncrypted: string; - tokenExpiresAt?: string; + tokenExpiresAt: string; scopes: string[]; updatedAt: string; } diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 931f7ef..7f473eb 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -738,6 +738,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag kind: "rotate", accessTokenEncrypted: `A${n}`, refreshTokenEncrypted: `R${n}`, + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), scopes: locked.scopes, updatedAt: new Date().toISOString(), }; From a911da93d8630823f24e69134f7d5c38b1baf3fe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 13:34:41 +0200 Subject: [PATCH 20/27] Claims: facts are the one owner of a capability-free claim Every shared capability needs a non-empty anyOf, so an empty fact set already yields no capabilities; the explicit empty list beside it was a second owner no test could tell apart from the first. Co-Authored-By: Claude Opus 5.5 --- src/claims.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/claims.ts b/src/claims.ts index b762cbd..4440ed3 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -90,13 +90,13 @@ export async function issueAccessClaim(options: { const facts = withoutCapabilities ? [] : normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts); - const sharedCapabilities = withoutCapabilities - ? [] - : evaluateSharedCapabilities(profile, { - accountId: options.input.accountId, - facts: new Set(facts), - identities: options.input.linkedIdentities, - }); + // 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: new Set(facts), + identities: options.input.linkedIdentities, + }); const issuedAt = nowEpochSeconds(); const ttlSeconds = clampTtlSeconds( From 26f264877a76fcb3f542c20d9d639ff39a7c7847 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 14:04:12 +0200 Subject: [PATCH 21/27] Pin the hybrid capability listing a viewer sign-in carries Co-Authored-By: Claude Opus 5.5 --- tests/managed-credential.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 7f473eb..da7f991 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -608,6 +608,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag for (const payload of [callback.json(), redeem.json()] as Array>) { expect(payload.claimSet.capabilities).toContain("viewer_access"); expect(payload.refreshToken).toEqual(expect.any(String)); + expect(payload.hybridCapabilities.map((c: { key: string }) => c.key)).toEqual(["creator_access", "creator_admin"]); } }); From e1ac84fb70708c3a3176709bf3621b9ee6757f13 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 14:34:24 +0200 Subject: [PATCH 22/27] Joystick custody needs a creator binding, at start and at the callback callbackMint now takes the connection: custody_only is a connect for a managed-connection-only provider bound to connection.kind creator with a targetId. Anything else is refused at start with creator_connection_required before any redirect, and at the callback from the signed state before the code exchange. The streampixels profile note says what Heimdall guarantees and records the S1 rule that SP's redeem treats a non-creator Joystick completion as an error, never a sign-in. Co-Authored-By: Claude Opus 5.5 --- docs/app-profiles/streampixels.md | 2 +- src/app-profiles.ts | 24 +++++++++--- src/app.ts | 12 +++++- tests/app.test.ts | 61 +++++++++++++++++++++++++++++-- tests/managed-credential.test.ts | 2 +- 5 files changed, 88 insertions(+), 13 deletions(-) diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md index fe101eb..dbd91f1 100644 --- a/docs/app-profiles/streampixels.md +++ b/docs/app-profiles/streampixels.md @@ -42,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is: - provider OAuth authority - identity-linking authority -- 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 stores the credential and mints a claim with no facts, no capabilities and no refresh token, so it can never be a viewer session) +- managed Twitch/YouTube/Joystick credential custody for creator connections (Joystick is a managed connection only, not a viewer sign-in provider: `startOAuthFlow` admits `sign_in` and `link` only for `identityProviders` and `connect` only for `managedConnectionProviders` or `identityProviders`, and refuses anything else before any redirect. The callback decides again from the signed state's mode: a `connect` for a managed-connection-only provider is admitted only with `connection.kind` `creator` and a `targetId`, refused otherwise at start and again at the callback before the code exchange; an admitted one stores the credential and mints a claim with no facts, no capabilities and no refresh token. Heimdall's claim is therefore never a viewer session. Heimdall cannot stop an app from minting its own viewer from a completion, so StreamPixels' redeem must treat a non-creator Joystick completion as an error, never as a sign-in. This is an S1 rule, recorded for SP) - entitlement refresh - shared claim semantics diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 621f3cb..3489a48 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -3,7 +3,7 @@ import { type CapabilityDefinition, type CapabilityMode, } from "./capability-rules.js"; -import { type AppSlug, type LinkedIdentityInput, type OAuthEntitlementPolicy, type OAuthMode, 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 }; @@ -257,15 +257,27 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo * 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) stores the creator - * credential and mints a claim with no capabilities and no refresh token. - * `refused`: any other combination, which the start gate never admits. + * 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): CallbackMint { +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) ? "custody_only" : "refused"; + return mode === "connect" && + profile.managedConnectionProviders.includes(provider) && + connection?.kind === "creator" && + connection.targetId !== "" + ? "custody_only" + : "refused"; } diff --git a/src/app.ts b/src/app.ts index a60e2d6..40ceb20 100644 --- a/src/app.ts +++ b/src/app.ts @@ -501,6 +501,16 @@ export async function startOAuthFlow( }; } + 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.`, + }, + }; + } + if (!isAllowedReturnOrigin(profile, input.returnTo)) { return { statusCode: 400, @@ -1009,7 +1019,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { }); describe("joystick connect", () => { - async function connect(app: FastifyInstance) { + const creator = { kind: "creator", targetId: "creator-1" }; + async function connect(app: FastifyInstance, extra: Record = { connection: creator }) { const start = await app.inject({ method: "POST", url: "/v1/oauth/joystick/start", - payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect" }, + payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra }, }); return start; } + it.each([ + ["no connection", {}], + ["an account connection", { connection: { kind: "account", targetId: "victim-1" } }], + ["an empty targetId", { connection: { kind: "creator", targetId: "" } }], + ])("refuses a joystick connect with %s at start, before any redirect", async (_name, extra) => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + const start = await connect(app, extra); + expect(start.statusCode).toBe(400); + expect(start.json().authorizationUrl).toBeUndefined(); + expect(start.json().stateToken).toBeUndefined(); + }); + + it.each([ + ["no connection", null], + ["an account connection", { kind: "account", targetId: "victim-1" }], + ["an empty targetId", { kind: "creator", targetId: "" }], + ])("the callback refuses a signed joystick connect with %s before the code exchange, and nothing is written", async (_name, connection) => { + const dir = mkdtempSync(join(tmpdir(), "heimdall-forge-")); + try { + const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; + const store = new InMemoryStore(); + const app = await buildApp({ config, store }); + apps.push(app); + const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); + const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, keys.publicKey); + expect(verified.valid).toBe(true); + const forged = signJwt({ ...(verified as { payload: Record }).payload, connection }, keys); + + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(calls).toEqual([]); + expect(await store.findStoredLinkedIdentity("joystick", "chan-forged")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("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); @@ -2096,10 +2140,19 @@ describe("callbackMint", () => { ["discord", "connect", "refused"], ["discord", "sign_in", "refused"], ] as const)("%s %s on streampixels is %s", (provider, mode, expected) => { - expect(callbackMint(streampixels, provider, mode)).toBe(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")).toBe("viewer_session"); + expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect", null)).toBe("viewer_session"); }); }); diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index da7f991..4cd7396 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -562,7 +562,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag async function flow(rig: Rig, provider: "joystick" | "twitch" | "youtube", mode: "connect" | "sign_in") { const app = rig.apps[0]!; - const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo } }); + const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } }); expect(start.statusCode).toBe(201); const callback = await app.inject({ method: "GET", From 09ea233592eabf89bc94e12f22b2ca92e863c32f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 14:34:43 +0200 Subject: [PATCH 23/27] Token answers: an expires_in beyond a year is unusable, so the default lifetime applies A value like 3e11 overflowed the Date the expiry is stored as and surfaced as an untyped error. usableExpiresIn now caps a usable value at 365 days; the refresh path is pinned on memory and live Postgres. Co-Authored-By: Claude Opus 5.5 --- src/oauth.ts | 13 +++++++++++-- tests/managed-credential.test.ts | 21 +++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/oauth.ts b/src/oauth.ts index 2ad2c76..e5411ff 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -228,10 +228,19 @@ export async function requestProviderTokens( return tokenSet; } -/** A positive finite number, or a string that is a positive integer. */ +/** 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 : undefined; + return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 && seconds <= MAX_USABLE_EXPIRES_IN_SECONDS + ? seconds + : undefined; } function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] { diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 4cd7396..8a9b8de 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -65,10 +65,23 @@ type Mode = | "no_expires_in" | "unusable_expires_in" | "string_expires_in" + | "huge_expires_in" + | "huge_string_expires_in" | "hang"; function expiresInFor(mode: Mode): unknown { - return mode === "unusable_expires_in" ? "soon" : mode === "string_expires_in" ? "7200" : 3600; + switch (mode) { + case "unusable_expires_in": + return "soon"; + case "string_expires_in": + return "7200"; + case "huge_expires_in": + return 3e11; + case "huge_string_expires_in": + return "300000000000"; + default: + return 3600; + } } /** @@ -122,7 +135,9 @@ function installTokenEndpoint(provider: Provider, firstRefreshToken: string) { case "normal": case "no_expires_in": case "unusable_expires_in": - case "string_expires_in": { + case "string_expires_in": + case "huge_expires_in": + case "huge_string_expires_in": { if (state.installationRevoked || presented !== state.live) { state.installationRevoked = true; return new Response(JSON.stringify({ error: "invalid_grant", error_description: "reused CANARY" }), { status: 400 }); @@ -487,6 +502,8 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag ["no expires_in", "no_expires_in", 3600], ["an unusable expires_in", "unusable_expires_in", 3600], ["a numeric-string expires_in", "string_expires_in", 7200], + ["a numeric expires_in beyond a year", "huge_expires_in", 3600], + ["a numeric-string expires_in beyond a year", "huge_string_expires_in", 3600], ] as const)( "refresh-once-per-token: an answer with %s stores now plus %s s, not the old due expiry, and resolve refreshes again only once it passes", timeout, From 4e312828c3cd7918c0ad08ad6754148eb70fb77b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 14:34:44 +0200 Subject: [PATCH 24/27] Pin the handoff strip: a custody-only backend_callback delivery carries no refresh token Delivers a joystick connect through a backend_callback handoff and checks the posted body has no refreshToken or refresh and empty capabilities, so the handoff spreading `issued` instead of `delivered` fails. Co-Authored-By: Claude Opus 5.5 --- tests/app.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/app.test.ts b/tests/app.test.ts index 6aed7c6..0a061df 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1409,6 +1409,44 @@ describe("Heimdall service", () => { } }); + it("a backend_callback delivery of a joystick connect carries no refresh token and no capabilities", async () => { + const callbackUrl = "https://streampixels.gamecult.org/api/heimdall/callback"; + const app = await buildApp({ + config: { ...createTestConfig(), appBackendCallbacks: { streampixels: [callbackUrl] } }, + }); + apps.push(app); + stubJoystick({ channel_id: "chan-hand", username: "deru" }); + const providerFetch = globalThis.fetch; + const deliveries: Array> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + if (String(input) === callbackUrl) { + deliveries.push(JSON.parse(String(init?.body)) as Record); + return new Response(null, { status: 204 }); + } + return providerFetch(input, init); + }) as typeof fetch; + + const start = await connect(app, { + connection: creator, + handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl }, + }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + }); + expect(callback.statusCode).toBeLessThan(300); + + expect(deliveries).toHaveLength(1); + const delivered = deliveries[0]!; + expect(delivered).toMatchObject({ status: "success", handoffKind: "backend_callback", mode: "connect", attemptId: "attempt-js-1" }); + expect(typeof delivered.accessToken).toBe("string"); + expect(delivered.refreshToken).toBeUndefined(); + expect(delivered.refresh).toBeUndefined(); + expect(delivered.claimSet.capabilities).toEqual([]); + expect(JSON.stringify(delivered)).not.toContain("js-refresh"); + }); + it("starts a streampixels connect at joystick.tv with the five scopes and the callback redirect", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); From 1d856ebd40118fe82eaf3afa57966ac2c516ffe9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 14:35:11 +0200 Subject: [PATCH 25/27] Code exchange: a rotating provider's grant without a refresh token fails the callback Joystick rotates refresh tokens, so a code-exchange answer whose refresh_token is absent or empty would store a credential the first refresh revokes. The shared exchange now throws a typed ProviderResponseError before the identity is resolved or anything is written. The residual-risk comment on the in-flight refresh map cites its follow-up. Co-Authored-By: Claude Opus 5.5 --- src/managed-credential.ts | 6 ++++-- src/oauth.ts | 12 ++++++++++-- tests/managed-credential.test.ts | 33 ++++++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/managed-credential.ts b/src/managed-credential.ts index e2fd124..32e9f51 100644 --- a/src/managed-credential.ts +++ b/src/managed-credential.ts @@ -68,8 +68,10 @@ function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity { * 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. * - * Accepted residual risk (docs/service-contract.md): one presentation per - * process per burst, so N processes may present one token N times. + * 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>>(); diff --git a/src/oauth.ts b/src/oauth.ts index e5411ff..b6658e8 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -244,12 +244,20 @@ function usableExpiresIn(value: unknown): number | undefined { } function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] { - return (options) => - requestProviderTokens(provider, options.config, { + return async (options) => { + const tokenSet = await requestProviderTokens(provider, options.config, { grant_type: "authorization_code", code: options.code, redirect_uri: options.redirectUri, }); + // A rotating provider's refresh token is the only way back to the + // credential: a grant that arrives without one would be stored as a row + // the first refresh revokes, so the callback fails before it writes. + if (providerCatalog[provider].rotatesRefreshTokens && !tokenSet.refreshToken) { + throw new ProviderResponseError(200, "provider_body_not_token_set", `${providerCatalog[provider].displayName} token exchange failed`); + } + return tokenSet; + }; } function sharedRuntimeRefresh(provider: Provider): NonNullable { diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 8a9b8de..50ca9ba 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -561,12 +561,12 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag describe("what the callback mints is decided by mode and provider role", () => { const returnTo = "https://streampixels.gamecult.org/auth/connect"; - function stubProviders(identity: Record) { + function stubProviders(identity: Record, tokenAnswer: Record = { refresh_token: "tok-refresh-CANARY" }) { globalThis.fetch = (async (input: string | URL) => { const url = String(input); const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); if (url.startsWith("https://api.joystick.tv/api/oauth/token") || Object.values(providerCatalog).some((d) => d.tokenEndpoint === url)) { - return json({ access_token: "tok-access", refresh_token: "tok-refresh-CANARY", token_type: "Bearer", expires_in: 3600 }); + return json({ access_token: "tok-access", token_type: "Bearer", expires_in: 3600, ...tokenAnswer }); } if (url === "https://api.joystick.tv/api/v1/me/identity") return json({ channel_id: identity.joystick, username: "deru" }); if (url === "https://api.twitch.tv/helix/users") return json({ data: [{ id: identity.twitch, login: "deru", display_name: "Deru" }] }); @@ -618,6 +618,35 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag expect(rig.custody.decrypt(row.refreshTokenEncrypted!)).toBe("tok-refresh-CANARY"); }); + it.each([ + ["no refresh_token", {}], + ["an empty refresh_token", { refresh_token: "" }], + ])("a rotating provider's code exchange with %s fails the callback before anything is written", timeout, async (_name, tokenAnswer) => { + const rig = await makeRig("joystick"); + const before = await rig.row(); + const auditBefore = (await rig.audit()).length; + stubProviders({ joystick: "channel-new", twitch: "t-1", youtube: "y-1" }, tokenAnswer); + const app = rig.apps[0]!; + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + payload: { appSlug: "streampixels", mode: "connect", returnTo, connection: { kind: "creator", targetId: "creator-1" } }, + }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(callback.body).not.toContain("tok-access"); + expect(await rig.row()).toEqual(before); + expect(await rig.stores[0]!.findStoredLinkedIdentity("joystick", "channel-new")).toBeNull(); + const failures = (await rig.audit()).slice(auditBefore); + expect(failures).toEqual([ + { eventType: "oauth_callback_failed", payload: expect.objectContaining({ errorClass: "ProviderResponseError", providerStatus: 200 }) }, + ]); + }); + it.each(["twitch", "youtube"] as const)("a %s sign_in on streampixels still yields viewer_access and a refresh token", timeout, async (provider) => { const rig = await makeRig("joystick"); stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); From 6db84bfae8a9bfa29b7a8bf09c25f6889b13b4aa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 14:45:35 +0200 Subject: [PATCH 26/27] Pin the year boundary, the typed exchange failure and the start refusal's code Closes StrykerJS survivors on the diff: the cap at exactly 365 days against a second over, the rotating-exchange failure's typed code (and that a non-rotating provider is unaffected), and the start refusal's error word. Co-Authored-By: Claude Opus 5.5 --- tests/app.test.ts | 9 +++++---- tests/provider-token-request.test.ts | 29 +++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/app.test.ts b/tests/app.test.ts index 0a061df..7f4d96d 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1367,14 +1367,15 @@ describe("Heimdall service", () => { } it.each([ - ["no connection", {}], - ["an account connection", { connection: { kind: "account", targetId: "victim-1" } }], - ["an empty targetId", { connection: { kind: "creator", targetId: "" } }], - ])("refuses a joystick connect with %s at start, before any redirect", async (_name, extra) => { + ["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(); }); diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts index ff415fd..6a778f5 100644 --- a/tests/provider-token-request.test.ts +++ b/tests/provider-token-request.test.ts @@ -2,7 +2,7 @@ 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 { requestProviderTokens } from "../src/oauth.js"; +import { createOAuthRuntimeRegistry, requestProviderTokens } from "../src/oauth.js"; import { providerCatalog } from "../src/providers.js"; const originalFetch = globalThis.fetch; @@ -138,6 +138,33 @@ describe("requestProviderTokens follows the provider descriptor", () => { 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 }], From 778eabaedc9b662dfd13ee93d00bbc4cd459bfcc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 15:49:34 +0200 Subject: [PATCH 27/27] Docs: map managed credential custody, the callback mint and the open follow-ups architecture.md gains a Managed credential custody section: the single refresh owner, the store-port lock, per-process single flight, typed revoked and refresh-failed answers, the provider descriptor token fields and the one token request, the mode x role gates, custody-only completions, verification layer and follow-ups by id. The spotify finding now matches the removed spiverse profile; JOYSTICK joins the provider env lists. implementation-plan and service-contract lose two stale lines. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 184 ++++++++++++++++++++++++++++++++---- docs/implementation-plan.md | 4 +- docs/service-contract.md | 2 +- 3 files changed, 168 insertions(+), 22 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 88bfeaa..8ae5c19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -305,6 +305,159 @@ Important rule: connection is tied to a user or account rather than a purely app-local secret - app-specific binding semantics and runtime behavior still stay app-owned +## Managed credential custody + +Source-grounded as of branch `eureka/joystick-h1` at 6db84bf (PR +GameCult/Heimdall#1, unmerged). Nothing here is deployed. + +### One owner: `refreshManagedCredential` + +`src/managed-credential.ts` owns every decision to present a stored refresh +token to a provider. The entitlement path (`refreshAppSession`), the Bifrost +Patreon sync and `managed-credentials/resolve` all call it; none of them reads +`tokenExpiresAt` or a provider token endpoint itself. The earlier +`refreshLinkedIdentityCredentialIfNeeded` is deleted, and callers no longer +write refreshed tokens through `upsertLinkedIdentity`: a refresh reaches the row +only through the store port below. The only other writer of credential columns +is the OAuth callback's grant write (`app.ts`, once). + +| | | +|---|---| +| Input | a `StoredLinkedIdentity` snapshot, the config, the token custody (AES-256-GCM seal), the provider runtimes, an injectable clock and timeout | +| Output | `{kind: "current" or "revoked", identity}`, or a thrown `ManagedCredentialRefreshFailed` (carries the provider and nothing the provider said) | +| Forbidden writers | any caller writing token columns or `token_expires_at` outside `updateLinkedIdentityCredential` and the callback's grant write; any caller computing "is a refresh due" | + +The snapshot is a hint. It may answer without a lock when the identity holds no +access token (`revoked`) or no refresh is due (`current`; due means a known +expiry within 5 minutes, a stored refresh token, and a runtime that refreshes). +It never decides that a refresh happens. + +### Three nested guards + +1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then + linked identity id. A caller arriving while an attempt is in flight joins it + and receives its outcome, success or failure, so nobody queues behind a + failed holder and re-presents the same token. The entry ends with the + attempt; a later request retries. +2. **Store-port lock, across processes.** + `HeimdallStore.updateLinkedIdentityCredential(id, decide)` runs `decide` + against the row as read under the lock. `PostgresStore` takes + `SELECT ... FOR UPDATE` inside a transaction; `InMemoryStore` serialises per + id on a promise chain that deletes its entry only when it is still the tail. + `decide` re-checks `dueForRefresh` on the locked row, so a process that + waited finds the token already rotated and returns `keep`. The provider call + is aborted at 10 seconds (`MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS`), which + bounds how long any waiter holds a pooled connection. +3. **Decision, not mutation.** `decide` returns a `CredentialDecision`: `keep`, + `revoke`, or `rotate` with the access token, refresh token and + `tokenExpiresAt` all required. The store applies it and returns the row. The + new pair is persisted before the access token is returned. A store that + keeps the old expiry on rotate is a type error, not a test. + +### Typed answers + +- **Revoked** (`410 managed_credential_revoked`, audit + `managed_credential_revoked {provider, reason}`, all three token columns + cleared). Two reasons only: `provider_revoked`, an HTTP 400 whose OAuth + `error` is exactly `invalid_grant`; and `rotation_missing`, a well-formed + refresh answer without a refresh token from a provider whose descriptor says + `rotatesRefreshTokens`. An identity with no access token is also `revoked`. + `404` means no linked identity exists. On the entitlement path a revoked + identity is skipped; on the Bifrost sync it is `404 + patreon_identity_not_linked` with Bifrost uncalled. +- **Refresh failed** (`502 managed_credential_refresh_failed`, tokens + untouched, retry allowed): invalid client, a 401 or 5xx whatever its body, + timeout, a 2xx without a string `access_token` or carrying `error`, a + `refresh_token` present but not a string, an empty or non-JSON body. + `ProviderResponseError.oauthError` is drawn only from the closed RFC 6749 code + set and is read only from a 400; no provider body reaches a message, stack or + audit payload. +- An empty-string `refresh_token` counts as missing. + +### Provider descriptor and the one token request + +`providerCatalog` (`src/providers.ts`) is the only place a provider's token +behaviour is written down. Fields beyond the authorize side: + +- `tokenEndpoint`: where both grants go. +- `tokenEndpointAuthMethod`: `client_secret_basic` or `client_secret_post` + (RFC 7591 names); exactly one client-auth method per request. +- `rotatesRefreshTokens`: true only for Joystick. Drives fail-closed on + refresh (`rotation_missing`) and a failed callback on exchange when the grant + carries no refresh token. +- `defaultAccessTokenLifetimeSeconds`: 3600 for all seven providers. Applied + when an answer has no usable `expires_in`; unusable means absent, non-numeric, + not a positive number or positive-integer string, or above 365 days. An + unknown expiry is therefore never stored, so every credential comes due. + +`requestProviderTokens(provider, config, grant)` in `src/oauth.ts` replaces the +ten per-provider exchange and refresh functions. Authorization-code and refresh +grants share it, it returns an `ExpiringTokenSet` (`expiresAt` required), and it +rejects a malformed 2xx typed as `provider_body_not_token_set` on both grants. +The per-provider runtimes keep only identity resolution. + +### Mode x role at the gates + +`src/app-profiles.ts` holds both questions, and the profile's role lists are +their only input: + +- `admitsProviderForMode(profile, provider, mode)`, the start gate: `sign_in` + and `link` take `identityProviders` only; `connect` takes + `managedConnectionProviders` or `identityProviders` (Bifrost connects its + identity providers). `supportsProvider` is deleted. +- `callbackMint(profile, provider, mode, connection)`, decided at the callback + from the signed state and asked again at start, so an unbound connect never + reaches a redirect: + - `viewer_session`: the provider is in `identityProviders`; a normal session + with the profile's capabilities. + - `custody_only`: a `connect` for a provider that is only a managed + connection (Joystick on StreamPixels) with `connection.kind = "creator"` and + a non-empty `targetId`. + - `refused`: everything else, before the code exchange. + +A custody-only completion stores the credential and issues a claim with no +facts, no capabilities and no hybrid capabilities (`withoutCapabilities` in +`src/claims.ts`, the one owner of a capability-free claim). The completion +payload and the `backend_callback` handoff carry no `refreshToken` or +`refresh`, because the refresh route recomputes facts from the account and would +turn that session into a viewer session. A session row still exists, and +`BackendHandoffSuccessPayload` types both fields optional for this reason. The +consumer's redeem reads only account id, provider, mode, connection and claim +identities, so the claim form is unchanged for it. + +### Verification layer + +The rules above are pinned by `tests/managed-credential.test.ts` on three rigs +(memory, Postgres with one app, Postgres with two pools), +`tests/provider-token-request.test.ts`, `tests/app.test.ts` (a matrix over all +profiles x providers x modes, callback mint, handoff strip) and +`tests/recipe-secrets.test.ts`. Evidence is the Yggdrasil `ygg-verify` run on +Linux (node:24, postgres:16, `HEIMDALL_REQUIRE_LIVE_PG=1`; 600 passed, 8 +skipped, none a live-Postgres suite), hand and StrykerJS mutants over the diff, +and Soul's pass 4 verdict `joystick:verdict:cut-h1.s4`. The deploy path's own +builder was not used. + +### Open follow-ups + +- `joystick:follow_up:cross-process-failed-refresh-marker`: single flight is per + process, so N Heimdall processes can present one token N times after a + transient failure. Needs a failed-attempt marker on the row, read under the + lock. Must land before or with Idunn onboarding. +- `joystick:follow_up:connect-start-needs-app-auth`: connect starts are + unauthenticated, so anyone can name `{creator, targetId}`; the callback + re-check proves a binding exists, not that the caller owns it. It also owns + the whitespace-only `targetId` that passes both gates and the callback's + fail-open `''` fallback for a vanished profile (finding + `joystick:finding:cut-h1.s4.whitespace-targetid-passes-custody-gate`). Must + land before the public Joystick bot or a second creator. +- `joystick:follow_up:heimdall-idunn-onboarding` (gamecult-ops): live Heimdall + is a hand-copied release under `heimdall.service`, not an Idunn-admitted + generation; the recipe's Joystick lines are declared but unproven by a + deploy. +- `joystick:follow_up:joystick-viewer-signin-public-bot`: Joystick stays out of + StreamPixels `identityProviders` until the public bot is registered; viewer + sign-in must not rewrite a creator's credential row. + ## Policy model The shared authority should not hard-code one app's access rules. It should @@ -514,24 +667,17 @@ each provider**. That URL cannot move on deploy. ### Finding: the `spotify` provider outlived its consumer -`spotiverse` is the only profile naming `spotify`, and Spotiverse was lost with -the `E:` drive — it was a small daemon exposing the Spotify API to agents, and -it no longer exists in the swarm. +`spotiverse` was the only profile naming `spotify`, and Spotiverse was lost with +the `E:` drive. The profile has since been removed from the registry +(`docs/app-profiles/spotiverse.md` is the restore spec), and +`admitsProviderForMode` refuses a provider no profile lists, so +`/v1/oauth/spotify/start` no longer reaches a handshake. -What survives it: `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and -`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET` are configured in -`/srv/heimdall/env/service.env`, the profile is advertised in the public -discovery document, and `/v1/oauth/spotify/start` is reachable from the open -web. That path would run a real OAuth handshake against a real client secret and -take custody of a user's Spotify refresh token on behalf of a consumer that -cannot use it. - -This is not an active compromise. It is a live credential and a token-custody -code path kept alive for nothing, which is exactly the shape that becomes an -incident later. Retire the profile and the provider config together, or record -why Spotiverse is expected back. Retiring is a change to `src/app-profiles.ts` -and the provider catalog and should be decided on its own, not folded into the -boundary change. +What survives: the `spotify` catalog entry and runtime, and, wherever an +operator env still holds them, `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and +`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE`. A live credential kept for a +consumer that does not exist is the shape that becomes an incident later. +Retire the provider config, or record why Spotiverse is expected back. ## Deployment modes @@ -588,14 +734,14 @@ Plain settings: - `GC_ACCESS_SIGNING_KEY_ID=...` - `GC_ACCESS_SESSION_TTL_SECONDS=3600` - `GC_ACCESS_STATE_TTL_SECONDS=600` -- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY}_CLIENT_ID=...` +- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_ID=...` - `GC_ACCESS_APP_{APP_SLUG}_BACKEND_CALLBACK_URLS=...` Secret files: - `GC_ACCESS_DATABASE_URL_FILE=/etc/gamecult/heimdall/credentials/GC_ACCESS_DATABASE_URL` - `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE=...` -- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY}_CLIENT_SECRET_FILE=...` +- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_SECRET_FILE=...` - `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET_FILE=...` - `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE=...` diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 9a1f84d..44bcbc7 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -44,7 +44,7 @@ What exists right now is: and managed creator connections - an app-authenticated managed-credential resolve endpoint so StreamPixels can call provider APIs without storing refresh tokens locally -- managed Twitch/YouTube credentials refresh inside Heimdall before resolution +- managed Twitch/YouTube/Joystick credentials refresh inside Heimdall before resolution when they are expired or near expiry - StreamPixels now starts viewer and creator Twitch/YouTube OAuth through Heimdall, redeems one-time completion codes server-side, and keeps local @@ -215,7 +215,7 @@ Landed in the first pass: Still to do in this phase: - key rotation policy instead of just stable key loading -- token custody retrieval/refresh surfaces beyond initial storage +- token custody retrieval and refresh have landed (see `docs/architecture.md`, Managed credential custody); the open pieces are the follow-ups listed there ### Phase 2: Heimdall service skeleton diff --git a/docs/service-contract.md b/docs/service-contract.md index fdcc48f..1567ae5 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -173,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