Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- Automatic session-layer bearer-token refresh for child-scoped clients. A
`SynergiaApiClient` obtained from `LibrusSession.forChild()` now transparently
refreshes its bearer token and retries the request once on a `401`, instead of
surfacing the expired-token error. Concurrent `401`s for the same child
collapse to a single refresh, and a `401` that persists after refresh throws
`LibrusAuthenticationError` with code `AUTH_REFRESH_FAILED` (the original
`401` preserved as `cause`). `LibrusSession.refreshBearerToken(childId)` is
also available directly. Standalone `new SynergiaApiClient(token)` is
unchanged and still throws on `401`.
- Automatic retry with exponential backoff and jitter for transient Synergia
failures (`408, 425, 429, 500, 502, 503, 504`). Idempotent methods
(`GET`/`HEAD`) only by default, honors `Retry-After` on `429`, and respects an
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ Current high-level methods on `LibrusSession`:
- `forChild(selectorOrChild)`
- `forChildWiadomosci(selectorOrChild)`
- `forChildBff(selectorOrChild)`
- `refreshBearerToken(childId)` — manually refresh the Synergia bearer for a child (normally called automatically on 401)

Current methods on `PortalClient`:

Expand Down
124 changes: 120 additions & 4 deletions src/sdk/LibrusSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type WiadomosciMessagesClientOptions,
} from "./wiadomosci/WiadomosciMessagesClient.js";
import {
LibrusApiError,
LibrusConfigurationError,
LibrusSdkError,
type ChildAccount,
Expand Down Expand Up @@ -122,6 +123,22 @@ export class LibrusSession {
>
| undefined;
private accountsCache?: SynergiaAccountsResponse;
/**
* Latest known bearer token per child id (keyed by `String(id)`). Updated
* after every successful portal refresh so that:
* - `forChild(childObject)` always starts with the freshest token even when
* the caller holds a stale `ChildAccount` reference.
* - A delayed `401` (one that fires after an in-flight refresh has already
* cleared `refreshInFlight`) can detect the superseded token and return
* the already-refreshed value without a second portal round-trip.
*/
private readonly latestTokenPerChild = new Map<string, string>();
/**
* In-flight bearer-token refreshes keyed by child id. Collapses concurrent
* refreshes for the same child to a single portal round-trip (stampede
* protection) while the refresh is pending.
*/
private readonly refreshInFlight = new Map<string, Promise<string>>();

constructor(options: LibrusSessionOptions) {
if (options.authMode !== undefined) {
Expand Down Expand Up @@ -384,7 +401,94 @@ export class LibrusSession {
? await this.resolveChild(selectorOrChild)
: selectorOrChild;

return new SynergiaApiClient(child.accessToken, this.synergiaClientOptions);
const token =
this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken;

return new SynergiaApiClient(token, {
...this.synergiaClientOptions,
onAuthInvalidated: (staleToken: string) =>
this.acquireFreshToken(String(child.id), staleToken),
} as SynergiaApiClientOptions);
}

/**
* Re-fetch a fresh Synergia bearer token for a known child, reusing the
* cached portal login (and re-logging in if the portal session itself is
* dead). Concurrent calls for the same child share one in-flight promise.
*/
async refreshBearerToken(childId: string | number): Promise<string> {
this.assertApiV3Backend("refreshBearerToken");
return this.acquireFreshToken(String(childId), undefined);
}

/**
* Internal refresh path used by the `onAuthInvalidated` callback. Accepts
* the token the client actually used for the failed request so it can detect
* when a sibling request already refreshed to a newer token and skip the
* portal round-trip.
*/
private acquireFreshToken(
key: string,
staleToken: string | undefined,
): Promise<string> {
if (staleToken !== undefined) {
const latest = this.latestTokenPerChild.get(key);

if (latest !== undefined && latest !== staleToken) {
return Promise.resolve(latest);
}
}

const existing = this.refreshInFlight.get(key);

if (existing) {
return existing;
}

const promise = this.performBearerTokenRefresh(key).finally(() => {
this.refreshInFlight.delete(key);
});

this.refreshInFlight.set(key, promise);

return promise;
}

private async performBearerTokenRefresh(childId: string): Promise<string> {
const child = await this.resolveChild(childId);
const portalClient = this.getPortalClient();

await this.login();

let fresh: ChildAccount;

try {
fresh = await portalClient.getFreshSynergiaAccount(child.login);
} catch (error) {
if (!isUnauthorized(error)) {
throw error;
}

// The cached portal session is dead. Force a fresh login and retry once.
await portalClient.login(this.getPortalCredentials());
fresh = await portalClient.getFreshSynergiaAccount(child.login);
}

// Record the latest token so delayed 401s and forChild(childObject) calls
// can pick up the fresh value without another portal round-trip.
this.latestTokenPerChild.set(String(fresh.id), fresh.accessToken);

// Keep accountsCache in sync so resolveChild() returns the fresh account.
if (this.accountsCache) {
this.accountsCache = {
...this.accountsCache,
accounts: this.accountsCache.accounts.map((a) =>
a.id === fresh.id ? fresh : a,
),
};
}

return fresh.accessToken;
}

async forChildWiadomosci(
Expand All @@ -401,10 +505,15 @@ export class LibrusSession {
portalClient: this.getPortalClient(),
});

return new SynergiaApiClient(child.accessToken, {
const token =
this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken;

return new SynergiaApiClient(token, {
...this.synergiaClientOptions,
messageBackend,
});
onAuthInvalidated: (staleToken: string) =>
this.acquireFreshToken(String(child.id), staleToken),
} as SynergiaApiClientOptions);
}

async forChildBff(
Expand All @@ -415,7 +524,10 @@ export class LibrusSession {
typeof selectorOrChild === "string"
? await this.resolveChild(selectorOrChild)
: selectorOrChild;
return new BffApiClient(child.accessToken, this.bffClientOptions);
const token =
this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken;

return new BffApiClient(token, this.bffClientOptions);
}

private getPortalCredentials(): PortalCredentials {
Expand Down Expand Up @@ -485,6 +597,10 @@ export class LibrusSession {
}
}

function isUnauthorized(error: unknown): boolean {
return error instanceof LibrusApiError && error.details?.status === 401;
}

function resolveApiBackend(env: NodeJS.ProcessEnv): ApiBackendEnvValue {
const rawApiBackend = env.LIBRUS_API_BACKEND;
const rawAuthMode = env.LIBRUS_AUTH_MODE;
Expand Down
32 changes: 28 additions & 4 deletions src/sdk/models/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* Error code used when a session-layer bearer-token refresh still yields a 401.
* Surfaced on {@link LibrusAuthenticationError} so callers can distinguish a
* failed refresh from an initial authentication failure.
*/
export const CODE_AUTH_REFRESH_FAILED = "AUTH_REFRESH_FAILED";

export interface LibrusErrorDetails {
endpoint?: string;
issues?: string[];
Expand All @@ -7,12 +14,21 @@ export interface LibrusErrorDetails {
[key: string]: unknown;
}

export interface LibrusSdkErrorOptions {
cause?: unknown;
}

export class LibrusSdkError extends Error {
readonly code: string;
readonly details: LibrusErrorDetails | undefined;

constructor(code: string, message: string, details?: LibrusErrorDetails) {
super(message);
constructor(
code: string,
message: string,
details?: LibrusErrorDetails,
options?: LibrusSdkErrorOptions,
) {
super(message, options);
this.name = "LibrusSdkError";
this.code = code;
this.details = details;
Expand All @@ -30,9 +46,17 @@ export class LibrusApiError extends LibrusSdkError {
}
}

export interface LibrusAuthenticationErrorOptions extends LibrusSdkErrorOptions {
code?: string;
}

export class LibrusAuthenticationError extends LibrusSdkError {
constructor(message = "Portal authentication failed") {
super("AUTHENTICATION_FAILED", message);
constructor(
message = "Portal authentication failed",
options: LibrusAuthenticationErrorOptions = {},
) {
const { code = "AUTHENTICATION_FAILED", ...sdkOptions } = options;
super(code, message, undefined, sdkOptions);
this.name = "LibrusAuthenticationError";
}
}
Expand Down
Loading