@dloizides/auth-client 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Tiny dependency-free event emitter for auth lifecycle events.
3
+ *
4
+ * Consumers subscribe to `onSessionExpired` to navigate to the login screen
5
+ * when refresh fails or the inactivity timeout fires. We don't reach for
6
+ * `EventTarget`/`EventEmitter` because we want one consistent API across web,
7
+ * React Native, and node test environments without polyfills.
8
+ */
9
+ type AuthEventName = 'sessionExpired';
10
+ type AuthEventListener = () => void;
11
+ interface AuthEventUnsubscribe {
12
+ (): void;
13
+ }
14
+ declare class AuthEventEmitter {
15
+ private readonly listeners;
16
+ on(event: AuthEventName, listener: AuthEventListener): AuthEventUnsubscribe;
17
+ emit(event: AuthEventName): void;
18
+ /** Remove all listeners. Useful for `AuthClient.dispose()` and tests. */
19
+ clear(): void;
20
+ }
21
+
22
+ /**
23
+ * Minimal HTTP transport the package depends on.
24
+ *
25
+ * `AuthClient` orchestrates token-related HTTP calls (login, refresh, logout,
26
+ * password reset) but doesn't import `fetch` directly — keeping the package
27
+ * runtime-agnostic. Consumers wire native fetch, axios, ky, or whatever their
28
+ * platform exposes.
29
+ */
30
+ interface HttpRequest {
31
+ url: string;
32
+ method: 'GET' | 'POST' | 'DELETE';
33
+ headers?: Record<string, string>;
34
+ /** When set, body is sent as the request body; serialization is the caller's job. */
35
+ body?: string;
36
+ /** Browser fetch only — pass through `credentials: 'include'` for cookie auth. */
37
+ credentials?: 'include' | 'same-origin' | 'omit';
38
+ }
39
+ interface HttpResponse {
40
+ status: number;
41
+ ok: boolean;
42
+ /** Parsed body (already JSON-decoded). `undefined` for 204 / empty bodies. */
43
+ data?: unknown;
44
+ }
45
+ type HttpClient = (request: HttpRequest) => Promise<HttpResponse>;
46
+ /**
47
+ * Wrap the platform's native `fetch` into the package's `HttpClient` shape.
48
+ * Decoded JSON when `Content-Type` is JSON, otherwise leaves data undefined.
49
+ *
50
+ * Errors thrown by `fetch` (network / abort) are NOT swallowed — callers
51
+ * decide whether to treat them as session-ending.
52
+ */
53
+ declare function createFetchHttpClient(fetchImpl: typeof fetch): HttpClient;
54
+
55
+ /**
56
+ * Backend session record returned by `GET /me/sessions`.
57
+ *
58
+ * Shape mirrors the existing IdentityService response — see
59
+ * `Services/Identity/.../GetSessions.cs`. Defined as a permissive interface
60
+ * so newer fields (added server-side) flow through without a package bump.
61
+ */
62
+ interface AuthSessionInfo {
63
+ id: string;
64
+ isCurrent?: boolean;
65
+ ipAddress?: string;
66
+ userAgent?: string;
67
+ createdAt?: string;
68
+ lastSeenAt?: string;
69
+ [key: string]: unknown;
70
+ }
71
+ interface AuthApiClientOptions {
72
+ http: HttpClient;
73
+ /** API base, e.g. `https://api.dloizides.com`. No trailing slash needed. */
74
+ baseUrl: string;
75
+ /**
76
+ * Optional supplier of the current access token, used as a Bearer header on
77
+ * authenticated calls (sessions list, revoke, logout). When omitted those
78
+ * calls send no Authorization header — typical for cookie-based web auth.
79
+ */
80
+ getAccessToken?: () => Promise<string | null>;
81
+ /**
82
+ * When true, every request adds `credentials: 'include'`. Required for
83
+ * cookie-based web auth (`__Host-refresh` lives in an httpOnly cookie).
84
+ */
85
+ useCredentials?: boolean;
86
+ }
87
+ interface OtpLoginRequest {
88
+ email: string;
89
+ otp: string;
90
+ tenantId?: string;
91
+ offlineAccess?: boolean;
92
+ }
93
+ interface PasswordLoginRequest {
94
+ email: string;
95
+ password: string;
96
+ tenantId?: string;
97
+ offlineAccess?: boolean;
98
+ }
99
+ interface ForgotPasswordRequest {
100
+ email: string;
101
+ tenantId?: string;
102
+ }
103
+ interface ResetPasswordRequest {
104
+ token: string;
105
+ newPassword: string;
106
+ }
107
+ interface RawAuthLoginResponse {
108
+ access_token?: string;
109
+ refresh_token?: string;
110
+ id_token?: string;
111
+ expires_in?: number;
112
+ token_type?: string;
113
+ scope?: string;
114
+ [key: string]: unknown;
115
+ }
116
+ /**
117
+ * Thin HTTP client for the IdentityService auth surface.
118
+ *
119
+ * Endpoint paths match the backend task `auth-password-reset-backend.md`:
120
+ *
121
+ * - `POST /auth/verify-otp`
122
+ * - `POST /auth/login` (password)
123
+ * - `POST /auth/logout` and `POST /auth/logout?everywhere=true`
124
+ * - `POST /auth/refresh-cookie` (web cookie flow)
125
+ * - `POST /auth/forgot-password`
126
+ * - `POST /auth/reset-password`
127
+ * - `GET /me/sessions`
128
+ * - `POST /me/sessions/{id}/revoke`
129
+ *
130
+ * Doesn't touch token storage — that's `AuthClient`'s job. Doesn't decide
131
+ * what to do with errors — callers handle them. Just builds requests and
132
+ * deserialises responses.
133
+ */
134
+ declare class AuthApiClient {
135
+ private readonly http;
136
+ private readonly baseUrl;
137
+ private readonly getAccessToken;
138
+ private readonly useCredentials;
139
+ constructor(options: AuthApiClientOptions);
140
+ loginWithOtp(request: OtpLoginRequest): Promise<RawAuthLoginResponse>;
141
+ loginWithPassword(request: PasswordLoginRequest): Promise<RawAuthLoginResponse>;
142
+ /** Web cookie-flow refresh. Sends no body; cookie travels via `credentials`. */
143
+ refreshCookie(): Promise<RawAuthLoginResponse>;
144
+ logout(everywhere?: boolean): Promise<void>;
145
+ forgotPassword(request: ForgotPasswordRequest): Promise<void>;
146
+ resetPassword(request: ResetPasswordRequest): Promise<void>;
147
+ listSessions(): Promise<AuthSessionInfo[]>;
148
+ revokeSession(sessionId: string): Promise<void>;
149
+ private postLogin;
150
+ private authHeaders;
151
+ }
152
+
153
+ /**
154
+ * Configuration for {@link AuthClient}.
155
+ *
156
+ * `realm` and `clientId` are **required** and never have sensible portfolio-wide
157
+ * defaults. Each consumer (Questioner web, OnlineMenu web, future apps) supplies
158
+ * its own values. This is the contract that enables the cross-realm hard wall
159
+ * planned in Phase 2 of the product split.
160
+ */
161
+ interface AuthClientConfig {
162
+ /** Keycloak base URL, e.g. `https://identity.dloizides.com`. Trailing slash optional. */
163
+ baseUrl: string;
164
+ /** Realm name, e.g. `OnlineMenu` or `Questioner`. NEVER hardcoded. */
165
+ realm: string;
166
+ /** OAuth client id registered in the realm. */
167
+ clientId: string;
168
+ /** Where Keycloak should redirect after authorization (PKCE / authorization-code flow). */
169
+ redirectUri?: string;
170
+ /** Space-separated OAuth scopes. Defaults to `openid profile email`. */
171
+ scope?: string;
172
+ }
173
+
174
+ /**
175
+ * Persisted token bundle.
176
+ *
177
+ * `expiresAt` is an absolute UNIX millisecond timestamp computed at
178
+ * acquisition time (`Date.now() + expires_in * 1000`) so consumers can
179
+ * trivially compare against `Date.now()` without re-deriving an expiry clock.
180
+ */
181
+ interface AuthTokens {
182
+ accessToken: string;
183
+ refreshToken?: string;
184
+ idToken?: string;
185
+ /** Absolute UNIX millis. `0` or missing means unknown — treat as expired. */
186
+ expiresAt: number;
187
+ }
188
+
189
+ /**
190
+ * Pluggable token persistence.
191
+ *
192
+ * Consumers (web, native, server-side) provide an implementation tailored to
193
+ * their platform: `localStorage`, `sessionStorage`, `expo-secure-store`,
194
+ * `AsyncStorage`, or an in-memory map for tests. Keeping the interface narrow
195
+ * (read / write / clear) keeps the package transport-agnostic.
196
+ */
197
+ interface TokenStorage {
198
+ read(): Promise<AuthTokens | null>;
199
+ write(tokens: AuthTokens): Promise<void>;
200
+ clear(): Promise<void>;
201
+ }
202
+
203
+ /**
204
+ * Pluggable persistence for the `lastRefreshedAt` timestamp.
205
+ *
206
+ * Decoupled from `TokenStorage` so consumers can pick a different backend
207
+ * (e.g., write through `AsyncStorage` on RN where the secure store would
208
+ * gate every read on biometric).
209
+ */
210
+ interface InactivityStore {
211
+ read(): Promise<number | null>;
212
+ write(timestamp: number): Promise<void>;
213
+ clear(): Promise<void>;
214
+ }
215
+ interface InactivityTrackerOptions {
216
+ store: InactivityStore;
217
+ /**
218
+ * Maximum days the user can be inactive (no successful refresh) before
219
+ * sessions are forcibly cleared. Default 90 (matches mobile decision).
220
+ */
221
+ maxInactivityDays?: number;
222
+ /** Inject for tests; defaults to `Date.now`. */
223
+ now?: () => number;
224
+ }
225
+ /**
226
+ * Tracks the last time a refresh succeeded and decides whether the session
227
+ * has aged past its inactivity threshold.
228
+ *
229
+ * - `markActive(now?)` is called by `RefreshInterceptor` after every
230
+ * successful token swap.
231
+ * - `isExpired()` is called from `AuthClient.init()` at boot. If true,
232
+ * consumers clear tokens and emit `sessionExpired`.
233
+ *
234
+ * Choosing days (not e.g. minutes) makes the policy match what users
235
+ * understand: a session left untouched for 90 days needs re-auth.
236
+ */
237
+ declare class InactivityTracker {
238
+ private readonly store;
239
+ private readonly maxInactivityMs;
240
+ private readonly now;
241
+ constructor(options: InactivityTrackerOptions);
242
+ markActive(timestamp?: number): Promise<void>;
243
+ getLastActive(): Promise<number | null>;
244
+ isExpired(): Promise<boolean>;
245
+ clear(): Promise<void>;
246
+ }
247
+
248
+ /**
249
+ * The pluggable refresh function the interceptor calls when an access token
250
+ * is missing or expired. Implementations differ per transport:
251
+ *
252
+ * - **Mobile (SecureStore)**: posts to `/auth/refresh` with the refresh token
253
+ * from `AuthTokens.refreshToken`.
254
+ * - **Web (Cookie)**: posts to `/auth/refresh-cookie` with `credentials:
255
+ * 'include'` — the refresh token rides on the httpOnly cookie; `current`
256
+ * carries only the access token (refresh token will be undefined).
257
+ *
258
+ * Returns the new token bundle, or `null` when refresh failed in a way that
259
+ * means "session over" (e.g., 401 from the auth server).
260
+ */
261
+ type RefreshFn = (current: AuthTokens | null) => Promise<AuthTokens | null>;
262
+ interface RefreshInterceptorOptions {
263
+ storage: TokenStorage;
264
+ refresh: RefreshFn;
265
+ events: AuthEventEmitter;
266
+ /**
267
+ * Optional callback fired AFTER tokens are persisted on a successful
268
+ * refresh. Used by `AuthClient` to update the inactivity tracker.
269
+ */
270
+ onRefreshSuccess?: (tokens: AuthTokens) => Promise<void> | void;
271
+ }
272
+ /**
273
+ * Coordinates refresh-token swaps so concurrent 401s don't trigger N parallel
274
+ * refreshes. The first caller to hit `refreshTokens()` while no refresh is
275
+ * already in flight wins the role of "refresher"; everyone else awaits the
276
+ * same promise.
277
+ *
278
+ * On failure, storage is cleared and `sessionExpired` is emitted exactly once
279
+ * per refresh attempt.
280
+ */
281
+ declare class RefreshInterceptor {
282
+ private readonly storage;
283
+ private readonly refresh;
284
+ private readonly events;
285
+ private readonly onRefreshSuccess;
286
+ private inflight;
287
+ constructor(options: RefreshInterceptorOptions);
288
+ /**
289
+ * Trigger (or join) a refresh. Returns the new tokens, or `null` if the
290
+ * refresh failed — in which case storage has already been cleared and
291
+ * `sessionExpired` already fired.
292
+ */
293
+ refreshTokens(): Promise<AuthTokens | null>;
294
+ /**
295
+ * Whether a refresh is currently in flight. Exposed for tests / debug.
296
+ */
297
+ get isRefreshing(): boolean;
298
+ private runRefresh;
299
+ private failHard;
300
+ }
301
+
302
+ /**
303
+ * Inputs to {@link AuthClient.fromIssuerUrl}.
304
+ *
305
+ * Used by consumers that store only an issuer URL and want to derive `realm`
306
+ * + `baseUrl` rather than configure them separately.
307
+ */
308
+ interface AuthClientFromIssuerInput {
309
+ issuerUrl: string;
310
+ clientId: string;
311
+ redirectUri?: string;
312
+ scope?: string;
313
+ }
314
+ /**
315
+ * Optional collaborators wired into {@link AuthClient} for the v2 surface.
316
+ *
317
+ * - `api` enables `loginWith*`, `logout`, `requestPasswordReset`,
318
+ * `confirmPasswordReset`. Without it, those methods throw.
319
+ * - `interceptor` enables `init()` to silently refresh tokens at boot, and is
320
+ * used by `loginWithOtp/Password` to mark inactivity-active.
321
+ * - `inactivityTracker` enforces the 90-day timeout at `init()`.
322
+ *
323
+ * Consumers can omit any/all of these — the v1 PKCE / token-storage surface
324
+ * keeps working unchanged.
325
+ */
326
+ interface AuthClientCollaborators {
327
+ api?: AuthApiClient;
328
+ interceptor?: RefreshInterceptor;
329
+ inactivityTracker?: InactivityTracker;
330
+ events?: AuthEventEmitter;
331
+ }
332
+ interface LoginOptions {
333
+ /** When true, request `offline_access` scope so the IdP issues a long-lived refresh token. */
334
+ offlineAccess?: boolean;
335
+ }
336
+ interface LogoutOptions {
337
+ /** Revoke all sessions on the IdP, not just the current one. */
338
+ everywhere?: boolean;
339
+ }
340
+ declare class AuthClient {
341
+ private readonly config;
342
+ private readonly tokenStorage;
343
+ private readonly api;
344
+ private readonly interceptor;
345
+ private readonly inactivityTracker;
346
+ private readonly events;
347
+ /**
348
+ * @throws Error when `baseUrl`, `realm`, or `clientId` is missing or empty.
349
+ */
350
+ constructor(config: AuthClientConfig, storage: TokenStorage, collaborators?: AuthClientCollaborators);
351
+ /**
352
+ * Build an {@link AuthClient} from a standalone issuer URL by parsing the
353
+ * realm and base URL. Useful when migrating from the legacy
354
+ * `KEYCLOAK_ISSUER` env var convention.
355
+ *
356
+ * @throws Error when the issuer URL doesn't match `{base}/realms/{realm}`.
357
+ */
358
+ static fromIssuerUrl(input: AuthClientFromIssuerInput, storage: TokenStorage, collaborators?: AuthClientCollaborators): AuthClient;
359
+ private static validateConfig;
360
+ get realm(): string;
361
+ get clientId(): string;
362
+ get baseUrl(): string;
363
+ get scope(): string;
364
+ get redirectUri(): string | undefined;
365
+ /** Issuer URL: `{baseUrl}/realms/{realm}`. */
366
+ get issuerUrl(): string;
367
+ get authorizationEndpoint(): string;
368
+ get tokenEndpoint(): string;
369
+ get userInfoEndpoint(): string;
370
+ get logoutEndpoint(): string;
371
+ /**
372
+ * Build a fully-formed authorization URL the user agent can navigate to.
373
+ *
374
+ * @throws Error when `redirectUri` is not configured.
375
+ */
376
+ buildAuthorizationUrl(input?: {
377
+ state?: string;
378
+ codeChallenge?: string;
379
+ codeChallengeMethod?: 'S256' | 'plain';
380
+ offlineAccess?: boolean;
381
+ }): string;
382
+ getTokens(): Promise<AuthTokens | null>;
383
+ setTokens(tokens: AuthTokens): Promise<void>;
384
+ clearTokens(): Promise<void>;
385
+ /**
386
+ * Read the current access token if it exists and is not expired.
387
+ * Returns `null` for "no usable token".
388
+ */
389
+ getAccessToken(now?: number): Promise<string | null>;
390
+ /** Subscribe to lifecycle events (currently `sessionExpired` only). */
391
+ on(event: AuthEventName, listener: AuthEventListener): AuthEventUnsubscribe;
392
+ /**
393
+ * Boot-time wiring. Checks the inactivity tracker; if expired, clears
394
+ * tokens and emits `sessionExpired`. Returns whether a usable session
395
+ * survived.
396
+ */
397
+ init(): Promise<{
398
+ hasSession: boolean;
399
+ }>;
400
+ /**
401
+ * Trigger a refresh via the configured interceptor. Returns the new tokens
402
+ * or `null` when the refresh failed (in which case `sessionExpired` has
403
+ * already fired).
404
+ *
405
+ * @throws Error when no interceptor is configured.
406
+ */
407
+ refresh(): Promise<AuthTokens | null>;
408
+ loginWithOtp(input: {
409
+ email: string;
410
+ otp: string;
411
+ tenantId?: string;
412
+ } & LoginOptions): Promise<AuthTokens>;
413
+ loginWithPassword(input: {
414
+ email: string;
415
+ password: string;
416
+ tenantId?: string;
417
+ } & LoginOptions): Promise<AuthTokens>;
418
+ logout(options?: LogoutOptions): Promise<void>;
419
+ requestPasswordReset(input: {
420
+ email: string;
421
+ tenantId?: string;
422
+ }): Promise<void>;
423
+ confirmPasswordReset(input: {
424
+ token: string;
425
+ newPassword: string;
426
+ }): Promise<void>;
427
+ /** Internal: run a login HTTP call, persist tokens, mark inactivity-active. */
428
+ private runLogin;
429
+ private requireApi;
430
+ private resolveScope;
431
+ }
432
+
433
+ export { type AuthTokens as A, type ForgotPasswordRequest as F, type HttpClient as H, type InactivityStore as I, type LoginOptions as L, type OtpLoginRequest as O, type PasswordLoginRequest as P, type RawAuthLoginResponse as R, type TokenStorage as T, AuthApiClient as a, type AuthApiClientOptions as b, AuthClient as c, type AuthClientCollaborators as d, type AuthClientConfig as e, type AuthClientFromIssuerInput as f, AuthEventEmitter as g, type AuthEventListener as h, type AuthEventName as i, type AuthEventUnsubscribe as j, type AuthSessionInfo as k, type HttpRequest as l, type HttpResponse as m, InactivityTracker as n, type InactivityTrackerOptions as o, type LogoutOptions as p, type RefreshFn as q, RefreshInterceptor as r, type RefreshInterceptorOptions as s, type ResetPasswordRequest as t, createFetchHttpClient as u };