From 655fcc7d228b29c7ab55561c9bb1abcc606735a4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 13:29:10 +0200 Subject: [PATCH] Credential rotate decisions carry a required expiry Refresh and exchange answers now type as ExpiringTokenSet, and a rotate decision requires tokenExpiresAt, so neither store has a no-expiry rule left to mutate: a NULL expiry can only be written by a revoke. Co-Authored-By: Claude Opus 5.5 --- src/managed-credential.ts | 5 ++--- src/oauth.ts | 17 ++++++++++------- src/store/in-memory.ts | 4 +--- src/store/types.ts | 2 +- tests/managed-credential.test.ts | 1 + 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/managed-credential.ts b/src/managed-credential.ts index 7d15e2c..e2fd124 100644 --- a/src/managed-credential.ts +++ b/src/managed-credential.ts @@ -150,15 +150,14 @@ async function refreshUnderLock( refreshTokenEncrypted = locked.refreshTokenEncrypted!; } - // The token set always carries an expiry (the provider's, or the + // The refreshed set always carries an expiry (the provider's, or the // descriptor's default lifetime): the old one is already due, and an // unknown one would never come due (dueForRefresh). - const tokenExpiresAt = refreshed.expiresAt; return { kind: "rotate", accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken), refreshTokenEncrypted, - ...(tokenExpiresAt !== undefined ? { tokenExpiresAt } : {}), + tokenExpiresAt: refreshed.expiresAt, scopes: refreshed.scope.length ? refreshed.scope : locked.scopes, updatedAt, }; diff --git a/src/oauth.ts b/src/oauth.ts index 53f0f85..2ad2c76 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -41,6 +41,9 @@ export interface OAuthCallbackContext { entitlementPolicy: OAuthEntitlementPolicy | null; } +/** A token set whose access-token expiry is known: the provider's own, or the descriptor's default. */ +export type ExpiringTokenSet = OAuthTokenSet & { expiresAt: string }; + export interface OAuthProviderRuntime { exchangeAuthorizationCode(options: { config: HeimdallConfig; @@ -51,7 +54,7 @@ export interface OAuthProviderRuntime { config: HeimdallConfig; refreshToken: string; signal?: AbortSignal; - }): Promise; + }): Promise; resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; evaluateEntitlements(options: { config: HeimdallConfig; @@ -156,7 +159,7 @@ export async function requestProviderTokens( config: HeimdallConfig, grant: TokenGrant, signal?: AbortSignal -): Promise { +): Promise { const descriptor = providerCatalog[provider]; const providerConfig = config.providers[provider]; if (!providerConfig.clientId || !providerConfig.clientSecret) { @@ -209,19 +212,19 @@ export async function requestProviderTokens( throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure); } - const tokenSet: OAuthTokenSet = { + // Nothing issued here has an unknown expiry: an answer without a usable + // expires_in takes the descriptor's default lifetime. + const lifetimeSeconds = usableExpiresIn(tokenResponse.expires_in) ?? descriptor.defaultAccessTokenLifetimeSeconds; + const tokenSet: ExpiringTokenSet = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type ?? "Bearer", scope: normalizeScopes(tokenResponse.scope), + expiresAt: new Date(Date.now() + lifetimeSeconds * 1000).toISOString(), raw: tokenResponse as unknown as Record, }; if (tokenResponse.refresh_token) { tokenSet.refreshToken = tokenResponse.refresh_token; } - // Nothing issued here has an unknown expiry: an answer without a usable - // expires_in takes the descriptor's default lifetime. - const lifetimeSeconds = usableExpiresIn(tokenResponse.expires_in) ?? descriptor.defaultAccessTokenLifetimeSeconds; - tokenSet.expiresAt = new Date(Date.now() + lifetimeSeconds * 1000).toISOString(); return tokenSet; } diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 0dea560..6a53dad 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -198,9 +198,7 @@ export class InMemoryStore implements HeimdallStore { if (decision.kind === "rotate") { next.accessTokenEncrypted = decision.accessTokenEncrypted; next.refreshTokenEncrypted = decision.refreshTokenEncrypted; - if (decision.tokenExpiresAt !== undefined) { - next.tokenExpiresAt = decision.tokenExpiresAt; - } + next.tokenExpiresAt = decision.tokenExpiresAt; next.scopes = [...decision.scopes]; } this.linkedIdentities.set(id, clone(next)); diff --git a/src/store/types.ts b/src/store/types.ts index 0145626..5307e1f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -67,7 +67,7 @@ export type CredentialDecision = kind: "rotate"; accessTokenEncrypted: string; refreshTokenEncrypted: string; - tokenExpiresAt?: string; + tokenExpiresAt: string; scopes: string[]; updatedAt: string; } diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 931f7ef..7f473eb 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -738,6 +738,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag kind: "rotate", accessTokenEncrypted: `A${n}`, refreshTokenEncrypted: `R${n}`, + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), scopes: locked.scopes, updatedAt: new Date().toISOString(), };