@dloizides/auth-client 1.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,418 @@
1
+ /**
2
+ * Configuration for {@link AuthClient}.
3
+ *
4
+ * `realm` and `clientId` are **required** and never have sensible portfolio-wide
5
+ * defaults. Each consumer (Questioner web, OnlineMenu web, future apps) supplies
6
+ * its own values. This is the contract that enables the cross-realm hard wall
7
+ * planned in Phase 2 of the product split.
8
+ */
9
+ interface AuthClientConfig {
10
+ /** Keycloak base URL, e.g. `https://identity.dloizides.com`. Trailing slash optional. */
11
+ baseUrl: string;
12
+ /** Realm name, e.g. `OnlineMenu` or `Questioner`. NEVER hardcoded. */
13
+ realm: string;
14
+ /** OAuth client id registered in the realm. */
15
+ clientId: string;
16
+ /** Where Keycloak should redirect after authorization (PKCE / authorization-code flow). */
17
+ redirectUri?: string;
18
+ /** Space-separated OAuth scopes. Defaults to `openid profile email`. */
19
+ scope?: string;
20
+ }
21
+
22
+ /**
23
+ * Persisted token bundle.
24
+ *
25
+ * `expiresAt` is an absolute UNIX millisecond timestamp computed at
26
+ * acquisition time (`Date.now() + expires_in * 1000`) so consumers can
27
+ * trivially compare against `Date.now()` without re-deriving an expiry clock.
28
+ */
29
+ interface AuthTokens {
30
+ accessToken: string;
31
+ refreshToken?: string;
32
+ idToken?: string;
33
+ /** Absolute UNIX millis. `0` or missing means unknown — treat as expired. */
34
+ expiresAt: number;
35
+ }
36
+
37
+ /**
38
+ * Pluggable token persistence.
39
+ *
40
+ * Consumers (web, native, server-side) provide an implementation tailored to
41
+ * their platform: `localStorage`, `sessionStorage`, `expo-secure-store`,
42
+ * `AsyncStorage`, or an in-memory map for tests. Keeping the interface narrow
43
+ * (read / write / clear) keeps the package transport-agnostic.
44
+ */
45
+ interface TokenStorage {
46
+ read(): Promise<AuthTokens | null>;
47
+ write(tokens: AuthTokens): Promise<void>;
48
+ clear(): Promise<void>;
49
+ }
50
+
51
+ /**
52
+ * Inputs to {@link AuthClient.fromIssuerUrl}.
53
+ *
54
+ * Used by consumers that store only an issuer URL and want to derive `realm`
55
+ * + `baseUrl` rather than configure them separately.
56
+ */
57
+ interface AuthClientFromIssuerInput {
58
+ issuerUrl: string;
59
+ clientId: string;
60
+ redirectUri?: string;
61
+ scope?: string;
62
+ }
63
+ declare class AuthClient {
64
+ private readonly config;
65
+ private readonly tokenStorage;
66
+ /**
67
+ * @throws Error when `baseUrl`, `realm`, or `clientId` is missing or empty.
68
+ */
69
+ constructor(config: AuthClientConfig, storage: TokenStorage);
70
+ /**
71
+ * Build an {@link AuthClient} from a standalone issuer URL by parsing the
72
+ * realm and base URL. Useful when migrating from the legacy
73
+ * `KEYCLOAK_ISSUER` env var convention.
74
+ *
75
+ * @throws Error when the issuer URL doesn't match `{base}/realms/{realm}`.
76
+ */
77
+ static fromIssuerUrl(input: AuthClientFromIssuerInput, storage: TokenStorage): AuthClient;
78
+ private static validateConfig;
79
+ get realm(): string;
80
+ get clientId(): string;
81
+ get baseUrl(): string;
82
+ get scope(): string;
83
+ get redirectUri(): string | undefined;
84
+ /** Issuer URL: `{baseUrl}/realms/{realm}`. */
85
+ get issuerUrl(): string;
86
+ get authorizationEndpoint(): string;
87
+ get tokenEndpoint(): string;
88
+ get userInfoEndpoint(): string;
89
+ get logoutEndpoint(): string;
90
+ /**
91
+ * Build a fully-formed authorization URL the user agent can navigate to.
92
+ *
93
+ * @throws Error when `redirectUri` is not configured.
94
+ */
95
+ buildAuthorizationUrl(input?: {
96
+ state?: string;
97
+ codeChallenge?: string;
98
+ codeChallengeMethod?: 'S256' | 'plain';
99
+ }): string;
100
+ getTokens(): Promise<AuthTokens | null>;
101
+ setTokens(tokens: AuthTokens): Promise<void>;
102
+ clearTokens(): Promise<void>;
103
+ /**
104
+ * Read the current access token if it exists and is not expired.
105
+ * Returns `null` for "no usable token".
106
+ */
107
+ getAccessToken(now?: number): Promise<string | null>;
108
+ }
109
+
110
+ /**
111
+ * Roles emitted by Keycloak realms in the dloizides.com portfolio.
112
+ *
113
+ * Lives in its own file per the project convention: each exported `const enum`
114
+ * sits alone so it can be imported without dragging the rest of the type tree.
115
+ */
116
+ declare const enum KeycloakRoles {
117
+ SuperUser = "superUser",
118
+ Admin = "admin",
119
+ User = "user"
120
+ }
121
+ /**
122
+ * Type guard that narrows a string to a known {@link KeycloakRoles} value.
123
+ *
124
+ * Use this when ingesting role claims from the network, where the wire payload
125
+ * is `string[]` but downstream code wants `KeycloakRoles[]`.
126
+ */
127
+ declare function isKeycloakRole(value: string): value is KeycloakRoles;
128
+
129
+ /**
130
+ * Shape of the Keycloak `/userinfo` response (and any equivalent payload returned
131
+ * by a backend Identity service that wraps Keycloak).
132
+ *
133
+ * Fields are optional because Keycloak realms can include or omit claims based
134
+ * on scope / mapper configuration. Consumers should narrow before use.
135
+ */
136
+ interface KeycloakUserInfo {
137
+ sub?: string;
138
+ name?: string;
139
+ preferred_username?: string;
140
+ given_name?: string;
141
+ family_name?: string;
142
+ email?: string;
143
+ email_verified?: boolean;
144
+ locale?: string;
145
+ tenantId?: string;
146
+ realm_access?: {
147
+ roles?: KeycloakRoles[];
148
+ };
149
+ resource_access?: Record<string, {
150
+ roles?: KeycloakRoles[];
151
+ }>;
152
+ roles: KeycloakRoles[];
153
+ [key: string]: unknown;
154
+ }
155
+
156
+ /**
157
+ * Application-friendly view of a Keycloak user.
158
+ *
159
+ * Consumers should prefer this over {@link KeycloakUserInfo} for rendering and
160
+ * authorization checks: it has predictable fields and a deduplicated roles array.
161
+ */
162
+ interface NormalizedUser {
163
+ id?: string;
164
+ username?: string;
165
+ email?: string;
166
+ displayName?: string;
167
+ firstName?: string;
168
+ lastName?: string;
169
+ emailVerified?: boolean;
170
+ roles: KeycloakRoles[];
171
+ raw?: KeycloakUserInfo;
172
+ }
173
+
174
+ /**
175
+ * Raw token endpoint response (snake_case, OIDC standard).
176
+ */
177
+ interface RawTokenResponse {
178
+ access_token: string;
179
+ refresh_token?: string;
180
+ id_token?: string;
181
+ expires_in?: number;
182
+ token_type?: string;
183
+ scope?: string;
184
+ [key: string]: unknown;
185
+ }
186
+ /**
187
+ * Application-friendly camelCase view of a token endpoint response.
188
+ */
189
+ interface TokenResponse {
190
+ accessToken: string;
191
+ refreshToken?: string;
192
+ idToken?: string;
193
+ /** Seconds until expiry, as returned by Keycloak. */
194
+ expiresIn?: number;
195
+ tokenType?: string;
196
+ scope?: string;
197
+ }
198
+
199
+ /**
200
+ * Subset of `Storage` we actually use. Lets callers inject `localStorage`,
201
+ * `sessionStorage`, or any compatible polyfill.
202
+ */
203
+ interface StorageLike {
204
+ getItem(key: string): string | null;
205
+ setItem(key: string, value: string): void;
206
+ removeItem(key: string): void;
207
+ }
208
+ interface BrowserStorageTokenStorageOptions {
209
+ storage: StorageLike;
210
+ /** Storage key. Defaults to `auth.tokens`. */
211
+ key?: string;
212
+ }
213
+ /**
214
+ * Persist tokens in any `Storage`-shaped backend (`localStorage`, `sessionStorage`,
215
+ * AsyncStorage shim, etc.). The class is sync-aware but exposes a Promise-based
216
+ * API to match {@link TokenStorage}.
217
+ *
218
+ * Errors during read are swallowed and surfaced as `null` (corrupt JSON, denied
219
+ * access in some private-mode browsers, etc.). Errors during write/clear are
220
+ * propagated so callers can decide whether to retry or fall back.
221
+ */
222
+ declare class BrowserStorageTokenStorage implements TokenStorage {
223
+ private readonly storage;
224
+ private readonly key;
225
+ constructor(options: BrowserStorageTokenStorageOptions);
226
+ read(): Promise<AuthTokens | null>;
227
+ write(tokens: AuthTokens): Promise<void>;
228
+ clear(): Promise<void>;
229
+ private readSync;
230
+ }
231
+
232
+ /**
233
+ * In-memory storage backed by a single instance variable.
234
+ *
235
+ * Useful for tests, server-side rendering, and as a default fallback when no
236
+ * platform-specific storage is available. Tokens are lost on process exit.
237
+ */
238
+ declare class InMemoryTokenStorage implements TokenStorage {
239
+ private tokens;
240
+ read(): Promise<AuthTokens | null>;
241
+ write(tokens: AuthTokens): Promise<void>;
242
+ clear(): Promise<void>;
243
+ }
244
+
245
+ /**
246
+ * Convert a Keycloak `/userinfo` payload into a flat, app-friendly user object.
247
+ *
248
+ * - Aggregates `realm_access.roles` and every `resource_access[*].roles` into a
249
+ * deduplicated `roles` array.
250
+ * - Picks a sensible `displayName` / `username` from whatever claims are
251
+ * present (Keycloak realms vary in which fields they emit).
252
+ * - Returns a safe default (`{ roles: [] }`) when input is undefined.
253
+ */
254
+ declare function normalizeKeycloakUser(u?: KeycloakUserInfo): NormalizedUser;
255
+
256
+ /**
257
+ * Extract the realm name from a Keycloak issuer URL.
258
+ *
259
+ * Keycloak issuer URLs follow the shape `{baseUrl}/realms/{realm}` (with optional
260
+ * `/protocol/openid-connect` suffix on token endpoints). This helper reverses
261
+ * that convention so existing apps that store only the issuer URL can derive
262
+ * `realm` for the realm-aware {@link AuthClient} constructor.
263
+ *
264
+ * @returns the realm name, or `null` if the URL doesn't match the convention.
265
+ */
266
+ declare function parseRealmFromIssuer(issuerUrl: string | null | undefined): string | null;
267
+ /**
268
+ * Extract the base URL (scheme + host + optional path prefix) from a
269
+ * Keycloak issuer URL by stripping the `/realms/{realm}...` suffix.
270
+ *
271
+ * Returns the original input unchanged when no `/realms/` segment is found —
272
+ * callers that need strict validation should pair this with `parseRealmFromIssuer`.
273
+ */
274
+ declare function parseBaseUrlFromIssuer(issuerUrl: string | null | undefined): string | null;
275
+
276
+ /**
277
+ * Inputs for the OAuth `authorization_code` token request.
278
+ */
279
+ interface AuthorizationCodeBodyInput {
280
+ clientId: string;
281
+ code: string;
282
+ redirectUri: string;
283
+ codeVerifier: string;
284
+ }
285
+ /**
286
+ * Inputs for the OAuth `refresh_token` token request.
287
+ */
288
+ interface RefreshTokenBodyInput {
289
+ clientId: string;
290
+ refreshToken: string;
291
+ }
292
+ /**
293
+ * Build the `application/x-www-form-urlencoded` body for the
294
+ * `grant_type=authorization_code` token endpoint call (PKCE flow).
295
+ */
296
+ declare function buildAuthorizationCodeBody(input: AuthorizationCodeBodyInput): string;
297
+ /**
298
+ * Build the `application/x-www-form-urlencoded` body for the
299
+ * `grant_type=refresh_token` token endpoint call.
300
+ */
301
+ declare function buildRefreshTokenBody(input: RefreshTokenBodyInput): string;
302
+
303
+ /**
304
+ * URL builders for the realm-aware Keycloak surface area.
305
+ *
306
+ * Every helper takes `baseUrl` and `realm` explicitly — no hardcoded realm
307
+ * names. This is the contract that Phase 2 of the product split relies on:
308
+ * the same package serves the future Questioner-realm app and OnlineMenu-realm
309
+ * app without code change.
310
+ */
311
+ /**
312
+ * Compute the issuer URL: `{baseUrl}/realms/{realm}`.
313
+ */
314
+ declare function buildIssuerUrl(baseUrl: string, realm: string): string;
315
+ /**
316
+ * Compute the authorization endpoint URL.
317
+ */
318
+ declare function buildAuthorizationEndpoint(baseUrl: string, realm: string): string;
319
+ /**
320
+ * Compute the token endpoint URL.
321
+ */
322
+ declare function buildTokenEndpoint(baseUrl: string, realm: string): string;
323
+ /**
324
+ * Compute the userinfo endpoint URL.
325
+ */
326
+ declare function buildUserInfoEndpoint(baseUrl: string, realm: string): string;
327
+ /**
328
+ * Compute the logout endpoint URL.
329
+ */
330
+ declare function buildLogoutEndpoint(baseUrl: string, realm: string): string;
331
+ interface AuthorizationUrlInput {
332
+ baseUrl: string;
333
+ realm: string;
334
+ clientId: string;
335
+ redirectUri: string;
336
+ scope?: string;
337
+ state?: string;
338
+ codeChallenge?: string;
339
+ codeChallengeMethod?: 'S256' | 'plain';
340
+ }
341
+ /**
342
+ * Build a complete authorization URL the user agent can navigate to.
343
+ *
344
+ * All PKCE-related fields are optional so this helper also serves
345
+ * non-PKCE flows (e.g. confidential server-side clients) — but PKCE is
346
+ * the recommended path for SPA / native consumers.
347
+ */
348
+ declare function buildAuthorizationUrl(input: AuthorizationUrlInput): string;
349
+
350
+ /**
351
+ * Loose shape of an `expo-auth-session` (or browser-side) authorization response.
352
+ *
353
+ * Kept as a structural type rather than importing from `expo-auth-session` so
354
+ * the package stays usable in plain web apps and Node tests.
355
+ */
356
+ interface AuthorizationResponseLike {
357
+ type?: string;
358
+ params?: {
359
+ code?: string;
360
+ error?: string;
361
+ };
362
+ }
363
+ /**
364
+ * Pull the authorization `code` out of a successful redirect response.
365
+ *
366
+ * Returns `undefined` when the response is missing, indicates an error type,
367
+ * or doesn't carry a non-empty `code` query param.
368
+ */
369
+ declare function extractAuthCode(response: AuthorizationResponseLike | null | undefined): string | undefined;
370
+
371
+ /**
372
+ * Determine whether a token bundle is expired.
373
+ *
374
+ * `expiresAt` is interpreted as an absolute UNIX millisecond timestamp.
375
+ *
376
+ * `leewayMs` (default 30 s) shaves a small window off the expiry to compensate
377
+ * for clock skew and round-trip latency: a token that expires at exactly
378
+ * `Date.now()` is essentially useless because by the time the request arrives
379
+ * at the API it will have expired.
380
+ */
381
+ declare function isTokenExpired(tokens: Pick<AuthTokens, 'expiresAt'> | null | undefined, leewayMs?: number, now?: number): boolean;
382
+ /**
383
+ * Compute the absolute expiry timestamp from a token endpoint `expires_in` value.
384
+ *
385
+ * Returns `0` when `expiresIn` is missing or non-positive — signalling "unknown
386
+ * expiry, treat as expired" downstream.
387
+ */
388
+ declare function computeExpiresAt(expiresInSeconds: number | undefined, now?: number): number;
389
+
390
+ /**
391
+ * Decode the payload segment of a compact JWT.
392
+ *
393
+ * No signature verification — that responsibility belongs to the backend that
394
+ * accepts the token. This helper is for UI concerns: reading `exp` to schedule
395
+ * refresh, reading custom claims for routing decisions, etc.
396
+ *
397
+ * Returns `null` when the input is malformed, base64url-decodes incorrectly, or
398
+ * does not produce a JSON object payload.
399
+ *
400
+ * Runtime requirement: a global `atob` function. Available in browsers, in
401
+ * Node ≥ 16, and in modern bundler test envs (jsdom, node-jest).
402
+ */
403
+ declare function decodeJwt<T = Record<string, unknown>>(token: string | null | undefined): T | null;
404
+
405
+ /**
406
+ * Map a raw OIDC token endpoint response (snake_case) to camelCase.
407
+ *
408
+ * Throws when `access_token` is missing or empty — callers should let this
409
+ * propagate to the auth state machine, which treats it as a login failure.
410
+ */
411
+ declare function normalizeTokenResponse(raw: RawTokenResponse): TokenResponse;
412
+ /**
413
+ * Convert a normalized {@link TokenResponse} into a persistable
414
+ * {@link AuthTokens} bundle by computing `expiresAt` from `expiresIn`.
415
+ */
416
+ declare function tokenResponseToAuthTokens(response: TokenResponse, now?: number): AuthTokens;
417
+
418
+ export { AuthClient, type AuthClientConfig, type AuthClientFromIssuerInput, type AuthTokens, type AuthorizationCodeBodyInput, type AuthorizationResponseLike, type AuthorizationUrlInput, BrowserStorageTokenStorage, type BrowserStorageTokenStorageOptions, InMemoryTokenStorage, KeycloakRoles, type KeycloakUserInfo, type NormalizedUser, type RawTokenResponse, type RefreshTokenBodyInput, type StorageLike, type TokenResponse, type TokenStorage, buildAuthorizationCodeBody, buildAuthorizationEndpoint, buildAuthorizationUrl, buildIssuerUrl, buildLogoutEndpoint, buildRefreshTokenBody, buildTokenEndpoint, buildUserInfoEndpoint, computeExpiresAt, decodeJwt, extractAuthCode, isKeycloakRole, isTokenExpired, normalizeKeycloakUser, normalizeTokenResponse, parseBaseUrlFromIssuer, parseRealmFromIssuer, tokenResponseToAuthTokens };