Add Spotiverse Spotify credential custody
This commit is contained in:
parent
a662ce30ed
commit
e2a2f20693
15 changed files with 373 additions and 12 deletions
|
|
@ -24,3 +24,7 @@ GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID=
|
|||
GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET=
|
||||
GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET=
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=
|
||||
|
||||
GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET=
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ The planned non-shared-by-default layer is:
|
|||
- Treat `docs/implementation-plan.md` as the forward plan.
|
||||
- Treat `docs/app-profiles/repixelizer.md` and
|
||||
`docs/app-profiles/streampixels.md` as thin app bindings, not the whole
|
||||
shared architecture.
|
||||
shared architecture. Add new app-profile notes when a new app binding needs
|
||||
durable context.
|
||||
|
||||
## Important Paths
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ The planned non-shared-by-default layer is:
|
|||
- Implementation plan: `E:\Projects\Heimdall\docs\implementation-plan.md`
|
||||
- Repixelizer profile: `E:\Projects\Heimdall\docs\app-profiles\repixelizer.md`
|
||||
- StreamPixels profile: `E:\Projects\Heimdall\docs\app-profiles\streampixels.md`
|
||||
- Spotiverse profile: `E:\Projects\Heimdall\docs\app-profiles\spotiverse.md`
|
||||
- Handoff summary: `E:\Projects\Heimdall\notes\fresh-workspace-handoff.md`
|
||||
- State CLI: `E:\Projects\Heimdall\tools\heimdall_state.py`
|
||||
- Pre-compaction helper: `E:\Projects\Heimdall\tools\heimdall_prepare_compaction.py`
|
||||
|
|
|
|||
37
docs/app-profiles/spotiverse.md
Normal file
37
docs/app-profiles/spotiverse.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Spotiverse App Profile
|
||||
|
||||
Spotiverse consumes Heimdall as a managed Spotify credential authority.
|
||||
|
||||
## Boundary
|
||||
|
||||
- Heimdall owns Spotify OAuth, linked identity custody, refresh-token custody,
|
||||
and managed access-token projection.
|
||||
- Spotiverse owns Spotify playback polling, queue command execution, command
|
||||
receipts, and its CultMesh/Eve provider surface.
|
||||
- Odin, Eve, VoidBot Faces, and renderers consume the surface and command
|
||||
boundary; they do not receive Spotify tokens.
|
||||
|
||||
## Providers
|
||||
|
||||
- identity providers: `spotify`
|
||||
- entitlement sources: none
|
||||
- managed connection providers: `spotify`
|
||||
|
||||
## Capabilities
|
||||
|
||||
- `spotify_player_read`: shared capability after a linked Spotify identity.
|
||||
- `spotify_queue_add`: shared capability after a linked Spotify identity;
|
||||
Spotiverse still owns URI validation and command receipts.
|
||||
|
||||
## Local Configuration
|
||||
|
||||
Heimdall needs:
|
||||
|
||||
```text
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=...
|
||||
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=...
|
||||
GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET=...
|
||||
```
|
||||
|
||||
Spotiverse needs only the Heimdall base URL, matching app secret, and local
|
||||
callback/return URLs.
|
||||
|
|
@ -475,6 +475,8 @@ Suggested generic env surface:
|
|||
- `GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_ENTITLEMENT_CACHE_TTL_SECONDS=900`
|
||||
- `GC_ACCESS_PROVIDER_FAILURE_GRACE_SECONDS=3600`
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ Supported providers in the skeleton:
|
|||
- `github`
|
||||
- `twitch`
|
||||
- `youtube`
|
||||
- `spotify`
|
||||
|
||||
Request body:
|
||||
|
||||
|
|
@ -445,8 +446,8 @@ Request body:
|
|||
|
||||
```json
|
||||
{
|
||||
"accountId": "acct_streampixels_001",
|
||||
"provider": "youtube"
|
||||
"accountId": "acct_spotiverse_001",
|
||||
"provider": "spotify"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -454,12 +455,12 @@ Response:
|
|||
|
||||
```json
|
||||
{
|
||||
"accountId": "acct_streampixels_001",
|
||||
"provider": "youtube",
|
||||
"providerUserId": "channel-123",
|
||||
"accountId": "acct_spotiverse_001",
|
||||
"provider": "spotify",
|
||||
"providerUserId": "spotify-user-123",
|
||||
"accessToken": "<provider-access-token>",
|
||||
"tokenExpiresAt": "2026-04-26T13:00:00.000Z",
|
||||
"scopes": ["openid", "profile", "email"]
|
||||
"tokenExpiresAt": "2026-06-02T13:00:00.000Z",
|
||||
"scopes": ["user-read-playback-state", "user-modify-playback-state"]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
Heimdall, redeems Heimdall completion codes server-side, writes its own
|
||||
viewer sessions and connector bindings, and resolves current provider access
|
||||
tokens from Heimdall without storing refresh tokens locally
|
||||
- Spotiverse now delegates Spotify OAuth to Heimdall. Heimdall has a
|
||||
`spotiverse` app profile and `spotify` provider runtime; Spotiverse stores a
|
||||
local Heimdall account binding and resolves current Spotify access through
|
||||
`/v1/apps/spotiverse/managed-credentials/resolve` without storing provider
|
||||
refresh tokens.
|
||||
- Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels
|
||||
migration slice; Twitch callback handling has been patched so provider token
|
||||
`scope` can be either a space-delimited string or an array
|
||||
|
|
@ -109,6 +114,14 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
|
||||
Do not continue implementation automatically from a rehydrate-only request.
|
||||
|
||||
If the user asks to continue on Spotiverse, configure Heimdall with
|
||||
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID`,
|
||||
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET`, and
|
||||
`GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET`, configure Spotiverse with the matching
|
||||
`HEIMDALL_APP_SECRET`, then run a real browser `/auth/login` and verify
|
||||
managed credential resolution. Spotiverse should not regain local Spotify
|
||||
OAuth token storage.
|
||||
|
||||
If the user asks to continue on StreamPixels, the current next move is real
|
||||
browser verification:
|
||||
|
||||
|
|
|
|||
|
|
@ -141,9 +141,40 @@ const streampixelsProfile: AppProfile = {
|
|||
},
|
||||
};
|
||||
|
||||
const spotiverseProfile: AppProfile = {
|
||||
slug: "spotiverse",
|
||||
displayName: "Spotiverse",
|
||||
profileVersion: "2026-06-02",
|
||||
identityProviders: ["spotify"],
|
||||
entitlementSources: [],
|
||||
managedConnectionProviders: ["spotify"],
|
||||
capabilities: [
|
||||
{
|
||||
key: "spotify_player_read",
|
||||
mode: "shared",
|
||||
summary: "May read the linked Spotify account playback, queue, and device state.",
|
||||
sharedRule: identityFacts.authenticated,
|
||||
},
|
||||
{
|
||||
key: "spotify_queue_add",
|
||||
mode: "shared",
|
||||
summary: "May request add-to-queue through the Spotiverse command boundary.",
|
||||
sharedRule: identityFacts.authenticated,
|
||||
},
|
||||
],
|
||||
evaluateSharedCapabilities(context) {
|
||||
if (!context.facts.has(identityFacts.authenticated)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["spotify_player_read", "spotify_queue_add"];
|
||||
},
|
||||
};
|
||||
|
||||
export const appProfiles: Record<AppSlug, AppProfile> = {
|
||||
repixelizer: repixelizerProfile,
|
||||
streampixels: streampixelsProfile,
|
||||
spotiverse: spotiverseProfile,
|
||||
};
|
||||
|
||||
export function getAppProfile(appSlug: AppSlug): AppProfile {
|
||||
|
|
|
|||
|
|
@ -467,6 +467,10 @@ function acceptsBackendPolicy(appSlug: AppSlug, handoff: OAuthHandoff): boolean
|
|||
return handoff.callbackUrl === "https://repixelizer.gamecult.org/api/auth/heimdall/callback";
|
||||
}
|
||||
|
||||
if (appSlug === "spotiverse") {
|
||||
return handoff.callbackUrl === "http://127.0.0.1:8796/auth/heimdall/callback";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
export const providers = ["discord", "patreon", "github", "twitch", "youtube"] as const;
|
||||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify"] as const;
|
||||
|
||||
export type Provider = (typeof providers)[number];
|
||||
|
||||
export const appSlugs = ["repixelizer", "streampixels"] as const;
|
||||
export const appSlugs = ["repixelizer", "streampixels", "spotiverse"] as const;
|
||||
|
||||
export type AppSlug = (typeof appSlugs)[number];
|
||||
|
||||
|
|
|
|||
153
src/oauth.ts
153
src/oauth.ts
|
|
@ -487,6 +487,108 @@ async function refreshYouTubeAccessToken(options: {
|
|||
return tokenSet;
|
||||
}
|
||||
|
||||
async function exchangeSpotifyAuthorizationCode(options: {
|
||||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
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<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 refreshSpotifyAccessToken(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
}): Promise<OAuthTokenSet> {
|
||||
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<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;
|
||||
|
|
@ -740,6 +842,47 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
|
|||
return identity;
|
||||
}
|
||||
|
||||
async function resolveSpotifyIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
||||
const profile = await fetchJson<{
|
||||
id?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
images?: Array<{ url?: string }>;
|
||||
uri?: string;
|
||||
}>(
|
||||
"https://api.spotify.com/v1/me",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.accessToken}`,
|
||||
},
|
||||
},
|
||||
"Spotify identity lookup failed"
|
||||
);
|
||||
|
||||
if (!profile.id) {
|
||||
throw new Error("Spotify identity response did not include a user id.");
|
||||
}
|
||||
|
||||
const identity: ResolvedIdentity = {
|
||||
provider: "spotify",
|
||||
providerUserId: profile.id,
|
||||
username: profile.id,
|
||||
displayName: profile.display_name ?? profile.id,
|
||||
profile: profile as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (profile.email) {
|
||||
identity.primaryEmail = profile.email;
|
||||
}
|
||||
|
||||
const avatarUrl = profile.images?.find((image) => image.url)?.url;
|
||||
if (avatarUrl) {
|
||||
identity.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function evaluateDiscordEntitlements(options: {
|
||||
config: HeimdallConfig;
|
||||
callback: OAuthCallbackContext;
|
||||
|
|
@ -991,6 +1134,15 @@ const youtubeRuntime: OAuthProviderRuntime = {
|
|||
},
|
||||
};
|
||||
|
||||
const spotifyRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: exchangeSpotifyAuthorizationCode,
|
||||
refreshAccessToken: refreshSpotifyAccessToken,
|
||||
resolveIdentity: resolveSpotifyIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const notImplementedRuntime: OAuthProviderRuntime = {
|
||||
async exchangeAuthorizationCode() {
|
||||
throw new Error("Provider callback exchange is not implemented yet.");
|
||||
|
|
@ -1012,5 +1164,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
|
|||
github: notImplementedRuntime,
|
||||
twitch: twitchRuntime,
|
||||
youtube: youtubeRuntime,
|
||||
spotify: spotifyRuntime,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,19 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
prompt: "consent",
|
||||
},
|
||||
},
|
||||
spotify: {
|
||||
key: "spotify",
|
||||
displayName: "Spotify",
|
||||
authorizationEndpoint: "https://accounts.spotify.com/authorize",
|
||||
defaultScopes: [
|
||||
"user-read-currently-playing",
|
||||
"user-read-playback-state",
|
||||
"user-modify-playback-state",
|
||||
],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET",
|
||||
},
|
||||
};
|
||||
|
||||
export function buildAuthorizationUrl(options: {
|
||||
|
|
|
|||
|
|
@ -23,3 +23,4 @@
|
|||
{"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."}
|
||||
{"ts": "2026-05-20T17:56:35+00:00", "type": "deployment", "status": "ok", "note": "Deployed Heimdall refresh-token session support to Yggdrasil and verified heimdall.service active, health/JWKS/discovery checks passing, and /v1/apps/repixelizer/sessions/refresh live with validation."}
|
||||
{"ts": "2026-06-02T15:25:08+00:00", "type": "implementation", "status": "accepted", "note": "Spotiverse now consumes Spotify as a Heimdall-managed credential: Heimdall owns Spotify OAuth/token custody, and Spotiverse resolves current access via /v1/apps/spotiverse/managed-credentials/resolve without storing provider refresh tokens."}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ invariants:
|
|||
- App-domain data stays with the host app unless there is a deliberate, source-grounded reason to share it.
|
||||
- Host apps verify signed claims locally and keep resource ownership checks local.
|
||||
- StreamPixels can reuse shared auth authority without centralizing streamer-audience data.
|
||||
- Spotiverse consumes Spotify as a Heimdall-managed credential; Spotiverse does not own Spotify refresh-token custody.
|
||||
- Persistent state cruft is the same failure class as architecture cruft.
|
||||
|
||||
data_flow:
|
||||
|
|
@ -91,6 +92,7 @@ landed_baseline:
|
|||
- 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.
|
||||
- Spotiverse now has a Heimdall app profile and Spotify provider runtime for managed credential custody.
|
||||
|
||||
rejected_paths:
|
||||
- name: per_app_auth_copy_paste
|
||||
|
|
@ -101,5 +103,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. 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.
|
||||
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 server-side, 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. Spotiverse is now added as a Heimdall app profile with Spotify as a managed credential provider; Spotiverse should resolve access tokens from Heimdall and must not own Spotify refresh-token custody. Repixelizer access has a signed Heimdall refresh-token path. The key boundary still holds: Heimdall owns provider OAuth and credential custody; host apps own local sessions, route/resource checks, runtime behavior, and app-domain data.
|
||||
next_action: Configure Heimdall with Spotify OAuth credentials and `GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET`, then run a real browser Spotiverse `/auth/login` flow and verify `/v1/apps/spotiverse/managed-credentials/resolve` returns only an access-token projection. After that, return to the remaining StreamPixels browser verification.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function createTestConfig(): HeimdallConfig {
|
|||
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: {
|
||||
streampixels: "streampixels-secret",
|
||||
spotiverse: "spotiverse-secret",
|
||||
},
|
||||
storage: {
|
||||
backend: "memory",
|
||||
|
|
@ -34,6 +35,7 @@ function createTestConfig(): HeimdallConfig {
|
|||
github: { clientId: "github-client", clientSecret: "github-secret" },
|
||||
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
||||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -682,6 +684,101 @@ describe("Heimdall service", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("exposes Spotiverse as a Spotify managed-credential app", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const profileResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/apps/spotiverse",
|
||||
});
|
||||
|
||||
expect(profileResponse.statusCode).toBe(200);
|
||||
expect(profileResponse.json()).toEqual(
|
||||
expect.objectContaining({
|
||||
slug: "spotiverse",
|
||||
identityProviders: ["spotify"],
|
||||
managedConnectionProviders: ["spotify"],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Spotiverse Spotify credentials without exposing refresh custody", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const app = await buildApp({
|
||||
config: createTestConfig(),
|
||||
store,
|
||||
oauthRuntimes: {
|
||||
spotify: {
|
||||
async exchangeAuthorizationCode() {
|
||||
return {
|
||||
accessToken: "spotify-access-token",
|
||||
refreshToken: "spotify-refresh-token",
|
||||
tokenType: "Bearer",
|
||||
scope: ["user-read-playback-state", "user-modify-playback-state"],
|
||||
expiresAt: "2026-06-02T13:00:00.000Z",
|
||||
raw: { source: "test" },
|
||||
};
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return {
|
||||
provider: "spotify",
|
||||
providerUserId: "spotify-user-123",
|
||||
username: "spotify-user-123",
|
||||
displayName: "Spotiverse Operator",
|
||||
profile: { id: "spotify-user-123" },
|
||||
};
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/spotify/start",
|
||||
payload: {
|
||||
appSlug: "spotiverse",
|
||||
mode: "connect",
|
||||
returnTo: "http://127.0.0.1:8796/auth/complete",
|
||||
},
|
||||
});
|
||||
const stateToken = startResponse.json().stateToken as string;
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/spotify/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/spotiverse/managed-credentials/resolve",
|
||||
headers: {
|
||||
"x-heimdall-app-secret": "spotiverse-secret",
|
||||
},
|
||||
payload: {
|
||||
accountId,
|
||||
provider: "spotify",
|
||||
},
|
||||
});
|
||||
|
||||
expect(credentialResponse.statusCode).toBe(200);
|
||||
expect(credentialResponse.json()).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId,
|
||||
provider: "spotify",
|
||||
providerUserId: "spotify-user-123",
|
||||
accessToken: "spotify-access-token",
|
||||
scopes: ["user-read-playback-state", "user-modify-playback-state"],
|
||||
})
|
||||
);
|
||||
expect(credentialResponse.body).not.toContain("spotify-refresh-token");
|
||||
});
|
||||
|
||||
it("resolves app-managed provider credentials without exposing refresh custody", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const app = await buildApp({
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
|
|||
github: {},
|
||||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue