@adonis-agora/authkit-client 0.20.1 → 0.20.3

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.
@@ -64,6 +64,9 @@ export interface ClientConfigInput {
64
64
  * Discovery runs lazily, on first use, so the app boots while the IdP is down.
65
65
  * A failure throws `OidcDiscoveryError` and is retried on the next request; a
66
66
  * previously discovered document keeps being served while refetches fail.
67
+ * Concurrent callers share one fetch per issuer; once `cacheTtlMs` (default
68
+ * 15 min) runs out, the cached document is served while one background refetch
69
+ * replaces it. Each request attempt is aborted after 10 s.
67
70
  *
68
71
  * `false` skips the network and uses `endpoints` on top of the authkit-server
69
72
  * convention (`/auth`, `/token`, `/jwks`, `/session/end`).
@@ -51,7 +51,7 @@ export declare class OidcDiscoveryError extends Error {
51
51
  export interface DiscoverOptions {
52
52
  /** Manual overrides — win over the discovery document, field by field. */
53
53
  overrides?: Partial<OidcEndpoints>;
54
- /** Per-issuer cache TTL. Default: 15 min. */
54
+ /** Per-issuer cache TTL; past it the document is served stale while one background refetch runs. Default: 15 min. */
55
55
  cacheTtlMs?: number;
56
56
  fetchImpl?: typeof fetch;
57
57
  /**
@@ -77,6 +77,8 @@ export interface DiscoverOptions {
77
77
  * Resolves the IdP endpoints via `/.well-known/openid-configuration`.
78
78
  *
79
79
  * - Successful documents are cached per issuer (TTL 15 min) — safe to call per request.
80
+ * Once the TTL runs out the cached document keeps being served while one
81
+ * background refetch replaces it; concurrent first calls share one fetch.
80
82
  * - The document's `issuer` must match the configured one (OIDC Discovery §4.3;
81
83
  * a trailing-slash difference is tolerated and the document's exact value is
82
84
  * returned in `issuer`).
@@ -57,11 +57,17 @@ export class OidcDiscoveryError extends Error {
57
57
  }
58
58
  }
59
59
  const DEFAULT_TTL_MS = 15 * 60_000;
60
+ /** Each discovery request is aborted after this long. */
61
+ const FETCH_TIMEOUT_MS = 10_000;
60
62
  const cache = new Map();
63
+ /** One fetch per issuer at a time; concurrent callers share it. */
64
+ const inflight = new Map();
61
65
  /**
62
66
  * Resolves the IdP endpoints via `/.well-known/openid-configuration`.
63
67
  *
64
68
  * - Successful documents are cached per issuer (TTL 15 min) — safe to call per request.
69
+ * Once the TTL runs out the cached document keeps being served while one
70
+ * background refetch replaces it; concurrent first calls share one fetch.
65
71
  * - The document's `issuer` must match the configured one (OIDC Discovery §4.3;
66
72
  * a trailing-slash difference is tolerated and the document's exact value is
67
73
  * returned in `issuer`).
@@ -73,20 +79,20 @@ export async function discoverEndpoints(issuer, options = {}) {
73
79
  const onError = options.onError ?? 'fallback';
74
80
  const cached = cache.get(base);
75
81
  let doc;
76
- if (cached && Date.now() - cached.fetchedAt < ttl) {
82
+ if (cached) {
77
83
  doc = cached.doc;
84
+ // Stale-while-revalidate (and stale-while-error): an IdP blip must not break a
85
+ // client that already knows the endpoints. A failed refetch writes nothing, so
86
+ // the next call retries.
87
+ if (Date.now() - cached.fetchedAt >= ttl)
88
+ refresh(base, options).catch(() => { });
78
89
  }
79
90
  else {
80
91
  try {
81
- doc = await fetchDiscovery(base, options.fetchImpl ?? fetch, options.resilience);
82
- cache.set(base, { doc, fetchedAt: Date.now() });
92
+ doc = await refresh(base, options);
83
93
  }
84
94
  catch (error) {
85
- // Stale-while-error: an IdP blip must not break a client that already knows
86
- // the endpoints. Nothing is written, so the next call retries.
87
- if (cached)
88
- doc = cached.doc;
89
- else if (onError === 'throw')
95
+ if (onError === 'throw')
90
96
  throw error;
91
97
  }
92
98
  }
@@ -99,6 +105,28 @@ export async function discoverEndpoints(issuer, options = {}) {
99
105
  assertRequired(base, endpoints);
100
106
  return endpoints;
101
107
  }
108
+ function refresh(base, options) {
109
+ let pending = inflight.get(base);
110
+ if (!pending) {
111
+ pending = fetchDiscovery(base, withTimeout(options.fetchImpl ?? fetch), options.resilience)
112
+ .then((doc) => {
113
+ cache.set(base, { doc, fetchedAt: Date.now() });
114
+ return doc;
115
+ })
116
+ .finally(() => inflight.delete(base));
117
+ inflight.set(base, pending);
118
+ }
119
+ return pending;
120
+ }
121
+ /** A fresh timeout per attempt, so a retrying resilience policy is not cut short. */
122
+ function withTimeout(fetchImpl) {
123
+ return ((input, init) => fetchImpl(input, {
124
+ ...init,
125
+ signal: init?.signal
126
+ ? AbortSignal.any([init.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
127
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS),
128
+ }));
129
+ }
102
130
  async function fetchDiscovery(base, fetchImpl, resilience) {
103
131
  const url = `${base}/.well-known/openid-configuration`;
104
132
  const fail = (message, cause) => {
@@ -149,4 +177,5 @@ function assertRequired(base, endpoints) {
149
177
  /** Clears the discovery cache (tests). */
150
178
  export function __clearDiscoveryCacheForTests() {
151
179
  cache.clear();
180
+ inflight.clear();
152
181
  }
@@ -1,7 +1,9 @@
1
1
  import type { Identity } from '@adonis-agora/authkit-core';
2
- /** Model Lucid mínimo que o mirror precisa (só `updateOrCreate`). */
2
+ /** Model Lucid mínimo que o mirror precisa. */
3
3
  interface LucidModelLike {
4
4
  updateOrCreate(search: Record<string, unknown>, payload: Record<string, unknown>): Promise<any>;
5
+ /** Leitura sem transação nem lock, usada quando o espelho já está em dia. */
6
+ findBy?(key: string, value: unknown): Promise<any>;
5
7
  }
6
8
  export interface LucidMirrorOptions {
7
9
  /**
@@ -22,7 +24,9 @@ export interface LucidMirrorOptions {
22
24
  /**
23
25
  * Factory do padrão "espelho local" de `resolveUser`: faz `updateOrCreate` do usuário
24
26
  * de domínio a partir das claims do id_token, opcionalmente pré-carrega relações e
25
- * injeta os papéis globais. Substitui o `resolveUser` que cada app reescrevia.
27
+ * injeta os papéis globais. Quando a linha já tem os valores das claims (o caso de
28
+ * todo request depois do login), lê com `findBy` em vez de abrir a transação com
29
+ * `SELECT ... FOR UPDATE` do `updateOrCreate`. Substitui o `resolveUser` que cada app reescrevia.
26
30
  *
27
31
  * ```ts
28
32
  * resolveUser: lucidMirror(AppUser, {
@@ -1,7 +1,17 @@
1
+ /** O usuário já tem exatamente esses valores? Valores não primitivos contam como diferentes. */
2
+ function alreadyMirrored(user, payload) {
3
+ return Object.entries(payload).every(([key, value]) => {
4
+ if (value !== null && typeof value === 'object')
5
+ return false;
6
+ return Object.is(user[key], value);
7
+ });
8
+ }
1
9
  /**
2
10
  * Factory do padrão "espelho local" de `resolveUser`: faz `updateOrCreate` do usuário
3
11
  * de domínio a partir das claims do id_token, opcionalmente pré-carrega relações e
4
- * injeta os papéis globais. Substitui o `resolveUser` que cada app reescrevia.
12
+ * injeta os papéis globais. Quando a linha já tem os valores das claims (o caso de
13
+ * todo request depois do login), lê com `findBy` em vez de abrir a transação com
14
+ * `SELECT ... FOR UPDATE` do `updateOrCreate`. Substitui o `resolveUser` que cada app reescrevia.
5
15
  *
6
16
  * ```ts
7
17
  * resolveUser: lucidMirror(AppUser, {
@@ -17,7 +27,11 @@ export function lucidMirror(Model, options = {}) {
17
27
  const idColumn = options.idColumn ?? 'id';
18
28
  return async (identity) => {
19
29
  const synced = options.sync ? options.sync(identity) : { email: identity.email };
20
- const user = await Model.updateOrCreate({ [idColumn]: identity.userId }, { [idColumn]: identity.userId, ...synced });
30
+ const payload = { [idColumn]: identity.userId, ...synced };
31
+ let user = Model.findBy ? await Model.findBy(idColumn, identity.userId) : null;
32
+ if (!user || !alreadyMirrored(user, payload)) {
33
+ user = await Model.updateOrCreate({ [idColumn]: identity.userId }, payload);
34
+ }
21
35
  if (options.preload) {
22
36
  for (const relation of options.preload) {
23
37
  await user.load(relation);
@@ -20,7 +20,8 @@ export interface OpaqueResolverFactoryConfig {
20
20
  introspectionUrl?: string;
21
21
  /**
22
22
  * TTL (ms) do cache de respostas `active:true`. Default 0 (revogação imediata).
23
- * Suba para reduzir round-trips ao custo de imediatismo.
23
+ * Suba para reduzir round-trips ao custo de imediatismo. Nunca além do `exp` do
24
+ * token; no máximo 10.000 tokens por processo.
24
25
  */
25
26
  cacheTtlMs?: number;
26
27
  }
@@ -24,7 +24,9 @@ export interface OpaqueResolverConfig {
24
24
  /**
25
25
  * TTL (ms) do cache em memória de respostas `active:true`, para não introspectar
26
26
  * a cada request. Default 0 (sem cache → revogação propaga imediatamente). Suba
27
- * para trocar imediatismo por menos round-trips.
27
+ * para trocar imediatismo por menos round-trips. Uma entrada vence no que vier
28
+ * primeiro entre o TTL e o `exp` do token; o cache guarda no máximo 10.000 tokens
29
+ * (expirados saem primeiro, depois o mais antigo).
28
30
  */
29
31
  cacheTtlMs?: number;
30
32
  fetchImpl?: FetchImpl;
@@ -1,5 +1,7 @@
1
1
  import { getTokenFromSource } from '../token_source.js';
2
2
  import { buildIdentityFromClaims, introspectToken } from './identity.js';
3
+ /** Teto de tokens no cache; o mais antigo sai primeiro. */
4
+ const MAX_CACHE_ENTRIES = 10_000;
3
5
  /**
4
6
  * Resolver de access token OPACO via introspection padrão (RFC 7662) a cada request.
5
7
  *
@@ -29,9 +31,10 @@ export class OpaqueResolver {
29
31
  const ttl = this.config.cacheTtlMs ?? 0;
30
32
  if (ttl > 0) {
31
33
  const hit = this.#cache.get(token);
32
- // `nowMs` vem do ctx (request) para não depender de Date.* — ver #now().
33
- if (hit && this.#now() - hit.storedAt < ttl)
34
+ if (hit && this.#now() < hit.expiresAt)
34
35
  return hit.identity;
36
+ if (hit)
37
+ this.#cache.delete(token);
35
38
  }
36
39
  const introspectionUrl = await this.config.introspectionUrl();
37
40
  if (!introspectionUrl)
@@ -44,9 +47,32 @@ export class OpaqueResolver {
44
47
  }
45
48
  const identity = buildIdentityFromClaims(data, this.config.globalRolesClaim);
46
49
  if (ttl > 0)
47
- this.#cache.set(token, { identity, storedAt: this.#now() });
50
+ this.#store(token, identity, ttl, data.exp);
48
51
  return identity;
49
52
  }
53
+ /**
54
+ * Guarda a identidade por `ttl`, nunca além do `exp` do token: um token que
55
+ * expira antes do TTL deixa de resolver quando expira, não quando o cache vence.
56
+ */
57
+ #store(token, identity, ttl, exp) {
58
+ const lifetime = typeof exp === 'number' ? Math.min(ttl, exp * 1000 - Date.now()) : ttl;
59
+ if (lifetime <= 0)
60
+ return;
61
+ const now = this.#now();
62
+ if (this.#cache.size >= MAX_CACHE_ENTRIES) {
63
+ for (const [key, entry] of this.#cache) {
64
+ if (entry.expiresAt <= now)
65
+ this.#cache.delete(key);
66
+ }
67
+ for (const key of this.#cache.keys()) {
68
+ if (this.#cache.size < MAX_CACHE_ENTRIES)
69
+ break;
70
+ this.#cache.delete(key);
71
+ }
72
+ }
73
+ this.#cache.delete(token);
74
+ this.#cache.set(token, { identity, expiresAt: now + lifetime });
75
+ }
50
76
  /** Relógio monotônico de processo (evita Date.* direto; testável). */
51
77
  #now() {
52
78
  return Math.trunc(performance.now());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-client",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
4
4
  "description": "AdonisJS OIDC/OAuth2 relying-party (client) toolkit: session-based authentication against an OpenID Connect identity provider, with JWT/PAT user resolvers and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -82,22 +82,22 @@
82
82
  }
83
83
  },
84
84
  "dependencies": {
85
- "@adonis-agora/authkit-core": "0.10.0",
85
+ "@adonis-agora/authkit-core": "0.10.1",
86
86
  "jose": "6.2.13"
87
87
  },
88
88
  "devDependencies": {
89
- "@adonis-agora/authkit-server": "0.81.2",
89
+ "@adonis-agora/authkit-server": "0.82.1",
90
90
  "@adonis-agora/resilience": "0.4.0",
91
91
  "@adonisjs/auth": "10.1.0",
92
92
  "@adonisjs/core": "7.6.0",
93
93
  "@adonisjs/lucid": "22.4.2",
94
- "@adonisjs/session": "8.1.0",
94
+ "@adonisjs/session": "8.3.0",
95
95
  "@adonisjs/tsconfig": "2.0.0",
96
96
  "@japa/assert": "4.2.0",
97
- "@japa/runner": "5.3.0",
98
- "@poppinss/ts-exec": "1.4.4",
99
- "@tanstack/intent": "0.4.0",
100
- "@types/node": "26.5.1",
97
+ "@japa/runner": "5.4.0",
98
+ "@poppinss/ts-exec": "1.5.1",
99
+ "@tanstack/intent": "0.5.6",
100
+ "@types/node": "26.6.5",
101
101
  "ioredis-mock": "8.13.1",
102
102
  "tempura": "0.4.1",
103
103
  "testcontainers": "12.2.1",
@@ -222,6 +222,9 @@ The opaque resolver introspects with `clientId`/`clientSecret` and the factory
222
222
  throws `resolvers.opaque requer um client confidencial (clientSecret)` when the
223
223
  config lacks a secret — a public client cannot authenticate introspection.
224
224
 
225
+ With `cacheTtlMs`, a cached identity expires at the earlier of the TTL and the
226
+ token's `exp`, and the cache holds at most 10,000 tokens per resolver.
227
+
225
228
  Source: `src/resolvers/factory.ts` (`resolvers.opaque` throw), README.
226
229
 
227
230
  ### MEDIUM — Bearer resolver in a browser session app (or vice versa)