@adonis-agora/authkit-client 0.18.9 → 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,12 +73,8 @@ 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.
27
- *
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.
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.
30
78
  *
31
79
  * Dedup por sessionId: requests concorrentes da mesma sessão (ex.: autosave + vários
32
80
  * GETs chegando juntos quando o access token vence) disparam UM refresh só. Quem
@@ -35,8 +83,8 @@ export declare class AuthkitClientManager {
35
83
  * com o access token vencido (→ 401) e com o refresh token antigo, já invalidado
36
84
  * pela rotação, que ele poderia gravar de volta e envenenar a sessão.
37
85
  *
38
- * Best-effort: qualquer falha (token revogado, IdP fora) é silenciosa — o resolver
39
- * 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.
40
88
  */
41
89
  maybeRefresh(ctx: HttpContext, deps?: RefreshDeps): Promise<void>;
42
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,6 +27,7 @@ function readParked(value) {
22
27
  }
23
28
  export class AuthkitClientManager {
24
29
  config;
30
+ deps;
25
31
  #resolverCache;
26
32
  /**
27
33
  * Refreshes in-flight por sessionId (dedup thundering herd). A promise resolve com o
@@ -29,8 +35,11 @@ export class AuthkitClientManager {
29
35
  * o refresh token de origem, pra quem espera saber se é da mesma linhagem.
30
36
  */
31
37
  #refreshPromises = new Map();
32
- constructor(config) {
38
+ #logger;
39
+ constructor(config, deps = {}) {
33
40
  this.config = config;
41
+ this.deps = deps;
42
+ this.#logger = deps.logger ?? { warn: () => { } };
34
43
  }
35
44
  async #getResolver() {
36
45
  if (!this.#resolverCache) {
@@ -40,10 +49,90 @@ export class AuthkitClientManager {
40
49
  clientSecret: this.config.clientSecret,
41
50
  sessionKey: this.config.sessionKey,
42
51
  globalRolesClaim: this.config.globalRolesClaim,
52
+ endpoints: () => this.endpoints(),
53
+ logger: this.#logger,
43
54
  });
44
55
  }
45
56
  return this.#resolverCache;
46
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
+ }
47
136
  get clientConfig() {
48
137
  return this.config;
49
138
  }
@@ -57,12 +146,8 @@ export class AuthkitClientManager {
57
146
  }
58
147
  /**
59
148
  * Renova o TokenSet da sessão proativamente se o access token estiver perto de
60
- * expirar (dentro de REFRESH_SKEW_MS) e houver refresh_token. Persiste o TokenSet
61
- * renovado — incluindo o refresh_token ROTACIONADO — de volta na sessão. Preserva
62
- * o id_token / refresh_token anteriores quando o IdP não os reemite.
63
- *
64
- * Preserva TAMBÉM o `impersonationBinding`: renovar o token do alvo não muda a
65
- * sessão, então não pode invalidar o vínculo e trancar o ator na impersonação.
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.
66
151
  *
67
152
  * Dedup por sessionId: requests concorrentes da mesma sessão (ex.: autosave + vários
68
153
  * GETs chegando juntos quando o access token vence) disparam UM refresh só. Quem
@@ -71,15 +156,16 @@ export class AuthkitClientManager {
71
156
  * com o access token vencido (→ 401) e com o refresh token antigo, já invalidado
72
157
  * pela rotação, que ele poderia gravar de volta e envenenar a sessão.
73
158
  *
74
- * Best-effort: qualquer falha (token revogado, IdP fora) é silenciosa — o resolver
75
- * 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.
76
161
  */
77
162
  async maybeRefresh(ctx, deps = {}) {
78
163
  const session = ctx.session;
79
164
  if (!session)
80
165
  return;
81
166
  const tokenSet = session.get(this.config.sessionKey);
82
- if (!tokenSet?.refreshToken)
167
+ const refreshToken = tokenSet?.refreshToken;
168
+ if (!tokenSet || !refreshToken)
83
169
  return;
84
170
  const now = deps.now ?? Date.now;
85
171
  // Sem expiresAt conhecido, não renova proativamente (evita refresh a cada request).
@@ -94,7 +180,7 @@ export class AuthkitClientManager {
94
180
  // Só aproveita o refresh in-flight se ele partiu do MESMO refresh token que esta
95
181
  // request tem — senão o TokenSet renovado é de outra linhagem (ex.: a sessão
96
182
  // trocou de token set no meio do caminho) e não pode ser gravado aqui.
97
- if (inFlight && inFlight.refreshToken === tokenSet.refreshToken) {
183
+ if (inFlight && inFlight.refreshToken === refreshToken) {
98
184
  const refreshed = await inFlight.promise;
99
185
  if (refreshed) {
100
186
  session.put(this.config.sessionKey, {
@@ -111,31 +197,22 @@ export class AuthkitClientManager {
111
197
  }
112
198
  // Promise que NUNCA rejeita: resolve com o TokenSet gravado, ou `null` se a
113
199
  // renovação falhou — aí o TokenSet original fica como está (o resolver decide).
114
- const promise = refreshTokens({
115
- issuer: this.config.issuer,
116
- clientId: this.config.clientId,
117
- clientSecret: this.config.clientSecret,
118
- refreshToken: tokenSet.refreshToken,
119
- fetchImpl: deps.fetchImpl,
120
- resilience: this.config.resilience,
121
- })
122
- .then((next) => {
123
- const merged = {
124
- idToken: next.idToken || tokenSet.idToken,
125
- accessToken: next.accessToken,
126
- refreshToken: next.refreshToken ?? tokenSet.refreshToken,
127
- expiresAt: next.expiresAt,
128
- impersonationBinding: tokenSet.impersonationBinding,
129
- };
130
- session.put(this.config.sessionKey, merged);
131
- return merged;
200
+ const promise = this.#renew(tokenSet, refreshToken, deps)
201
+ .then((renewed) => {
202
+ session.put(this.config.sessionKey, renewed);
203
+ return renewed;
132
204
  })
133
- .catch(() => null);
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
+ });
134
211
  if (!sessionId) {
135
212
  await promise;
136
213
  return;
137
214
  }
138
- const entry = { refreshToken: tokenSet.refreshToken, promise };
215
+ const entry = { refreshToken, promise };
139
216
  this.#refreshPromises.set(sessionId, entry);
140
217
  try {
141
218
  await promise;
@@ -146,6 +223,41 @@ export class AuthkitClientManager {
146
223
  this.#refreshPromises.delete(sessionId);
147
224
  }
148
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({
235
+ issuer: this.config.issuer,
236
+ clientId: this.config.clientId,
237
+ clientSecret: this.config.clientSecret,
238
+ refreshToken,
239
+ tokenEndpoint: endpoints.tokenEndpoint,
240
+ fetchImpl: deps.fetchImpl,
241
+ resilience: this.config.resilience,
242
+ });
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
+ };
260
+ }
149
261
  /**
150
262
  * Recebe o POST `application/x-www-form-urlencoded` de OIDC Back-Channel Logout. Lê o
151
263
  * `logout_token` do body, valida (assinatura + regras do spec), consulta o SessionIndex
@@ -161,11 +273,21 @@ export class AuthkitClientManager {
161
273
  if (!token || typeof token !== 'string') {
162
274
  return response.badRequest({ error: 'invalid_request' });
163
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
+ }
164
285
  let validated;
165
286
  try {
166
287
  validated = await validateLogoutToken(token, {
167
- issuer: this.config.issuer,
288
+ issuer: idp.endpoints.issuer,
168
289
  clientId: this.config.clientId,
290
+ keys: idp.keys,
169
291
  });
170
292
  }
171
293
  catch {
@@ -210,6 +332,7 @@ export class AuthkitClientManager {
210
332
  clientSecret: this.config.clientSecret,
211
333
  subjectToken: current.accessToken,
212
334
  requestedSubject,
335
+ tokenEndpoint: (await this.endpoints()).tokenEndpoint,
213
336
  resilience: this.config.resilience,
214
337
  });
215
338
  // Vínculo: o mesmo nonce nos dois lados. O lado ATIVO mora dentro do token set
@@ -339,13 +462,29 @@ export default class AuthkitClientProvider {
339
462
  this.app = app;
340
463
  }
341
464
  register() {
342
- 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) => {
343
468
  const value = this.app.config.get('authkit_client');
344
469
  const config = (await configProvider.resolve(this.app, value));
345
470
  if (!config) {
346
471
  throw new RuntimeException('Config inválido em "config/authkit_client.ts". Use defineConfig de @adonis-agora/authkit-client.');
347
472
  }
348
- return new AuthkitClientManager(config);
473
+ const logger = await resolver.make('logger');
474
+ return new AuthkitClientManager(config, { logger });
349
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;
350
489
  }
351
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>;