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:
parent
32208cc8e2
commit
655fcc7d22
5 changed files with 15 additions and 14 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
17
src/oauth.ts
17
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<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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export type CredentialDecision =
|
|||
kind: "rotate";
|
||||
accessTokenEncrypted: string;
|
||||
refreshTokenEncrypted: string;
|
||||
tokenExpiresAt?: string;
|
||||
tokenExpiresAt: string;
|
||||
scopes: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue