diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index d3d5187..b4cec3e 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -36,6 +36,10 @@ What exists right now is: - a local verifier helper that app backends can use against Heimdall JWKS - Repixelizer has consumed the direct backend callback flow in its hosted web layer and verifies Heimdall Ed25519 access tokens locally +- signed refresh tokens and `/v1/apps/{appSlug}/sessions/refresh` let app + backends reissue short-lived app claims without restarting provider OAuth +- Discord and Patreon access-token refresh landed for Repixelizer entitlement + re-evaluation - generic Twitch and YouTube OAuth callback runtimes for StreamPixels identity and managed creator connections - an app-authenticated managed-credential resolve endpoint so StreamPixels can @@ -191,7 +195,7 @@ Landed in the first pass: Next in this phase: -- provider refresh/revocation flows +- revocation flows - connection lifecycle and token-rotation work ### Phase 3: Repixelizer binding diff --git a/docs/service-contract.md b/docs/service-contract.md index 2e8c89b..700cb65 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -211,7 +211,12 @@ Success response for backend-callback handoff now includes: "sessionId": "uuid", "appSlug": "repixelizer", "accessRevision": 1, - "expiresAt": "2026-04-26T13:00:00.000Z" + "expiresAt": "2026-05-26T12:00:00.000Z" + }, + "accessToken": "", + "refreshToken": "", + "refresh": { + "expiresAt": "2026-05-26T12:00:00.000Z" } } ``` @@ -255,6 +260,10 @@ Current payload shape on success: }, "session": {}, "accessToken": "", + "refreshToken": "", + "refresh": { + "expiresAt": "2026-05-26T12:00:00.000Z" + }, "claimSet": {}, "verification": {}, "sharedCapabilities": ["app_access", "queue_submit"], @@ -330,9 +339,13 @@ Response: "sessionId": "uuid", "appSlug": "repixelizer", "accessRevision": 1, - "expiresAt": "2026-04-26T13:00:00.000Z" + "expiresAt": "2026-05-26T12:00:00.000Z" }, "accessToken": "", + "refreshToken": "", + "refresh": { + "expiresAt": "2026-05-26T12:00:00.000Z" + }, "claimSet": {}, "verification": { "issuer": "https://heimdall.gamecult.org", @@ -352,6 +365,68 @@ Important behavior: - app backends should redeem them server-side - the browser should not keep the final access token in callback URL fragments +### `POST /v1/apps/{appSlug}/sessions/refresh` + +Purpose: + +- reissue a short-lived app access claim from a longer-lived Heimdall refresh + token +- let app backends recover from an expired local access cookie without sending + the user through provider OAuth again +- keep provider access/refresh custody and entitlement refresh inside Heimdall + +Request body for Repixelizer: + +```json +{ + "refreshToken": "", + "entitlementPolicies": [ + { + "kind": "discord_role_access", + "guildId": "gamecult-guild", + "allowedRoleIds": ["role-repixelizer"] + }, + { + "kind": "patreon_membership_access", + "requiredTierTitle": "Inner Sanctum" + } + ] +} +``` + +Response: + +```json +{ + "session": { + "accountId": "acct_repixelizer_001", + "sessionId": "uuid", + "appSlug": "repixelizer", + "accessRevision": 1, + "expiresAt": "2026-05-26T12:00:00.000Z" + }, + "accessToken": "", + "refreshToken": "", + "refresh": { + "expiresAt": "2026-05-26T12:00:00.000Z" + }, + "sharedCapabilities": ["app_access", "queue_submit"], + "hybridCapabilities": [] +} +``` + +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 +- Repixelizer may send all of its app-owned provider policies; Heimdall matches + each stored identity to the policy for that provider +- the endpoint is not a provider OAuth start path and should not produce a + provider authorization URL + ### `POST /v1/apps/{appSlug}/managed-credentials/resolve` Purpose: @@ -458,9 +533,13 @@ Response: "sessionId": "uuid", "appSlug": "repixelizer", "accessRevision": 1, - "expiresAt": "2026-04-26T13:00:00.000Z" + "expiresAt": "2026-05-26T12:00:00.000Z" }, "accessToken": "", + "refreshToken": "", + "refresh": { + "expiresAt": "2026-05-26T12:00:00.000Z" + }, "claimSet": {}, "verification": { "issuer": "https://heimdall.gamecult.org", @@ -514,7 +593,7 @@ Examples: The following are not landed yet: -- refresh/revocation flows +- revocation flows - admin/grant management surfaces - GitHub callback runtime diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 01bef52..1a5ebd8 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -44,6 +44,7 @@ Do not trust this file for the exact live HEAD. Always check git. - `/v1/oauth/{provider}/callback` - `/v1/apps/{appSlug}/auth-completions/redeem` - `/v1/apps/{appSlug}/claims/issue` + - `/v1/apps/{appSlug}/sessions/refresh` - `/v1/apps/{appSlug}/managed-credentials/resolve` - the Discord callback path now performs real code exchange, identity resolution, auth persistence, entitlement evaluation, audit logging, and @@ -72,6 +73,10 @@ Do not trust this file for the exact live HEAD. Always check git. enabled - Repixelizer public auth-start returns Discord and Patreon authorization URLs using the Heimdall callback and Repixelizer backend handoff +- Repixelizer auth now has a refresh path: Heimdall issues signed refresh + tokens with access claims, `/v1/apps/{appSlug}/sessions/refresh` reissues + short-lived access claims, and Discord/Patreon provider tokens refresh inside + Heimdall before entitlement re-evaluation - Twitch and YouTube now have generic OAuth exchange and identity runtimes - GitHub is still catalogued, but its callback runtime is not implemented - StreamPixels now delegates viewer and creator Twitch/YouTube OAuth to diff --git a/src/app.ts b/src/app.ts index 3690059..4aac881 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,8 @@ import { type OAuthMode, type OAuthConnectionBinding, type RedeemAuthCompletionRequest, + type RefreshSessionRequest, + type RefreshTokenPayload, type OAuthStatePayload, type OAuthStartRequest, type Provider, @@ -167,6 +169,55 @@ function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] { return grants.map((grant) => grantFact(grant.capability)); } +function parseRefreshTokenPayload(payload: Record): RefreshTokenPayload | null { + if ( + payload.typ !== "heimdall_refresh" || + typeof payload.iss !== "string" || + typeof payload.aud !== "string" || + !appSlugs.includes(payload.aud as AppSlug) || + typeof payload.sub !== "string" || + typeof payload.sid !== "string" || + typeof payload.jti !== "string" || + typeof payload.iat !== "number" || + typeof payload.nbf !== "number" || + typeof payload.exp !== "number" || + typeof payload.account_id !== "string" || + typeof payload.access_revision !== "number" || + !payload.app || + typeof payload.app !== "object" || + (payload.app as Record).slug !== payload.aud || + typeof (payload.app as Record).profile_version !== "string" + ) { + return null; + } + + return payload as unknown as RefreshTokenPayload; +} + +function verifyRefreshToken( + token: string, + appSlug: AppSlug, + config: HeimdallConfig, + keys: ReturnType +): RefreshTokenPayload | null { + const verification = verifyJwt(token, keys.publicKey); + if (!verification.valid) { + return null; + } + + const payload = parseRefreshTokenPayload(verification.payload); + if (!payload || payload.iss !== config.issuer || payload.aud !== appSlug || payload.app.slug !== appSlug) { + return null; + } + + const now = nowEpochSeconds(); + if (payload.nbf > now + 30 || payload.exp <= now - 30) { + return null; + } + + return payload; +} + function prefersHtml(acceptHeader: string | undefined): boolean { if (!acceptHeader) { return false; @@ -236,6 +287,24 @@ function normalizeEntitlementPolicy(value: OAuthStartRequest["entitlementPolicy" throw new Error("Invalid entitlement policy definition."); } +function normalizeEntitlementPolicies(value: RefreshSessionRequest["entitlementPolicies"]): OAuthEntitlementPolicy[] { + if (!value) { + return []; + } + + return value.map((entry) => { + const policy = normalizeEntitlementPolicy(entry); + if (!policy) { + throw new Error("Invalid entitlement policy definition."); + } + return policy; + }); +} + +function entitlementPolicyProvider(policy: OAuthEntitlementPolicy): Provider { + return policy.kind === "discord_role_access" ? "discord" : "patreon"; +} + function getSharedSecret(request: { headers: Record }): string | undefined { const header = request.headers["x-heimdall-app-secret"]; return Array.isArray(header) ? header[0] : header; @@ -308,6 +377,87 @@ async function refreshLinkedIdentityCredentialIfNeeded(options: { return options.store.upsertLinkedIdentity(upsertInput); } +function storedIdentityToResolvedIdentity(identity: StoredLinkedIdentity) { + const resolved = { + provider: identity.provider, + providerUserId: identity.providerUserId, + profile: identity.profileJson, + }; + if (identity.username !== undefined) { + Object.assign(resolved, { username: identity.username }); + } + if (identity.displayName !== undefined) { + Object.assign(resolved, { displayName: identity.displayName }); + } + if (identity.primaryEmail !== undefined) { + Object.assign(resolved, { primaryEmail: identity.primaryEmail }); + } + return resolved; +} + +async function evaluateStoredEntitlements(options: { + config: HeimdallConfig; + store: HeimdallStore; + tokenCustody: TokenCustody; + oauthRuntimes: OAuthRuntimeRegistry; + appSlug: AppSlug; + accountId: string; + entitlementPolicies: OAuthEntitlementPolicy[]; + now: string; +}): Promise { + const profile = getAppProfile(options.appSlug); + const linkedIdentities = await options.store.listStoredLinkedIdentitiesForAccount(options.accountId); + const facts: string[] = []; + + for (const storedIdentity of linkedIdentities) { + if (!profile.entitlementSources.includes(storedIdentity.provider) || !storedIdentity.accessTokenEncrypted) { + 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) { + continue; + } + + const runtime = options.oauthRuntimes[linkedIdentity.provider]; + const entitlementPolicy = + options.entitlementPolicies.find((policy) => entitlementPolicyProvider(policy) === linkedIdentity.provider) ?? null; + const tokenSet = { + accessToken: options.tokenCustody.decrypt(linkedIdentity.accessTokenEncrypted), + tokenType: "Bearer", + scope: linkedIdentity.scopes, + raw: {}, + }; + if (linkedIdentity.tokenExpiresAt !== undefined) { + Object.assign(tokenSet, { expiresAt: linkedIdentity.tokenExpiresAt }); + } + const evaluation = await runtime.evaluateEntitlements({ + config: options.config, + callback: { + appSlug: options.appSlug, + accountId: options.accountId, + connection: null, + entitlementPolicy, + }, + identity: storedIdentityToResolvedIdentity(linkedIdentity), + tokenSet, + }); + for (const snapshot of evaluation.snapshots) { + await options.store.upsertEntitlementSnapshot(snapshot); + } + facts.push(...evaluation.facts); + } + + return [...new Set(facts)]; +} + function acceptsBackendPolicy(appSlug: AppSlug, handoff: OAuthHandoff): boolean { if (handoff.kind !== "backend_callback") { return false; @@ -1095,6 +1245,166 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + "/v1/apps/:appSlug/sessions/refresh", + { + schema: { + params: { + type: "object", + required: ["appSlug"], + additionalProperties: false, + properties: { + appSlug: { type: "string", enum: [...appSlugs] }, + }, + }, + body: { + type: "object", + required: ["refreshToken"], + additionalProperties: false, + properties: { + refreshToken: { type: "string", minLength: 1 }, + entitlementPolicy: { + anyOf: [ + { + type: "object", + required: ["kind", "guildId", "allowedRoleIds"], + additionalProperties: false, + properties: { + kind: { type: "string", const: "discord_role_access" }, + guildId: { type: "string", minLength: 1 }, + allowedRoleIds: { + type: "array", + minItems: 1, + items: { type: "string", minLength: 1 }, + uniqueItems: true, + }, + }, + }, + { + type: "object", + required: ["kind", "requiredTierTitle"], + additionalProperties: false, + properties: { + kind: { type: "string", const: "patreon_membership_access" }, + requiredTierTitle: { type: "string", minLength: 1 }, + }, + }, + ], + }, + entitlementPolicies: { + type: "array", + items: { + anyOf: [ + { + type: "object", + required: ["kind", "guildId", "allowedRoleIds"], + additionalProperties: false, + properties: { + kind: { type: "string", const: "discord_role_access" }, + guildId: { type: "string", minLength: 1 }, + allowedRoleIds: { + type: "array", + minItems: 1, + items: { type: "string", minLength: 1 }, + uniqueItems: true, + }, + }, + }, + { + type: "object", + required: ["kind", "requiredTierTitle"], + additionalProperties: false, + properties: { + kind: { type: "string", const: "patreon_membership_access" }, + requiredTierTitle: { type: "string", minLength: 1 }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + async (request, reply) => { + let entitlementPolicies: OAuthEntitlementPolicy[]; + try { + entitlementPolicies = normalizeEntitlementPolicies(request.body.entitlementPolicies); + const legacyPolicy = normalizeEntitlementPolicy(request.body.entitlementPolicy); + if (legacyPolicy) { + entitlementPolicies.push(legacyPolicy); + } + } catch (error) { + reply.code(400); + return { + error: "invalid_entitlement_policy", + detail: error instanceof Error ? error.message : "Invalid entitlement policy definition.", + }; + } + if (entitlementPolicies.length > 0 && request.params.appSlug !== "repixelizer") { + reply.code(400); + return { + error: "untrusted_entitlement_policy", + detail: "Caller-supplied entitlement policy is only accepted for Repixelizer session refresh.", + }; + } + + const refreshClaim = verifyRefreshToken(request.body.refreshToken, request.params.appSlug, config, keys); + if (!refreshClaim) { + reply.code(401); + return { error: "invalid_or_expired_refresh_token" }; + } + + const nowIso = new Date().toISOString(); + const account = await store.findAccountById(refreshClaim.account_id); + const linkedIdentities = await store.listLinkedIdentitiesForAccount(refreshClaim.account_id); + const grants = await store.listActiveGrants(refreshClaim.account_id, request.params.appSlug, nowIso); + const entitlementFactList = await evaluateStoredEntitlements({ + config, + store, + tokenCustody, + oauthRuntimes, + appSlug: request.params.appSlug, + accountId: refreshClaim.account_id, + entitlementPolicies, + now: nowIso, + }); + const issueInput: IssueAccessClaimInput = { + appSlug: request.params.appSlug, + accountId: refreshClaim.account_id, + sessionId: refreshClaim.sid, + accessRevision: refreshClaim.access_revision, + linkedIdentities, + facts: [...entitlementFactList, ...buildGrantFacts(grants)], + }; + if (account?.displayName !== undefined) { + issueInput.displayName = account.displayName; + } + + const issued = await issueAccessClaim({ + config, + keys, + store, + input: issueInput, + }); + + await store.createAuditEvent({ + accountId: refreshClaim.account_id, + sessionId: refreshClaim.sid, + appSlug: request.params.appSlug, + eventType: "session_refreshed", + eventPayloadJson: { + grantedFacts: issued.claimSet.facts, + sharedCapabilities: issued.sharedCapabilities, + }, + createdAt: nowIso, + }); + + reply.code(201); + return issued; + } + ); + app.post<{ Params: { appSlug: AppSlug }; Body: { accountId: string; provider: Provider }; diff --git a/src/claims.ts b/src/claims.ts index ad7814c..f1a483a 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -4,6 +4,7 @@ import { type AppSlug, type IssueClaimRequest, type LinkedIdentityInput, + type RefreshTokenPayload, } from "./contracts.js"; import { getAppProfile } from "./app-profiles.js"; import { type HeimdallConfig } from "./config.js"; @@ -53,6 +54,10 @@ export interface IssuedAccessClaimResult { expiresAt: string; }; accessToken: string; + refreshToken: string; + refresh: { + expiresAt: string; + }; claimSet: AccessClaimPayload; verification: { issuer: string; @@ -85,6 +90,8 @@ export async function issueAccessClaim(options: { options.config.sessionTtlSeconds ); const expiresAtEpoch = issuedAt + ttlSeconds; + const refreshTtlSeconds = Math.max(ttlSeconds, Math.trunc(options.config.refreshTtlSeconds)); + const refreshExpiresAtEpoch = issuedAt + refreshTtlSeconds; const sessionId = options.input.sessionId ?? randomUUID(); const accessRevision = options.input.accessRevision ?? 1; @@ -114,13 +121,31 @@ export async function issueAccessClaim(options: { } const accessToken = signJwt(claimSet as unknown as Record, options.keys); + const refreshClaimSet: RefreshTokenPayload = { + iss: options.config.issuer, + aud: profile.slug, + sub: options.input.accountId, + sid: sessionId, + jti: randomUUID(), + iat: issuedAt, + nbf: issuedAt, + exp: refreshExpiresAtEpoch, + typ: "heimdall_refresh", + account_id: options.input.accountId, + access_revision: accessRevision, + app: { + slug: profile.slug, + profile_version: profile.profileVersion, + }, + }; + const refreshToken = signJwt(refreshClaimSet as unknown as Record, options.keys); const sessionRecord: CreateSessionInput = { id: sessionId, accountId: options.input.accountId, appSlug: profile.slug, createdAt: new Date(issuedAt * 1000).toISOString(), lastSeenAt: new Date(issuedAt * 1000).toISOString(), - expiresAt: new Date(expiresAtEpoch * 1000).toISOString(), + expiresAt: new Date(refreshExpiresAtEpoch * 1000).toISOString(), accessRevision, claimsJson: claimSet as unknown as Record, }; @@ -135,6 +160,10 @@ export async function issueAccessClaim(options: { expiresAt: sessionRecord.expiresAt, }, accessToken, + refreshToken, + refresh: { + expiresAt: sessionRecord.expiresAt, + }, claimSet, verification: { issuer: options.config.issuer, diff --git a/src/config.ts b/src/config.ts index 52a045d..cc53cd5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,7 @@ export interface HeimdallConfig { publicBaseUrl: string; issuer: string; sessionTtlSeconds: number; + refreshTtlSeconds: number; stateTtlSeconds: number; completionTtlSeconds: number; signingPrivateKeyPem?: string; @@ -103,6 +104,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig publicBaseUrl, issuer, sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600), + refreshTtlSeconds: readInt(env.GC_ACCESS_REFRESH_TTL_SECONDS, 60 * 60 * 24 * 30), stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600), completionTtlSeconds: readInt(env.GC_ACCESS_COMPLETION_TTL_SECONDS, 300), bootstrapSigningPrivateKeyOnMissing: readBoolean(env.GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP, false), diff --git a/src/contracts.ts b/src/contracts.ts index f9692c1..c707d28 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -98,6 +98,12 @@ export interface RedeemAuthCompletionRequest { completionCode: string; } +export interface RefreshSessionRequest { + refreshToken: string; + entitlementPolicy?: OAuthEntitlementPolicy; + entitlementPolicies?: OAuthEntitlementPolicy[]; +} + export interface AccessClaimPayload { iss: string; aud: AppSlug; @@ -119,3 +125,21 @@ export interface AccessClaimPayload { capabilities: string[]; identities: LinkedIdentityInput[]; } + +export interface RefreshTokenPayload { + iss: string; + aud: AppSlug; + sub: string; + sid: string; + jti: string; + iat: number; + nbf: number; + exp: number; + typ: "heimdall_refresh"; + account_id: string; + access_revision: number; + app: { + slug: AppSlug; + profile_version: string; + }; +} diff --git a/src/oauth.ts b/src/oauth.ts index 6ffdf92..d3d72b9 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -132,6 +132,53 @@ async function exchangeDiscordAuthorizationCode(options: { 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 basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); + const tokenResponse = await fetchJson<{ + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; + scope?: string | string[]; + }>( + "https://discord.com/api/v10/oauth2/token", + { + method: "POST", + headers: { + Authorization: `Basic ${basicAuth}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: options.refreshToken, + }), + }, + "Discord token refresh failed" + ); + + const tokenSet: OAuthTokenSet = { + accessToken: tokenResponse.access_token, + tokenType: tokenResponse.token_type, + scope: normalizeScopes(tokenResponse.scope), + expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), + raw: tokenResponse as unknown as Record, + }; + + if (tokenResponse.refresh_token) { + tokenSet.refreshToken = tokenResponse.refresh_token; + } + + return tokenSet; +} + async function exchangePatreonAuthorizationCode(options: { config: HeimdallConfig; code: string; @@ -185,6 +232,56 @@ async function exchangePatreonAuthorizationCode(options: { 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; +} + async function exchangeTwitchAuthorizationCode(options: { config: HeimdallConfig; code: string; @@ -864,12 +961,14 @@ async function evaluatePatreonEntitlements(options: { const discordRuntime: OAuthProviderRuntime = { exchangeAuthorizationCode: exchangeDiscordAuthorizationCode, + refreshAccessToken: refreshDiscordAccessToken, resolveIdentity: resolveDiscordIdentity, evaluateEntitlements: evaluateDiscordEntitlements, }; const patreonRuntime: OAuthProviderRuntime = { exchangeAuthorizationCode: exchangePatreonAuthorizationCode, + refreshAccessToken: refreshPatreonAccessToken, resolveIdentity: resolvePatreonIdentity, evaluateEntitlements: evaluatePatreonEntitlements, }; diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 5b0fc6f..869c931 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -83,6 +83,10 @@ export class InMemoryStore implements HeimdallStore { } } + async findAccountById(accountId: string): Promise { + return clone(this.accounts.get(accountId) ?? null); + } + async findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise { for (const linkedIdentity of this.linkedIdentities.values()) { if (linkedIdentity.provider === provider && linkedIdentity.providerUserId === providerUserId) { @@ -113,6 +117,12 @@ export class InMemoryStore implements HeimdallStore { return null; } + async listStoredLinkedIdentitiesForAccount(accountId: string): Promise { + return [...this.linkedIdentities.values()] + .filter((identity) => identity.accountId === accountId) + .map((identity) => clone(identity)); + } + async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise { const existing = [...this.linkedIdentities.values()].find( (identity) => identity.provider === input.provider && identity.providerUserId === input.providerUserId diff --git a/src/store/postgres.ts b/src/store/postgres.ts index d374b36..e389202 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -258,6 +258,20 @@ export class PostgresStore implements HeimdallStore { ); } + async findAccountById(accountId: string): Promise { + const result = await this.pool.query( + ` + SELECT * + FROM accounts + WHERE id = $1 + LIMIT 1 + `, + [accountId] + ); + + return result.rowCount ? mapAccountRow(expectRow(result.rows[0], "findAccountById")) : null; + } + async findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise { const result = await this.pool.query( ` @@ -305,6 +319,20 @@ export class PostgresStore implements HeimdallStore { return result.rowCount ? mapLinkedIdentityRow(expectRow(result.rows[0], "findStoredLinkedIdentityForAccount")) : null; } + async listStoredLinkedIdentitiesForAccount(accountId: string): Promise { + const result = await this.pool.query( + ` + SELECT * + FROM linked_identities + WHERE account_id = $1 + ORDER BY created_at ASC + `, + [accountId] + ); + + return result.rows.map(mapLinkedIdentityRow); + } + async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise { const id = input.id ?? randomUUID(); const result = await this.pool.query( @@ -450,6 +478,12 @@ export class PostgresStore implements HeimdallStore { expires_at, claims_json, access_revision ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) + DO UPDATE SET + last_seen_at = EXCLUDED.last_seen_at, + expires_at = EXCLUDED.expires_at, + claims_json = EXCLUDED.claims_json, + access_revision = EXCLUDED.access_revision RETURNING * `, [ diff --git a/src/store/types.ts b/src/store/types.ts index 730b9af..f276ebd 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -153,9 +153,11 @@ export interface HeimdallStore { close(): Promise; createAccount(input: CreateAccountInput): Promise; touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise; + findAccountById(accountId: string): Promise; findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise; findStoredLinkedIdentity(provider: Provider, providerUserId: string): Promise; findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise; + listStoredLinkedIdentitiesForAccount(accountId: string): Promise; upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise; listLinkedIdentitiesForAccount(accountId: string): Promise; createCapabilityGrant(input: CreateCapabilityGrantInput): Promise; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 824a0f5..b2cd28f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -21,3 +21,4 @@ {"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."} +{"ts": "2026-05-20T12:43:02+00:00", "type": "implementation", "status": "accepted", "note": "Heimdall now issues signed app refresh tokens, exposes /v1/apps/{appSlug}/sessions/refresh, refreshes Discord/Patreon provider tokens before Repixelizer entitlement re-evaluation, and reissues short-lived app access claims without restarting provider OAuth."} diff --git a/state/map.yaml b/state/map.yaml index 13eb990..1206a7e 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -73,6 +73,8 @@ accepted_design: - The first stateful slice adds durable auth/control-plane storage plus a real Discord callback path for Repixelizer. - Repixelizer has consumed the Heimdall backend-callback flow in its hosted web layer; Heimdall no longer needs to wait on Repixelizer refactoring before live Discord launch work. - Heimdall is deployed on Yggdrasil at `https://heimdall.gamecult.org` with nginx/TLS, Postgres storage, file-backed signing-key bootstrap, configured token custody, and passing public health/discovery/JWKS checks. + - Heimdall now issues signed app refresh tokens alongside short-lived access claims and exposes `/v1/apps/{appSlug}/sessions/refresh` so app backends can reissue local claims without restarting provider OAuth. + - Discord and Patreon provider access tokens can refresh inside Heimdall before Repixelizer entitlement re-evaluation. - Repixelizer is deployed on Yggdrasil at `https://repixelizer.gamecult.org` with `GC_ACCESS_MODE=heimdall`, `GC_ACCESS_ALLOWED_PROVIDERS=discord,patreon`, required access, queue protection enabled, app-owned Discord guild/role policy, and app-owned Patreon tier-title policy sent to Heimdall during backend-callback OAuth start. - StreamPixels viewer and creator provider OAuth should start through Heimdall; StreamPixels redeems one-time completion codes, owns local profile/connector binding, and resolves current provider access tokens from Heimdall by app-authenticated managed-credential calls. @@ -85,6 +87,7 @@ landed_baseline: - The first real provider slice landed for Discord callback exchange, identity resolution, entitlement evaluation, and session/claim issuance for Repixelizer. - Signing-key persistence/bootstrap, managed token encryption at rest, and a local verifier seam landed for the first real consumer integration pass. - Direct backend callback handoff landed as the preferred same-host app-consumer path, with browser completion-code exchange retained as fallback. + - Signed session refresh landed for app-facing claims: refresh tokens remain Heimdall-issued, provider refresh tokens remain sealed in Heimdall, and callers can provide app-owned entitlement policies for re-evaluation. - Repixelizer's hosted web layer now creates Heimdall auth attempts, receives direct backend callbacks, verifies Heimdall Ed25519 access tokens locally, and adopts httpOnly local sessions. - Heimdall now has generic Twitch and YouTube OAuth callback runtimes plus an app-authenticated managed-credential resolve endpoint for app-owned connector runtimes. - StreamPixels now starts viewer and creator Twitch/YouTube OAuth through Heimdall, redeems Heimdall completion codes server-side, writes its own viewer sessions and creator connector bindings, and leaves provider refresh-token custody in Heimdall. @@ -98,5 +101,5 @@ rejected_paths: reason: reusing auth mechanics does not justify centralizing app-domain audience or product data current_status: - summary: Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels auth migration slice. Heimdall has Twitch/YouTube provider credentials, the shared StreamPixels app secret, generic Twitch/YouTube identity and managed-credential custody runtimes, and a live fix for Twitch token responses that return scopes as arrays. Live StreamPixels Twitch sign-in through Heimdall has succeeded and redeemed a completion code. StreamPixels delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes through its service, writes its own httpOnly viewer session, stores local profile/creator connector binding state, and asks Heimdall for current provider access tokens when runtime code needs them. The key boundary still holds: Heimdall owns provider OAuth and credential custody; StreamPixels owns viewer profiles, creator memberships, connector config, diagnostics, EventSub/live-chat runtime behavior, and audience data. + summary: Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels auth migration slice. Heimdall has Twitch/YouTube provider credentials, the shared StreamPixels app secret, generic Twitch/YouTube identity and managed-credential custody runtimes, and a live fix for Twitch token responses that return scopes as arrays. Live StreamPixels Twitch sign-in through Heimdall has succeeded and redeemed a completion code. StreamPixels delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes through its service, writes its own httpOnly viewer session, stores local profile/creator connector binding state, and asks Heimdall for current provider access tokens when runtime code needs them. Repixelizer access now has a signed Heimdall refresh-token path: apps can exchange the refresh token for a fresh short-lived access claim, while Heimdall refreshes Discord/Patreon provider tokens and re-evaluates app-owned entitlement policies without restarting provider OAuth. The key boundary still holds: Heimdall owns provider OAuth and credential custody; host apps own local sessions, route/resource checks, and app-domain data. next_action: Complete the remaining real browser verification for StreamPixels: YouTube sign-in/link, creator Twitch/YouTube connector attach from an admin creator session, and local StreamPixels persistence checks. If a provider callback fails, inspect Heimdall audit events and StreamPixels service logs first; do not move provider-specific app state into Heimdall while debugging. diff --git a/tests/app.test.ts b/tests/app.test.ts index fbc26e0..d489e3a 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -16,6 +16,7 @@ function createTestConfig(): HeimdallConfig { publicBaseUrl: "https://heimdall.gamecult.org", issuer: "https://heimdall.gamecult.org", sessionTtlSeconds: 3600, + refreshTtlSeconds: 60 * 60 * 24 * 30, stateTtlSeconds: 600, completionTtlSeconds: 300, bootstrapSigningPrivateKeyOnMissing: false, @@ -422,6 +423,107 @@ describe("Heimdall service", () => { expect(secondRedeemResponse.statusCode).toBe(410); }); + it("refreshes a Repixelizer app claim from a Heimdall refresh token without provider OAuth", async () => { + const store = new InMemoryStore(); + let exchangeCalls = 0; + let refreshCalls = 0; + const app = await buildApp({ + config: createTestConfig(), + store, + oauthRuntimes: { + discord: { + async exchangeAuthorizationCode() { + exchangeCalls += 1; + return { + accessToken: "expired-discord-access-token", + refreshToken: "discord-refresh-token", + tokenType: "Bearer", + scope: ["identify", "guilds.members.read"], + expiresAt: "2026-04-26T13:00:00.000Z", + raw: { source: "test" }, + }; + }, + async refreshAccessToken({ refreshToken }) { + refreshCalls += 1; + expect(refreshToken).toBe("discord-refresh-token"); + return { + accessToken: "fresh-discord-access-token", + refreshToken: "rotated-discord-refresh-token", + tokenType: "Bearer", + scope: ["identify", "guilds.members.read"], + expiresAt: "2999-04-26T13:00:00.000Z", + raw: { source: "refresh" }, + }; + }, + async resolveIdentity() { + return { + provider: "discord", + providerUserId: "discord-user-123", + username: "meta", + displayName: "Meta", + primaryEmail: "meta@gamecult.org", + profile: { id: "discord-user-123", username: "meta" }, + }; + }, + async evaluateEntitlements({ callback, tokenSet }) { + expect(tokenSet.accessToken).toBe(refreshCalls ? "fresh-discord-access-token" : "expired-discord-access-token"); + return { + facts: [entitlementFacts.appAccess], + snapshots: [ + { + accountId: callback.accountId, + provider: "discord", + scope: "repixelizer:discord_role_access:gamecult-guild", + evaluatedAt: "2026-04-26T12:00:00.000Z", + isAllowed: true, + reasonCode: "matched_role", + rawSummaryJson: {}, + }, + ], + }; + }, + }, + }, + }); + 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 refreshToken = callbackResponse.json().refreshToken as string; + + const refreshResponse = await app.inject({ + method: "POST", + url: "/v1/apps/repixelizer/sessions/refresh", + payload: { + refreshToken, + entitlementPolicies: [ + { + kind: "discord_role_access", + guildId: "gamecult-guild", + allowedRoleIds: ["role-repixelizer"], + }, + ], + }, + }); + + expect(refreshResponse.statusCode).toBe(201); + expect(exchangeCalls).toBe(1); + expect(refreshCalls).toBe(1); + const payload = refreshResponse.json(); + expect(payload.sharedCapabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"])); + expect(payload.refreshToken).toEqual(expect.any(String)); + const verified = verifyJwt(payload.accessToken as string, getPublicKey(app)); + expect(verified.valid).toBe(true); + if (verified.valid) { + expect(verified.payload.sid).toBe(callbackResponse.json().session.sessionId); + expect(verified.payload.capabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"])); + } + }); + it("renders a browser handoff page that posts a completion code instead of a token", async () => { const app = await buildApp({ config: createTestConfig(), diff --git a/tests/security.test.ts b/tests/security.test.ts index f746b51..4bd1046 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -14,6 +14,7 @@ function createTestConfig(overrides: Partial = {}): HeimdallConf publicBaseUrl: "https://heimdall.gamecult.org", issuer: "https://heimdall.gamecult.org", sessionTtlSeconds: 3600, + refreshTtlSeconds: 60 * 60 * 24 * 30, stateTtlSeconds: 600, completionTtlSeconds: 300, bootstrapSigningPrivateKeyOnMissing: false,