fix(auth): await OAuth storage load() to fix remote OAuth races
This commit is contained in:
parent
7bd26c938e
commit
a24091484c
5 changed files with 245 additions and 133 deletions
|
|
@ -34,6 +34,7 @@ async function resolveIdpMetadata(
|
|||
storage: OAuthStorage,
|
||||
fetchFn?: typeof fetch,
|
||||
): Promise<OAuthMetadata> {
|
||||
await storage.load();
|
||||
const storageKey = idpOAuthStorageKey(issuer);
|
||||
const cached = storage.getServerMetadata(storageKey);
|
||||
if (cached?.token_endpoint) {
|
||||
|
|
@ -63,7 +64,7 @@ export async function startIdpOidcAuthorization(params: {
|
|||
fetchFn?: typeof fetch;
|
||||
}): Promise<{ authorizationUrl: URL }> {
|
||||
const issuer = normalizeIdpIssuer(params.idp.issuer);
|
||||
const metadata = await discoverIdpMetadata(issuer, params.fetchFn);
|
||||
const metadata = await resolveIdpMetadata(issuer, params.storage, params.fetchFn);
|
||||
const clientInformation = idpClientInformation(params.idp);
|
||||
const storageKey = idpOAuthStorageKey(issuer);
|
||||
const state = generateOAuthState();
|
||||
|
|
@ -101,6 +102,7 @@ export async function completeIdpOidcAuthorization(params: {
|
|||
}> {
|
||||
const issuer = normalizeIdpIssuer(params.idp.issuer);
|
||||
const storageKey = idpOAuthStorageKey(issuer);
|
||||
await params.storage.load();
|
||||
const metadata = params.storage.getServerMetadata(storageKey);
|
||||
if (!metadata) {
|
||||
throw new Error("IdP OAuth metadata not found — restart EMA IdP login");
|
||||
|
|
@ -131,7 +133,7 @@ export async function completeIdpOidcAuthorization(params: {
|
|||
throw new Error("IdP token response did not include an ID Token");
|
||||
}
|
||||
|
||||
params.storage.clearCodeVerifier(storageKey);
|
||||
await params.storage.clearCodeVerifier(storageKey);
|
||||
const idTokenExpiresAt = jwtExpiresAtMs(idToken);
|
||||
await params.storage.saveIdpSession(issuer, {
|
||||
idToken,
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ export async function getEmaIdpLoginState(
|
|||
return "expired";
|
||||
}
|
||||
|
||||
export function clearEmaIdpSession(storage: OAuthStorage, issuer: string): void {
|
||||
export async function clearEmaIdpSession(
|
||||
storage: OAuthStorage,
|
||||
issuer: string,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeIdpIssuer(issuer);
|
||||
if (!normalized) return;
|
||||
storage.clearIdpSession(normalized);
|
||||
storage.clear(idpOAuthStorageKey(normalized));
|
||||
storage.clearEnterpriseManagedResourceServers();
|
||||
await storage.clearIdpSession(normalized);
|
||||
await storage.clear(idpOAuthStorageKey(normalized));
|
||||
await storage.clearEnterpriseManagedResourceServers();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,22 +16,48 @@ import type {
|
|||
SaveTokensOptions,
|
||||
} from "./storage.js";
|
||||
|
||||
type OAuthStoreWithPersist = ReturnType<typeof createOAuthStore> & {
|
||||
persist: {
|
||||
hasHydrated: () => boolean;
|
||||
onFinishHydration: (fn: () => void) => void;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Concrete OAuthStorage implementation parameterized on a Zustand store.
|
||||
* The store carries the storage adapter (sessionStorage, file, remote HTTP, …),
|
||||
* so the same body works for browser, Node, and remote environments.
|
||||
*/
|
||||
export class OAuthStorageBase implements OAuthStorage {
|
||||
private readonly store: ReturnType<typeof createOAuthStore>;
|
||||
private readonly store: OAuthStoreWithPersist;
|
||||
private loadedPromise: Promise<void> | undefined;
|
||||
|
||||
constructor(store: ReturnType<typeof createOAuthStore>) {
|
||||
this.store = store;
|
||||
this.store = store as OAuthStoreWithPersist;
|
||||
}
|
||||
|
||||
load(): Promise<void> {
|
||||
if (!this.loadedPromise) {
|
||||
this.loadedPromise = new Promise((resolve) => {
|
||||
if (this.store.persist.hasHydrated()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.store.persist.onFinishHydration(() => resolve());
|
||||
});
|
||||
}
|
||||
return this.loadedPromise;
|
||||
}
|
||||
|
||||
private async ensureLoaded(): Promise<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async getClientInformation(
|
||||
serverUrl: string,
|
||||
isPreregistered?: boolean,
|
||||
): Promise<OAuthClientInformation | undefined> {
|
||||
await this.ensureLoaded();
|
||||
const state = this.store.getState().getServerState(serverUrl);
|
||||
const clientInfo = isPreregistered
|
||||
? state.preregisteredClientInformation
|
||||
|
|
@ -56,6 +82,7 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
clientInformation: OAuthClientInformation,
|
||||
options: SaveClientInformationOptions,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().setServerState(serverUrl, {
|
||||
clientInformation,
|
||||
clientRegistrationKind: options.registrationKind,
|
||||
|
|
@ -66,13 +93,18 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
serverUrl: string,
|
||||
clientInformation: OAuthClientInformation,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().setServerState(serverUrl, {
|
||||
preregisteredClientInformation: clientInformation,
|
||||
clientRegistrationKind: "static",
|
||||
});
|
||||
}
|
||||
|
||||
clearClientInformation(serverUrl: string, isPreregistered?: boolean): void {
|
||||
async clearClientInformation(
|
||||
serverUrl: string,
|
||||
isPreregistered?: boolean,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const updates: Partial<ServerOAuthState> = {};
|
||||
|
||||
if (isPreregistered) {
|
||||
|
|
@ -86,6 +118,7 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
}
|
||||
|
||||
async getTokens(serverUrl: string): Promise<OAuthTokens | undefined> {
|
||||
await this.ensureLoaded();
|
||||
const state = this.store.getState().getServerState(serverUrl);
|
||||
if (!state.tokens) {
|
||||
return undefined;
|
||||
|
|
@ -99,13 +132,15 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
tokens: OAuthTokens,
|
||||
options?: SaveTokensOptions,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().setServerState(serverUrl, {
|
||||
tokens,
|
||||
...(options?.enterpriseManaged === true && { enterpriseManaged: true }),
|
||||
});
|
||||
}
|
||||
|
||||
clearTokens(serverUrl: string): void {
|
||||
async clearTokens(serverUrl: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().setServerState(serverUrl, { tokens: undefined });
|
||||
}
|
||||
|
||||
|
|
@ -118,10 +153,12 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
serverUrl: string,
|
||||
codeVerifier: string,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().setServerState(serverUrl, { codeVerifier });
|
||||
}
|
||||
|
||||
clearCodeVerifier(serverUrl: string): void {
|
||||
async clearCodeVerifier(serverUrl: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store
|
||||
.getState()
|
||||
.setServerState(serverUrl, { codeVerifier: undefined });
|
||||
|
|
@ -133,10 +170,12 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
}
|
||||
|
||||
async saveScope(serverUrl: string, scope: string | undefined): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().setServerState(serverUrl, { scope });
|
||||
}
|
||||
|
||||
clearScope(serverUrl: string): void {
|
||||
async clearScope(serverUrl: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().setServerState(serverUrl, { scope: undefined });
|
||||
}
|
||||
|
||||
|
|
@ -149,22 +188,26 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
serverUrl: string,
|
||||
metadata: OAuthMetadata,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store
|
||||
.getState()
|
||||
.setServerState(serverUrl, { serverMetadata: metadata });
|
||||
}
|
||||
|
||||
clearServerMetadata(serverUrl: string): void {
|
||||
async clearServerMetadata(serverUrl: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store
|
||||
.getState()
|
||||
.setServerState(serverUrl, { serverMetadata: undefined });
|
||||
}
|
||||
|
||||
clear(serverUrl: string): void {
|
||||
async clear(serverUrl: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().clearServerState(serverUrl);
|
||||
}
|
||||
|
||||
async getIdpSession(issuer: string): Promise<IdpSessionState | undefined> {
|
||||
await this.ensureLoaded();
|
||||
const session = this.store.getState().getIdpSession(issuer);
|
||||
if (
|
||||
!session.idToken &&
|
||||
|
|
@ -180,14 +223,17 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
issuer: string,
|
||||
session: Partial<IdpSessionState>,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().setIdpSession(issuer, session);
|
||||
}
|
||||
|
||||
clearIdpSession(issuer: string): void {
|
||||
async clearIdpSession(issuer: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().clearIdpSession(issuer);
|
||||
}
|
||||
|
||||
clearEnterpriseManagedResourceServers(): void {
|
||||
async clearEnterpriseManagedResourceServers(): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.store.getState().clearEnterpriseManagedResourceServers();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,8 +247,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
return verifier;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.storage.clear(this.serverUrl);
|
||||
async clear(): Promise<void> {
|
||||
await this.storage.clear(this.serverUrl);
|
||||
}
|
||||
|
||||
getServerMetadata(): OAuthMetadata | null {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ export interface SaveClientInformationOptions {
|
|||
}
|
||||
|
||||
export interface OAuthStorage {
|
||||
/**
|
||||
* Load persisted OAuth state into memory. Resolves when the backing store
|
||||
* (file, remote API, sessionStorage, …) has been read. Call before relying on
|
||||
* sync reads, or await mutating methods which call this internally.
|
||||
*/
|
||||
load(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get client information (preregistered or dynamically registered)
|
||||
*/
|
||||
|
|
@ -56,7 +63,10 @@ export interface OAuthStorage {
|
|||
/**
|
||||
* Clear client information
|
||||
*/
|
||||
clearClientInformation(serverUrl: string, isPreregistered?: boolean): void;
|
||||
clearClientInformation(
|
||||
serverUrl: string,
|
||||
isPreregistered?: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get OAuth tokens
|
||||
|
|
@ -75,7 +85,7 @@ export interface OAuthStorage {
|
|||
/**
|
||||
* Clear OAuth tokens
|
||||
*/
|
||||
clearTokens(serverUrl: string): void;
|
||||
clearTokens(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get code verifier (for PKCE)
|
||||
|
|
@ -90,7 +100,7 @@ export interface OAuthStorage {
|
|||
/**
|
||||
* Clear code verifier
|
||||
*/
|
||||
clearCodeVerifier(serverUrl: string): void;
|
||||
clearCodeVerifier(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get scope
|
||||
|
|
@ -105,7 +115,7 @@ export interface OAuthStorage {
|
|||
/**
|
||||
* Clear scope
|
||||
*/
|
||||
clearScope(serverUrl: string): void;
|
||||
clearScope(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get server metadata discovered during OAuth
|
||||
|
|
@ -120,12 +130,12 @@ export interface OAuthStorage {
|
|||
/**
|
||||
* Clear server metadata
|
||||
*/
|
||||
clearServerMetadata(serverUrl: string): void;
|
||||
clearServerMetadata(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear all OAuth data for a server
|
||||
*/
|
||||
clear(serverUrl: string): void;
|
||||
clear(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get cached IdP OIDC session for EMA (keyed by issuer).
|
||||
|
|
@ -143,12 +153,12 @@ export interface OAuthStorage {
|
|||
/**
|
||||
* Clear cached IdP session for an issuer.
|
||||
*/
|
||||
clearIdpSession(issuer: string): void;
|
||||
clearIdpSession(issuer: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove per-server OAuth state for MCP servers whose tokens were minted via EMA.
|
||||
*/
|
||||
clearEnterpriseManagedResourceServers(): void;
|
||||
clearEnterpriseManagedResourceServers(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue