@adonis-agora/authkit-client 0.18.8 → 0.19.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/build/index.d.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  export type { Identity } from '@adonis-agora/authkit-core';
2
2
  export { configure } from './commands/configure.js';
3
+ export type { AuthkitClientLogger, AuthkitClientManagerDeps, } from './providers/authkit_client_provider.js';
3
4
  export { AuthkitClientManager } from './providers/authkit_client_provider.js';
4
5
  export { Authenticator } from './src/authenticator.js';
6
+ export type { AuthorizationRequest, CompleteAuthorizationParams, CompletedAuthorization, CreateAuthorizationRequestParams, OidcCallbackErrorCode, PendingAuthorization, } from './src/authorization.js';
7
+ export { completeAuthorization, createAuthorizationRequest, OidcCallbackError, } from './src/authorization.js';
5
8
  export type { SessionIndex, SessionIndexEntry, ValidatedLogoutToken, ValidateLogoutTokenOptions, } from './src/backchannel_logout.js';
6
9
  export { BACKCHANNEL_LOGOUT_EVENT, InMemorySessionIndex, InvalidLogoutTokenError, validateLogoutToken, } from './src/backchannel_logout.js';
7
10
  export type { BackchannelLogoutCallback, BackchannelLogoutInput, ClientConfigInput, ResolvedClientConfig, } from './src/define_config.js';
8
11
  export { defineConfig, resolvers } from './src/define_config.js';
9
12
  export type { DiscoverOptions, OidcEndpoints } from './src/discovery.js';
10
- export { conventionEndpoints, discoverEndpoints } from './src/discovery.js';
13
+ export { conventionEndpoints, discoverEndpoints, OidcDiscoveryError } from './src/discovery.js';
11
14
  export type { CreateDpopProofInput, DpopKeyPair } from './src/dpop.js';
12
15
  export { createDpopProof, dpopJwkThumbprint, generateDpopKeyPair, } from './src/dpop.js';
13
16
  export type { UnauthorizedAccessConstructor } from './src/host/authkit_client_guard.js';
