Refresh managed provider credentials on resolve

This commit is contained in:
Meta Vens 2026-04-29 17:24:44 +01:00
commit f3e1f9c292
6 changed files with 280 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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<StoredLinkedIdentity> {
const expiresAt = options.linkedIdentity.tokenExpiresAt ? Date.parse(options.linkedIdentity.tokenExpiresAt) : undefined;
if (
!expiresAt ||
expiresAt - Date.now() > MANAGED_CREDENTIAL_REFRESH_SKEW_MS ||
!options.linkedIdentity.refreshTokenEncrypted
) {
return options.linkedIdentity;
}
const runtime = options.oauthRuntimes[options.linkedIdentity.provider];
if (!runtime.refreshAccessToken) {
return options.linkedIdentity;
}
const refreshToken = options.tokenCustody.decrypt(options.linkedIdentity.refreshTokenEncrypted);
const refreshed = await runtime.refreshAccessToken({
config: options.config,
refreshToken,
});
const upsertInput: UpsertLinkedIdentityInput = {
id: options.linkedIdentity.id,
accountId: options.linkedIdentity.accountId,
provider: options.linkedIdentity.provider,
providerUserId: options.linkedIdentity.providerUserId,
accessTokenEncrypted: options.tokenCustody.encrypt(refreshed.accessToken),
refreshTokenEncrypted: options.tokenCustody.encrypt(refreshed.refreshToken ?? refreshToken),
scopes: refreshed.scope.length ? refreshed.scope : options.linkedIdentity.scopes,
profileJson: options.linkedIdentity.profileJson,
createdAt: options.linkedIdentity.createdAt,
updatedAt: options.now,
};
const tokenExpiresAt = refreshed.expiresAt ?? options.linkedIdentity.tokenExpiresAt;
if (tokenExpiresAt !== undefined) {
upsertInput.tokenExpiresAt = tokenExpiresAt;
}
if (options.linkedIdentity.username !== undefined) {
upsertInput.username = options.linkedIdentity.username;
}
if (options.linkedIdentity.displayName !== undefined) {
upsertInput.displayName = options.linkedIdentity.displayName;
}
if (options.linkedIdentity.primaryEmail !== undefined) {
upsertInput.primaryEmail = options.linkedIdentity.primaryEmail;
}
return options.store.upsertLinkedIdentity(upsertInput);
}
function acceptsBackendPolicy(appSlug: AppSlug, handoff: OAuthHandoff): boolean {
if (handoff.kind !== "backend_callback") {
return false;
@ -1075,8 +1139,21 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
};
}
const linkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, request.body.provider);
if (!linkedIdentity?.accessTokenEncrypted) {
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, request.body.provider);
if (!storedLinkedIdentity?.accessTokenEncrypted) {
reply.code(404);
return { error: "managed_credential_not_found" };
}
const now = new Date().toISOString();
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
config,
store,
tokenCustody,
oauthRuntimes,
linkedIdentity: storedLinkedIdentity,
now,
});
if (!linkedIdentity.accessTokenEncrypted) {
reply.code(404);
return { error: "managed_credential_not_found" };
}

View file

@ -46,6 +46,10 @@ export interface OAuthProviderRuntime {
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet>;
refreshAccessToken?(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet>;
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
evaluateEntitlements(options: {
config: HeimdallConfig;
@ -233,6 +237,56 @@ async function exchangeTwitchAuthorizationCode(options: {
return tokenSet;
}
async function refreshTwitchAccessToken(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.twitch;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Twitch OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://id.twitch.tv/oauth2/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
grant_type: "refresh_token",
refresh_token: options.refreshToken,
}),
},
"Twitch token refresh failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function exchangeYouTubeAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
@ -286,6 +340,56 @@ async function exchangeYouTubeAuthorizationCode(options: {
return tokenSet;
}
async function refreshYouTubeAccessToken(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.youtube;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("YouTube OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
grant_type: "refresh_token",
refresh_token: options.refreshToken,
}),
},
"YouTube token refresh failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function resolveDiscordIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
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: [] };

View file

@ -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."}

View file

@ -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(