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!;
|
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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
17
src/oauth.ts
17
src/oauth.ts
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue