@douvery/auth 0.3.2 → 0.4.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.
package/dist/index.d.ts CHANGED
@@ -454,5 +454,180 @@ declare class TokenManager {
454
454
  clearReturnTo(): Promise<void>;
455
455
  clearAll(): Promise<void>;
456
456
  }
457
+ /**
458
+ * Options for createServerBridgedStorage.
459
+ *
460
+ * Use this when tokens are managed server-side (httpOnly cookies)
461
+ * but the OAuth/PKCE flow needs client-side ephemeral storage.
462
+ */
463
+ interface ServerBridgedStorageOptions {
464
+ /**
465
+ * Name of a **non-httpOnly** cookie that holds the access token
466
+ * expiration timestamp (in milliseconds). Used to infer whether
467
+ * a valid session exists without exposing the actual tokens.
468
+ */
469
+ tokenExpirationCookie: string;
470
+ /**
471
+ * Placeholder value returned by `get()` for server-managed keys
472
+ * (accessToken, refreshToken). Signals to the caller that the
473
+ * real token exists but is not readable from JS.
474
+ * @default "__server_managed__"
475
+ */
476
+ serverManagedPlaceholder?: string;
477
+ /**
478
+ * Enable debug logging.
479
+ * @default false
480
+ */
481
+ debug?: boolean;
482
+ }
483
+ /**
484
+ * Creates a `TokenStorage` adapter for apps where **tokens are
485
+ * managed server-side** (e.g. httpOnly cookies set by routeLoader$/
486
+ * routeAction$) but the OAuth PKCE flow still needs ephemeral
487
+ * client-side storage for state, nonce, codeVerifier and returnTo.
488
+ *
489
+ * Behaviour per key category:
490
+ *
491
+ * | Category | get() | set() / remove() |
492
+ * |-------------------|------------------------------------------|-------------------|
493
+ * | accessToken | returns placeholder if session is active | no-op (server) |
494
+ * | refreshToken | returns placeholder if session exists | no-op (server) |
495
+ * | idToken | always null | no-op |
496
+ * | expiresAt | reads from expiration cookie | no-op (server) |
497
+ * | state/nonce/etc. | sessionStorage | sessionStorage |
498
+ *
499
+ * @example
500
+ * ```ts
501
+ * import { createServerBridgedStorage } from '@douvery/auth';
502
+ *
503
+ * const bridgedStorage = createServerBridgedStorage({
504
+ * tokenExpirationCookie: 'dou_token_exp',
505
+ * debug: import.meta.env.DEV,
506
+ * });
507
+ *
508
+ * const config: DouveryAuthConfig = {
509
+ * clientId: 'my-app',
510
+ * redirectUri: '/callback',
511
+ * customStorage: bridgedStorage,
512
+ * autoRefresh: false, // server handles refresh
513
+ * };
514
+ * ```
515
+ */
516
+ declare function createServerBridgedStorage(options: ServerBridgedStorageOptions): TokenStorage;
517
+
518
+ /**
519
+ * @douvery/auth - Session Types
520
+ * Framework-agnostic opaque session resolution types
521
+ */
522
+ /** Configuration for the session resolver factory */
523
+ interface SessionResolverConfig {
524
+ /** Auth server session API base URL (e.g. "http://localhost:9924/api/session") */
525
+ sessionApiUrl: string;
526
+ /**
527
+ * Internal service name for IdP UI auth via X-Douvery-Internal-Service header.
528
+ * Used by the IdP's own UI (like accounts.google.com) — NOT an OAuth client.
529
+ * Requires internalServiceSecret.
530
+ */
531
+ internalServiceName?: string;
532
+ /**
533
+ * HMAC secret for internal service auth.
534
+ * Signs X-Douvery-Internal-Signature: HMAC-SHA256(timestamp:serviceName, secret).
535
+ * Used together with internalServiceName.
536
+ */
537
+ internalServiceSecret?: string;
538
+ /**
539
+ * OAuth Client ID for consumer app auth via X-Client-Id header.
540
+ * For apps that ARE OAuth clients (douvery-web, center, mobile).
541
+ */
542
+ clientId?: string;
543
+ /**
544
+ * OAuth Client Secret for consumer app auth via X-Client-Secret header.
545
+ * Used together with clientId. Validated against oauth_clients table (Argon2).
546
+ */
547
+ clientSecret?: string;
548
+ /** Cookie name for the session ID (e.g. "douvery-session") */
549
+ cookieName: string;
550
+ /** Session cookie max age in seconds @default 2592000 (30 days) */
551
+ cookieMaxAge?: number;
552
+ /** Whether cookies should use the 'secure' flag @default true */
553
+ secureCookies?: boolean;
554
+ /** Timeout for lightweight session API calls like /token (ms) @default 3000 */
555
+ fetchTimeoutMs?: number;
556
+ /** Timeout for heavy session API calls like /refresh (ms) @default 8000 */
557
+ refreshTimeoutMs?: number;
558
+ /** Fallback cache TTL when JWT exp cannot be parsed (ms) @default 30000 */
559
+ fallbackCacheTtlMs?: number;
560
+ /** Enable debug logging @default false */
561
+ debug?: boolean;
562
+ /** Custom logger implementation. Defaults to console when debug is true. */
563
+ logger?: SessionLogger;
564
+ }
565
+ /**
566
+ * Minimal cookie adapter interface.
567
+ * Each framework implements this for its own cookie API (Qwik, Next.js, Express, etc).
568
+ *
569
+ * The resolver uses the adapter object reference as a WeakMap key for per-request
570
+ * caching. Ensure the SAME adapter instance is used for all calls within a single
571
+ * request to benefit from deduplication and caching.
572
+ */
573
+ interface CookieAdapter {
574
+ /** Read a cookie value by name. Returns undefined if not found. */
575
+ get(name: string): string | undefined;
576
+ /** Set a cookie with the given name, value, and options. */
577
+ set(name: string, value: string, options: CookieSetOptions): void;
578
+ }
579
+ /** Options for setting a cookie (standard HTTP cookie attributes) */
580
+ interface CookieSetOptions {
581
+ path?: string;
582
+ httpOnly?: boolean;
583
+ secure?: boolean;
584
+ sameSite?: "strict" | "lax" | "none";
585
+ maxAge?: number;
586
+ }
587
+ /**
588
+ * Result of a refresh attempt. Callers MUST distinguish between:
589
+ * - 'success': tokens refreshed, cache invalidated — proceed normally
590
+ * - 'definitive_failure': server confirmed session is dead (401/404) — safe to clear cookie
591
+ * - 'transient_failure': timeout/network/500 — session may still be valid, DON'T clear cookie
592
+ */
593
+ type RefreshResult = "success" | "definitive_failure" | "transient_failure";
594
+ interface SessionLogger {
595
+ debug(...args: unknown[]): void;
596
+ warn(...args: unknown[]): void;
597
+ error(...args: unknown[]): void;
598
+ }
599
+ /** The public API returned by createSessionResolver() */
600
+ interface SessionResolver {
601
+ /**
602
+ * Resolve opaque session to JWT access_token (async).
603
+ * Uses per-request caching and deduplication.
604
+ * Returns the JWT even if expired — the caller handles refresh.
605
+ */
606
+ getAccessToken(cookies: CookieAdapter): Promise<string | undefined>;
607
+ /**
608
+ * Synchronous access to cached token (for sync header builders).
609
+ * Returns cached value only — NO network call.
610
+ */
611
+ getAccessTokenSync(cookies: CookieAdapter): string | undefined;
612
+ /**
613
+ * Refresh session tokens via auth server.
614
+ * Triggers full token rotation. Deduplicates concurrent refresh calls
615
+ * across all requests for the same session_id.
616
+ */
617
+ refreshSession(cookies: CookieAdapter): Promise<RefreshResult>;
618
+ /** Destroy session on auth server and clear local cookie. */
619
+ destroySession(cookies: CookieAdapter): Promise<void>;
620
+ /** Save session ID in an HttpOnly cookie after OAuth callback. */
621
+ setSessionCookie(sessionId: string, cookies: CookieAdapter): void;
622
+ /** Read session ID from cookie. */
623
+ getSessionId(cookies: CookieAdapter): string | undefined;
624
+ /** Check if user has an active session cookie. */
625
+ hasSession(cookies: CookieAdapter): boolean;
626
+ /**
627
+ * Clear session cookie and invalidate cached token.
628
+ * Sets an expired cookie to ensure the browser removes it.
629
+ */
630
+ clearSessionCookie(cookies: CookieAdapter): void;
631
+ }
457
632
 
458
- export { type AddAccountOptions, AuthError, type AuthErrorCode, type AuthEvent, type AuthEventHandler, type AuthNavigationOptions, type AuthState, type AuthStatus, type AuthUrl, type CallbackResult, CookieStorage, type DecodedIdToken, DouveryAuthClient, type DouveryAuthConfig, LocalStorage, type LoginOptions, type LogoutOptions, MemoryStorage, type OIDCDiscovery, type PKCEPair, type RecoverAccountOptions, type RegisterOptions, type RevokeTokenOptions, STORAGE_KEYS, type SelectAccountOptions, SessionStorage, type SetupAddressOptions, type SetupPasskeyOptions, type StorageKeys, type TokenInfo, TokenManager, type TokenSet, type TokenStorage, type UpgradeAccountOptions, type User, type VerifyAccountOptions, base64UrlDecode, base64UrlEncode, createDouveryAuth, createStorage, decodeJWT, generateCodeChallenge, generateCodeVerifier, generateNonce, generatePKCEPair, generateState, getTokenExpiration, isTokenExpired, verifyCodeChallenge };
633
+ export { type AddAccountOptions, AuthError, type AuthErrorCode, type AuthEvent, type AuthEventHandler, type AuthNavigationOptions, type AuthState, type AuthStatus, type AuthUrl, type CallbackResult, type CookieAdapter, type CookieSetOptions, CookieStorage, type DecodedIdToken, DouveryAuthClient, type DouveryAuthConfig, LocalStorage, type LoginOptions, type LogoutOptions, MemoryStorage, type OIDCDiscovery, type PKCEPair, type RecoverAccountOptions, type RefreshResult, type RegisterOptions, type RevokeTokenOptions, STORAGE_KEYS, type SelectAccountOptions, type ServerBridgedStorageOptions, type SessionLogger, type SessionResolver, type SessionResolverConfig, SessionStorage, type SetupAddressOptions, type SetupPasskeyOptions, type StorageKeys, type TokenInfo, TokenManager, type TokenSet, type TokenStorage, type UpgradeAccountOptions, type User, type VerifyAccountOptions, base64UrlDecode, base64UrlEncode, createDouveryAuth, createServerBridgedStorage, createStorage, decodeJWT, generateCodeChallenge, generateCodeVerifier, generateNonce, generatePKCEPair, generateState, getTokenExpiration, isTokenExpired, verifyCodeChallenge };
package/dist/index.js CHANGED
@@ -286,6 +286,113 @@ var TokenManager = class {
286
286
  await this.storage.clear();
287
287
  }
288
288
  };
289
+ var SERVER_TOKEN_KEYS = /* @__PURE__ */ new Set([
290
+ STORAGE_KEYS.accessToken,
291
+ STORAGE_KEYS.refreshToken,
292
+ STORAGE_KEYS.idToken
293
+ ]);
294
+ var PKCE_KEYS = /* @__PURE__ */ new Set([
295
+ STORAGE_KEYS.state,
296
+ STORAGE_KEYS.nonce,
297
+ STORAGE_KEYS.codeVerifier,
298
+ STORAGE_KEYS.returnTo
299
+ ]);
300
+ function readClientCookie(name) {
301
+ if (typeof document === "undefined") return null;
302
+ const cookies = document.cookie.split(";");
303
+ for (const c of cookies) {
304
+ const [key, ...parts] = c.trim().split("=");
305
+ if (key === name) return decodeURIComponent(parts.join("="));
306
+ }
307
+ return null;
308
+ }
309
+ function safeSessionStorage() {
310
+ if (typeof window === "undefined" || typeof sessionStorage === "undefined") {
311
+ return null;
312
+ }
313
+ return sessionStorage;
314
+ }
315
+ function createServerBridgedStorage(options) {
316
+ const {
317
+ tokenExpirationCookie,
318
+ serverManagedPlaceholder = "__server_managed__",
319
+ debug = false
320
+ } = options;
321
+ function log(msg) {
322
+ if (debug) console.debug(`[ServerBridgedStorage] ${msg}`);
323
+ }
324
+ return {
325
+ get(key) {
326
+ if (key === STORAGE_KEYS.accessToken) {
327
+ const exp = readClientCookie(tokenExpirationCookie);
328
+ if (exp) {
329
+ const ms = parseInt(exp, 10);
330
+ if (!isNaN(ms) && ms > Date.now()) return serverManagedPlaceholder;
331
+ }
332
+ return null;
333
+ }
334
+ if (key === STORAGE_KEYS.refreshToken) {
335
+ const exp = readClientCookie(tokenExpirationCookie);
336
+ return exp ? serverManagedPlaceholder : null;
337
+ }
338
+ if (key === STORAGE_KEYS.idToken) return null;
339
+ if (key === STORAGE_KEYS.expiresAt) {
340
+ return readClientCookie(tokenExpirationCookie);
341
+ }
342
+ if (PKCE_KEYS.has(key)) {
343
+ return safeSessionStorage()?.getItem(key) ?? null;
344
+ }
345
+ return safeSessionStorage()?.getItem(key) ?? null;
346
+ },
347
+ set(key, value) {
348
+ if (SERVER_TOKEN_KEYS.has(key) || key === STORAGE_KEYS.expiresAt) {
349
+ log(`Ignoring set("${key}") \u2013 managed by server`);
350
+ return;
351
+ }
352
+ const ss = safeSessionStorage();
353
+ if (ss) {
354
+ try {
355
+ ss.setItem(key, value);
356
+ } catch (e) {
357
+ if (debug)
358
+ console.warn(
359
+ "[ServerBridgedStorage] sessionStorage.setItem failed:",
360
+ e
361
+ );
362
+ }
363
+ }
364
+ },
365
+ remove(key) {
366
+ if (SERVER_TOKEN_KEYS.has(key) || key === STORAGE_KEYS.expiresAt) {
367
+ log(`Ignoring remove("${key}") \u2013 managed by server`);
368
+ return;
369
+ }
370
+ const ss = safeSessionStorage();
371
+ if (ss) {
372
+ try {
373
+ ss.removeItem(key);
374
+ } catch (e) {
375
+ if (debug)
376
+ console.warn(
377
+ "[ServerBridgedStorage] sessionStorage.removeItem failed:",
378
+ e
379
+ );
380
+ }
381
+ }
382
+ },
383
+ clear() {
384
+ const ss = safeSessionStorage();
385
+ if (!ss) return;
386
+ for (const key of PKCE_KEYS) {
387
+ try {
388
+ ss.removeItem(key);
389
+ } catch {
390
+ }
391
+ }
392
+ log("PKCE ephemeral data cleared from sessionStorage");
393
+ }
394
+ };
395
+ }
289
396
 
290
397
  // src/client.ts
291
398
  var DEFAULT_ISSUER = "https://auth.douvery.com";
@@ -303,6 +410,11 @@ var DouveryAuthClient = class {
303
410
  error: null
304
411
  };
305
412
  constructor(config) {
413
+ if (!config) {
414
+ throw new Error(
415
+ "[DouveryAuthClient] config is required. Make sure getDouveryAuthConfig() returns a valid DouveryAuthConfig object."
416
+ );
417
+ }
306
418
  this.config = {
307
419
  issuer: DEFAULT_ISSUER,
308
420
  scopes: DEFAULT_SCOPES,
@@ -866,6 +978,7 @@ var DouveryAuthClient = class {
866
978
  async getDiscovery() {
867
979
  if (this.discovery) return this.discovery;
868
980
  const discoveryUrl = `${this.config.issuer}/.well-known/openid-configuration`;
981
+ this.log("Fetching discovery from:", discoveryUrl);
869
982
  const response = await fetch(discoveryUrl);
870
983
  if (!response.ok) {
871
984
  throw new AuthError(
@@ -873,7 +986,29 @@ var DouveryAuthClient = class {
873
986
  "Failed to fetch discovery document"
874
987
  );
875
988
  }
876
- this.discovery = await response.json();
989
+ const doc = await response.json();
990
+ const docIssuer = doc.issuer;
991
+ const configIssuer = this.config.issuer;
992
+ if (docIssuer && docIssuer !== configIssuer) {
993
+ this.log(
994
+ `Rewriting discovery endpoints: "${docIssuer}" -> "${configIssuer}"`
995
+ );
996
+ const rewrite = (url) => url.replace(docIssuer, configIssuer);
997
+ doc.issuer = configIssuer;
998
+ doc.authorization_endpoint = rewrite(doc.authorization_endpoint);
999
+ doc.token_endpoint = rewrite(doc.token_endpoint);
1000
+ if (doc.userinfo_endpoint)
1001
+ doc.userinfo_endpoint = rewrite(doc.userinfo_endpoint);
1002
+ if (doc.end_session_endpoint)
1003
+ doc.end_session_endpoint = rewrite(doc.end_session_endpoint);
1004
+ if (doc.jwks_uri) doc.jwks_uri = rewrite(doc.jwks_uri);
1005
+ if (doc.revocation_endpoint)
1006
+ doc.revocation_endpoint = rewrite(doc.revocation_endpoint);
1007
+ if (doc.introspection_endpoint)
1008
+ doc.introspection_endpoint = rewrite(doc.introspection_endpoint);
1009
+ }
1010
+ this.log("authorization_endpoint:", doc.authorization_endpoint);
1011
+ this.discovery = doc;
877
1012
  return this.discovery;
878
1013
  }
879
1014
  setupAutoRefresh() {
@@ -931,6 +1066,6 @@ function createDouveryAuth(config) {
931
1066
  return new DouveryAuthClient(config);
932
1067
  }
933
1068
 
934
- export { AuthError, CookieStorage, DouveryAuthClient, LocalStorage, MemoryStorage, STORAGE_KEYS, SessionStorage, TokenManager, base64UrlDecode, base64UrlEncode, createDouveryAuth, createStorage, decodeJWT, generateCodeChallenge, generateCodeVerifier, generateNonce, generatePKCEPair, generateState, getTokenExpiration, isTokenExpired, verifyCodeChallenge };
1069
+ export { AuthError, CookieStorage, DouveryAuthClient, LocalStorage, MemoryStorage, STORAGE_KEYS, SessionStorage, TokenManager, base64UrlDecode, base64UrlEncode, createDouveryAuth, createServerBridgedStorage, createStorage, decodeJWT, generateCodeChallenge, generateCodeVerifier, generateNonce, generatePKCEPair, generateState, getTokenExpiration, isTokenExpired, verifyCodeChallenge };
935
1070
  //# sourceMappingURL=index.js.map
936
1071
  //# sourceMappingURL=index.js.map