@adonis-agora/authkit-server 0.55.2 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.d.ts CHANGED
@@ -129,7 +129,7 @@ export { resolveRegistration } from './src/define_config.js';
129
129
  export type { RegistrationConfigInput, ResolvedRegistrationConfig, } from './src/define_config.js';
130
130
  export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
131
131
  export { ACCOUNT_SESSION_KEY } from './src/host/middleware/account_auth.js';
132
- export { rememberAccessToken, startImpersonation, impersonationState, stopImpersonation, } from './src/host/impersonation_session.js';
132
+ export { rememberAccessToken, rememberRefreshToken, refreshAccessToken, startImpersonation, impersonationState, stopImpersonation, } from './src/host/impersonation_session.js';
133
133
  export type { StartImpersonationParams, ImpersonationState, } from './src/host/impersonation_session.js';
134
134
  export { SUDO_SESSION_KEY, SUDO_ACCOUNT_SESSION_KEY, SUDO_MODE_DEFAULTS, requireSudo, isSudoActive, markSudo, resolveEffectiveSudoMode, } from './src/host/sudo_mode.js';
135
135
  export type { SudoModeSetting, ResolvedSudoModeSetting, } from './src/host/sudo_mode.js';
package/build/index.js CHANGED
@@ -91,7 +91,7 @@ export { ACCOUNT_SESSION_KEY } from './src/host/middleware/account_auth.js';
91
91
  // Session impersonation — RP-side glue that routes through the IdP's RFC 8693
92
92
  // token-exchange (the IdP validates the admin role + audits). See
93
93
  // src/host/impersonation_session.ts.
94
- export { rememberAccessToken, startImpersonation, impersonationState, stopImpersonation, } from './src/host/impersonation_session.js';
94
+ export { rememberAccessToken, rememberRefreshToken, refreshAccessToken, startImpersonation, impersonationState, stopImpersonation, } from './src/host/impersonation_session.js';
95
95
  // Sudo mode — helpers for host controllers that require step-up authentication.
96
96
  export { SUDO_SESSION_KEY, SUDO_ACCOUNT_SESSION_KEY, SUDO_MODE_DEFAULTS, requireSudo, isSudoActive, markSudo, resolveEffectiveSudoMode, } from './src/host/sudo_mode.js';
97
97
  // SPI de métodos de confirmação de identidade (sudo mode).
@@ -8,13 +8,15 @@ export interface AccountDeleteWorkflowInput {
8
8
  actor: DeletionActor;
9
9
  }
10
10
  /**
11
- * Forma mínima do `ctx` durável que o corpo usa (um `ctx.step` checkpointado e
12
- * idempotente). Tipada estruturalmente para NÃO acoplar o build do authkit ao
13
- * pacote `@adonis-agora/durable` (peer OPCIONAL): o app passa o `engine.register`
14
- * real e o ctx satisfaz esta interface em runtime.
11
+ * Forma mínima do `ctx` durável que o corpo usa (um `ctx.localStep` checkpointado
12
+ * e idempotente executado IN-PROCESS, nunca dispatchado a um worker remoto;
13
+ * distinto do `ctx.step` do engine, que é SEMPRE dispatchado a um handler
14
+ * registrado por nome). Tipada estruturalmente para NÃO acoplar o build do
15
+ * authkit ao pacote `@adonis-agora/durable` (peer OPCIONAL): o app passa o
16
+ * `engine.register` real e o ctx satisfaz esta interface em runtime.
15
17
  */
16
18
  export interface DurableStepCtx {
17
- step<T>(name: string, fn: (...args: any[]) => Promise<T>, options?: unknown): Promise<T>;
19
+ localStep<T>(name: string, fn: (...args: any[]) => Promise<T>, options?: unknown): Promise<T>;
18
20
  }
19
21
  /** A assinatura do corpo do workflow (compatível com `engine.register`). */
20
22
  export type WorkflowBody = (ctx: DurableStepCtx, input: AccountDeleteWorkflowInput) => Promise<unknown>;
@@ -35,9 +37,9 @@ export interface AccountDeletionWorkflowDeps {
35
37
  * ```
36
38
  *
37
39
  * O corpo é FORWARD-ONLY (sem compensação — nunca des-deleta): cada etapa do
38
- * cascade é um `ctx.step` checkpointado, com retry por-etapa e resumabilidade. A
40
+ * cascade é um `ctx.localStep` checkpointado, com retry por-etapa e resumabilidade. A
39
41
  * linha da conta é a ÚLTIMA etapa. Todos os efeitos colaterais ficam DENTRO de
40
- * `ctx.step` (corpo determinístico: nada de Date.now()/random no corpo). A
42
+ * `ctx.localStep` (corpo determinístico: nada de Date.now()/random no corpo). A
41
43
  * idempotência por `accountId` é feita pelo run-id no enqueue (ver
42
44
  * `enqueueAccountDeletion`).
43
45
  */
@@ -30,9 +30,9 @@ function emptyResult() {
30
30
  * ```
31
31
  *
32
32
  * O corpo é FORWARD-ONLY (sem compensação — nunca des-deleta): cada etapa do
33
- * cascade é um `ctx.step` checkpointado, com retry por-etapa e resumabilidade. A
33
+ * cascade é um `ctx.localStep` checkpointado, com retry por-etapa e resumabilidade. A
34
34
  * linha da conta é a ÚLTIMA etapa. Todos os efeitos colaterais ficam DENTRO de
35
- * `ctx.step` (corpo determinístico: nada de Date.now()/random no corpo). A
35
+ * `ctx.localStep` (corpo determinístico: nada de Date.now()/random no corpo). A
36
36
  * idempotência por `accountId` é feita pelo run-id no enqueue (ver
37
37
  * `enqueueAccountDeletion`).
38
38
  */
@@ -42,7 +42,7 @@ export function defineAccountDeletionWorkflow(deps) {
42
42
  // Snapshot da conta ANTES de destruir (e-mail + avatar) — capturado num step
43
43
  // para ser determinístico no replay. Se a conta não existe (ou já foi
44
44
  // deletada num run anterior), encerra como no-op.
45
- const snapshot = await ctx.step('snapshot', async () => {
45
+ const snapshot = await ctx.localStep('snapshot', async () => {
46
46
  const cfg = (await deps.oidc()).config;
47
47
  return snapshotAccount(cfg, accountId);
48
48
  });
@@ -50,36 +50,36 @@ export function defineAccountDeletionWorkflow(deps) {
50
50
  return emptyResult();
51
51
  const result = emptyResult();
52
52
  // 1) Audit `account.deleted` ANTES de qualquer destruição.
53
- await ctx.step('audit.deleted', async () => {
53
+ await ctx.localStep('audit.deleted', async () => {
54
54
  const cfg = (await deps.oidc()).config;
55
55
  await auditDeleted(cfg, snapshot, actor);
56
56
  });
57
57
  // 2) Sessões + grants (cascateia os tokens do oidc-provider).
58
- const revoke = await ctx.step('revoke.sessions', async () => revokeSessions(await deps.oidc(), accountId));
58
+ const revoke = await ctx.localStep('revoke.sessions', async () => revokeSessions(await deps.oidc(), accountId));
59
59
  result.sessions = revoke.sessions;
60
60
  result.grants = revoke.grants;
61
61
  result.accessTokens = revoke.accessTokens;
62
62
  result.refreshTokens = revoke.refreshTokens;
63
63
  // 3) Personal Access Tokens.
64
- result.pats = (await ctx.step('revoke.pats', async () => revokePats((await deps.oidc()).config, accountId))).pats;
64
+ result.pats = (await ctx.localStep('revoke.pats', async () => revokePats((await deps.oidc()).config, accountId))).pats;
65
65
  // 4) Passkeys / credenciais WebAuthn.
66
- result.passkeys = (await ctx.step('remove.passkeys', async () => removePasskeys((await deps.oidc()).config, accountId))).passkeys;
66
+ result.passkeys = (await ctx.localStep('remove.passkeys', async () => removePasskeys((await deps.oidc()).config, accountId))).passkeys;
67
67
  // 5) MFA / TOTP.
68
- await ctx.step('disable.mfa', async () => {
68
+ await ctx.localStep('disable.mfa', async () => {
69
69
  await disableMfa((await deps.oidc()).config, accountId);
70
70
  });
71
71
  // 6) Identidades de provider linkadas.
72
- result.providerIdentities = (await ctx.step('unlink.providers', async () => unlinkProviders((await deps.oidc()).config, accountId))).providerIdentities;
72
+ result.providerIdentities = (await ctx.localStep('unlink.providers', async () => unlinkProviders((await deps.oidc()).config, accountId))).providerIdentities;
73
73
  // 6b) Organizations.
74
- const orgResult = await ctx.step('remove.orgs', async () => removeFromOrgs((await deps.oidc()).config, accountId));
74
+ const orgResult = await ctx.localStep('remove.orgs', async () => removeFromOrgs((await deps.oidc()).config, accountId));
75
75
  result.orgMemberships = orgResult.orgMemberships;
76
76
  result.orgInvitations = orgResult.orgInvitations;
77
77
  // 7) Avatar no drive.
78
- result.avatarDeleted = (await ctx.step('delete.avatar', async () => deleteAccountAvatar((await deps.oidc()).config, accountId, snapshot.avatarUrl))).avatarDeleted;
78
+ result.avatarDeleted = (await ctx.localStep('delete.avatar', async () => deleteAccountAvatar((await deps.oidc()).config, accountId, snapshot.avatarUrl))).avatarDeleted;
79
79
  // 8) Anonimiza o histórico de audit.
80
- result.auditAnonymized = (await ctx.step('anonymize.audit', async () => anonymizeAudit((await deps.oidc()).config, accountId))).auditAnonymized;
80
+ result.auditAnonymized = (await ctx.localStep('anonymize.audit', async () => anonymizeAudit((await deps.oidc()).config, accountId))).auditAnonymized;
81
81
  // 9) Deleta a linha da conta (ÚLTIMA etapa, forward-only).
82
- result.ok = (await ctx.step('delete.account', async () => deleteAccountRow((await deps.oidc()).config, accountId))).ok;
82
+ result.ok = (await ctx.localStep('delete.account', async () => deleteAccountRow((await deps.oidc()).config, accountId))).ok;
83
83
  return result;
84
84
  };
85
85
  return { name: ACCOUNT_DELETE_WORKFLOW, version: '1', body };
@@ -50,7 +50,7 @@ export interface AccountExportWorkflowDeps {
50
50
  /**
51
51
  * Define a REGISTRAÇÃO do workflow durável `authkit.account.export`.
52
52
  *
53
- * Etapas (todas efeitos colaterais dentro de `ctx.step`, corpo determinístico):
53
+ * Etapas (todas efeitos colaterais dentro de `ctx.localStep`, corpo determinístico):
54
54
  * 1. `collect` — reúne o payload (reusa {@link AccountExportService.collect});
55
55
  * 2. `audit` — registra `account.exported`;
56
56
  * 3. `persist` — serializa + grava o artefato no drive;
@@ -45,7 +45,7 @@ const noopDeliver = async () => { };
45
45
  /**
46
46
  * Define a REGISTRAÇÃO do workflow durável `authkit.account.export`.
47
47
  *
48
- * Etapas (todas efeitos colaterais dentro de `ctx.step`, corpo determinístico):
48
+ * Etapas (todas efeitos colaterais dentro de `ctx.localStep`, corpo determinístico):
49
49
  * 1. `collect` — reúne o payload (reusa {@link AccountExportService.collect});
50
50
  * 2. `audit` — registra `account.exported`;
51
51
  * 3. `persist` — serializa + grava o artefato no drive;
@@ -61,14 +61,14 @@ export function defineAccountExportWorkflow(deps) {
61
61
  const { accountId } = input;
62
62
  const runId = ctx.runId ?? accountId;
63
63
  // 1) Coleta o payload (reusa a coleta inline do AccountExportService).
64
- const payload = await ctx.step('collect', async () => {
64
+ const payload = await ctx.localStep('collect', async () => {
65
65
  const oidc = await deps.oidc();
66
66
  return new AccountExportService(oidc).collect(accountId);
67
67
  });
68
68
  if (!payload)
69
69
  return { ok: false, artifactKey: null, bytes: 0 };
70
70
  // 2) Audita o export (account.exported).
71
- await ctx.step('audit', async () => {
71
+ await ctx.localStep('audit', async () => {
72
72
  const cfg = (await deps.oidc()).config;
73
73
  await cfg.audit?.record({
74
74
  type: 'account.exported',
@@ -78,12 +78,12 @@ export function defineAccountExportWorkflow(deps) {
78
78
  });
79
79
  // 3) Serializa + persiste o artefato.
80
80
  const json = JSON.stringify(payload, null, 2);
81
- const artifactKey = await ctx.step('persist', async () => {
81
+ const artifactKey = await ctx.localStep('persist', async () => {
82
82
  const oidc = await deps.oidc();
83
83
  return persist({ accountId, runId, json, oidc });
84
84
  });
85
85
  // 4) Entrega ao titular (signal + e-mail, pluggable).
86
- await ctx.step('deliver', async () => {
86
+ await ctx.localStep('deliver', async () => {
87
87
  const oidc = await deps.oidc();
88
88
  await deliver({ accountId, runId, artifactKey, oidc });
89
89
  });
@@ -4,10 +4,16 @@ import type { HttpContext } from '@adonisjs/core/http';
4
4
  * após o login). Necessário porque o token-exchange exige o access token do admin
5
5
  * como `subject_token`, e o RP normalmente descarta os tokens após o login.
6
6
  *
7
- * Access tokens são curtos: se expirar, `startImpersonation` falha e o admin
8
- * re-loga (aceitável; refresh fica pra depoisYAGNI).
7
+ * Access tokens são curtos: se expirar, `startImpersonation` renova via refresh
8
+ * token (`rememberRefreshToken`) e tenta de novoo admin não precisa relogar.
9
9
  */
10
10
  export declare function rememberAccessToken(ctx: HttpContext, accessToken: string): void;
11
+ /**
12
+ * Guarda o refresh token do admin (do scope `offline_access`), usado para
13
+ * renovar o access token expirado antes do token-exchange. Chame no callback
14
+ * OIDC do RP junto com `rememberAccessToken`.
15
+ */
16
+ export declare function rememberRefreshToken(ctx: HttpContext, refreshToken: string): void;
11
17
  export interface StartImpersonationParams {
12
18
  /** Id do usuário-alvo a personificar. */
13
19
  targetId: string;
@@ -27,6 +33,20 @@ export interface ImpersonationState {
27
33
  /** O admin real (impersonator), quando `active`. */
28
34
  impersonatorId?: string;
29
35
  }
36
+ /**
37
+ * Renova o access token do admin via refresh grant (RFC 6749 §6), usando o
38
+ * refresh token guardado por `rememberRefreshToken`. Retorna o novo access
39
+ * token (e o refresh token rotacionado, se o IdP emitir outro), ou null se não
40
+ * houver refresh token / o refresh falhar.
41
+ *
42
+ * O IdP deste pacote roda `refresh_token` grant por default
43
+ * (`build_provider.ts`: `grant_types: ['authorization_code', 'refresh_token']`),
44
+ * então o refresh é válido na configuração padrão.
45
+ */
46
+ export declare function refreshAccessToken(ctx: HttpContext, params: Pick<StartImpersonationParams, 'issuer' | 'clientId' | 'clientSecret' | 'tokenEndpoint' | 'fetchImpl'>): Promise<{
47
+ accessToken: string;
48
+ refreshToken?: string;
49
+ } | null>;
30
50
  /**
31
51
  * Inicia a impersonation: lê o access token do admin da sessão, chama o
32
52
  * token-exchange (o IdP valida a role admin e audita) e SÓ em sucesso guarda o
@@ -27,17 +27,32 @@ const IMPERSONATOR_SESSION_KEY = 'impersonator_user_id';
27
27
  * `subject_token` do token-exchange. Interna: NÃO exporte o literal.
28
28
  */
29
29
  const ADMIN_ACCESS_TOKEN_SESSION_KEY = 'admin_access_token';
30
+ /**
31
+ * Key de sessão que guarda o refresh token do admin (do `offline_access`),
32
+ * usado para renovar o access token expirado antes do token-exchange. Interna:
33
+ * NÃO exporte o literal.
34
+ */
35
+ const ADMIN_REFRESH_TOKEN_SESSION_KEY = 'admin_refresh_token';
36
+ const REFRESH_TOKEN_GRANT = 'refresh_token';
30
37
  /**
31
38
  * Guarda o access token do admin na sessão (chame no callback OIDC do RP, logo
32
39
  * após o login). Necessário porque o token-exchange exige o access token do admin
33
40
  * como `subject_token`, e o RP normalmente descarta os tokens após o login.
34
41
  *
35
- * Access tokens são curtos: se expirar, `startImpersonation` falha e o admin
36
- * re-loga (aceitável; refresh fica pra depoisYAGNI).
42
+ * Access tokens são curtos: se expirar, `startImpersonation` renova via refresh
43
+ * token (`rememberRefreshToken`) e tenta de novoo admin não precisa relogar.
37
44
  */
38
45
  export function rememberAccessToken(ctx, accessToken) {
39
46
  ctx.session.put(ADMIN_ACCESS_TOKEN_SESSION_KEY, accessToken);
40
47
  }
48
+ /**
49
+ * Guarda o refresh token do admin (do scope `offline_access`), usado para
50
+ * renovar o access token expirado antes do token-exchange. Chame no callback
51
+ * OIDC do RP junto com `rememberAccessToken`.
52
+ */
53
+ export function rememberRefreshToken(ctx, refreshToken) {
54
+ ctx.session.put(ADMIN_REFRESH_TOKEN_SESSION_KEY, refreshToken);
55
+ }
41
56
  /**
42
57
  * POST inline do RFC 8693 token-exchange. Inline (em vez de depender de
43
58
  * `@adonis-agora/authkit-client`) porque o client NÃO é dependência do server e
@@ -68,6 +83,43 @@ async function requestTokenExchange(params, subjectToken) {
68
83
  throw new Error(`Token exchange failed: ${res.status}`);
69
84
  }
70
85
  }
86
+ /**
87
+ * Renova o access token do admin via refresh grant (RFC 6749 §6), usando o
88
+ * refresh token guardado por `rememberRefreshToken`. Retorna o novo access
89
+ * token (e o refresh token rotacionado, se o IdP emitir outro), ou null se não
90
+ * houver refresh token / o refresh falhar.
91
+ *
92
+ * O IdP deste pacote roda `refresh_token` grant por default
93
+ * (`build_provider.ts`: `grant_types: ['authorization_code', 'refresh_token']`),
94
+ * então o refresh é válido na configuração padrão.
95
+ */
96
+ export async function refreshAccessToken(ctx, params) {
97
+ const refreshToken = ctx.session.get(ADMIN_REFRESH_TOKEN_SESSION_KEY);
98
+ if (!refreshToken)
99
+ return null;
100
+ const body = new URLSearchParams({
101
+ grant_type: REFRESH_TOKEN_GRANT,
102
+ refresh_token: refreshToken,
103
+ client_id: params.clientId,
104
+ });
105
+ if (params.clientSecret)
106
+ body.set('client_secret', params.clientSecret);
107
+ const fetchImpl = params.fetchImpl ?? fetch;
108
+ const res = await fetchImpl(params.tokenEndpoint ?? `${params.issuer}/token`, {
109
+ method: 'POST',
110
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
111
+ body: body.toString(),
112
+ });
113
+ if (!res.ok)
114
+ return null;
115
+ const data = (await res.json());
116
+ if (!data.access_token)
117
+ return null;
118
+ const result = { accessToken: data.access_token };
119
+ if (data.refresh_token)
120
+ result.refreshToken = data.refresh_token;
121
+ return result;
122
+ }
71
123
  /**
72
124
  * Inicia a impersonation: lê o access token do admin da sessão, chama o
73
125
  * token-exchange (o IdP valida a role admin e audita) e SÓ em sucesso guarda o
@@ -94,7 +146,22 @@ export async function startImpersonation(ctx, params) {
94
146
  }
95
147
  // O IdP é o gatekeeper: lança se o admin não puder personificar. Chamado ANTES
96
148
  // de qualquer mutação de sessão — em caso de erro nada é trocado.
97
- await requestTokenExchange(params, adminAccessToken);
149
+ try {
150
+ await requestTokenExchange(params, adminAccessToken);
151
+ }
152
+ catch (err) {
153
+ // Access token expirado (4xx do exchange): renova via refresh token e tenta
154
+ // de novo. Sem refresh token / refresh falho → propaga o erro original.
155
+ if (!(err instanceof Error) || !/exchange failed: 4\d\d/.test(err.message))
156
+ throw err;
157
+ const refreshed = await refreshAccessToken(ctx, params);
158
+ if (!refreshed)
159
+ throw err;
160
+ rememberAccessToken(ctx, refreshed.accessToken);
161
+ if (refreshed.refreshToken)
162
+ rememberRefreshToken(ctx, refreshed.refreshToken);
163
+ await requestTokenExchange(params, refreshed.accessToken);
164
+ }
98
165
  // Anti-fixation: rotaciona o id da sessão (mantém os dados) antes de gravar a
99
166
  // nova identidade. Mesmo padrão do consumidor real no RP.
100
167
  await ctx.session.regenerate();