@@ -17,13 +20,16 @@ export { authkitDashboardAuthorize, authkitDashboardMiddleware, isAuthkitAdmin,
17
20
  export { getAuthkit } from './src/host/request_authenticator.js';
18
21
  export type { ResiliencePolicy } from './src/http/resilient_fetch.js';
19
22
  export { resilientFetch } from './src/http/resilient_fetch.js';
23
+ export type { IdTokenClaims, ValidateIdTokenOptions } from './src/id_token.js';
24
+ export { InvalidIdTokenError, validateIdToken } from './src/id_token.js';
25
+ export { clearJwksCache } from './src/jwks.js';
20
26
  export type { LucidMirrorOptions } from './src/lucid_mirror.js';
21
27
  export { lucidMirror } from './src/lucid_mirror.js';
22
28
  export { default as AuthkitContextMiddleware } from './src/middleware/authkit_context_middleware.js';
23
29
  export { default as BackchannelRevocationMiddleware } from './src/middleware/backchannel_revocation_middleware.js';
24
30
  export type { RequireOrgMiddlewareOptions } from './src/middleware/require_org_middleware.js';
25
31
  export { default as RequireOrgMiddleware } from './src/middleware/require_org_middleware.js';
26
- export type { EndSessionParams, RefreshParams } from './src/oidc_login.js';
32
+ export type { AuthorizeParams, EndSessionParams, ExchangeParams, RefreshParams, } from './src/oidc_login.js';
27
33
  export { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, exchangeToken, generatePkce, refreshTokens, } from './src/oidc_login.js';
28
34
  export type { PostLoginRedirects, RegisterOidcClientOptions, } from './src/register_oidc_client.js';
29
35
  export { registerOidcClient } from './src/register_oidc_client.js';
@@ -33,4 +39,4 @@ export type { TokenSet } from './src/types.js';
33
39
  export type { ClaimsUser, ResolveUserContext, UserinfoResolverOptions, } from './src/user_resolvers.js';
34
40
  export { createUserinfoResolver, identityToUser, } from './src/user_resolvers.js';
35
41
  export type { JwtAccessTokenClaims, VerifyJwtAccessTokenOptions, } from './src/verify_access_token.js';
36
- export { clearJwksCache, verifyJwtAccessToken, } from './src/verify_access_token.js';
42
+ export { verifyJwtAccessToken } from './src/verify_access_token.js';
package/build/index.js CHANGED
@@ -1,14 +1,17 @@
1
1
  export { configure } from './commands/configure.js';
2
2
  export { AuthkitClientManager } from './providers/authkit_client_provider.js';
3
3
  export { Authenticator } from './src/authenticator.js';
4
+ export { completeAuthorization, createAuthorizationRequest, OidcCallbackError, } from './src/authorization.js';
4
5
  export { BACKCHANNEL_LOGOUT_EVENT, InMemorySessionIndex, InvalidLogoutTokenError, validateLogoutToken, } from './src/backchannel_logout.js';
5
6
  export { defineConfig, resolvers } from './src/define_config.js';
6
- export { conventionEndpoints, discoverEndpoints } from './src/discovery.js';
7
+ export { conventionEndpoints, discoverEndpoints, OidcDiscoveryError } from './src/discovery.js';
7
8
  export { createDpopProof, dpopJwkThumbprint, generateDpopKeyPair, } from './src/dpop.js';
8
9
  export { AuthkitClientGuard, authkitClientGuard } from './src/host/authkit_client_guard.js';
9
10
  export { authkitDashboardAuthorize, authkitDashboardMiddleware, isAuthkitAdmin, } from './src/host/dashboard_guard.js';
10
11
  export { getAuthkit } from './src/host/request_authenticator.js';
11
12
  export { resilientFetch } from './src/http/resilient_fetch.js';
13
+ export { InvalidIdTokenError, validateIdToken } from './src/id_token.js';
14
+ export { clearJwksCache } from './src/jwks.js';
12
15
  export { lucidMirror } from './src/lucid_mirror.js';
13
16
  export { default as AuthkitContextMiddleware } from './src/middleware/authkit_context_middleware.js';
14
17
  export { default as BackchannelRevocationMiddleware } from './src/middleware/backchannel_revocation_middleware.js';
@@ -17,4 +20,4 @@ export { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, exchangeToken, gen
17
20
  export { registerOidcClient } from './src/register_oidc_client.js';
18
21
  export { DEFAULT_REVOCATION_TABLE, lucidRevocationStore, } from './src/revocation/revocation_store.js';
19
22
  export { createUserinfoResolver, identityToUser, } from './src/user_resolvers.js';
20
- export { clearJwksCache, verifyJwtAccessToken, } from './src/verify_access_token.js';
23
+ export { verifyJwtAccessToken } from './src/verify_access_token.js';
@@ -1,18 +1,70 @@
1
1
  import '../container_bindings.js';
2
2
  import type { HttpContext } from '@adonisjs/core/http';
3
3
  import type { ApplicationService } from '@adonisjs/core/types';
4
+ import { type JWTVerifyGetKey } from 'jose';
4
5
  import { Authenticator } from '../src/authenticator.js';
6
+ import { type AuthorizationRequest, type CompleteAuthorizationParams, type CompletedAuthorization, type CreateAuthorizationRequestParams } from '../src/authorization.js';
5
7
  import type { ResolvedClientConfig } from '../src/define_config.js';
8
+ import { type OidcEndpoints } from '../src/discovery.js';
9
+ import { type IdTokenClaims, type ValidateIdTokenOptions } from '../src/id_token.js';
6
10
  import type { TokenSet } from '../src/types.js';
7
11
  /** Deps injetáveis (relógio/fetch) para testar `maybeRefresh` sem rede/tempo real. */
8
12
  export interface RefreshDeps {
9
13
  now?: () => number;
10
14
  fetchImpl?: typeof fetch;
11
15
  }
16
+ /** The slice of the AdonisJS logger the manager uses. */
17
+ export interface AuthkitClientLogger {
18
+ warn(obj: object, msg: string): void;
19
+ }
20
+ export interface AuthkitClientManagerDeps {
21
+ /** Where best-effort failures (refresh, logout without end_session) are reported. */
22
+ logger?: AuthkitClientLogger;
23
+ /** fetch used for discovery (tests). */
24
+ fetchImpl?: typeof fetch;
25
+ /**
26
+ * Key function for id_token and logout_token signatures, replacing the
27
+ * discovered `jwks_uri` (tests, or a JWKS shipped with the app).
28
+ */
29
+ keys?: JWTVerifyGetKey;
30
+ }
12
31
  export declare class AuthkitClientManager {
13
32
  #private;
14
33
  private config;
15
- constructor(config: ResolvedClientConfig);
34
+ private deps;
35
+ constructor(config: ResolvedClientConfig, deps?: AuthkitClientManagerDeps);
36
+ /**
37
+ * The IdP endpoints this client talks to: discovered from
38
+ * `{issuer}/.well-known/openid-configuration` (cached, 15 min by default) with
39
+ * the config's `endpoints` overrides applied on top. With `discovery: false`,
40
+ * the authkit-server convention plus the overrides — no network.
41
+ *
42
+ * Throws `OidcDiscoveryError` when discovery fails and nothing was discovered
43
+ * before; the next call retries.
44
+ */
45
+ endpoints(): Promise<OidcEndpoints>;
46
+ /**
47
+ * Starts a login: authorize URL with fresh `state`, `nonce` and PKCE, built from
48
+ * this client's config and endpoints. Store `pending` in the session and pass it
49
+ * to {@link completeAuthorization} in the callback.
50
+ *
51
+ * ```ts
52
+ * const { url, pending } = await authkit.createAuthorizationRequest({ extraParams: { prompt: 'login' } })
53
+ * session.put('oidc_pending', pending)
54
+ * return response.redirect(url)
55
+ * ```
56
+ */
57
+ createAuthorizationRequest(options?: Partial<Pick<CreateAuthorizationRequestParams, 'scopes' | 'extraParams' | 'maxAge'>>): Promise<AuthorizationRequest>;
58
+ /**
59
+ * Finishes a login: checks `state`/`iss`, exchanges the code and validates the
60
+ * id_token (`nonce` included). Does NOT touch the session — call
61
+ * {@link startSession} with the returned `tokens`.
62
+ *
63
+ * Throws `OidcCallbackError`; `error.restartable` means "send them back to login".
64
+ */
65
+ completeAuthorization(params: Pick<CompleteAuthorizationParams, 'pending' | 'callbackParams'>): Promise<CompletedAuthorization>;
66
+ /** Validates an id_token issued to this client (issuer, client_id and keys filled in). */
67
+ validateIdToken(idToken: string, options?: Omit<ValidateIdTokenOptions, 'issuer' | 'clientId' | 'jwksUri' | 'keys'>): Promise<IdTokenClaims>;
16
68
  get clientConfig(): ResolvedClientConfig;
17
69
  /**
18
70
  * Lê o id_token armazenado no token set da sessão. Usado pelo logout federado
@@ -21,15 +73,18 @@ export declare class AuthkitClientManager {
21
73
  getIdToken(ctx: HttpContext): string | undefined;
22
74
  /**
23
75
  * Renova o TokenSet da sessão proativamente se o access token estiver perto de
24
- * expirar (dentro de REFRESH_SKEW_MS) e houver refresh_token. Persiste o TokenSet
25
- * renovado — incluindo o refresh_token ROTACIONADO — de volta na sessão. Preserva
26
- * o id_token / refresh_token anteriores quando o IdP não os reemite.
76
+ * expirar (dentro de REFRESH_SKEW_MS) e houver refresh_token, e persiste o resultado
77
+ * de {@link #renew} — incluindo o refresh_token ROTACIONADO — de volta na sessão.
27
78
  *
28
- * Preserva TAMBÉM o `impersonationBinding`: renovar o token do alvo não muda a
29
- * sessão, então não pode invalidar o vínculo e trancar o ator na impersonação.
79
+ * Dedup por sessionId: requests concorrentes da mesma sessão (ex.: autosave + vários
80
+ * GETs chegando juntos quando o access token vence) disparam UM refresh só. Quem
81
+ * chegou depois espera o refresh in-flight e grava o TokenSet renovado na SUA
82
+ * PRÓPRIA sessão — cada request tem seu objeto de sessão, e sem isso ele seguiria
83
+ * com o access token vencido (→ 401) e com o refresh token antigo, já invalidado
84
+ * pela rotação, que ele poderia gravar de volta e envenenar a sessão.
30
85
  *
31
- * Best-effort: qualquer falha (token revogado, IdP fora) é silenciosa — o resolver
32
- * lida com a sessão expirada no fluxo normal. Chamado pelo middleware por request.
86
+ * Best-effort: uma falha (token revogado, IdP fora) vai para o logger e não lança —
87
+ * o resolver lida com a sessão expirada no fluxo normal. Chamado pelo middleware.
33
88
  */
34
89
  maybeRefresh(ctx: HttpContext, deps?: RefreshDeps): Promise<void>;
35
90
  /**
@@ -4,8 +4,13 @@ import '../container_bindings.js';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { configProvider } from '@adonisjs/core';
6
6
  import { RuntimeException } from '@adonisjs/core/exceptions';
7
+ import { decodeJwt } from 'jose';
7
8
  import { Authenticator } from '../src/authenticator.js';
9
+ import { completeAuthorization, createAuthorizationRequest, } from '../src/authorization.js';
8
10
  import { validateLogoutToken } from '../src/backchannel_logout.js';
11
+ import { conventionEndpoints, discoverEndpoints, mergeEndpoints, } from '../src/discovery.js';
12
+ import { validateIdToken, } from '../src/id_token.js';
13
+ import { getJwks } from '../src/jwks.js';
9
14
  import { exchangeToken, refreshTokens } from '../src/oidc_login.js';
10
15
  /** Margem (ms) antes do `expiresAt` em que o access token é renovado proativamente. */
11
16
  const REFRESH_SKEW_MS = 60_000;
@@ -22,11 +27,19 @@ function readParked(value) {
22
27
  }
23
28
  export class AuthkitClientManager {
24
29
  config;
30
+ deps;
25
31
  #resolverCache;
26
- /** Mapa de promessas de refresh in-flight por sessionId (dedup thundering herd). */
32
+ /**
33
+ * Refreshes in-flight por sessionId (dedup thundering herd). A promise resolve com o
34
+ * TokenSet renovado (já mesclado) ou `null` se falhou — nunca rejeita. Guarda também
35
+ * o refresh token de origem, pra quem espera saber se é da mesma linhagem.
36
+ */
27
37
  #refreshPromises = new Map();
28
- constructor(config) {
38
+ #logger;
39
+ constructor(config, deps = {}) {
29
40
  this.config = config;
41
+ this.deps = deps;
42
+ this.#logger = deps.logger ?? { warn: () => { } };
30
43
  }
31
44
  async #getResolver() {
32
45
  if (!this.#resolverCache) {
@@ -36,10 +49,90 @@ export class AuthkitClientManager {
36
49
  clientSecret: this.config.clientSecret,
37
50
  sessionKey: this.config.sessionKey,
38
51
  globalRolesClaim: this.config.globalRolesClaim,
52
+ endpoints: () => this.endpoints(),
53
+ logger: this.#logger,
39
54
  });
40
55
  }
41
56
  return this.#resolverCache;
42
57
  }
58
+ /**
59
+ * The IdP endpoints this client talks to: discovered from
60
+ * `{issuer}/.well-known/openid-configuration` (cached, 15 min by default) with
61
+ * the config's `endpoints` overrides applied on top. With `discovery: false`,
62
+ * the authkit-server convention plus the overrides — no network.
63
+ *
64
+ * Throws `OidcDiscoveryError` when discovery fails and nothing was discovered
65
+ * before; the next call retries.
66
+ */
67
+ async endpoints() {
68
+ const { discovery, endpoints: overrides, issuer } = this.config;
69
+ if (discovery === false)
70
+ return mergeEndpoints(conventionEndpoints(issuer), overrides);
71
+ return discoverEndpoints(issuer, {
72
+ overrides,
73
+ cacheTtlMs: discovery.cacheTtlMs,
74
+ resilience: this.config.resilience,
75
+ fetchImpl: this.deps.fetchImpl,
76
+ onError: 'throw',
77
+ });
78
+ }
79
+ /** {@link endpoints} plus the key function every token signature is checked with. */
80
+ async #idp() {
81
+ const endpoints = await this.endpoints();
82
+ return { endpoints, keys: this.deps.keys ?? getJwks(endpoints.jwksUri) };
83
+ }
84
+ /**
85
+ * Starts a login: authorize URL with fresh `state`, `nonce` and PKCE, built from
86
+ * this client's config and endpoints. Store `pending` in the session and pass it
87
+ * to {@link completeAuthorization} in the callback.
88
+ *
89
+ * ```ts
90
+ * const { url, pending } = await authkit.createAuthorizationRequest({ extraParams: { prompt: 'login' } })
91
+ * session.put('oidc_pending', pending)
92
+ * return response.redirect(url)
93
+ * ```
94
+ */
95
+ async createAuthorizationRequest(options = {}) {
96
+ return createAuthorizationRequest({
97
+ issuer: this.config.issuer,
98
+ clientId: this.config.clientId,
99
+ redirectUri: this.config.redirectUri,
100
+ scopes: options.scopes ?? this.config.scopes,
101
+ extraParams: options.extraParams,
102
+ maxAge: options.maxAge,
103
+ endpoints: await this.endpoints(),
104
+ });
105
+ }
106
+ /**
107
+ * Finishes a login: checks `state`/`iss`, exchanges the code and validates the
108
+ * id_token (`nonce` included). Does NOT touch the session — call
109
+ * {@link startSession} with the returned `tokens`.
110
+ *
111
+ * Throws `OidcCallbackError`; `error.restartable` means "send them back to login".
112
+ */
113
+ async completeAuthorization(params) {
114
+ const { endpoints, keys } = await this.#idp();
115
+ return completeAuthorization({
116
+ ...params,
117
+ issuer: this.config.issuer,
118
+ clientId: this.config.clientId,
119
+ clientSecret: this.config.clientSecret,
120
+ redirectUri: this.config.redirectUri,
121
+ endpoints,
122
+ idToken: { keys },
123
+ resilience: this.config.resilience,
124
+ });
125
+ }
126
+ /** Validates an id_token issued to this client (issuer, client_id and keys filled in). */
127
+ async validateIdToken(idToken, options = {}) {
128
+ const { endpoints, keys } = await this.#idp();
129
+ return validateIdToken(idToken, {
130
+ ...options,
131
+ issuer: endpoints.issuer,
132
+ clientId: this.config.clientId,
133
+ keys,
134
+ });
135
+ }
43
136
  get clientConfig() {
44
137
  return this.config;
45
138
  }
@@ -53,22 +146,26 @@ export class AuthkitClientManager {
53
146
  }
54
147
  /**
55
148
  * Renova o TokenSet da sessão proativamente se o access token estiver perto de
56
- * expirar (dentro de REFRESH_SKEW_MS) e houver refresh_token. Persiste o TokenSet
57
- * renovado — incluindo o refresh_token ROTACIONADO — de volta na sessão. Preserva
58
- * o id_token / refresh_token anteriores quando o IdP não os reemite.
149
+ * expirar (dentro de REFRESH_SKEW_MS) e houver refresh_token, e persiste o resultado
150
+ * de {@link #renew} — incluindo o refresh_token ROTACIONADO — de volta na sessão.
59
151
  *
60
- * Preserva TAMBÉM o `impersonationBinding`: renovar o token do alvo não muda a
61
- * sessão, então não pode invalidar o vínculo e trancar o ator na impersonação.
152
+ * Dedup por sessionId: requests concorrentes da mesma sessão (ex.: autosave + vários
153
+ * GETs chegando juntos quando o access token vence) disparam UM refresh só. Quem
154
+ * chegou depois espera o refresh in-flight e grava o TokenSet renovado na SUA
155
+ * PRÓPRIA sessão — cada request tem seu objeto de sessão, e sem isso ele seguiria
156
+ * com o access token vencido (→ 401) e com o refresh token antigo, já invalidado
157
+ * pela rotação, que ele poderia gravar de volta e envenenar a sessão.
62
158
  *
63
- * Best-effort: qualquer falha (token revogado, IdP fora) é silenciosa — o resolver
64
- * lida com a sessão expirada no fluxo normal. Chamado pelo middleware por request.
159
+ * Best-effort: uma falha (token revogado, IdP fora) vai para o logger e não lança —
160
+ * o resolver lida com a sessão expirada no fluxo normal. Chamado pelo middleware.
65
161
  */
66
162
  async maybeRefresh(ctx, deps = {}) {
67
163
  const session = ctx.session;
68
164
  if (!session)
69
165
  return;
70
166
  const tokenSet = session.get(this.config.sessionKey);
71
- if (!tokenSet?.refreshToken)
167
+ const refreshToken = tokenSet?.refreshToken;
168
+ if (!tokenSet || !refreshToken)
72
169
  return;
73
170
  const now = deps.now ?? Date.now;
74
171
  // Sem expiresAt conhecido, não renova proativamente (evita refresh a cada request).
@@ -79,39 +176,87 @@ export class AuthkitClientManager {
79
176
  // juntos fariam N refreshes + N-1 falhas com refresh token já inválido.
80
177
  const sessionId = session.sessionId;
81
178
  if (sessionId) {
82
- const pending = this.#refreshPromises.get(sessionId);
83
- if (pending) {
84
- await pending.catch(() => { });
179
+ const inFlight = this.#refreshPromises.get(sessionId);
180
+ // Só aproveita o refresh in-flight se ele partiu do MESMO refresh token que esta
181
+ // request tem — senão o TokenSet renovado é de outra linhagem (ex.: a sessão
182
+ // trocou de token set no meio do caminho) e não pode ser gravado aqui.
183
+ if (inFlight && inFlight.refreshToken === refreshToken) {
184
+ const refreshed = await inFlight.promise;
185
+ if (refreshed) {
186
+ session.put(this.config.sessionKey, {
187
+ ...refreshed,
188
+ // O vínculo é por sessão (mesmo sessionId → mesmo vínculo); mantém o desta
189
+ // request e só cai no do refresh se esta não tiver.
190
+ impersonationBinding: tokenSet.impersonationBinding ?? refreshed.impersonationBinding,
191
+ });
192
+ }
85
193
  return;
86
194
  }
195
+ // Linhagem diferente: segue e faz o próprio refresh (a entrada do mapa passa a
196
+ // ser a desta request; a anterior não a apaga — ver o `finally`).
87
197
  }
88
- // Promise que SEMPRE resolve — o catch silencioso mantém o TokenSet original
89
- // em caso de falha, igual ao comportamento do maybeRefresh original.
90
- const promise = refreshTokens({
198
+ // Promise que NUNCA rejeita: resolve com o TokenSet gravado, ou `null` se a
199
+ // renovação falhou — aí o TokenSet original fica como está (o resolver decide).
200
+ const promise = this.#renew(tokenSet, refreshToken, deps)
201
+ .then((renewed) => {
202
+ session.put(this.config.sessionKey, renewed);
203
+ return renewed;
204
+ })
205
+ .catch((error) => {
206
+ // Still best-effort — the resolver handles the expired session — but no
207
+ // longer silent: a misconfigured token endpoint used to fail here forever.
208
+ this.#logger.warn({ err: error, issuer: this.config.issuer }, 'authkit-client: token refresh failed; keeping the current token set');
209
+ return null;
210
+ });
211
+ if (!sessionId) {
212
+ await promise;
213
+ return;
214
+ }
215
+ const entry = { refreshToken, promise };
216
+ this.#refreshPromises.set(sessionId, entry);
217
+ try {
218
+ await promise;
219
+ }
220
+ finally {
221
+ // Só remove se a entrada ainda é a nossa.
222
+ if (this.#refreshPromises.get(sessionId) === entry)
223
+ this.#refreshPromises.delete(sessionId);
224
+ }
225
+ }
226
+ /**
227
+ * Um refresh: tokens novos do IdP, id_token reemitido validado, mesclados sobre
228
+ * `tokenSet`. Preserva o id_token / refresh_token anteriores quando o IdP não os
229
+ * reemite, e TAMBÉM o `impersonationBinding`: renovar o token do alvo não muda a
230
+ * sessão, então não pode invalidar o vínculo e trancar o ator na impersonação.
231
+ */
232
+ async #renew(tokenSet, refreshToken, deps) {
233
+ const { endpoints, keys } = await this.#idp();
234
+ const next = await refreshTokens({
91
235
  issuer: this.config.issuer,
92
236
  clientId: this.config.clientId,
93
237
  clientSecret: this.config.clientSecret,
94
- refreshToken: tokenSet.refreshToken,
238
+ refreshToken,
239
+ tokenEndpoint: endpoints.tokenEndpoint,
95
240
  fetchImpl: deps.fetchImpl,
96
241
  resilience: this.config.resilience,
97
- })
98
- .then((next) => {
99
- session.put(this.config.sessionKey, {
100
- idToken: next.idToken || tokenSet.idToken,
101
- accessToken: next.accessToken,
102
- refreshToken: next.refreshToken ?? tokenSet.refreshToken,
103
- expiresAt: next.expiresAt,
104
- impersonationBinding: tokenSet.impersonationBinding,
105
- });
106
- })
107
- .catch(() => {
108
- // Renovação falhou — deixa o TokenSet como está; o resolver decide a sessão.
109
242
  });
110
- if (sessionId)
111
- this.#refreshPromises.set(sessionId, promise);
112
- await promise;
113
- if (sessionId)
114
- this.#refreshPromises.delete(sessionId);
243
+ // OIDC Core §12.2: a re-issued id_token must be valid and about the same
244
+ // end-user. No nonce here — none is sent on refresh.
245
+ if (next.idToken) {
246
+ await validateIdToken(next.idToken, {
247
+ issuer: endpoints.issuer,
248
+ clientId: this.config.clientId,
249
+ keys,
250
+ subject: subjectOf(tokenSet.idToken),
251
+ });
252
+ }
253
+ return {
254
+ idToken: next.idToken || tokenSet.idToken,
255
+ accessToken: next.accessToken,
256
+ refreshToken: next.refreshToken ?? refreshToken,
257
+ expiresAt: next.expiresAt,
258
+ impersonationBinding: tokenSet.impersonationBinding,
259
+ };
115
260
  }
116
261
  /**
117
262
  * Recebe o POST `application/x-www-form-urlencoded` de OIDC Back-Channel Logout. Lê o
@@ -128,11 +273,21 @@ export class AuthkitClientManager {
128
273
  if (!token || typeof token !== 'string') {
129
274
  return response.badRequest({ error: 'invalid_request' });
130
275
  }
276
+ let idp;
277
+ try {
278
+ idp = await this.#idp();
279
+ }
280
+ catch (error) {
281
+ // Not the token's fault: tell the OP to retry instead of rejecting it.
282
+ this.#logger.warn({ err: error, issuer: this.config.issuer }, 'authkit-client: back-channel logout received while discovery is failing');
283
+ return response.status(503).send({ error: 'temporarily_unavailable' });
284
+ }
131
285
  let validated;
132
286
  try {
133
287
  validated = await validateLogoutToken(token, {
134
- issuer: this.config.issuer,
288
+ issuer: idp.endpoints.issuer,
135
289
  clientId: this.config.clientId,
290
+ keys: idp.keys,
136
291
  });
137
292
  }
138
293
  catch {
@@ -177,6 +332,7 @@ export class AuthkitClientManager {
177
332
  clientSecret: this.config.clientSecret,
178
333
  subjectToken: current.accessToken,
179
334
  requestedSubject,
335
+ tokenEndpoint: (await this.endpoints()).tokenEndpoint,
180
336
  resilience: this.config.resilience,
181
337
  });
182
338
  // Vínculo: o mesmo nonce nos dois lados. O lado ATIVO mora dentro do token set
@@ -306,13 +462,29 @@ export default class AuthkitClientProvider {
306
462
  this.app = app;
307
463
  }
308
464
  register() {
309
- this.app.container.singleton('authkit.client', async () => {
465
+ // Bound by class, like the first-party packages do, so `@inject()` works with
466
+ // `AuthkitClientManager` as a constructor type. The string key stays as an alias.
467
+ this.app.container.singleton(AuthkitClientManager, async (resolver) => {
310
468
  const value = this.app.config.get('authkit_client');
311
469
  const config = (await configProvider.resolve(this.app, value));
312
470
  if (!config) {
313
471
  throw new RuntimeException('Config inválido em "config/authkit_client.ts". Use defineConfig de @adonis-agora/authkit-client.');
314
472
  }
315
- return new AuthkitClientManager(config);
473
+ const logger = await resolver.make('logger');
474
+ return new AuthkitClientManager(config, { logger });
316
475
  });
476
+ this.app.container.alias('authkit.client', AuthkitClientManager);
477
+ }
478
+ }
479
+ /** `sub` of a stored id_token, or undefined when there is none / it is unreadable. */
480
+ function subjectOf(idToken) {
481
+ if (!idToken)
482
+ return undefined;
483
+ try {
484
+ const sub = decodeJwt(idToken).sub;
485
+ return typeof sub === 'string' ? sub : undefined;
486
+ }
487
+ catch {
488
+ return undefined;
317
489
  }
318
490
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Authorization-code flow in two calls: one to start it, one to finish it.
3
+ *
4
+ * `buildAuthorizeUrl` + `exchangeCode` are the raw steps; these two wrap them
5
+ * with everything a callback must check and is easy to forget — `state`, the
6
+ * RFC 9207 `iss` parameter, PKCE and the id_token (signature, `aud`, `nonce`…).
7
+ *
8
+ * ```ts
9
+ * const { url, pending } = await createAuthorizationRequest({ issuer, clientId, redirectUri, scopes, endpoints })
10
+ * session.put('oidc_pending', pending)
11
+ * // ...IdP redirects back...
12
+ * const { tokens, claims } = await completeAuthorization({
13
+ * issuer, clientId, clientSecret, redirectUri, endpoints,
14
+ * pending: session.pull('oidc_pending'),
15
+ * callbackParams: request.qs(),
16
+ * })
17
+ * ```
18
+ *
19
+ * Inside an AdonisJS app, prefer the `AuthkitClientManager` methods of the same
20
+ * name — they fill the config and the discovered endpoints in for you.
21
+ */
22
+ import { type OidcEndpoints } from './discovery.js';
23
+ import type { ResiliencePolicy } from './http/resilient_fetch.js';
24
+ import { type IdTokenClaims, type ValidateIdTokenOptions } from './id_token.js';
25
+ import type { TokenSet } from './types.js';
26
+ /**
27
+ * What the app keeps (in the session) between the redirect to the IdP and the
28
+ * callback. Plain JSON — safe to store in any session driver.
29
+ */
30
+ export interface PendingAuthorization {
31
+ state: string;
32
+ nonce: string;
33
+ codeVerifier: string;
34
+ /** `max_age` sent in the request, if any — re-checked against `auth_time`. */
35
+ maxAge?: number;
36
+ }
37
+ export interface CreateAuthorizationRequestParams {
38
+ issuer: string;
39
+ clientId: string;
40
+ redirectUri: string;
41
+ scopes: string[];
42
+ /** Discovered endpoints. Without them, the authkit-server convention is used. */
43
+ endpoints?: Pick<OidcEndpoints, 'authorizationEndpoint'>;
44
+ /** Extra authorize parameters (`prompt`, `login_hint`, `audience`, `ui_locales`…). */
45
+ extraParams?: Record<string, string | number | undefined | null>;
46
+ /** OIDC `max_age` in seconds; the callback then requires a fresh enough `auth_time`. */
47
+ maxAge?: number;
48
+ }
49
+ export interface AuthorizationRequest {
50
+ /** Where to redirect the browser. */
51
+ url: string;
52
+ /** Store this until the callback, then pass it to `completeAuthorization`. */
53
+ pending: PendingAuthorization;
54
+ }
55
+ /** Builds the authorize URL with fresh `state`, `nonce` and PKCE (S256). */
56
+ export declare function createAuthorizationRequest(p: CreateAuthorizationRequestParams): Promise<AuthorizationRequest>;
57
+ /** Why a callback was rejected. */
58
+ export type OidcCallbackErrorCode =
59
+ /** No pending authorization in the session (expired, or a stale/forged callback). */
60
+ 'missing_pending'
61
+ /** `state` does not match the pending authorization. */
62
+ | 'state_mismatch'
63
+ /** RFC 9207 `iss` parameter present and not the expected issuer (mix-up attack). */
64
+ | 'issuer_mismatch'
65
+ /** The IdP redirected back with `error` (e.g. `access_denied`, `login_required`). */
66
+ | 'idp_error'
67
+ /** No `code` in the callback. */
68
+ | 'missing_code'
69
+ /** Token endpoint call failed. */
70
+ | 'token_exchange_failed'
71
+ /** Token endpoint answered without an id_token (was `openid` in the scopes?). */
72
+ | 'missing_id_token'
73
+ /** id_token failed validation — see `cause`. */
74
+ | 'invalid_id_token';
75
+ export declare class OidcCallbackError extends Error {
76
+ readonly code: OidcCallbackErrorCode;
77
+ constructor(code: OidcCallbackErrorCode, message: string, options?: {
78
+ cause?: unknown;
79
+ /** `error` / `error_description` sent by the IdP, for `idp_error`. */
80
+ idpError?: {
81
+ error: string;
82
+ description?: string;
83
+ };
84
+ });
85
+ readonly idpError?: {
86
+ error: string;
87
+ description?: string;
88
+ };
89
+ /**
90
+ * True when the right answer is to send the user back to login: the callback is
91
+ * stale or did not belong to this browser, or the IdP sent the user back with an
92
+ * error (`access_denied`, `login_required`…; see `idpError` to tell them apart).
93
+ * False for real failures — a mix-up, a failed token exchange or id_token check.
94
+ */
95
+ get restartable(): boolean;
96
+ }
97
+ export interface CompleteAuthorizationParams {
98
+ issuer: string;
99
+ clientId: string;
100
+ clientSecret?: string;
101
+ redirectUri: string;
102
+ /** Discovered endpoints. Without them, the authkit-server convention is used. */
103
+ endpoints?: Pick<OidcEndpoints, 'issuer' | 'tokenEndpoint' | 'jwksUri'>;
104
+ /** What `createAuthorizationRequest` returned in `pending`. */
105
+ pending: PendingAuthorization | null | undefined;
106
+ /** The callback query string (`request.qs()`). */
107
+ callbackParams: Record<string, unknown>;
108
+ /** Overrides for the id_token check (keys in tests, clock tolerance…). */
109
+ idToken?: Pick<ValidateIdTokenOptions, 'keys' | 'algorithms' | 'clockTolerance' | 'currentDate'>;
110
+ fetchImpl?: typeof fetch;
111
+ resilience?: ResiliencePolicy;
112
+ }
113
+ export interface CompletedAuthorization {
114
+ tokens: TokenSet;
115
+ /** Validated id_token claims. */
116
+ claims: IdTokenClaims;
117
+ }
118
+ /**
119
+ * Finishes the authorization-code flow: checks `state` and `iss`, exchanges the
120
+ * code (with the PKCE verifier) and validates the id_token, `nonce` included.
121
+ *
122
+ * Throws {@link OidcCallbackError}; `error.restartable` tells "send the user back
123
+ * to login" from a real failure.
124
+ */
125
+ export declare function completeAuthorization(p: CompleteAuthorizationParams): Promise<CompletedAuthorization>;