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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-19 13:29:10 +02:00
commit 655fcc7d22
5 changed files with 15 additions and 14 deletions

View file

@ -150,15 +150,14 @@ async function refreshUnderLock(
refreshTokenEncrypted = locked.refreshTokenEncrypted!; 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 // descriptor's default lifetime): the old one is already due, and an
// unknown one would never come due (dueForRefresh). // unknown one would never come due (dueForRefresh).
const tokenExpiresAt = refreshed.expiresAt;
return { return {
kind: "rotate", kind: "rotate",
accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken), accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken),
refreshTokenEncrypted, refreshTokenEncrypted,
...(tokenExpiresAt !== undefined ? { tokenExpiresAt } : {}), tokenExpiresAt: refreshed.expiresAt,
scopes: refreshed.scope.length ? refreshed.scope : locked.scopes, scopes: refreshed.scope.length ? refreshed.scope : locked.scopes,
updatedAt, updatedAt,
}; };

View file

@ -41,6 +41,9 @@ export interface OAuthCallbackContext {
entitlementPolicy: OAuthEntitlementPolicy | null; 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 { export interface OAuthProviderRuntime {
exchangeAuthorizationCode(options: { exchangeAuthorizationCode(options: {
config: HeimdallConfig; config: HeimdallConfig;
@ -51,7 +54,7 @@ export interface OAuthProviderRuntime {
config: HeimdallConfig; config: HeimdallConfig;
refreshToken: string; refreshToken: string;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<OAuthTokenSet>; }): Promise<ExpiringTokenSet>;
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>; resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
evaluateEntitlements(options: { evaluateEntitlements(options: {
config: HeimdallConfig; config: HeimdallConfig;
@ -156,7 +159,7 @@ export async function requestProviderTokens(
config: HeimdallConfig, config: HeimdallConfig,
grant: TokenGrant, grant: TokenGrant,
signal?: AbortSignal signal?: AbortSignal
): Promise<OAuthTokenSet> { ): Promise<ExpiringTokenSet> {
const descriptor = providerCatalog[provider]; const descriptor = providerCatalog[provider];
const providerConfig = config.providers[provider]; const providerConfig = config.providers[provider];
if (!providerConfig.clientId || !providerConfig.clientSecret) { if (!providerConfig.clientId || !providerConfig.clientSecret) {
@ -209,19 +212,19 @@ export async function requestProviderTokens(
throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure); 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, accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type ?? "Bearer", tokenType: tokenResponse.token_type ?? "Bearer",
scope: normalizeScopes(tokenResponse.scope), scope: normalizeScopes(tokenResponse.scope),
expiresAt: new Date(Date.now() + lifetimeSeconds * 1000).toISOString(),
raw: tokenResponse as unknown as Record<string, unknown>, raw: tokenResponse as unknown as Record<string, unknown>,
}; };
if (tokenResponse.refresh_token) { if (tokenResponse.refresh_token) {
tokenSet.refreshToken = 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; return tokenSet;
} }

View file

@ -198,9 +198,7 @@ export class InMemoryStore implements HeimdallStore {
if (decision.kind === "rotate") { if (decision.kind === "rotate") {
next.accessTokenEncrypted = decision.accessTokenEncrypted; next.accessTokenEncrypted = decision.accessTokenEncrypted;
next.refreshTokenEncrypted = decision.refreshTokenEncrypted; next.refreshTokenEncrypted = decision.refreshTokenEncrypted;
if (decision.tokenExpiresAt !== undefined) { next.tokenExpiresAt = decision.tokenExpiresAt;
next.tokenExpiresAt = decision.tokenExpiresAt;
}
next.scopes = [...decision.scopes]; next.scopes = [...decision.scopes];
} }
this.linkedIdentities.set(id, clone(next)); this.linkedIdentities.set(id, clone(next));

View file

@ -67,7 +67,7 @@ export type CredentialDecision =
kind: "rotate"; kind: "rotate";
accessTokenEncrypted: string; accessTokenEncrypted: string;
refreshTokenEncrypted: string; refreshTokenEncrypted: string;
tokenExpiresAt?: string; tokenExpiresAt: string;
scopes: string[]; scopes: string[];
updatedAt: string; updatedAt: string;
} }

View file

@ -738,6 +738,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
kind: "rotate", kind: "rotate",
accessTokenEncrypted: `A${n}`, accessTokenEncrypted: `A${n}`,
refreshTokenEncrypted: `R${n}`, refreshTokenEncrypted: `R${n}`,
tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(),
scopes: locked.scopes, scopes: locked.scopes,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };