@adonis-agora/authkit-server 0.72.0 → 0.74.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/README.md +4 -0
- package/build/commands/clients_create.d.ts +2 -0
- package/build/commands/clients_create.js +41 -13
- package/build/commands/commands.json +7 -0
- package/build/index.d.ts +6 -0
- package/build/index.js +7 -0
- package/build/src/commands/import_clients.js +2 -0
- package/build/src/host/access_token_verifier.d.ts +100 -0
- package/build/src/host/access_token_verifier.js +240 -0
- package/build/src/host/account_api/account_orgs_api_controller.js +6 -1
- package/build/src/host/active_org_cookie.d.ts +21 -1
- package/build/src/host/active_org_cookie.js +38 -0
- package/build/src/host/admin_api/admin_orgs_service.d.ts +7 -1
- package/build/src/host/admin_api/admin_orgs_service.js +11 -1
- package/build/src/host/admin_api/api_clients_controller.d.ts +4 -1
- package/build/src/host/admin_api/api_clients_controller.js +25 -2
- package/build/src/host/admin_api/api_orgs_controller.js +20 -20
- package/build/src/host/admin_api/dto.d.ts +1 -0
- package/build/src/host/admin_api/dto.js +1 -0
- package/build/src/host/admin_clients_service.d.ts +9 -0
- package/build/src/host/admin_clients_service.js +21 -0
- package/build/src/host/admin_console/console_clients_controller.d.ts +3 -1
- package/build/src/host/admin_console/console_clients_controller.js +25 -2
- package/build/src/host/admin_console/console_orgs_controller.js +1 -1
- package/build/src/host/admin_sessions_service.d.ts +21 -0
- package/build/src/host/admin_sessions_service.js +49 -0
- package/build/src/host/admin_validators.d.ts +11 -1
- package/build/src/host/admin_validators.js +36 -8
- package/build/src/host/bearer_account.d.ts +9 -0
- package/build/src/host/bearer_account.js +28 -0
- package/build/src/host/client_metadata.d.ts +51 -0
- package/build/src/host/client_metadata.js +98 -0
- package/build/src/host/console_session.d.ts +14 -3
- package/build/src/host/console_session.js +21 -9
- package/build/src/host/controllers/account_orgs_controller.js +3 -0
- package/build/src/host/controllers/interaction_controller.d.ts +18 -1
- package/build/src/host/controllers/interaction_controller.js +55 -14
- package/build/src/host/oidc_bearer_guard.d.ts +113 -0
- package/build/src/host/oidc_bearer_guard.js +223 -0
- package/build/src/host/oidc_rp_guard.d.ts +3 -1
- package/build/src/host/oidc_rp_guard.js +6 -2
- package/build/src/host/org_access_revocation.d.ts +20 -0
- package/build/src/host/org_access_revocation.js +41 -0
- package/build/src/host/ui-dist/assets/{index-Dct63ai-.js → index-CIqtoNcO.js} +1 -1
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/build_provider.js +42 -17
- package/build/src/provider/oidc_service.d.ts +8 -0
- package/build/src/provider/oidc_service.js +23 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -11,6 +11,10 @@ wrapper around [`oidc-provider`](https://github.com/panva/node-oidc-provider).
|
|
|
11
11
|
- **Passwordless** — magic-link email login and passkey-first login.
|
|
12
12
|
- **Protocol extensions** — Device Flow (RFC 8628), DPoP (RFC 9449), PAR (RFC 9126), step-up
|
|
13
13
|
auth via `acr_values`, Dynamic Client Registration (RFC 7591/7592).
|
|
14
|
+
- **Native apps** — RFC 8252 native clients (`authkit:clients:create --native`: private-use
|
|
15
|
+
scheme / claimed https / loopback redirects, always public + PKCE) and the `@adonisjs/auth`
|
|
16
|
+
guards `oidcRpGuard` (session) and `oidcBearerGuard` (`Authorization: Bearer` access tokens,
|
|
17
|
+
in-process or remote resource server).
|
|
14
18
|
- **Admin console** — user CRUD (+ disable), client CRUD, sessions, audit log (opt-in,
|
|
15
19
|
role-gated).
|
|
16
20
|
- **Account console** — self-service apps/consent, security (password/email/sessions),
|
|
@@ -10,6 +10,7 @@ import type { CommandOptions } from '@adonisjs/core/types/ace';
|
|
|
10
10
|
* node ace authkit:clients:create --client-id=my-spa --redirect-uri=https://app/cb --public
|
|
11
11
|
* node ace authkit:clients:create --client-id=my-api --redirect-uri=https://api/cb --redirect-uri=https://api/cb2 --grant=client_credentials
|
|
12
12
|
* node ace authkit:clients:create --redirect-uri=https://app/cb --json
|
|
13
|
+
* node ace authkit:clients:create --client-id=my-mobile --native --redirect-uri=com.example.app:/oauth
|
|
13
14
|
*/
|
|
14
15
|
export default class AuthkitClientsCreate extends BaseCommand {
|
|
15
16
|
static commandName: string;
|
|
@@ -21,6 +22,7 @@ export default class AuthkitClientsCreate extends BaseCommand {
|
|
|
21
22
|
postLogoutUri?: string[];
|
|
22
23
|
grant?: string[];
|
|
23
24
|
public?: boolean;
|
|
25
|
+
native?: boolean;
|
|
24
26
|
backchannelLogoutUri?: string;
|
|
25
27
|
json?: boolean;
|
|
26
28
|
run(): Promise<void>;
|
|
@@ -15,6 +15,7 @@ import { BaseCommand, flags } from '@adonisjs/core/ace';
|
|
|
15
15
|
* node ace authkit:clients:create --client-id=my-spa --redirect-uri=https://app/cb --public
|
|
16
16
|
* node ace authkit:clients:create --client-id=my-api --redirect-uri=https://api/cb --redirect-uri=https://api/cb2 --grant=client_credentials
|
|
17
17
|
* node ace authkit:clients:create --redirect-uri=https://app/cb --json
|
|
18
|
+
* node ace authkit:clients:create --client-id=my-mobile --native --redirect-uri=com.example.app:/oauth
|
|
18
19
|
*/
|
|
19
20
|
export default class AuthkitClientsCreate extends BaseCommand {
|
|
20
21
|
static commandName = 'authkit:clients:create';
|
|
@@ -31,10 +32,15 @@ export default class AuthkitClientsCreate extends BaseCommand {
|
|
|
31
32
|
' --post-logout-uri URI(s) de post-logout redirect.',
|
|
32
33
|
' --grant Grant types. Default: authorization_code + refresh_token.',
|
|
33
34
|
'',
|
|
35
|
+
'App nativo (React Native/Expo, iOS, Android, desktop — RFC 8252): use --native.',
|
|
36
|
+
'Implica client público (sem secret; PKCE já é obrigatório) e aceita redirect de',
|
|
37
|
+
'esquema privado (com.example.app:/oauth), https claimed e loopback http://127.0.0.1.',
|
|
38
|
+
'',
|
|
34
39
|
'Exemplos:',
|
|
35
40
|
' node ace authkit:clients:create --client-id=my-spa --redirect-uri=https://app/cb --public',
|
|
36
41
|
' node ace authkit:clients:create --redirect-uri=https://app/cb --backchannel-logout-uri=https://app/bc',
|
|
37
42
|
' node ace authkit:clients:create --client-id=my-app --redirect-uri=https://a/cb --redirect-uri=https://b/cb --json',
|
|
43
|
+
' node ace authkit:clients:create --client-id=my-mobile --native --redirect-uri=com.example.app:/oauth',
|
|
38
44
|
];
|
|
39
45
|
static options = { startApp: true };
|
|
40
46
|
async run() {
|
|
@@ -48,17 +54,31 @@ export default class AuthkitClientsCreate extends BaseCommand {
|
|
|
48
54
|
const { AdminClientsService } = await import('../src/host/admin_clients_service.js');
|
|
49
55
|
const svc = new AdminClientsService(service);
|
|
50
56
|
const grantTypes = this.grant && this.grant.length > 0 ? this.grant : ['authorization_code', 'refresh_token'];
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
// App nativo é sempre público (RFC 8252 §8.5): `--native` implica `--public`.
|
|
58
|
+
const isPublic = !!this.public || !!this.native;
|
|
59
|
+
const applicationType = this.native ? 'native' : 'web';
|
|
60
|
+
const tokenEndpointAuthMethod = isPublic ? 'none' : 'client_secret_basic';
|
|
61
|
+
const { ClientMetadataError } = await import('../src/host/client_metadata.js');
|
|
62
|
+
let created;
|
|
63
|
+
try {
|
|
64
|
+
created = await svc.create({
|
|
65
|
+
clientId: this.clientId,
|
|
66
|
+
applicationType,
|
|
67
|
+
redirectUris,
|
|
68
|
+
postLogoutRedirectUris: this.postLogoutUri ?? [],
|
|
69
|
+
grantTypes,
|
|
70
|
+
tokenEndpointAuthMethod,
|
|
71
|
+
backchannelLogoutUri: this.backchannelLogoutUri,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err instanceof ClientMetadataError) {
|
|
76
|
+
this.logger.logError(`❌ ${err.message}`);
|
|
77
|
+
this.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
62
82
|
if (this.json) {
|
|
63
83
|
const out = {
|
|
64
84
|
clientId: created.clientId,
|
|
@@ -66,7 +86,8 @@ export default class AuthkitClientsCreate extends BaseCommand {
|
|
|
66
86
|
postLogoutRedirectUris: this.postLogoutUri ?? [],
|
|
67
87
|
grantTypes,
|
|
68
88
|
tokenEndpointAuthMethod,
|
|
69
|
-
|
|
89
|
+
applicationType,
|
|
90
|
+
confidential: !isPublic,
|
|
70
91
|
};
|
|
71
92
|
if (created.clientSecret)
|
|
72
93
|
out.clientSecret = created.clientSecret;
|
|
@@ -78,7 +99,9 @@ export default class AuthkitClientsCreate extends BaseCommand {
|
|
|
78
99
|
this.logger.success(`Client criado: ${created.clientId}`);
|
|
79
100
|
this.logger.info(` redirect_uris: ${redirectUris.join(', ')}`);
|
|
80
101
|
this.logger.info(` grant_types: ${grantTypes.join(', ')}`);
|
|
81
|
-
this.logger.info(` type: ${
|
|
102
|
+
this.logger.info(` type: ${isPublic ? 'publico (sem secret)' : 'confidencial'}`);
|
|
103
|
+
if (this.native)
|
|
104
|
+
this.logger.info(' application: native (RFC 8252)');
|
|
82
105
|
if (this.backchannelLogoutUri) {
|
|
83
106
|
this.logger.info(` backchannel: ${this.backchannelLogoutUri}`);
|
|
84
107
|
}
|
|
@@ -109,6 +132,11 @@ __decorate([
|
|
|
109
132
|
description: 'Cria um client público (sem secret; token_endpoint_auth_method=none). Default: false (confidencial).',
|
|
110
133
|
})
|
|
111
134
|
], AuthkitClientsCreate.prototype, "public", void 0);
|
|
135
|
+
__decorate([
|
|
136
|
+
flags.boolean({
|
|
137
|
+
description: 'Client de app nativo (application_type=native, RFC 8252): aceita redirect de esquema privado/loopback. Implica --public.',
|
|
138
|
+
})
|
|
139
|
+
], AuthkitClientsCreate.prototype, "native", void 0);
|
|
112
140
|
__decorate([
|
|
113
141
|
flags.string({
|
|
114
142
|
description: 'Endpoint de OIDC Back-Channel Logout do RP (POST de logout_token).',
|
|
@@ -109,6 +109,13 @@
|
|
|
109
109
|
"type": "boolean",
|
|
110
110
|
"description": "Cria um client público (sem secret; token_endpoint_auth_method=none)."
|
|
111
111
|
},
|
|
112
|
+
{
|
|
113
|
+
"name": "native",
|
|
114
|
+
"flagName": "native",
|
|
115
|
+
"required": false,
|
|
116
|
+
"type": "boolean",
|
|
117
|
+
"description": "Client de app nativo (application_type=native, RFC 8252): aceita redirect de esquema privado/loopback. Implica --public."
|
|
118
|
+
},
|
|
112
119
|
{
|
|
113
120
|
"name": "backchannelLogoutUri",
|
|
114
121
|
"flagName": "backchannel-logout-uri",
|
package/build/index.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type { AdminApiConfigInput, AdminConfigInput, AuthHostRenderer, AuthMetho
|
|
|
16
16
|
export { adapters, defineConfig, resolveAdmin, resolveAdminApi, resolveAuthMethodsConfig, resolveDynamicRegistration, resolveLogin, resolveNotifications, resolveOrganizations, resolvePasswordless, resolveRateLimit, resolveRegistration, resolveWebauthn, toSeconds, } from './src/define_config.js';
|
|
17
17
|
export type { EventsConfigInput, ResolvedEventsConfig } from './src/events/dispatcher.js';
|
|
18
18
|
export { buildWebhookBody, composeAuditSink, resolveEvents, signWebhookBody, } from './src/events/dispatcher.js';
|
|
19
|
+
export type { AccessTokenVerifier, InProcessIssuer, IssueAccessTokenOptions, RemoteAccessTokenVerifierOptions, VerifiedAccessToken, } from './src/host/access_token_verifier.js';
|
|
20
|
+
export { inProcessAccessTokenVerifier, remoteAccessTokenVerifier, } from './src/host/access_token_verifier.js';
|
|
19
21
|
export type { DeletionActor, DeletionResult, } from './src/host/account_deletion_service.js';
|
|
20
22
|
export { AccountDeletionService } from './src/host/account_deletion_service.js';
|
|
21
23
|
export type { AccountExport } from './src/host/account_export_service.js';
|
|
@@ -59,6 +61,8 @@ export type { BotProtectionAction, BotProtectionConfigInput, BotProtectionSettin
|
|
|
59
61
|
export { botProtectionApplies, DEFAULT_BOT_TOKEN_FIELDS, extractBotToken, guardBotProtection, resolveBotProtection, resolveEffectiveBotProtection, verifyBotProtection, } from './src/host/bot_protection.js';
|
|
60
62
|
export type { BrandingConfig, ClientBrand } from './src/host/branding.js';
|
|
61
63
|
export { brandFor, isFirstParty, isFirstPartyClient } from './src/host/branding.js';
|
|
64
|
+
export type { ApplicationType } from './src/host/client_metadata.js';
|
|
65
|
+
export { assertClientMetadata, ClientMetadataError, redirectUriProblem, } from './src/host/client_metadata.js';
|
|
62
66
|
export type { PolicyRouteOption } from './src/host/config_locks.js';
|
|
63
67
|
export { deriveLockedSettingKeys, isSettingLocked, lockedSettingKeys, POLICY_ROUTE_OPTIONS, resetLockedSettingKeys, SettingLockedError, setLockedSettingKeys, } from './src/host/config_locks.js';
|
|
64
68
|
export { consoleLoginUrl, getAccountId, hasAccountSession, realAccountId, } from './src/host/console_session.js';
|
|
@@ -76,6 +80,8 @@ export type { ImpersonationStartErrorCode, ImpersonationState, StartImpersonatio
|
|
|
76
80
|
export { ImpersonationStartError, impersonationState, refreshAccessToken, rememberAccessToken, rememberRefreshToken, startImpersonation, stopImpersonation, } from './src/host/impersonation_session.js';
|
|
77
81
|
export type { KeysStatus as ServerKeysStatus } from './src/host/key_rotation_actions.js';
|
|
78
82
|
export { buildKeysStatus, rotateNow } from './src/host/key_rotation_actions.js';
|
|
83
|
+
export type { OidcBearerGuardEvents, OidcBearerGuardOptions, OidcBearerGuardPolicy, } from './src/host/oidc_bearer_guard.js';
|
|
84
|
+
export { OidcBearerGuard, oidcBearerGuard } from './src/host/oidc_bearer_guard.js';
|
|
79
85
|
export type { OidcRpGuardEvents, OidcRpGuardOptions } from './src/host/oidc_rp_guard.js';
|
|
80
86
|
export { OidcRpGuard, oidcRpGuard } from './src/host/oidc_rp_guard.js';
|
|
81
87
|
export { evaluateLoginOtp, generateOtpCode, OTP_LOGIN_DEFAULTS, type OtpLoginConfigInput, type OtpVerifyOutcome, type ResolvedOtpLoginConfig, resolveOtpLoginConfig, } from './src/host/otp_login.js';
|
package/build/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { lucidStores } from './src/accounts/lucid_stores.js';
|
|
|
10
10
|
export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
|
|
11
11
|
export { adapters, defineConfig, resolveAdmin, resolveAdminApi, resolveAuthMethodsConfig, resolveDynamicRegistration, resolveLogin, resolveNotifications, resolveOrganizations, resolvePasswordless, resolveRateLimit, resolveRegistration, resolveWebauthn, toSeconds, } from './src/define_config.js';
|
|
12
12
|
export { buildWebhookBody, composeAuditSink, resolveEvents, signWebhookBody, } from './src/events/dispatcher.js';
|
|
13
|
+
export { inProcessAccessTokenVerifier, remoteAccessTokenVerifier, } from './src/host/access_token_verifier.js';
|
|
13
14
|
export { AccountDeletionService } from './src/host/account_deletion_service.js';
|
|
14
15
|
export { AccountExportService } from './src/host/account_export_service.js';
|
|
15
16
|
/**
|
|
@@ -46,6 +47,7 @@ export { computeAdminStats } from './src/host/admin_stats_service.js';
|
|
|
46
47
|
export { authkitUserProvider } from './src/host/adonis_auth_user_provider.js';
|
|
47
48
|
export { botProtectionApplies, DEFAULT_BOT_TOKEN_FIELDS, extractBotToken, guardBotProtection, resolveBotProtection, resolveEffectiveBotProtection, verifyBotProtection, } from './src/host/bot_protection.js';
|
|
48
49
|
export { brandFor, isFirstParty, isFirstPartyClient } from './src/host/branding.js';
|
|
50
|
+
export { assertClientMetadata, ClientMetadataError, redirectUriProblem, } from './src/host/client_metadata.js';
|
|
49
51
|
export { deriveLockedSettingKeys, isSettingLocked, lockedSettingKeys, POLICY_ROUTE_OPTIONS, resetLockedSettingKeys, SettingLockedError, setLockedSettingKeys, } from './src/host/config_locks.js';
|
|
50
52
|
export { consoleLoginUrl, getAccountId, hasAccountSession, realAccountId, } from './src/host/console_session.js';
|
|
51
53
|
export { authkitCsrfExceptions } from './src/host/csrf.js';
|
|
@@ -60,6 +62,11 @@ export { buildImpersonationPanel } from './src/host/impersonation.js';
|
|
|
60
62
|
export { ImpersonationStartError, impersonationState, refreshAccessToken, rememberAccessToken, rememberRefreshToken, startImpersonation, stopImpersonation, } from './src/host/impersonation_session.js';
|
|
61
63
|
// Key rotation actions — shared between the Admin REST API controller and the SDK embedded driver.
|
|
62
64
|
export { buildKeysStatus, rotateNow } from './src/host/key_rotation_actions.js';
|
|
65
|
+
// @adonisjs/auth integration (opt-in) — guard de access token (Authorization:
|
|
66
|
+
// Bearer) pra APIs chamadas por apps nativos (RFC 8252), SPAs e serviços. Par do
|
|
67
|
+
// oidcRpGuard para clientes sem sessão web; verifica in-process (issuer embarcado)
|
|
68
|
+
// ou como resource server remoto (JWKS / introspecção).
|
|
69
|
+
export { OidcBearerGuard, oidcBearerGuard } from './src/host/oidc_bearer_guard.js';
|
|
63
70
|
// @adonisjs/auth integration (opt-in) — guard pra Relying Parties OIDC.
|
|
64
71
|
// O RP não autentica ninguém (sem senha); a identidade vem da sessão gravada
|
|
65
72
|
// pelo callback OIDC. Plugado em config/auth.ts, faz ctx.auth.user funcionar
|
|
@@ -44,6 +44,7 @@ export async function importClients(clients, svc, options = {}) {
|
|
|
44
44
|
}
|
|
45
45
|
// Determina o auth method a partir do config estático.
|
|
46
46
|
const authMethod = client.tokenEndpointAuthMethod ?? (client.clientSecret ? 'client_secret_basic' : 'none');
|
|
47
|
+
const applicationType = client.applicationType ?? 'web';
|
|
47
48
|
try {
|
|
48
49
|
// Cria via AdminClientsService, que usa o mesmo caminho do console admin / registro dinâmico.
|
|
49
50
|
// Para preservar o client_secret do config estático, escrevemos diretamente via adapter —
|
|
@@ -52,6 +53,7 @@ export async function importClients(clients, svc, options = {}) {
|
|
|
52
53
|
const preservedSecret = client.clientSecret;
|
|
53
54
|
const created = await svc.createWithSecret({
|
|
54
55
|
clientId,
|
|
56
|
+
applicationType,
|
|
55
57
|
redirectUris: client.redirectUris ?? [],
|
|
56
58
|
postLogoutRedirectUris: client.postLogoutRedirectUris ?? [],
|
|
57
59
|
grantTypes: client.grants ?? ['authorization_code', 'refresh_token'],
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access token já verificado — a forma comum que o `oidcBearerGuard` consome,
|
|
3
|
+
* qualquer que seja o modo de verificação (in-process ou resource server remoto)
|
|
4
|
+
* e o formato do token (opaco ou JWT RFC 9068).
|
|
5
|
+
*/
|
|
6
|
+
export interface VerifiedAccessToken {
|
|
7
|
+
/** `opaque` (referência no adapter / introspecção) ou `jwt` (RFC 9068, auto-contido). */
|
|
8
|
+
format: 'opaque' | 'jwt';
|
|
9
|
+
/** `sub` — o id da conta. */
|
|
10
|
+
sub: string;
|
|
11
|
+
/** Client que recebeu o token. */
|
|
12
|
+
clientId: string | null;
|
|
13
|
+
/** Escopos concedidos. */
|
|
14
|
+
scopes: string[];
|
|
15
|
+
/** Audience(s) do token (resource indicators, RFC 8707). Vazio = sem `aud`. */
|
|
16
|
+
audience: string[];
|
|
17
|
+
/** Expiração (epoch em segundos), quando conhecida. */
|
|
18
|
+
exp: number | null;
|
|
19
|
+
/** Id do token (`jti`), quando conhecido. */
|
|
20
|
+
jti: string | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Estratégia de verificação de access token plugável no `oidcBearerGuard`.
|
|
24
|
+
* `verify` devolve `null` para QUALQUER token não aceitável (desconhecido,
|
|
25
|
+
* expirado, revogado, assinatura inválida, sender-constrained) — o guard não
|
|
26
|
+
* distingue os motivos na resposta (401 genérico).
|
|
27
|
+
*/
|
|
28
|
+
export interface AccessTokenVerifier {
|
|
29
|
+
verify(token: string): Promise<VerifiedAccessToken | null>;
|
|
30
|
+
/**
|
|
31
|
+
* Emite um access token de verdade para `accountId` — usado pelo
|
|
32
|
+
* `authenticateAsClient` do guard em testes. Só existe no modo in-process.
|
|
33
|
+
*/
|
|
34
|
+
issue?(accountId: string, options?: IssueAccessTokenOptions): Promise<string>;
|
|
35
|
+
}
|
|
36
|
+
/** Opções de {@link AccessTokenVerifier.issue}. */
|
|
37
|
+
export interface IssueAccessTokenOptions {
|
|
38
|
+
/** Client "dono" do token. Default: `'authkit-test-client'`. */
|
|
39
|
+
clientId?: string;
|
|
40
|
+
/** Escopos do token. Default: `['openid']`. */
|
|
41
|
+
scopes?: string[];
|
|
42
|
+
}
|
|
43
|
+
/** O mínimo do `OidcService` que a verificação in-process usa. */
|
|
44
|
+
export interface InProcessIssuer {
|
|
45
|
+
readonly provider: any;
|
|
46
|
+
readonly publicJwks: {
|
|
47
|
+
keys: Record<string, any>[];
|
|
48
|
+
};
|
|
49
|
+
readonly config: {
|
|
50
|
+
issuer: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Verificação IN-PROCESS: o app hospeda o issuer (`authkit.server`).
|
|
55
|
+
*
|
|
56
|
+
* - token opaco → `AccessToken.find` do oidc-provider (mesma rota do
|
|
57
|
+
* {@link TokenVerifyService}). Revogação (`/revoke`, logout, revogação de
|
|
58
|
+
* grant/sessão pelo admin) apaga o artefato, então revogado = não encontrado.
|
|
59
|
+
* - JWT (RFC 9068, `accessTokens.format: 'jwt'`) → assinatura contra o JWKS
|
|
60
|
+
* PÚBLICO do provider atual, `iss` e `typ: at+jwt`. JWT AT é auto-contido: o
|
|
61
|
+
* oidc-provider não o persiste, então ele vale até o `exp` mesmo após revogar o
|
|
62
|
+
* grant — mantenha o TTL do access token curto.
|
|
63
|
+
*
|
|
64
|
+
* `resolveIssuer` é chamado a cada verificação (e não guardado): o `OidcService`
|
|
65
|
+
* troca a instância do provider em `reloadKeys`/rotação de chaves.
|
|
66
|
+
*/
|
|
67
|
+
export declare function inProcessAccessTokenVerifier(resolveIssuer: () => Promise<InProcessIssuer>): AccessTokenVerifier;
|
|
68
|
+
/** Opções do modo resource server REMOTO. */
|
|
69
|
+
export interface RemoteAccessTokenVerifierOptions {
|
|
70
|
+
/** Issuer do authkit (o mesmo `issuer` do `config/authkit.ts` do IdP). */
|
|
71
|
+
issuer: string;
|
|
72
|
+
/**
|
|
73
|
+
* URL do JWKS. Default: o `jwks_uri` da discovery
|
|
74
|
+
* (`{issuer}/.well-known/openid-configuration`).
|
|
75
|
+
*/
|
|
76
|
+
jwksUri?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Introspecção (RFC 7662) para tokens OPACOS, autenticada com as credenciais de
|
|
79
|
+
* um client CONFIDENCIAL do issuer (o resource server). Sem isto, só JWT access
|
|
80
|
+
* tokens (RFC 9068) são aceitos.
|
|
81
|
+
*/
|
|
82
|
+
introspection?: {
|
|
83
|
+
clientId: string;
|
|
84
|
+
clientSecret: string;
|
|
85
|
+
/** Default: o `introspection_endpoint` da discovery. */
|
|
86
|
+
endpoint?: string;
|
|
87
|
+
};
|
|
88
|
+
/** `fetch` alternativo (testes, proxy). Default: o global. */
|
|
89
|
+
fetch?: typeof fetch;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Verificação de RESOURCE SERVER remoto: a API NÃO hospeda o issuer.
|
|
93
|
+
*
|
|
94
|
+
* - JWT (RFC 9068) → assinatura contra o JWKS remoto (cacheado e recarregado
|
|
95
|
+
* pelo `jose` quando aparece um `kid` novo), `iss` e `typ: at+jwt`.
|
|
96
|
+
* - opaco → introspecção (RFC 7662) com client credentials; exige
|
|
97
|
+
* `active: true` e `token_type` Bearer (refresh token introspectado vem sem
|
|
98
|
+
* `token_type` e é recusado).
|
|
99
|
+
*/
|
|
100
|
+
export declare function remoteAccessTokenVerifier(options: RemoteAccessTokenVerifierOptions): AccessTokenVerifier;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createLocalJWKSet, createRemoteJWKSet, customFetch, errors as joseErrors, jwtVerify, } from 'jose';
|
|
2
|
+
import { errors as providerErrors } from 'oidc-provider';
|
|
3
|
+
/**
|
|
4
|
+
* Token recusado (assinatura, `iss`, `exp`, formato…) vira `null` → 401. Falha de
|
|
5
|
+
* infraestrutura (banco fora, rede, JWKS inalcançável) SOBE: responder
|
|
6
|
+
* `invalid_token` num apagão ensinaria o cliente a jogar fora um token bom.
|
|
7
|
+
*/
|
|
8
|
+
function rejectedJwt(error) {
|
|
9
|
+
return (error instanceof joseErrors.JOSEError &&
|
|
10
|
+
!(error instanceof joseErrors.JWKSTimeout) &&
|
|
11
|
+
!(error instanceof joseErrors.JWKSInvalid));
|
|
12
|
+
}
|
|
13
|
+
/** `header.payload.signature` em base64url — o formato compacto de um JWS. */
|
|
14
|
+
const JWS_COMPACT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
15
|
+
function looksLikeJwt(token) {
|
|
16
|
+
return JWS_COMPACT.test(token);
|
|
17
|
+
}
|
|
18
|
+
function splitScope(scope) {
|
|
19
|
+
return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
|
|
20
|
+
}
|
|
21
|
+
function toAudience(aud) {
|
|
22
|
+
if (typeof aud === 'string')
|
|
23
|
+
return [aud];
|
|
24
|
+
if (Array.isArray(aud))
|
|
25
|
+
return aud.filter((a) => typeof a === 'string');
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* JWT access token (RFC 9068) → {@link VerifiedAccessToken}. Recusa token com
|
|
30
|
+
* `cnf` (DPoP / mTLS): ele é sender-constrained e apresentá-lo como Bearer puro,
|
|
31
|
+
* sem a prova de posse, é exatamente o que o `cnf` existe para impedir.
|
|
32
|
+
*/
|
|
33
|
+
function fromJwtPayload(payload) {
|
|
34
|
+
if (payload.cnf)
|
|
35
|
+
return null;
|
|
36
|
+
if (typeof payload.sub !== 'string' || !payload.sub)
|
|
37
|
+
return null;
|
|
38
|
+
const clientId = payload.client_id;
|
|
39
|
+
return {
|
|
40
|
+
format: 'jwt',
|
|
41
|
+
sub: payload.sub,
|
|
42
|
+
clientId: typeof clientId === 'string' ? clientId : null,
|
|
43
|
+
scopes: splitScope(payload.scope),
|
|
44
|
+
audience: toAudience(payload.aud),
|
|
45
|
+
exp: typeof payload.exp === 'number' ? payload.exp : null,
|
|
46
|
+
jti: typeof payload.jti === 'string' ? payload.jti : null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Verificação IN-PROCESS: o app hospeda o issuer (`authkit.server`).
|
|
51
|
+
*
|
|
52
|
+
* - token opaco → `AccessToken.find` do oidc-provider (mesma rota do
|
|
53
|
+
* {@link TokenVerifyService}). Revogação (`/revoke`, logout, revogação de
|
|
54
|
+
* grant/sessão pelo admin) apaga o artefato, então revogado = não encontrado.
|
|
55
|
+
* - JWT (RFC 9068, `accessTokens.format: 'jwt'`) → assinatura contra o JWKS
|
|
56
|
+
* PÚBLICO do provider atual, `iss` e `typ: at+jwt`. JWT AT é auto-contido: o
|
|
57
|
+
* oidc-provider não o persiste, então ele vale até o `exp` mesmo após revogar o
|
|
58
|
+
* grant — mantenha o TTL do access token curto.
|
|
59
|
+
*
|
|
60
|
+
* `resolveIssuer` é chamado a cada verificação (e não guardado): o `OidcService`
|
|
61
|
+
* troca a instância do provider em `reloadKeys`/rotação de chaves.
|
|
62
|
+
*/
|
|
63
|
+
export function inProcessAccessTokenVerifier(resolveIssuer) {
|
|
64
|
+
// JWKS local memoizado por identidade: o `publicJwks` só muda (novo objeto)
|
|
65
|
+
// quando o provider é reconstruído.
|
|
66
|
+
let cachedJwks;
|
|
67
|
+
let cachedKeySet;
|
|
68
|
+
const keySetFor = (jwks) => {
|
|
69
|
+
if (jwks !== cachedJwks || !cachedKeySet) {
|
|
70
|
+
cachedJwks = jwks;
|
|
71
|
+
cachedKeySet = createLocalJWKSet(jwks);
|
|
72
|
+
}
|
|
73
|
+
return cachedKeySet;
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
async verify(token) {
|
|
77
|
+
if (!token)
|
|
78
|
+
return null;
|
|
79
|
+
const issuer = await resolveIssuer();
|
|
80
|
+
if (looksLikeJwt(token)) {
|
|
81
|
+
try {
|
|
82
|
+
const { payload } = await jwtVerify(token, keySetFor(issuer.publicJwks), {
|
|
83
|
+
issuer: issuer.config.issuer,
|
|
84
|
+
typ: 'at+jwt',
|
|
85
|
+
});
|
|
86
|
+
return fromJwtPayload(payload);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (rejectedJwt(error))
|
|
90
|
+
return null;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let at;
|
|
95
|
+
try {
|
|
96
|
+
at = await issuer.provider?.AccessToken?.find(token);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Só erro do próprio provider (token malformado) é recusa; erro do adapter sobe.
|
|
100
|
+
if (error instanceof providerErrors.OIDCProviderError)
|
|
101
|
+
return null;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (!at)
|
|
105
|
+
return null;
|
|
106
|
+
// O oidc-provider já recusa artefato expirado no `find`; isto é guarda extra.
|
|
107
|
+
if (at.isExpired === true)
|
|
108
|
+
return null;
|
|
109
|
+
if (typeof at.exp === 'number' && at.exp <= Math.floor(Date.now() / 1000))
|
|
110
|
+
return null;
|
|
111
|
+
// Sender-constrained (DPoP `jkt` / mTLS `x5t#S256`): sem a prova, não aceita.
|
|
112
|
+
if (at.jkt || at['x5t#S256'])
|
|
113
|
+
return null;
|
|
114
|
+
if (typeof at.accountId !== 'string' || !at.accountId)
|
|
115
|
+
return null;
|
|
116
|
+
return {
|
|
117
|
+
format: 'opaque',
|
|
118
|
+
sub: at.accountId,
|
|
119
|
+
clientId: typeof at.clientId === 'string' ? at.clientId : null,
|
|
120
|
+
scopes: splitScope(at.scope),
|
|
121
|
+
audience: toAudience(at.aud),
|
|
122
|
+
exp: typeof at.exp === 'number' ? at.exp : null,
|
|
123
|
+
jti: typeof at.jti === 'string' ? at.jti : null,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
async issue(accountId, options = {}) {
|
|
127
|
+
const { provider } = await resolveIssuer();
|
|
128
|
+
const at = new provider.AccessToken({
|
|
129
|
+
accountId,
|
|
130
|
+
clientId: options.clientId ?? 'authkit-test-client',
|
|
131
|
+
scope: (options.scopes ?? ['openid']).join(' '),
|
|
132
|
+
gty: 'authorization_code',
|
|
133
|
+
});
|
|
134
|
+
return at.save();
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Verificação de RESOURCE SERVER remoto: a API NÃO hospeda o issuer.
|
|
140
|
+
*
|
|
141
|
+
* - JWT (RFC 9068) → assinatura contra o JWKS remoto (cacheado e recarregado
|
|
142
|
+
* pelo `jose` quando aparece um `kid` novo), `iss` e `typ: at+jwt`.
|
|
143
|
+
* - opaco → introspecção (RFC 7662) com client credentials; exige
|
|
144
|
+
* `active: true` e `token_type` Bearer (refresh token introspectado vem sem
|
|
145
|
+
* `token_type` e é recusado).
|
|
146
|
+
*/
|
|
147
|
+
export function remoteAccessTokenVerifier(options) {
|
|
148
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
149
|
+
const issuer = options.issuer.replace(/\/+$/, '');
|
|
150
|
+
let discovery;
|
|
151
|
+
const discover = () => {
|
|
152
|
+
discovery ??= (async () => {
|
|
153
|
+
const res = await doFetch(`${issuer}/.well-known/openid-configuration`);
|
|
154
|
+
if (!res.ok)
|
|
155
|
+
throw new Error(`discovery ${res.status}`);
|
|
156
|
+
return (await res.json());
|
|
157
|
+
})().catch((error) => {
|
|
158
|
+
// Falha transitória não fica memoizada: a próxima request tenta de novo.
|
|
159
|
+
discovery = undefined;
|
|
160
|
+
throw error;
|
|
161
|
+
});
|
|
162
|
+
return discovery;
|
|
163
|
+
};
|
|
164
|
+
let keySet;
|
|
165
|
+
const remoteKeySet = async () => {
|
|
166
|
+
if (keySet)
|
|
167
|
+
return keySet;
|
|
168
|
+
const jwksUri = options.jwksUri ?? (await discover()).jwks_uri;
|
|
169
|
+
if (!jwksUri)
|
|
170
|
+
throw new Error('issuer sem jwks_uri');
|
|
171
|
+
keySet = createRemoteJWKSet(new URL(jwksUri), options.fetch ? { [customFetch]: options.fetch } : undefined);
|
|
172
|
+
return keySet;
|
|
173
|
+
};
|
|
174
|
+
const introspect = async (token) => {
|
|
175
|
+
const creds = options.introspection;
|
|
176
|
+
if (!creds)
|
|
177
|
+
return null;
|
|
178
|
+
const endpoint = creds.endpoint ?? (await discover()).introspection_endpoint;
|
|
179
|
+
if (!endpoint)
|
|
180
|
+
return null;
|
|
181
|
+
// RFC 6749 §2.3.1: client_id/secret form-urlencoded antes do Basic.
|
|
182
|
+
const basic = Buffer.from(`${encodeURIComponent(creds.clientId)}:${encodeURIComponent(creds.clientSecret)}`).toString('base64');
|
|
183
|
+
const res = await doFetch(endpoint, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: {
|
|
186
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
187
|
+
accept: 'application/json',
|
|
188
|
+
authorization: `Basic ${basic}`,
|
|
189
|
+
},
|
|
190
|
+
body: new URLSearchParams({ token, token_type_hint: 'access_token' }).toString(),
|
|
191
|
+
});
|
|
192
|
+
// 5xx ou credencial do resource server recusada: problema de infra/config, não do token.
|
|
193
|
+
if (!res.ok)
|
|
194
|
+
throw new Error(`authkit: introspecção falhou (HTTP ${res.status})`);
|
|
195
|
+
const body = (await res.json());
|
|
196
|
+
if (body.active !== true)
|
|
197
|
+
return null;
|
|
198
|
+
if (typeof body.token_type !== 'string' || body.token_type.toLowerCase() !== 'bearer') {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (body.cnf)
|
|
202
|
+
return null;
|
|
203
|
+
if (typeof body.sub !== 'string' || !body.sub)
|
|
204
|
+
return null;
|
|
205
|
+
if (typeof body.exp === 'number' && body.exp <= Math.floor(Date.now() / 1000))
|
|
206
|
+
return null;
|
|
207
|
+
return {
|
|
208
|
+
format: 'opaque',
|
|
209
|
+
sub: body.sub,
|
|
210
|
+
clientId: typeof body.client_id === 'string' ? body.client_id : null,
|
|
211
|
+
scopes: splitScope(body.scope),
|
|
212
|
+
audience: toAudience(body.aud),
|
|
213
|
+
exp: typeof body.exp === 'number' ? body.exp : null,
|
|
214
|
+
jti: typeof body.jti === 'string' ? body.jti : null,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
async verify(token) {
|
|
219
|
+
if (!token)
|
|
220
|
+
return null;
|
|
221
|
+
if (looksLikeJwt(token)) {
|
|
222
|
+
try {
|
|
223
|
+
const { payload } = await jwtVerify(token, await remoteKeySet(), {
|
|
224
|
+
// Mesma forma da discovery (sem barra final), aceitando o `iss` com ou sem ela.
|
|
225
|
+
issuer: [issuer, `${issuer}/`],
|
|
226
|
+
typ: 'at+jwt',
|
|
227
|
+
});
|
|
228
|
+
return fromJwtPayload(payload);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (rejectedJwt(error))
|
|
232
|
+
return null;
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Introspecção fora do ar sobe como erro (não é "token inválido").
|
|
237
|
+
return introspect(token);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -52,6 +52,7 @@ import { accountPath } from '../account_paths.js';
|
|
|
52
52
|
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
53
53
|
import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
|
|
54
54
|
import { sendOrgInvitationEmail } from '../default_mailer.js';
|
|
55
|
+
import { revokeOrgAccess } from '../org_access_revocation.js';
|
|
55
56
|
// Política efetiva: o MESMO módulo que o console HTML usa. Duas cópias da
|
|
56
57
|
// resolução são como o espelho JSON acabaria mais frouxo que o formulário.
|
|
57
58
|
import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
|
|
@@ -213,6 +214,8 @@ export default class AccountOrgsApiController {
|
|
|
213
214
|
}
|
|
214
215
|
return ctx.response.notFound(apiErr('not_found', 'Membership not found.'));
|
|
215
216
|
}
|
|
217
|
+
// Os grants que carregam esta org para a conta caem já — não no refresh.
|
|
218
|
+
await revokeOrgAccess(c.service, orgId, c.accountId);
|
|
216
219
|
await c.cfg.audit?.record({
|
|
217
220
|
type: 'organization.member_removed',
|
|
218
221
|
accountId: c.accountId,
|
|
@@ -335,6 +338,8 @@ export default class AccountOrgsApiController {
|
|
|
335
338
|
}
|
|
336
339
|
return ctx.response.notFound(apiErr('not_found', 'Member not found in this organization.'));
|
|
337
340
|
}
|
|
341
|
+
// Os grants que carregam esta org para a conta caem já — não no refresh.
|
|
342
|
+
await revokeOrgAccess(c.service, orgId, targetId);
|
|
338
343
|
await c.cfg.audit?.record({
|
|
339
344
|
type: 'organization.member_removed',
|
|
340
345
|
actorId: c.accountId,
|
|
@@ -446,7 +451,7 @@ export default class AccountOrgsApiController {
|
|
|
446
451
|
ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
|
|
447
452
|
return null;
|
|
448
453
|
}
|
|
449
|
-
return { cfg, store, accountId };
|
|
454
|
+
return { cfg, store, accountId, service };
|
|
450
455
|
}
|
|
451
456
|
/**
|
|
452
457
|
* Exige que o ator seja owner/admin NA ORG DO PATH. Responde 403 e devolve
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ActiveOrgInfo } from '../accounts/account_store.js';
|
|
1
|
+
import type { AccountStore, ActiveOrgInfo } from '../accounts/account_store.js';
|
|
2
2
|
/** Nome do cookie da org ativa. HttpOnly, SameSite=Lax, Secure em prod. */
|
|
3
3
|
export declare const ACTIVE_ORG_COOKIE = "authkit_active_org";
|
|
4
4
|
/** TTL máximo do cookie da org ativa (30 dias em segundos). */
|
|
@@ -51,3 +51,23 @@ export declare function normalizeActiveOrg(value: unknown): ActiveOrgInfo | null
|
|
|
51
51
|
export declare function readActiveOrgFromHostCtx(ctx: unknown, opts?: {
|
|
52
52
|
appKey?: string;
|
|
53
53
|
}): ActiveOrgInfo | null;
|
|
54
|
+
/**
|
|
55
|
+
* Confere, NO STORE, se a org que a sessão diz ser a ativa ainda vale para a conta,
|
|
56
|
+
* e devolve os dados ATUAIS dela — ou `null` quando não vale mais.
|
|
57
|
+
*
|
|
58
|
+
* O `activeOrg` que chega aqui é um RETRATO: foi gravado no Grant no consent (ou lido
|
|
59
|
+
* do cookie) e o refresh token o reemite por até 30 dias. Sem esta conferência, quem
|
|
60
|
+
* é removido da equipe continua recebendo `org_*` até o grant expirar, e um rebaixado
|
|
61
|
+
* continua recebendo o papel antigo. Por isso:
|
|
62
|
+
*
|
|
63
|
+
* - a conta precisa ser membro da org AGORA (`getOrgMembership`);
|
|
64
|
+
* - a org precisa existir AGORA (`findOrgById`) — o slug sai daqui, não do retrato;
|
|
65
|
+
* - o papel é o da membership ATUAL, nunca o `orgRole` do retrato.
|
|
66
|
+
*
|
|
67
|
+
* Store sem `findOrgById`/`getOrgMembership`: não há como conferir, então não há
|
|
68
|
+
* claim (fail-closed). As rotas que gravam o cookie de org só existem com a capacidade.
|
|
69
|
+
*
|
|
70
|
+
* Erro do store PROPAGA: um banco fora do ar não pode virar, em silêncio, um token
|
|
71
|
+
* sem org (que o app leria como "usuário sem tenant") nem um token com a org velha.
|
|
72
|
+
*/
|
|
73
|
+
export declare function verifyActiveOrgMembership(store: AccountStore, accountId: string, claimed: ActiveOrgInfo | null): Promise<ActiveOrgInfo | null>;
|