@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 +9 -3
- package/build/index.js +5 -2
- package/build/providers/authkit_client_provider.d.ts +63 -8
- package/build/providers/authkit_client_provider.js +209 -37
- package/build/src/authorization.d.ts +125 -0
- package/build/src/authorization.js +142 -0
- package/build/src/backchannel_logout.d.ts +1 -1
- package/build/src/backchannel_logout.js +3 -15
- package/build/src/define_config.d.ts +24 -0
- package/build/src/define_config.js +6 -0
- package/build/src/discovery.d.ts +52 -20
- package/build/src/discovery.js +104 -42
- package/build/src/id_token.d.ts +57 -0
- package/build/src/id_token.js +61 -0
- package/build/src/jwks.d.ts +15 -0
- package/build/src/jwks.js +36 -0
- package/build/src/oidc_login.d.ts +7 -1
- package/build/src/oidc_login.js +31 -2
- package/build/src/register_oidc_client.js +30 -31
- package/build/src/resolvers/factory.d.ts +9 -2
- package/build/src/resolvers/factory.js +37 -5
- package/build/src/resolvers/jwt_resolver.d.ts +7 -3
- package/build/src/resolvers/jwt_resolver.js +8 -17
- package/build/src/resolvers/opaque_resolver.d.ts +5 -2
- package/build/src/resolvers/opaque_resolver.js +4 -1
- package/build/src/verify_access_token.d.ts +0 -2
- package/build/src/verify_access_token.js +2 -27
- package/build/stubs/controllers/oidc_session_controller.stub +31 -26
- package/package.json +7 -6
- package/stubs/controllers/oidc_session_controller.stub +31 -26
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
25
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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:
|
|
32
|
-
* lida com a sessão expirada no fluxo normal. Chamado pelo middleware
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
57
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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:
|
|
64
|
-
* lida com a sessão expirada no fluxo normal. Chamado pelo middleware
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
89
|
-
//
|
|
90
|
-
const promise =
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|