2026-10-19 11:11:34 +02:00
import { randomUUID } from "node:crypto" ;
import { Pool } from "pg" ;
import { afterAll , afterEach , beforeAll , describe , expect , it } from "vitest" ;
import { buildApp } from "../src/app.js" ;
import { type HeimdallConfig } from "../src/config.js" ;
import { type Provider } from "../src/contracts.js" ;
import { createTokenCustody } from "../src/custody.js" ;
import {
MANAGED_CREDENTIAL_REFRESH_SKEW_MS ,
ManagedCredentialRefreshFailed ,
refreshManagedCredential ,
} from "../src/managed-credential.js" ;
import { createOAuthRuntimeRegistry } from "../src/oauth.js" ;
import { providerCatalog } from "../src/providers.js" ;
import { InMemoryStore , PostgresStore , type HeimdallStore } from "../src/store/index.js" ;
import { type StoredLinkedIdentity } from "../src/store/types.js" ;
const originalFetch = globalThis . fetch ;
const sleep = ( ms : number ) = > new Promise ( ( resolve ) = > setTimeout ( resolve , ms ) ) ;
function testConfig ( ) : HeimdallConfig {
const clients = Object . fromEntries (
[ "discord" , "patreon" , "github" , "twitch" , "youtube" , "spotify" , "joystick" ] . map ( ( p ) = > [
p ,
{ clientId : ` ${ p } -client ` , clientSecret : ` ${ p } -secret ` } ,
] )
) ;
return {
serviceName : "heimdall" ,
host : "127.0.0.1" ,
port : 4100 ,
workspaceRoot : "E:/Projects/Heimdall" ,
dataRoot : "E:/Projects/Heimdall/.heimdall-data" ,
cultCachePath : "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc" ,
publicBaseUrl : "https://heimdall.gamecult.org" ,
issuer : "https://heimdall.gamecult.org" ,
daemonId : "yggdrasil-heimdall" ,
idunnRudpHealth : undefined ,
idunnHealthContract : "heimdall.cultnet-rudp-provider-health" ,
providerHealthIdentityPath : "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc" ,
sessionTtlSeconds : 3600 ,
refreshTtlSeconds : 60 * 60 * 24 * 30 ,
stateTtlSeconds : 600 ,
completionTtlSeconds : 300 ,
bootstrapSigningPrivateKeyOnMissing : false ,
tokenEncryptionKeyBase64 : Buffer.alloc ( 32 , 7 ) . toString ( "base64" ) ,
appSharedSecrets : { streampixels : "streampixels-secret" , bifrost : "bifrost-secret" } ,
appBackendCallbacks : { } ,
storage : { backend : "memory" , applySchemaOnStartup : true } ,
bifrostPatronSupportEndpoint : "https://bifrost.gamecult.org/heimdall/patron-support/events" ,
bifrostPatronSupportSecret : "bifrost-support-secret" ,
providers : clients ,
} as unknown as HeimdallConfig ;
}
2026-10-19 13:24:21 +02:00
type Mode =
| "normal"
| "invalid_grant"
| "invalid_client"
| "server_error"
| "not_json"
| "no_refresh_token"
| "empty_refresh_token"
| "bad_refresh_token"
| "no_expires_in"
| "unusable_expires_in"
| "string_expires_in"
2026-10-19 14:34:43 +02:00
| "huge_expires_in"
| "huge_string_expires_in"
2026-10-19 13:24:21 +02:00
| "hang" ;
function expiresInFor ( mode : Mode ) : unknown {
2026-10-19 14:34:43 +02:00
switch ( mode ) {
case "unusable_expires_in" :
return "soon" ;
case "string_expires_in" :
return "7200" ;
case "huge_expires_in" :
return 3 e11 ;
case "huge_string_expires_in" :
return "300000000000" ;
default :
return 3600 ;
}
2026-10-19 13:24:21 +02:00
}
2026-10-19 11:11:34 +02:00
/ * *
* A provider token endpoint with single - use refresh tokens . Presenting a
* consumed refresh token revokes the whole installation , as Joystick does :
* every later refresh answers 400 invalid_grant .
* /
function installTokenEndpoint ( provider : Provider , firstRefreshToken : string ) {
const state = {
calls : 0 ,
mode : "normal" as Mode ,
live : firstRefreshToken ,
issued : 0 ,
installationRevoked : false ,
otherCalls : [ ] as string [ ] ,
} ;
globalThis . fetch = ( async ( input : string | URL , init? : RequestInit ) = > {
const url = String ( input ) ;
if ( url !== providerCatalog [ provider ] . tokenEndpoint ) {
state . otherCalls . push ( url ) ;
return new Response ( "unexpected" , { status : 500 } ) ;
}
state . calls += 1 ;
const presented = ( init ! . body as URLSearchParams ) . get ( "refresh_token" ) ;
await sleep ( 25 ) ;
switch ( state . mode ) {
case "hang" :
return new Promise < Response > ( ( _resolve , reject ) = > {
init ! . signal ? . addEventListener ( "abort" , ( ) = > reject ( new DOMException ( "aborted" , "AbortError" ) ) ) ;
} ) ;
case "invalid_grant" :
return new Response ( JSON . stringify ( { error : "invalid_grant" , error_description : "CANARY" } ) , { status : 400 } ) ;
case "invalid_client" :
return new Response ( JSON . stringify ( { error : "invalid_client" , error_description : "CANARY" } ) , { status : 401 } ) ;
case "server_error" :
return new Response ( "CANARY upstream exploded" , { status : 500 } ) ;
case "not_json" :
return new Response ( "CANARY <html>" , { status : 200 } ) ;
case "no_refresh_token" :
return new Response ( JSON . stringify ( { access_token : "access-norefresh" , token_type : "Bearer" , expires_in : 3600 } ) , {
status : 200 ,
} ) ;
2026-10-19 13:24:21 +02:00
case "empty_refresh_token" :
return new Response ( JSON . stringify ( { access_token : "access-empty" , refresh_token : "" , token_type : "Bearer" , expires_in : 3600 } ) , {
status : 200 ,
} ) ;
case "bad_refresh_token" :
return new Response ( JSON . stringify ( { access_token : "access-bad" , refresh_token : 42 , token_type : "Bearer" , expires_in : 3600 } ) , {
status : 200 ,
} ) ;
case "normal" :
case "no_expires_in" :
case "unusable_expires_in" :
2026-10-19 14:34:43 +02:00
case "string_expires_in" :
case "huge_expires_in" :
case "huge_string_expires_in" : {
2026-10-19 11:11:34 +02:00
if ( state . installationRevoked || presented !== state . live ) {
state . installationRevoked = true ;
return new Response ( JSON . stringify ( { error : "invalid_grant" , error_description : "reused CANARY" } ) , { status : 400 } ) ;
}
state . issued += 1 ;
state . live = ` refresh- ${ state . issued } ` ;
return new Response (
JSON . stringify ( {
access_token : ` access- ${ state . issued } ` ,
refresh_token : state.live ,
token_type : "Bearer" ,
2026-10-19 13:24:21 +02:00
. . . ( state . mode === "no_expires_in" ? { } : { expires_in : expiresInFor ( state . mode ) } ) ,
2026-10-19 11:11:34 +02:00
} ) ,
{ status : 200 }
) ;
}
}
} ) as typeof fetch ;
return state ;
}
interface Rig {
stores : HeimdallStore [ ] ;
apps : Awaited < ReturnType < typeof buildApp > > [ ] ;
accountId : string ;
identityId : string ;
custody : ReturnType < typeof createTokenCustody > ;
config : HeimdallConfig ;
provider : Provider ;
2026-10-19 11:47:37 +02:00
databaseUrl : string | undefined ;
2026-10-19 11:11:34 +02:00
row ( ) : Promise < StoredLinkedIdentity > ;
audit ( ) : Promise < Array < { eventType : string ; payload : unknown } > > ;
resolve ( appIndex? : number ) : Promise < { statusCode : number ; json : Record < string , unknown > ; body : string } > ;
close ( ) : Promise < void > ;
}
const adminUrl = process . env . HEIMDALL_TEST_PG_ADMIN_URL ;
const rigKinds = [
{ kind : "memory" , live : false } ,
{ kind : "postgres one app" , live : true } ,
{ kind : "postgres two apps" , live : true } ,
] as const ;
// Providers whose refresh goes through the real shared runtime; rotation differs by descriptor.
2026-10-19 11:13:40 +02:00
const refreshProviders : Provider [ ] = [ "twitch" , "joystick" ] ;
2026-10-19 11:11:34 +02:00
for ( const { kind , live } of rigKinds ) describe . skipIf ( live && ! adminUrl ) ( ` managed credential refresh, ${ kind } ` , ( ) = > {
const admin = live && adminUrl ? new Pool ( { connectionString : adminUrl , max : 2 } ) : undefined ;
const suffix = randomUUID ( ) . replace ( /-/g , "" ) . slice ( 0 , 12 ) ;
const template = ` heimdall_mc_template_ ${ suffix } ` ;
const cleanup : Array < ( ) = > Promise < void > > = [ ] ;
function urlFor ( database : string ) : string {
const url = new URL ( adminUrl ! ) ;
url . pathname = ` / ${ database } ` ;
return url . toString ( ) ;
}
beforeAll ( async ( ) = > {
if ( ! live ) return ;
await admin ! . query ( ` CREATE DATABASE ${ template } ` ) ;
const pool = new Pool ( { connectionString : urlFor ( template ) , max : 1 } ) ;
await new PostgresStore ( pool ) . ensureSchema ( ) ;
await pool . end ( ) ;
} , 120 _000 ) ;
afterEach ( async ( ) = > {
globalThis . fetch = originalFetch ;
for ( const step of cleanup . splice ( 0 ) . reverse ( ) ) await step ( ) ;
} ) ;
afterAll ( async ( ) = > {
if ( ! admin ) return ;
await admin . query ( ` DROP DATABASE IF EXISTS ${ template } ` ) ;
await admin . end ( ) ;
} , 120 _000 ) ;
async function makeRig (
provider : Provider ,
options : { expiresInMs? : number ; refreshToken? : string | null ; accessToken? : string | null } = { }
) : Promise < Rig > {
const config = testConfig ( ) ;
const custody = createTokenCustody ( config ) ;
const stores : HeimdallStore [ ] = [ ] ;
let adminForDb : Pool | undefined ;
2026-10-19 11:47:37 +02:00
let databaseUrl : string | undefined ;
2026-10-19 11:11:34 +02:00
if ( ! live ) {
stores . push ( new InMemoryStore ( ) ) ;
} else {
const name = ` heimdall_mc_ ${ randomUUID ( ) . replace ( /-/g , "" ) } ` ;
await admin ! . query ( ` CREATE DATABASE ${ name } TEMPLATE ${ template } ` ) ;
const count = kind === "postgres two apps" ? 2 : 1 ;
for ( let i = 0 ; i < count ; i += 1 ) {
const pool = new Pool ( { connectionString : urlFor ( name ) , max : 12 } ) ;
pool . on ( "error" , ( ) = > undefined ) ;
stores . push ( new PostgresStore ( pool ) ) ;
}
2026-10-19 11:47:37 +02:00
databaseUrl = urlFor ( name ) ;
2026-10-19 11:11:34 +02:00
adminForDb = new Pool ( { connectionString : urlFor ( name ) , max : 1 } ) ;
adminForDb . on ( "error" , ( ) = > undefined ) ;
cleanup . push ( async ( ) = > {
await adminForDb ! . end ( ) ;
await admin ! . query ( ` DROP DATABASE IF EXISTS ${ name } WITH (FORCE) ` ) ;
} ) ;
}
const now = new Date ( ) . toISOString ( ) ;
const account = await stores [ 0 ] ! . createAccount ( { createdAt : now , lastSeenAt : now } ) ;
const expiresInMs = options . expiresInMs ? ? 60 _000 ;
const input = {
accountId : account.id ,
provider ,
providerUserId : "channel-1" ,
username : "deru" ,
displayName : "Deru" ,
scopes : [ "identity:read" ] ,
profileJson : { keep : "me" } ,
tokenExpiresAt : new Date ( Date . now ( ) + expiresInMs ) . toISOString ( ) ,
createdAt : now ,
updatedAt : now ,
} ;
const seeded = await stores [ 0 ] ! . upsertLinkedIdentity ( {
. . . input ,
. . . ( options . accessToken === null ? { } : { accessTokenEncrypted : custody.encrypt ( options . accessToken ? ? "access-0" ) } ) ,
. . . ( options . refreshToken === null ? { } : { refreshTokenEncrypted : custody.encrypt ( options . refreshToken ? ? "refresh-0" ) } ) ,
} ) ;
const apps : Rig [ "apps" ] = [ ] ;
for ( const store of stores ) {
apps . push ( await buildApp ( { config , store , tokenCustody : custody , oauthRuntimes : createOAuthRuntimeRegistry ( ) } ) ) ;
}
cleanup . push ( async ( ) = > {
for ( const app of apps ) await app . close ( ) . catch ( ( ) = > undefined ) ;
} ) ;
return {
stores ,
apps ,
accountId : account.id ,
identityId : seeded.id ,
custody ,
config ,
provider ,
2026-10-19 11:47:37 +02:00
databaseUrl ,
2026-10-19 11:11:34 +02:00
async row() {
const row = await stores [ 0 ] ! . findStoredLinkedIdentityForAccount ( account . id , provider ) ;
return row ! ;
} ,
async audit() {
if ( ! live ) {
const events = ( stores [ 0 ] as unknown as { auditEvents : Map < string , { eventType : string ; eventPayloadJson : unknown } > } ) . auditEvents ;
return [ . . . events . values ( ) ] . map ( ( e ) = > ( { eventType : e.eventType , payload : e.eventPayloadJson } ) ) ;
}
const result = await adminForDb ! . query < { event_type : string ; event_payload_json : unknown } > (
"SELECT event_type, event_payload_json FROM audit_events ORDER BY created_at"
) ;
return result . rows . map ( ( r ) = > ( { eventType : r.event_type , payload : r.event_payload_json } ) ) ;
} ,
async resolve ( appIndex = 0 ) {
const response = await apps [ appIndex ] ! . inject ( {
method : "POST" ,
url : "/v1/apps/streampixels/managed-credentials/resolve" ,
headers : { "x-heimdall-app-secret" : "streampixels-secret" } ,
payload : { accountId : account.id , provider } ,
} ) ;
return { statusCode : response.statusCode , json : response.json ( ) as Record < string , unknown > , body : response.body } ;
} ,
async close() { } ,
} ;
}
const timeout = { timeout : 60_000 } ;
describe . each ( refreshProviders ) ( "%s" , ( provider ) = > {
it ( "refresh-once-per-token: 20 concurrent resolves present the refresh token once" , timeout , async ( ) = > {
const rig = await makeRig ( provider ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
const answers = await Promise . all ( Array . from ( { length : 20 } , ( _ , i ) = > rig . resolve ( i % rig . apps . length ) ) ) ;
expect ( endpoint . calls ) . toBe ( 1 ) ;
expect ( answers . map ( ( a ) = > a . statusCode ) ) . toEqual ( Array ( 20 ) . fill ( 200 ) ) ;
expect ( new Set ( answers . map ( ( a ) = > a . json . accessToken ) ) ) . toEqual ( new Set ( [ "access-1" ] ) ) ;
const row = await rig . row ( ) ;
expect ( rig . custody . decrypt ( row . accessTokenEncrypted ! ) ) . toBe ( "access-1" ) ;
expect ( rig . custody . decrypt ( row . refreshTokenEncrypted ! ) ) . toBe ( "refresh-1" ) ;
expect ( await rig . audit ( ) ) . toEqual ( [ ] ) ;
} ) ;
it ( "rotated-pair-persisted-before-use: the stored pair is the one the caller was given" , timeout , async ( ) = > {
const rig = await makeRig ( provider ) ;
installTokenEndpoint ( provider , "refresh-0" ) ;
const answer = await rig . resolve ( ) ;
const row = await rig . row ( ) ;
expect ( rig . custody . decrypt ( row . accessTokenEncrypted ! ) ) . toBe ( answer . json . accessToken ) ;
expect ( Date . parse ( row . tokenExpiresAt ! ) ) . toBeGreaterThan ( Date . now ( ) + MANAGED_CREDENTIAL_REFRESH_SKEW_MS ) ;
} ) ;
it ( "outside the skew: no token call, the stored token is returned" , timeout , async ( ) = > {
const rig = await makeRig ( provider , { expiresInMs : 60 * 60 * 1000 } ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
const answer = await rig . resolve ( ) ;
expect ( endpoint . calls ) . toBe ( 0 ) ;
expect ( answer . statusCode ) . toBe ( 200 ) ;
expect ( answer . json . accessToken ) . toBe ( "access-0" ) ;
} ) ;
it ( "revocation-is-typed: invalid_grant clears the tokens, answers 410, audits once, and stops calling the provider" , timeout , async ( ) = > {
const rig = await makeRig ( provider ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
endpoint . mode = "invalid_grant" ;
const first = await rig . resolve ( ) ;
expect ( first . statusCode ) . toBe ( 410 ) ;
expect ( first . json ) . toEqual ( { error : "managed_credential_revoked" , provider } ) ;
const row = await rig . row ( ) ;
expect ( row . accessTokenEncrypted ) . toBeUndefined ( ) ;
expect ( row . refreshTokenEncrypted ) . toBeUndefined ( ) ;
expect ( row . tokenExpiresAt ) . toBeUndefined ( ) ;
expect ( row . username ) . toBe ( "deru" ) ;
expect ( row . profileJson ) . toEqual ( { keep : "me" } ) ;
expect ( row . scopes ) . toEqual ( [ "identity:read" ] ) ;
const second = await rig . resolve ( ) ;
expect ( second . statusCode ) . toBe ( 410 ) ;
expect ( endpoint . calls ) . toBe ( 1 ) ;
const events = await rig . audit ( ) ;
expect ( events . filter ( ( e ) = > e . eventType === "managed_credential_revoked" ) ) . toEqual ( [
{ eventType : "managed_credential_revoked" , payload : { provider , reason : "provider_revoked" } } ,
] ) ;
expect ( first . body + second . body + JSON . stringify ( events ) ) . not . toContain ( "CANARY" ) ;
} ) ;
2026-10-19 13:24:21 +02:00
it . each ( [ "invalid_client" , "server_error" , "not_json" , "bad_refresh_token" , "hang" ] as const ) (
2026-10-19 11:11:34 +02:00
"transient failure (%s) keeps custody: 502, tokens byte-identical, nothing leaked" ,
timeout ,
async ( mode ) = > {
const rig = await makeRig ( provider ) ;
const before = await rig . row ( ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
endpoint . mode = mode ;
let answer ;
if ( mode === "hang" ) {
// The route's own bound is 10 s; the primitive takes an injected one.
const failure = await refreshManagedCredential (
{ config : rig.config , store : rig.stores [ 0 ] ! , tokenCustody : rig.custody , oauthRuntimes : createOAuthRuntimeRegistry ( ) , timeoutMs : 100 } ,
before
) . then (
( ) = > undefined ,
( error : unknown ) = > error
) ;
expect ( failure ) . toBeInstanceOf ( ManagedCredentialRefreshFailed ) ;
expect ( ( failure as ManagedCredentialRefreshFailed ) . provider ) . toBe ( provider ) ;
expect ( String ( failure ) + JSON . stringify ( failure ) ) . not . toContain ( "CANARY" ) ;
} else {
answer = await rig . resolve ( ) ;
expect ( answer . statusCode ) . toBe ( 502 ) ;
expect ( answer . json ) . toEqual ( { error : "managed_credential_refresh_failed" , provider } ) ;
expect ( answer . body ) . not . toContain ( "CANARY" ) ;
}
const after = await rig . row ( ) ;
expect ( after . accessTokenEncrypted ) . toBe ( before . accessTokenEncrypted ) ;
expect ( after . refreshTokenEncrypted ) . toBe ( before . refreshTokenEncrypted ) ;
expect ( after . tokenExpiresAt ) . toBe ( before . tokenExpiresAt ) ;
expect ( await rig . audit ( ) ) . toEqual ( [ ] ) ;
}
) ;
it ( "bounded lock: a refresh that never answers is aborted and the next resolve takes the lock" , timeout , async ( ) = > {
const rig = await makeRig ( provider ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
endpoint . mode = "hang" ;
const ctx = { config : rig.config , store : rig.stores [ 0 ] ! , tokenCustody : rig.custody , oauthRuntimes : createOAuthRuntimeRegistry ( ) , timeoutMs : 100 } ;
const started = Date . now ( ) ;
await expect ( refreshManagedCredential ( ctx , await rig . row ( ) ) ) . rejects . toBeInstanceOf ( ManagedCredentialRefreshFailed ) ;
expect ( Date . now ( ) - started ) . toBeLessThan ( 5 _000 ) ;
endpoint . mode = "normal" ;
const outcome = await refreshManagedCredential ( ctx , await rig . row ( ) ) ;
expect ( outcome . kind ) . toBe ( "current" ) ;
expect ( rig . custody . decrypt ( outcome . identity . accessTokenEncrypted ! ) ) . toBe ( "access-1" ) ;
} ) ;
2026-10-19 12:34:39 +02:00
it ( "single flight: concurrent refreshes against a never-answering endpoint make one provider call per process, and every caller gets the typed failure" , timeout , async ( ) = > {
const rig = await makeRig ( provider ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
endpoint . mode = "hang" ;
const snapshot = await rig . row ( ) ;
const ctxFor = ( store : HeimdallStore ) = > ( { config : rig.config , store , tokenCustody : rig.custody , oauthRuntimes : createOAuthRuntimeRegistry ( ) , timeoutMs : 150 } ) ;
const results = await Promise . allSettled (
Array . from ( { length : 12 } , ( _ , i ) = > refreshManagedCredential ( ctxFor ( rig . stores [ i % rig . stores . length ] ! ) , snapshot ) )
) ;
expect ( results . map ( ( r ) = > r . status ) ) . toEqual ( Array ( 12 ) . fill ( "rejected" ) ) ;
for ( const result of results ) {
expect ( ( result as PromiseRejectedResult ) . reason ) . toBeInstanceOf ( ManagedCredentialRefreshFailed ) ;
}
expect ( endpoint . calls ) . toBe ( rig . stores . length ) ;
expect ( rig . custody . decrypt ( ( await rig . row ( ) ) . refreshTokenEncrypted ! ) ) . toBe ( "refresh-0" ) ;
// The burst is over: a later independent request tries again.
endpoint . mode = "normal" ;
const later = await refreshManagedCredential ( ctxFor ( rig . stores [ 0 ] ! ) , await rig . row ( ) ) ;
expect ( later . kind ) . toBe ( "current" ) ;
expect ( endpoint . calls ) . toBe ( rig . stores . length + 1 ) ;
} ) ;
2026-10-19 11:11:34 +02:00
it ( "a snapshot taken before another caller's refresh does not decide: the locked row does" , timeout , async ( ) = > {
const rig = await makeRig ( provider ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
const stale = await rig . row ( ) ;
await rig . resolve ( ) ;
expect ( endpoint . calls ) . toBe ( 1 ) ;
const outcome = await refreshManagedCredential (
{ config : rig.config , store : rig.stores [ 0 ] ! , tokenCustody : rig.custody , oauthRuntimes : createOAuthRuntimeRegistry ( ) } ,
stale
) ;
expect ( endpoint . calls ) . toBe ( 1 ) ;
expect ( outcome . kind ) . toBe ( "current" ) ;
expect ( rig . custody . decrypt ( outcome . identity . accessTokenEncrypted ! ) ) . toBe ( "access-1" ) ;
} ) ;
2026-10-19 11:13:40 +02:00
if ( providerCatalog [ provider ] . rotatesRefreshTokens ) {
2026-10-19 13:24:21 +02:00
it . each ( [ "no_refresh_token" , "empty_refresh_token" ] as const ) ( "rotation-fails-closed: a response with %s revokes, 410, and the old token is never presented again" , timeout , async ( mode ) = > {
2026-10-19 11:13:40 +02:00
const rig = await makeRig ( provider ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
2026-10-19 13:24:21 +02:00
endpoint . mode = mode ;
2026-10-19 11:13:40 +02:00
const first = await rig . resolve ( ) ;
expect ( first . statusCode ) . toBe ( 410 ) ;
expect ( first . json ) . toEqual ( { error : "managed_credential_revoked" , provider } ) ;
expect ( first . body ) . not . toContain ( "access-norefresh" ) ;
2026-10-19 13:24:21 +02:00
expect ( first . body ) . not . toContain ( "access-empty" ) ;
2026-10-19 11:13:40 +02:00
const row = await rig . row ( ) ;
expect ( row . accessTokenEncrypted ) . toBeUndefined ( ) ;
expect ( row . refreshTokenEncrypted ) . toBeUndefined ( ) ;
expect ( row . tokenExpiresAt ) . toBeUndefined ( ) ;
const second = await rig . resolve ( ) ;
expect ( second . statusCode ) . toBe ( 410 ) ;
expect ( endpoint . calls ) . toBe ( 1 ) ;
expect ( ( await rig . audit ( ) ) . filter ( ( e ) = > e . eventType === "managed_credential_revoked" ) ) . toEqual ( [
{ eventType : "managed_credential_revoked" , payload : { provider , reason : "rotation_missing" } } ,
] ) ;
} ) ;
} else {
2026-10-19 13:24:21 +02:00
it . each ( [ "no_refresh_token" , "empty_refresh_token" ] as const ) ( "a response with %s keeps the old one for a non-rotating provider" , timeout , async ( mode ) = > {
2026-10-19 11:13:40 +02:00
const rig = await makeRig ( provider ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
2026-10-19 13:24:21 +02:00
endpoint . mode = mode ;
2026-10-19 11:13:40 +02:00
const answer = await rig . resolve ( ) ;
expect ( answer . statusCode ) . toBe ( 200 ) ;
2026-10-19 13:24:21 +02:00
expect ( answer . json . accessToken ) . toBe ( mode === "no_refresh_token" ? "access-norefresh" : "access-empty" ) ;
2026-10-19 11:13:40 +02:00
expect ( rig . custody . decrypt ( ( await rig . row ( ) ) . refreshTokenEncrypted ! ) ) . toBe ( "refresh-0" ) ;
} ) ;
}
2026-10-19 11:11:34 +02:00
2026-10-19 13:24:21 +02:00
it . each ( [
[ "no expires_in" , "no_expires_in" , 3600 ] ,
[ "an unusable expires_in" , "unusable_expires_in" , 3600 ] ,
[ "a numeric-string expires_in" , "string_expires_in" , 7200 ] ,
2026-10-19 14:34:43 +02:00
[ "a numeric expires_in beyond a year" , "huge_expires_in" , 3600 ] ,
[ "a numeric-string expires_in beyond a year" , "huge_string_expires_in" , 3600 ] ,
2026-10-19 13:24:21 +02:00
] as const ) (
"refresh-once-per-token: an answer with %s stores now plus %s s, not the old due expiry, and resolve refreshes again only once it passes" ,
timeout ,
async ( _name , mode , seconds ) = > {
const rig = await makeRig ( provider ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
endpoint . mode = mode ;
const before = Date . now ( ) ;
expect ( ( await rig . resolve ( ) ) . statusCode ) . toBe ( 200 ) ;
expect ( endpoint . calls ) . toBe ( 1 ) ;
const stored = Date . parse ( ( await rig . row ( ) ) . tokenExpiresAt ! ) ;
expect ( stored - before ) . toBeGreaterThanOrEqual ( seconds * 1000 - 1000 ) ;
expect ( stored - before ) . toBeLessThan ( seconds * 1000 + 10 _000 ) ;
expect ( ( await rig . resolve ( ) ) . statusCode ) . toBe ( 200 ) ;
expect ( endpoint . calls ) . toBe ( 1 ) ;
// The stored expiry passes: the next resolve refreshes.
const past = new Date ( Date . now ( ) - 1000 ) . toISOString ( ) ;
await rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , async ( locked ) = > ( {
kind : "rotate" ,
accessTokenEncrypted : locked.accessTokenEncrypted ! ,
refreshTokenEncrypted : locked.refreshTokenEncrypted ! ,
tokenExpiresAt : past ,
scopes : locked.scopes ,
updatedAt : past ,
} ) ) ;
expect ( ( await rig . resolve ( ) ) . statusCode ) . toBe ( 200 ) ;
expect ( endpoint . calls ) . toBe ( 2 ) ;
const again = Date . parse ( ( await rig . row ( ) ) . tokenExpiresAt ! ) ;
expect ( again ) . toBeGreaterThan ( Date . now ( ) + MANAGED_CREDENTIAL_REFRESH_SKEW_MS ) ;
}
) ;
2026-10-19 11:11:34 +02:00
it ( "a row with no access token resolves as revoked (410), a missing row as not found (404)" , timeout , async ( ) = > {
const rig = await makeRig ( provider , { accessToken : null , refreshToken : null } ) ;
const endpoint = installTokenEndpoint ( provider , "refresh-0" ) ;
const revoked = await rig . resolve ( ) ;
expect ( revoked . statusCode ) . toBe ( 410 ) ;
expect ( revoked . json ) . toEqual ( { error : "managed_credential_revoked" , provider } ) ;
expect ( endpoint . calls ) . toBe ( 0 ) ;
const missing = await rig . apps [ 0 ] ! . inject ( {
method : "POST" ,
url : "/v1/apps/streampixels/managed-credentials/resolve" ,
headers : { "x-heimdall-app-secret" : "streampixels-secret" } ,
payload : { accountId : "no-such-account" , provider } ,
} ) ;
expect ( missing . statusCode ) . toBe ( 404 ) ;
expect ( missing . json ( ) ) . toEqual ( { error : "managed_credential_not_found" } ) ;
} ) ;
} ) ;
2026-10-19 13:23:53 +02:00
describe ( "what the callback mints is decided by mode and provider role" , ( ) = > {
const returnTo = "https://streampixels.gamecult.org/auth/connect" ;
2026-10-19 14:35:11 +02:00
function stubProviders ( identity : Record < string , string > , tokenAnswer : Record < string , unknown > = { refresh_token : "tok-refresh-CANARY" } ) {
2026-10-19 13:23:53 +02:00
globalThis . fetch = ( async ( input : string | URL ) = > {
const url = String ( input ) ;
const json = ( body : unknown ) = > new Response ( JSON . stringify ( body ) , { status : 200 } ) ;
if ( url . startsWith ( "https://api.joystick.tv/api/oauth/token" ) || Object . values ( providerCatalog ) . some ( ( d ) = > d . tokenEndpoint === url ) ) {
2026-10-19 14:35:11 +02:00
return json ( { access_token : "tok-access" , token_type : "Bearer" , expires_in : 3600 , . . . tokenAnswer } ) ;
2026-10-19 13:23:53 +02:00
}
if ( url === "https://api.joystick.tv/api/v1/me/identity" ) return json ( { channel_id : identity.joystick , username : "deru" } ) ;
if ( url === "https://api.twitch.tv/helix/users" ) return json ( { data : [ { id : identity.twitch , login : "deru" , display_name : "Deru" } ] } ) ;
if ( url . startsWith ( "https://www.googleapis.com/youtube/v3/channels" ) ) {
return json ( { items : [ { id : identity.youtube , snippet : { title : "Deru" } } ] } ) ;
}
return new Response ( "unexpected" , { status : 500 } ) ;
} ) as typeof fetch ;
}
async function flow ( rig : Rig , provider : "joystick" | "twitch" | "youtube" , mode : "connect" | "sign_in" ) {
const app = rig . apps [ 0 ] ! ;
2026-10-19 14:34:24 +02:00
const start = await app . inject ( { method : "POST" , url : ` /v1/oauth/ ${ provider } /start ` , payload : { appSlug : "streampixels" , mode , returnTo , . . . ( mode === "connect" ? { connection : { kind : "creator" , targetId : "creator-1" } } : { } ) } } ) ;
2026-10-19 13:23:53 +02:00
expect ( start . statusCode ) . toBe ( 201 ) ;
const callback = await app . inject ( {
method : "GET" ,
url : ` /v1/oauth/ ${ provider } /callback?code=c&state= ${ encodeURIComponent ( start . json ( ) . stateToken as string ) } ` ,
} ) ;
expect ( callback . statusCode ) . toBe ( 201 ) ;
const redeem = await app . inject ( {
method : "POST" ,
url : "/v1/apps/streampixels/auth-completions/redeem" ,
payload : { completionCode : callback.json ( ) . completion . code as string } ,
} ) ;
expect ( redeem . statusCode ) . toBe ( 201 ) ;
return { callback , redeem } ;
}
it ( "a joystick connect on streampixels stores custody and yields no viewer_access, no capability and no refresh token" , timeout , async ( ) = > {
const rig = await makeRig ( "joystick" ) ;
stubProviders ( { joystick : "channel-1" , twitch : "t-1" , youtube : "y-1" } ) ;
const { callback , redeem } = await flow ( rig , "joystick" , "connect" ) ;
for ( const payload of [ callback . json ( ) , redeem . json ( ) ] as Array < Record < string , any > > ) {
expect ( payload . mode ) . toBe ( "connect" ) ;
expect ( payload . claimSet . capabilities ) . toEqual ( [ ] ) ;
expect ( payload . claimSet . facts ) . toEqual ( [ ] ) ;
expect ( payload . sharedCapabilities ) . toEqual ( [ ] ) ;
expect ( payload . hybridCapabilities ) . toEqual ( [ ] ) ;
expect ( payload . claimSet . identities ) . toEqual ( [ expect . objectContaining ( { provider : "joystick" , providerUserId : "channel-1" } ) ] ) ;
expect ( payload . account . id ) . toBe ( rig . accountId ) ;
expect ( payload . refreshToken ) . toBeUndefined ( ) ;
expect ( payload . refresh ) . toBeUndefined ( ) ;
}
expect ( callback . body + redeem . body ) . not . toContain ( "viewer_access" ) ;
expect ( callback . body + redeem . body ) . not . toContain ( "tok-refresh-CANARY" ) ;
const row = await rig . row ( ) ;
expect ( rig . custody . decrypt ( row . accessTokenEncrypted ! ) ) . toBe ( "tok-access" ) ;
expect ( rig . custody . decrypt ( row . refreshTokenEncrypted ! ) ) . toBe ( "tok-refresh-CANARY" ) ;
} ) ;
2026-10-19 14:35:11 +02:00
it . each ( [
[ "no refresh_token" , { } ] ,
[ "an empty refresh_token" , { refresh_token : "" } ] ,
] ) ( "a rotating provider's code exchange with %s fails the callback before anything is written" , timeout , async ( _name , tokenAnswer ) = > {
const rig = await makeRig ( "joystick" ) ;
const before = await rig . row ( ) ;
const auditBefore = ( await rig . audit ( ) ) . length ;
stubProviders ( { joystick : "channel-new" , twitch : "t-1" , youtube : "y-1" } , tokenAnswer ) ;
const app = rig . apps [ 0 ] ! ;
const start = await app . inject ( {
method : "POST" ,
url : "/v1/oauth/joystick/start" ,
payload : { appSlug : "streampixels" , mode : "connect" , returnTo , connection : { kind : "creator" , targetId : "creator-1" } } ,
} ) ;
expect ( start . statusCode ) . toBe ( 201 ) ;
const callback = await app . inject ( {
method : "GET" ,
url : ` /v1/oauth/joystick/callback?code=c&state= ${ encodeURIComponent ( start . json ( ) . stateToken as string ) } ` ,
} ) ;
expect ( callback . statusCode ) . toBeGreaterThanOrEqual ( 400 ) ;
expect ( callback . body ) . not . toContain ( "tok-access" ) ;
expect ( await rig . row ( ) ) . toEqual ( before ) ;
expect ( await rig . stores [ 0 ] ! . findStoredLinkedIdentity ( "joystick" , "channel-new" ) ) . toBeNull ( ) ;
const failures = ( await rig . audit ( ) ) . slice ( auditBefore ) ;
expect ( failures ) . toEqual ( [
{ eventType : "oauth_callback_failed" , payload : expect.objectContaining ( { errorClass : "ProviderResponseError" , providerStatus : 200 } ) } ,
] ) ;
} ) ;
2026-10-19 13:23:53 +02:00
it . each ( [ "twitch" , "youtube" ] as const ) ( "a %s sign_in on streampixels still yields viewer_access and a refresh token" , timeout , async ( provider ) = > {
const rig = await makeRig ( "joystick" ) ;
stubProviders ( { joystick : "channel-1" , twitch : "t-1" , youtube : "y-1" } ) ;
const { callback , redeem } = await flow ( rig , provider , "sign_in" ) ;
for ( const payload of [ callback . json ( ) , redeem . json ( ) ] as Array < Record < string , any > > ) {
expect ( payload . claimSet . capabilities ) . toContain ( "viewer_access" ) ;
expect ( payload . refreshToken ) . toEqual ( expect . any ( String ) ) ;
2026-10-19 14:04:12 +02:00
expect ( payload . hybridCapabilities . map ( ( c : { key : string } ) = > c . key ) ) . toEqual ( [ "creator_access" , "creator_admin" ] ) ;
2026-10-19 13:23:53 +02:00
}
} ) ;
it ( "a twitch connect on streampixels keeps its current behaviour: an identity provider still signs the viewer in" , timeout , async ( ) = > {
const rig = await makeRig ( "joystick" ) ;
stubProviders ( { joystick : "channel-1" , twitch : "t-1" , youtube : "y-1" } ) ;
const { redeem } = await flow ( rig , "twitch" , "connect" ) ;
expect ( redeem . json ( ) . claimSet . capabilities ) . toContain ( "viewer_access" ) ;
} ) ;
} ) ;
2026-10-19 11:11:34 +02:00
describe ( "updateLinkedIdentityCredential" , ( ) = > {
it ( "returns null for a missing id and does not call decide" , async ( ) = > {
const rig = await makeRig ( "twitch" ) ;
let called = false ;
const result = await rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( randomUUID ( ) , async ( ) = > {
called = true ;
return { kind : "keep" } ;
} ) ;
expect ( result ) . toBeNull ( ) ;
expect ( called ) . toBe ( false ) ;
} ) ;
it ( "hands decide the committed row, and a throw writes nothing" , async ( ) = > {
const rig = await makeRig ( "twitch" ) ;
const before = await rig . row ( ) ;
let seen : StoredLinkedIdentity | undefined ;
await expect (
rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , async ( locked ) = > {
seen = locked ;
throw new Error ( "boom" ) ;
} )
) . rejects . toThrow ( "boom" ) ;
expect ( seen ) . toEqual ( before ) ;
expect ( await rig . row ( ) ) . toEqual ( before ) ;
} ) ;
it ( "rotate writes token columns, scopes and updated_at and nothing else; keep writes nothing" , async ( ) = > {
const rig = await makeRig ( "twitch" ) ;
const before = await rig . row ( ) ;
const kept = await rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , async ( ) = > ( { kind : "keep" } ) ) ;
expect ( kept ) . toEqual ( before ) ;
const rotated = await rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , async ( ) = > ( {
kind : "rotate" ,
accessTokenEncrypted : "A2" ,
refreshTokenEncrypted : "R2" ,
tokenExpiresAt : "2999-01-01T00:00:00.000Z" ,
scopes : [ "a" , "b" ] ,
updatedAt : "2999-01-02T00:00:00.000Z" ,
} ) ) ;
expect ( rotated ) . toEqual ( {
. . . before ,
accessTokenEncrypted : "A2" ,
refreshTokenEncrypted : "R2" ,
tokenExpiresAt : "2999-01-01T00:00:00.000Z" ,
scopes : [ "a" , "b" ] ,
updatedAt : "2999-01-02T00:00:00.000Z" ,
} ) ;
expect ( await rig . row ( ) ) . toEqual ( rotated ) ;
const revoked = await rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , async ( ) = > ( {
kind : "revoke" ,
updatedAt : "2999-01-03T00:00:00.000Z" ,
} ) ) ;
const { accessTokenEncrypted : _a , refreshTokenEncrypted : _r , tokenExpiresAt : _e , . . . rest } = rotated ! ;
expect ( revoked ) . toEqual ( { . . . rest , updatedAt : "2999-01-03T00:00:00.000Z" } ) ;
expect ( await rig . row ( ) ) . toEqual ( revoked ) ;
} ) ;
2026-10-19 11:47:37 +02:00
it . runIf ( live ) ( "leaves no transaction open after a missing row or a throwing decide" , timeout , async ( ) = > {
const rig = await makeRig ( "twitch" ) ;
const pool = new Pool ( { connectionString : rig.databaseUrl , max : 1 } ) ;
pool . on ( "error" , ( ) = > undefined ) ;
try {
const store = new PostgresStore ( pool ) ;
const idle = async ( ) = > {
await sleep ( 10 ) ;
const result = await pool . query < { fresh : boolean } > ( "SELECT now() = statement_timestamp() AS fresh" ) ;
return result . rows [ 0 ] ! . fresh ;
} ;
expect ( await store . updateLinkedIdentityCredential ( randomUUID ( ) , async ( ) = > ( { kind : "keep" } ) ) ) . toBeNull ( ) ;
expect ( await idle ( ) ) . toBe ( true ) ;
await expect (
store . updateLinkedIdentityCredential ( rig . identityId , async ( ) = > {
throw new Error ( "boom" ) ;
} )
) . rejects . toThrow ( "boom" ) ;
expect ( await idle ( ) ) . toBe ( true ) ;
} finally {
await pool . end ( ) ;
}
} ) ;
2026-10-19 11:52:27 +02:00
it . runIf ( ! live ) ( "a decider arriving after the first drains still waits for the one queued behind it" , async ( ) = > {
const rig = await makeRig ( "twitch" ) ;
const events : string [ ] = [ ] ;
const decider = ( name : string , ms : number ) = > async ( locked : StoredLinkedIdentity ) = > {
events . push ( ` start ${ name } ` ) ;
await sleep ( ms ) ;
events . push ( ` end ${ name } ` ) ;
return { kind : "keep" as const , seen : locked } ;
} ;
const a = rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , decider ( "A" , 20 ) ) ;
const b = rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , decider ( "B" , 120 ) ) ;
await a ;
const c = rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , decider ( "C" , 1 ) ) ;
await Promise . all ( [ b , c ] ) ;
expect ( events ) . toEqual ( [ "start A" , "end A" , "start B" , "end B" , "start C" , "end C" ] ) ;
} ) ;
2026-10-19 11:47:37 +02:00
it . runIf ( ! live ) ( "drops a drained row's chain entry" , async ( ) = > {
const rig = await makeRig ( "twitch" ) ;
await Promise . all ( [ 1 , 2 , 3 ] . map ( ( ) = > rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , async ( ) = > ( { kind : "keep" } ) ) ) ) ;
await sleep ( 5 ) ;
expect ( ( rig . stores [ 0 ] as unknown as { credentialChains : Map < string , unknown > } ) . credentialChains . size ) . toBe ( 0 ) ;
} ) ;
2026-10-19 11:11:34 +02:00
it ( "serialises deciders on one row: each sees the previous commit" , timeout , async ( ) = > {
const rig = await makeRig ( "twitch" ) ;
const order : string [ ] = [ ] ;
await Promise . all (
[ 1 , 2 , 3 , 4 , 5 ] . map ( ( n ) = >
rig . stores [ 0 ] ! . updateLinkedIdentityCredential ( rig . identityId , async ( locked ) = > {
order . push ( ` ${ n } : ${ locked . accessTokenEncrypted } ` ) ;
await sleep ( 20 ) ;
return {
kind : "rotate" ,
accessTokenEncrypted : ` A ${ n } ` ,
refreshTokenEncrypted : ` R ${ n } ` ,
2026-10-19 13:29:10 +02:00
tokenExpiresAt : new Date ( Date . now ( ) + 3600 _000 ) . toISOString ( ) ,
2026-10-19 11:11:34 +02:00
scopes : locked.scopes ,
updatedAt : new Date ( ) . toISOString ( ) ,
} ;
} )
)
) ;
const seenValues = order . map ( ( entry ) = > entry . split ( ":" ) [ 1 ] ) ;
// Each decider saw the value the one before it wrote, so their reads form a chain.
expect ( new Set ( seenValues ) . size ) . toBe ( 5 ) ;
const wrote = order . map ( ( entry ) = > ` A ${ entry . split ( ":" ) [ 0 ] } ` ) ;
for ( let i = 1 ; i < seenValues . length ; i += 1 ) expect ( seenValues [ i ] ) . toBe ( wrote [ i - 1 ] ) ;
} ) ;
} ) ;
} ) ;
2026-10-19 11:47:37 +02:00
describe ( "refreshManagedCredential without the lock" , ( ) = > {
const config = testConfig ( ) ;
const custody = createTokenCustody ( config ) ;
const now = Date . parse ( "2026-10-19T12:00:00.000Z" ) ;
function identity ( overrides : Partial < StoredLinkedIdentity > = { } , provider : Provider = "twitch" ) : StoredLinkedIdentity {
return {
id : "id-1" ,
accountId : "acct-1" ,
provider ,
providerUserId : "u1" ,
scopes : [ "s" ] ,
profileJson : { } ,
createdAt : "2026-01-01T00:00:00.000Z" ,
updatedAt : "2026-01-01T00:00:00.000Z" ,
accessTokenEncrypted : custody.encrypt ( "access-0" ) ,
refreshTokenEncrypted : custody.encrypt ( "refresh-0" ) ,
tokenExpiresAt : new Date ( now + 60 * 60 * 1000 ) . toISOString ( ) ,
. . . overrides ,
} ;
}
function countingStore ( locked : StoredLinkedIdentity | null ) {
const log = { locks : 0 , decisions : [ ] as unknown [ ] , audits : [ ] as unknown [ ] } ;
const store = {
async updateLinkedIdentityCredential ( _id : string , decide : ( row : StoredLinkedIdentity ) = > Promise < unknown > ) {
log . locks += 1 ;
if ( locked === null ) return null ;
log . decisions . push ( await decide ( locked ) ) ;
return locked ;
} ,
async createAuditEvent ( event : unknown ) {
log . audits . push ( event ) ;
} ,
} ;
return { store : store as unknown as HeimdallStore , log } ;
}
const ctxFor = ( store : HeimdallStore ) = > ( { config , store , tokenCustody : custody , oauthRuntimes : createOAuthRuntimeRegistry ( ) , clock : ( ) = > now } ) ;
afterEach ( ( ) = > {
globalThis . fetch = originalFetch ;
} ) ;
it ( "a snapshot with no access token is revoked without a lock or a provider call" , async ( ) = > {
const { store , log } = countingStore ( null ) ;
const endpoint = installTokenEndpoint ( "twitch" , "refresh-0" ) ;
const outcome = await refreshManagedCredential ( ctxFor ( store ) , identity ( { accessTokenEncrypted : undefined as never } ) ) ;
expect ( outcome . kind ) . toBe ( "revoked" ) ;
expect ( log . locks ) . toBe ( 0 ) ;
expect ( endpoint . calls ) . toBe ( 0 ) ;
} ) ;
it . each ( [
[ "outside the skew" , { } , "twitch" ] ,
[ "without an expiry" , { tokenExpiresAt : undefined as never } , "twitch" ] ,
[ "without a refresh token" , { refreshTokenEncrypted : undefined as never , tokenExpiresAt : new Date ( now ) . toISOString ( ) } , "twitch" ] ,
[ "for a provider whose runtime cannot refresh" , { tokenExpiresAt : new Date ( now ) . toISOString ( ) } , "github" ] ,
] as const ) ( "a credential %s is current with no lock and no provider call" , async ( _name , overrides , provider ) = > {
const { store , log } = countingStore ( null ) ;
const endpoint = installTokenEndpoint ( "twitch" , "refresh-0" ) ;
const snapshot = identity ( overrides as Partial < StoredLinkedIdentity > , provider ) ;
const outcome = await refreshManagedCredential ( ctxFor ( store ) , snapshot ) ;
expect ( outcome ) . toEqual ( { kind : "current" , identity : snapshot } ) ;
expect ( log . locks ) . toBe ( 0 ) ;
expect ( endpoint . calls ) . toBe ( 0 ) ;
} ) ;
it ( "an expiry exactly one skew away is due; one millisecond further is not" , async ( ) = > {
const due = identity ( { tokenExpiresAt : new Date ( now + MANAGED_CREDENTIAL_REFRESH_SKEW_MS ) . toISOString ( ) } ) ;
const notDue = identity ( { tokenExpiresAt : new Date ( now + MANAGED_CREDENTIAL_REFRESH_SKEW_MS + 1 ) . toISOString ( ) } ) ;
const a = countingStore ( due ) ;
installTokenEndpoint ( "twitch" , "refresh-0" ) ;
await refreshManagedCredential ( ctxFor ( a . store ) , due ) ;
expect ( a . log . locks ) . toBe ( 1 ) ;
const b = countingStore ( notDue ) ;
await refreshManagedCredential ( ctxFor ( b . store ) , notDue ) ;
expect ( b . log . locks ) . toBe ( 0 ) ;
} ) ;
it ( "decides revoke for a grant verdict and for a missing rotated token, rotate otherwise" , async ( ) = > {
const due = identity ( { tokenExpiresAt : new Date ( now ) . toISOString ( ) } ) ;
const grant = countingStore ( due ) ;
installTokenEndpoint ( "twitch" , "refresh-0" ) . mode = "invalid_grant" ;
await refreshManagedCredential ( ctxFor ( grant . store ) , due ) ;
expect ( grant . log . decisions ) . toEqual ( [ { kind : "revoke" , updatedAt : new Date ( now ) . toISOString ( ) } ] ) ;
expect ( grant . log . audits ) . toEqual ( [
expect . objectContaining ( { eventType : "managed_credential_revoked" , eventPayloadJson : { provider : "twitch" , reason : "provider_revoked" } } ) ,
] ) ;
const rotating = identity ( { tokenExpiresAt : new Date ( now ) . toISOString ( ) } , "joystick" ) ;
const missing = countingStore ( rotating ) ;
installTokenEndpoint ( "joystick" , "refresh-0" ) . mode = "no_refresh_token" ;
await refreshManagedCredential ( ctxFor ( missing . store ) , rotating ) ;
expect ( missing . log . decisions ) . toEqual ( [ { kind : "revoke" , updatedAt : new Date ( now ) . toISOString ( ) } ] ) ;
const fine = countingStore ( due ) ;
installTokenEndpoint ( "twitch" , "refresh-0" ) ;
await refreshManagedCredential ( ctxFor ( fine . store ) , due ) ;
expect ( fine . log . decisions ) . toEqual ( [ expect . objectContaining ( { kind : "rotate" , refreshTokenEncrypted : expect.any ( String ) , scopes : [ "s" ] } ) ] ) ;
expect ( fine . log . audits ) . toEqual ( [ ] ) ;
} ) ;
it ( "a row that vanished is revoked with its tokens cleared" , async ( ) = > {
const due = identity ( { tokenExpiresAt : new Date ( now ) . toISOString ( ) } ) ;
const { store } = countingStore ( null ) ;
installTokenEndpoint ( "twitch" , "refresh-0" ) ;
const outcome = await refreshManagedCredential ( ctxFor ( store ) , due ) ;
expect ( outcome . kind ) . toBe ( "revoked" ) ;
expect ( outcome . identity . accessTokenEncrypted ) . toBeUndefined ( ) ;
expect ( outcome . identity . refreshTokenEncrypted ) . toBeUndefined ( ) ;
expect ( outcome . identity . tokenExpiresAt ) . toBeUndefined ( ) ;
expect ( outcome . identity . providerUserId ) . toBe ( "u1" ) ;
} ) ;
it ( "a row revoked by someone else between the snapshot and the lock is revoked, with no provider call" , async ( ) = > {
const due = identity ( { tokenExpiresAt : new Date ( now ) . toISOString ( ) } ) ;
const gone = identity ( { accessTokenEncrypted : undefined as never , refreshTokenEncrypted : undefined as never , tokenExpiresAt : undefined as never } ) ;
const { store } = countingStore ( gone ) ;
const endpoint = installTokenEndpoint ( "twitch" , "refresh-0" ) ;
const outcome = await refreshManagedCredential ( ctxFor ( store ) , due ) ;
expect ( outcome . kind ) . toBe ( "revoked" ) ;
expect ( endpoint . calls ) . toBe ( 0 ) ;
} ) ;
it ( "names the provider in the failure and carries nothing the provider said" , async ( ) = > {
const due = identity ( { tokenExpiresAt : new Date ( now ) . toISOString ( ) } ) ;
const { store } = countingStore ( due ) ;
installTokenEndpoint ( "twitch" , "refresh-0" ) . mode = "server_error" ;
const failure = await refreshManagedCredential ( ctxFor ( store ) , due ) . catch ( ( error : unknown ) = > error as ManagedCredentialRefreshFailed ) ;
expect ( failure ) . toBeInstanceOf ( ManagedCredentialRefreshFailed ) ;
expect ( ( failure as Error ) . message ) . toBe ( "Twitch managed credential refresh failed." ) ;
expect ( ( failure as ManagedCredentialRefreshFailed ) . code ) . toBe ( "managed_credential_refresh_failed" ) ;
} ) ;
} ) ;
2026-10-19 12:34:17 +02:00
describe ( "refresh answer classification" , ( ) = > {
const config = testConfig ( ) ;
const custody = createTokenCustody ( config ) ;
const now = Date . parse ( "2026-10-19T12:00:00.000Z" ) ;
const dueAt = new Date ( now ) . toISOString ( ) ;
async function seedRow ( store : InMemoryStore , provider : Provider ) {
const created = new Date ( now ) . toISOString ( ) ;
const account = await store . createAccount ( { createdAt : created , lastSeenAt : created } ) ;
return store . upsertLinkedIdentity ( {
accountId : account.id ,
provider ,
providerUserId : ` u- ${ randomUUID ( ) } ` ,
scopes : [ "s" ] ,
profileJson : { } ,
accessTokenEncrypted : custody.encrypt ( "access-0" ) ,
refreshTokenEncrypted : custody.encrypt ( "refresh-0" ) ,
tokenExpiresAt : dueAt ,
createdAt : created ,
updatedAt : created ,
} ) ;
}
const ctxFor = ( store : HeimdallStore , extra : { timeoutMs? : number } = { } ) = > ( {
config ,
store ,
tokenCustody : custody ,
oauthRuntimes : createOAuthRuntimeRegistry ( ) ,
clock : ( ) = > now ,
. . . extra ,
} ) ;
function answer ( body : string , status : number ) {
const calls : string [ ] = [ ] ;
globalThis . fetch = ( async ( _input : string | URL , init? : RequestInit ) = > {
calls . push ( String ( ( init ! . body as URLSearchParams ) . get ( "refresh_token" ) ) ) ;
return new Response ( body , { status } ) ;
} ) as typeof fetch ;
return calls ;
}
afterEach ( ( ) = > {
globalThis . fetch = originalFetch ;
} ) ;
const grantVerdict = JSON . stringify ( { error : "invalid_grant" } ) ;
const keepsTokens : Array < [ string , string , number ] > = [
[ "an error code that only contains invalid_grant" , JSON . stringify ( { error : "invalid_grant_extra" } ) , 400 ] ,
[ "an error code that is contained in a longer one" , JSON . stringify ( { error : "not_invalid_grant" } ) , 400 ] ,
[ "a different error code" , JSON . stringify ( { error : "invalid_request" } ) , 400 ] ,
[ "a 401 carrying invalid_grant" , grantVerdict , 401 ] ,
[ "a 500 carrying invalid_grant" , grantVerdict , 500 ] ,
[ "a 503 carrying invalid_grant" , grantVerdict , 503 ] ,
[ "a 2xx carrying invalid_grant" , grantVerdict , 200 ] ,
[ "a 2xx carrying an error beside an access token" , JSON . stringify ( { access_token : "a" , error : "server_error" } ) , 200 ] ,
[ "a 2xx with an empty object" , "{}" , 200 ] ,
[ "a 2xx with an empty body" , "" , 200 ] ,
[ "a 2xx with JSON null" , "null" , 200 ] ,
[ "a 2xx with a JSON array" , "[]" , 200 ] ,
[ "a 2xx with a non-string access token" , JSON . stringify ( { access_token : 7 , refresh_token : "r" } ) , 200 ] ,
2026-10-19 13:24:21 +02:00
[ "a 2xx with a numeric refresh token" , JSON . stringify ( { access_token : "a" , refresh_token : 42 } ) , 200 ] ,
[ "a 2xx with an object refresh token" , JSON . stringify ( { access_token : "a" , refresh_token : { r : 1 } } ) , 200 ] ,
2026-10-19 12:34:17 +02:00
[ "a 2xx with an empty access token" , JSON . stringify ( { access_token : "" , refresh_token : "r" } ) , 200 ] ,
[ "a 2xx that is not JSON" , "<html>" , 200 ] ,
[ "a 5xx page" , "upstream exploded" , 502 ] ,
] ;
describe . each ( [ "twitch" , "joystick" ] as const ) ( "%s" , ( provider ) = > {
it . each ( keepsTokens ) ( "keeps the tokens and fails typed for %s" , async ( _name , body , status ) = > {
const store = new InMemoryStore ( ) ;
const row = await seedRow ( store , provider ) ;
const calls = answer ( body , status ) ;
await expect ( refreshManagedCredential ( ctxFor ( store ) , row ) ) . rejects . toBeInstanceOf ( ManagedCredentialRefreshFailed ) ;
expect ( calls ) . toEqual ( [ "refresh-0" ] ) ;
const after = ( await store . findStoredLinkedIdentity ( provider , row . providerUserId ) ) ! ;
expect ( after . accessTokenEncrypted ) . toBe ( row . accessTokenEncrypted ) ;
expect ( after . refreshTokenEncrypted ) . toBe ( row . refreshTokenEncrypted ) ;
expect ( after . tokenExpiresAt ) . toBe ( dueAt ) ;
} ) ;
it ( "keeps the tokens and fails typed when the provider never answers" , async ( ) = > {
const store = new InMemoryStore ( ) ;
const row = await seedRow ( store , provider ) ;
globalThis . fetch = ( ( _input : string | URL , init? : RequestInit ) = >
new Promise < Response > ( ( _resolve , reject ) = > {
init ! . signal ? . addEventListener ( "abort" , ( ) = > reject ( new DOMException ( "aborted" , "AbortError" ) ) ) ;
} ) ) as typeof fetch ;
await expect ( refreshManagedCredential ( ctxFor ( store , { timeoutMs : 30 } ) , row ) ) . rejects . toBeInstanceOf ( ManagedCredentialRefreshFailed ) ;
const after = ( await store . findStoredLinkedIdentity ( provider , row . providerUserId ) ) ! ;
expect ( after . refreshTokenEncrypted ) . toBe ( row . refreshTokenEncrypted ) ;
} ) ;
it ( "revokes and clears the tokens on a 400 whose error is exactly invalid_grant" , async ( ) = > {
const store = new InMemoryStore ( ) ;
const row = await seedRow ( store , provider ) ;
answer ( grantVerdict , 400 ) ;
const outcome = await refreshManagedCredential ( ctxFor ( store ) , row ) ;
expect ( outcome . kind ) . toBe ( "revoked" ) ;
const after = ( await store . findStoredLinkedIdentity ( provider , row . providerUserId ) ) ! ;
expect ( after . accessTokenEncrypted ) . toBeUndefined ( ) ;
expect ( after . refreshTokenEncrypted ) . toBeUndefined ( ) ;
} ) ;
} ) ;
2026-10-19 13:24:21 +02:00
it . each ( [
[ "without a refresh token" , { access_token : "a" , expires_in : 3600 } ] ,
[ "with an empty refresh token" , { access_token : "a" , refresh_token : "" , expires_in : 3600 } ] ,
] ) ( "a rotating provider's well-formed answer %s still revokes (rotation_missing)" , async ( _n , body ) = > {
2026-10-19 12:34:17 +02:00
const store = new InMemoryStore ( ) ;
const row = await seedRow ( store , "joystick" ) ;
2026-10-19 13:24:21 +02:00
answer ( JSON . stringify ( body ) , 200 ) ;
2026-10-19 12:34:17 +02:00
expect ( ( await refreshManagedCredential ( ctxFor ( store ) , row ) ) . kind ) . toBe ( "revoked" ) ;
expect ( ( await store . findStoredLinkedIdentity ( "joystick" , row . providerUserId ) ) ! . refreshTokenEncrypted ) . toBeUndefined ( ) ;
} ) ;
2026-10-19 13:24:21 +02:00
it . each ( [
[ "no expires_in" , { } ] ,
[ "a non-numeric string expires_in" , { expires_in : "soon" } ] ,
] ) ( "an answer with %s stores now plus the default lifetime; resolve refreshes once it passes" , async ( _n , extra ) = > {
2026-10-19 12:34:17 +02:00
const store = new InMemoryStore ( ) ;
2026-10-19 13:24:21 +02:00
const seeded = await seedRow ( store , "twitch" ) ;
// Due against the real clock, which the token answer's expiry is computed from.
const row = ( await store . updateLinkedIdentityCredential ( seeded . id , async ( locked ) = > ( {
kind : "rotate" ,
accessTokenEncrypted : locked.accessTokenEncrypted ! ,
refreshTokenEncrypted : locked.refreshTokenEncrypted ! ,
tokenExpiresAt : new Date ( ) . toISOString ( ) ,
scopes : locked.scopes ,
updatedAt : new Date ( ) . toISOString ( ) ,
} ) ) ) ! ;
const calls = answer ( JSON . stringify ( { access_token : "access-1" , refresh_token : "refresh-1" , . . . extra } ) , 200 ) ;
let skew = 0 ;
const ctx = { . . . ctxFor ( store ) , clock : ( ) = > Date . now ( ) + skew } ;
const before = Date . now ( ) ;
const first = await refreshManagedCredential ( ctx , row ) ;
2026-10-19 12:34:17 +02:00
expect ( first . kind ) . toBe ( "current" ) ;
const stored = ( await store . findStoredLinkedIdentity ( "twitch" , row . providerUserId ) ) ! ;
2026-10-19 13:24:21 +02:00
const expiry = Date . parse ( stored . tokenExpiresAt ! ) ;
expect ( expiry - before ) . toBeGreaterThanOrEqual ( providerCatalog . twitch . defaultAccessTokenLifetimeSeconds * 1000 ) ;
expect ( expiry - before ) . toBeLessThan ( providerCatalog . twitch . defaultAccessTokenLifetimeSeconds * 1000 + 5 _000 ) ;
2026-10-19 12:34:17 +02:00
expect ( custody . decrypt ( stored . accessTokenEncrypted ! ) ) . toBe ( "access-1" ) ;
2026-10-19 13:24:21 +02:00
await refreshManagedCredential ( ctx , stored ) ;
2026-10-19 12:34:17 +02:00
expect ( calls ) . toEqual ( [ "refresh-0" ] ) ;
2026-10-19 13:24:21 +02:00
skew = providerCatalog . twitch . defaultAccessTokenLifetimeSeconds * 1000 ;
await refreshManagedCredential ( ctx , stored ) ;
expect ( calls ) . toEqual ( [ "refresh-0" , "refresh-1" ] ) ;
2026-10-19 12:34:17 +02:00
} ) ;
it ( "a non-rotating provider keeps the locked row's refresh token, not the snapshot's" , async ( ) = > {
const store = new InMemoryStore ( ) ;
const snapshot = await seedRow ( store , "twitch" ) ;
// Another caller rotated the pair after the snapshot; its expiry is still due.
await store . updateLinkedIdentityCredential ( snapshot . id , async ( ) = > ( {
kind : "rotate" ,
accessTokenEncrypted : custody.encrypt ( "access-1" ) ,
refreshTokenEncrypted : custody.encrypt ( "refresh-1" ) ,
tokenExpiresAt : dueAt ,
scopes : [ "s" ] ,
updatedAt : dueAt ,
} ) ) ;
const calls = answer ( JSON . stringify ( { access_token : "access-2" , expires_in : 3600 } ) , 200 ) ;
await refreshManagedCredential ( ctxFor ( store ) , snapshot ) ;
expect ( calls ) . toEqual ( [ "refresh-1" ] ) ;
const after = ( await store . findStoredLinkedIdentity ( "twitch" , snapshot . providerUserId ) ) ! ;
expect ( custody . decrypt ( after . refreshTokenEncrypted ! ) ) . toBe ( "refresh-1" ) ;
expect ( custody . decrypt ( after . accessTokenEncrypted ! ) ) . toBe ( "access-2" ) ;
} ) ;
} ) ;
2026-10-19 11:11:34 +02:00
describe ( "live Postgres availability" , ( ) = > {
it . skipIf ( ! ! adminUrl ) ( "is present whenever the job requires it" , ( ) = > {
// Local workstation runs skip the live suites; the verification job sets
// HEIMDALL_REQUIRE_LIVE_PG=1 and fails here when they would have been skipped.
expect ( process . env . HEIMDALL_REQUIRE_LIVE_PG ) . toBeUndefined ( ) ;
} ) ;
} ) ;