diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 8c38e6e..d3d5187 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -40,6 +40,8 @@ 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 + 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 profile/creator connector binding state in StreamPixels diff --git a/docs/service-contract.md b/docs/service-contract.md index 55ffc12..2e8c89b 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -393,6 +393,9 @@ Important behavior: - this endpoint only works for providers listed in the app profile's `managedConnectionProviders` - 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 - app-local binding, diagnostics, subscription setup, and runtime behavior stay app-owned diff --git a/src/app.ts b/src/app.ts index 974397c..3690059 100644 --- a/src/app.ts +++ b/src/app.ts @@ -28,7 +28,14 @@ import { createOAuthRuntimeRegistry, type OAuthRuntimeRegistry } from "./oauth.j import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; import { createStore, type HeimdallStore } from "./store/index.js"; -import { type CreateAccountInput, type StoredCapabilityGrant, type UpsertLinkedIdentityInput } from "./store/types.js"; +import { + type CreateAccountInput, + type StoredCapabilityGrant, + type StoredLinkedIdentity, + type UpsertLinkedIdentityInput, +} from "./store/types.js"; + +const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; interface BuildAppOptions { config?: HeimdallConfig; @@ -244,6 +251,63 @@ function secretMatches(expected: string | undefined, provided: string | undefine return expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer); } +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 acceptsBackendPolicy(appSlug: AppSlug, handoff: OAuthHandoff): boolean { if (handoff.kind !== "backend_callback") { return false; @@ -1075,8 +1139,21 @@ export async function buildApp(options: BuildAppOptions = {}): Promise; + refreshAccessToken?(options: { + config: HeimdallConfig; + refreshToken: string; + }): Promise; resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; evaluateEntitlements(options: { config: HeimdallConfig; @@ -233,6 +237,56 @@ async function exchangeTwitchAuthorizationCode(options: { return tokenSet; } +async function refreshTwitchAccessToken(options: { + config: HeimdallConfig; + refreshToken: string; +}): Promise { + const providerConfig = options.config.providers.twitch; + if (!providerConfig.clientId || !providerConfig.clientSecret) { + throw new Error("Twitch OAuth is not fully configured."); + } + + const tokenResponse = await fetchJson<{ + access_token: string; + refresh_token?: string; + token_type: string; + expires_in?: number; + scope?: string | string[]; + }>( + "https://id.twitch.tv/oauth2/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: providerConfig.clientId, + client_secret: providerConfig.clientSecret, + grant_type: "refresh_token", + refresh_token: options.refreshToken, + }), + }, + "Twitch token refresh failed" + ); + + const tokenSet: OAuthTokenSet = { + accessToken: tokenResponse.access_token, + tokenType: tokenResponse.token_type, + scope: normalizeScopes(tokenResponse.scope), + raw: tokenResponse as unknown as Record, + }; + + if (tokenResponse.refresh_token) { + tokenSet.refreshToken = tokenResponse.refresh_token; + } + + if (tokenResponse.expires_in) { + tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); + } + + return tokenSet; +} + async function exchangeYouTubeAuthorizationCode(options: { config: HeimdallConfig; code: string; @@ -286,6 +340,56 @@ async function exchangeYouTubeAuthorizationCode(options: { 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 resolveDiscordIdentity(options: { accessToken: string }): Promise { const user = await fetchJson<{ id: string; @@ -772,6 +876,7 @@ const patreonRuntime: OAuthProviderRuntime = { const twitchRuntime: OAuthProviderRuntime = { exchangeAuthorizationCode: exchangeTwitchAuthorizationCode, + refreshAccessToken: refreshTwitchAccessToken, resolveIdentity: resolveTwitchIdentity, async evaluateEntitlements() { return { facts: [], snapshots: [] }; @@ -780,6 +885,7 @@ const twitchRuntime: OAuthProviderRuntime = { const youtubeRuntime: OAuthProviderRuntime = { exchangeAuthorizationCode: exchangeYouTubeAuthorizationCode, + refreshAccessToken: refreshYouTubeAccessToken, resolveIdentity: resolveYouTubeIdentity, async evaluateEntitlements() { return { facts: [], snapshots: [] }; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 7678d58..824a0f5 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -20,3 +20,4 @@ {"ts": "2026-04-29T15:23:01+00:00", "type": "implementation", "status": "accepted", "note": "Twitch token exchange can return scope as an array; Heimdall now normalizes provider token scopes from string or array before storing managed credentials."} {"ts": "2026-04-29T15:25:08+00:00", "type": "verification", "status": "accepted", "note": "Live StreamPixels Twitch sign-in through Heimdall succeeded on Yggdrasil: Heimdall audit shows oauth_callback_succeeded, auth_completion_created, and auth_completion_redeemed for twitch after the scope normalization deploy."} {"ts": "2026-04-29T16:17:34+00:00", "type": "design", "status": "accepted", "note": "Twitch EventSub subscription management must use the same Twitch application Client-ID as Heimdall creator OAuth, because Twitch authorizes EventSub creation against the app token Client-ID even when StreamPixels stores no creator refresh token."} +{"ts": "2026-04-29T16:24:26+00:00", "type": "implementation", "status": "accepted", "note": "Heimdall managed-credential resolution now refreshes Twitch and YouTube access tokens when they are expired or within the refresh window, preserving refresh-token custody in Heimdall while returning only access tokens to app runtimes."} diff --git a/tests/app.test.ts b/tests/app.test.ts index a09298b..fbc26e0 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -656,6 +656,94 @@ describe("Heimdall service", () => { expect(credentialResponse.body).not.toContain("twitch-refresh-token"); }); + it("refreshes expiring app-managed provider credentials before resolving them", async () => { + const store = new InMemoryStore(); + const app = await buildApp({ + config: createTestConfig(), + store, + oauthRuntimes: { + twitch: { + async exchangeAuthorizationCode() { + return { + accessToken: "expired-twitch-access-token", + refreshToken: "twitch-refresh-token", + tokenType: "Bearer", + scope: ["user:read:email"], + expiresAt: "2026-04-26T13:00:00.000Z", + raw: { source: "test" }, + }; + }, + async refreshAccessToken({ refreshToken }) { + expect(refreshToken).toBe("twitch-refresh-token"); + return { + accessToken: "fresh-twitch-access-token", + refreshToken: "rotated-twitch-refresh-token", + tokenType: "Bearer", + scope: ["user:read:email", "user:read:chat"], + expiresAt: "2999-04-26T13:00:00.000Z", + raw: { source: "refresh" }, + }; + }, + async resolveIdentity() { + return { + provider: "twitch", + providerUserId: "twitch-user-123", + username: "pixelpaladin", + displayName: "PixelPaladin", + profile: { id: "twitch-user-123" }, + }; + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, + }, + }, + }); + apps.push(app); + + const startResponse = await app.inject({ + method: "POST", + url: "/v1/oauth/twitch/start", + payload: { + appSlug: "streampixels", + mode: "connect", + returnTo: "https://streampixels.gamecult.org/auth/connect", + }, + }); + const stateToken = startResponse.json().stateToken as string; + + const callbackResponse = await app.inject({ + method: "GET", + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + }); + const accountId = callbackResponse.json().account.id as string; + + const credentialResponse = await app.inject({ + method: "POST", + url: "/v1/apps/streampixels/managed-credentials/resolve", + headers: { + "x-heimdall-app-secret": "streampixels-secret", + }, + payload: { + accountId, + provider: "twitch", + }, + }); + + expect(credentialResponse.statusCode).toBe(200); + expect(credentialResponse.json()).toEqual( + expect.objectContaining({ + accountId, + provider: "twitch", + providerUserId: "twitch-user-123", + accessToken: "fresh-twitch-access-token", + tokenExpiresAt: "2999-04-26T13:00:00.000Z", + scopes: ["user:read:email", "user:read:chat"], + }) + ); + expect(credentialResponse.body).not.toContain("rotated-twitch-refresh-token"); + }); + it("accepts Twitch token scopes returned as an array", async () => { globalThis.fetch = async () => new Response(