@adonis-agora/authkit-client 0.18.9 → 0.19.1
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/commands/configure.js +6 -0
- package/build/index.d.ts +9 -3
- package/build/index.js +5 -2
- package/build/providers/authkit_client_provider.d.ts +57 -9
- package/build/providers/authkit_client_provider.js +173 -34
- 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/skills/authkit-rp-client/SKILL.md +11 -6
- package/stubs/controllers/oidc_session_controller.stub +31 -26
|
@@ -16,4 +16,10 @@ export async function configure(command) {
|
|
|
16
16
|
AUTHKIT_REDIRECT_URI: `Env.schema.string({ format: 'url', tld: false })`,
|
|
17
17
|
},
|
|
18
18
|
});
|
|
19
|
+
// As rotas não são registradas pelo configure. O caminho é o `registerOidcClient`
|
|
20
|
+
// (startSession/endSession, RP-initiated logout, back-channel); o controller
|
|
21
|
+
// ejetado acima existe só para quem precisa customizar o fluxo.
|
|
22
|
+
command.logger.info('Registre as rotas de sessão em start/routes.ts com registerOidcClient(router) ' +
|
|
23
|
+
"(import { registerOidcClient } from '@adonis-agora/authkit-client'). " +
|
|
24
|
+
'O app/controllers/oidc_session_controller.ts publicado é só para customizar o fluxo.');
|
|
19
25
|
}
|
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,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
|
|
25
|
-
*
|
|
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:
|
|
39
|
-
* 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.
|
|
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
|
-
|
|
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
|
|
61
|
-
*
|
|
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:
|
|
75
|
-
* 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.
|
|
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
|
-
|
|
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 ===
|
|
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 =
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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(() =>
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|