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!;
}
// 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,
};

View file

@ -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<OAuthTokenSet>;
}): Promise<ExpiringTokenSet>;
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
evaluateEntitlements(options: {
config: HeimdallConfig;
@ -156,7 +159,7 @@ export async function requestProviderTokens(
config: HeimdallConfig,
grant: TokenGrant,
signal?: AbortSignal
): Promise<OAuthTokenSet> {
): Promise<ExpiringTokenSet> {
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<string, unknown>,
};
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;
}

View file

@ -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));

View file

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

View file

@ -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(),
};