Refresh managed provider credentials on resolve
This commit is contained in:
parent
d6f11821c2
commit
f3e1f9c292
6 changed files with 280 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
83
src/app.ts
83
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<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" };
|
||||
}
|
||||
|
|
|
|||
106
src/oauth.ts
106
src/oauth.ts
|
|
@ -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: [] };
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue