@dudousxd/adonis-authkit-server 0.3.0 → 0.4.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.
Files changed (54) hide show
  1. package/build/commands/commands.json +28 -0
  2. package/build/commands/doctor.d.ts +10 -0
  3. package/build/commands/doctor.js +66 -0
  4. package/build/commands/rotate_keys.d.ts +10 -0
  5. package/build/commands/rotate_keys.js +53 -0
  6. package/build/host/views/account/email-confirmed.edge +15 -0
  7. package/build/host/views/account/security.edge +83 -0
  8. package/build/host/views/account/tokens.edge +7 -4
  9. package/build/host/views/admin/sessions.edge +89 -0
  10. package/build/host/views/admin/users.edge +1 -0
  11. package/build/host/views/mfa-challenge.edge +29 -23
  12. package/build/index.d.ts +4 -3
  13. package/build/index.js +2 -2
  14. package/build/src/accounts/account_store.d.ts +46 -1
  15. package/build/src/accounts/account_store.js +4 -0
  16. package/build/src/accounts/lucid_store/core.d.ts +5 -4
  17. package/build/src/accounts/lucid_store/core.js +67 -2
  18. package/build/src/adapters/adapter_contract.d.ts +17 -0
  19. package/build/src/adapters/database_adapter.d.ts +9 -5
  20. package/build/src/adapters/database_adapter.js +13 -6
  21. package/build/src/adapters/redis_adapter.d.ts +11 -5
  22. package/build/src/adapters/redis_adapter.js +16 -7
  23. package/build/src/audit/audit_sink.d.ts +1 -1
  24. package/build/src/define_config.d.ts +102 -0
  25. package/build/src/define_config.js +46 -3
  26. package/build/src/doctor/checks.d.ts +51 -0
  27. package/build/src/doctor/checks.js +231 -0
  28. package/build/src/host/admin_clients_service.js +12 -5
  29. package/build/src/host/admin_sessions_service.d.ts +63 -0
  30. package/build/src/host/admin_sessions_service.js +127 -0
  31. package/build/src/host/controllers/account_security_controller.d.ts +16 -0
  32. package/build/src/host/controllers/account_security_controller.js +119 -0
  33. package/build/src/host/controllers/account_session_controller.js +2 -1
  34. package/build/src/host/controllers/admin/admin_sessions_controller.d.ts +14 -0
  35. package/build/src/host/controllers/admin/admin_sessions_controller.js +64 -0
  36. package/build/src/host/controllers/interaction_controller.d.ts +11 -0
  37. package/build/src/host/controllers/interaction_controller.js +49 -10
  38. package/build/src/host/default_mailer.d.ts +17 -0
  39. package/build/src/host/default_mailer.js +51 -0
  40. package/build/src/host/i18n.d.ts +53 -0
  41. package/build/src/host/i18n.js +58 -0
  42. package/build/src/host/login_notify.d.ts +20 -0
  43. package/build/src/host/login_notify.js +71 -0
  44. package/build/src/host/register_auth_host.js +12 -0
  45. package/build/src/host/validators.d.ts +32 -0
  46. package/build/src/host/validators.js +14 -0
  47. package/build/src/keys/keystore.d.ts +43 -0
  48. package/build/src/keys/keystore.js +74 -0
  49. package/build/src/provider/build_provider.js +23 -0
  50. package/build/src/provider/device_sources.d.ts +6 -0
  51. package/build/src/provider/device_sources.js +65 -0
  52. package/build/src/provider/interaction_actions.d.ts +6 -1
  53. package/build/src/provider/interaction_actions.js +9 -2
  54. package/package.json +2 -2
@@ -0,0 +1,43 @@
1
+ import type { SigningAlg } from './jwks_manager.js';
2
+ /**
3
+ * Keystore JSON em arquivo para o modo `jwks: { source: 'managed', store }`.
4
+ *
5
+ * O modo `managed` "puro" gera UMA chave efêmera por boot (em
6
+ * {@link generateJwks}) — não persiste, então rotacionar não faz sentido: a cada
7
+ * restart o kid muda e tokens antigos param de validar. Para suportar rotação de
8
+ * verdade, este keystore persiste o JWKS PRIVADO em um arquivo. A rotação gera
9
+ * um novo par (novo kid) e mantém as N chaves mais recentes; o JWKS público
10
+ * servido inclui todas (as antigas continuam validando), e a PRIMEIRA chave do
11
+ * array é a de assinatura corrente (o oidc-provider assina com a primeira chave
12
+ * compatível).
13
+ */
14
+ /** Estrutura persistida: JWKS privado (chaves com `d`). */
15
+ export interface PersistedKeystore {
16
+ keys: Record<string, any>[];
17
+ }
18
+ /** Gera uma chave de assinatura privada como JWK (com use/alg/kid). */
19
+ export declare function generateSigningJwk(alg: SigningAlg): Promise<Record<string, any>>;
20
+ /** Lê o keystore do arquivo, ou null se não existir/for inválido. */
21
+ export declare function readKeystore(path: string): PersistedKeystore | null;
22
+ /** Escreve o keystore no arquivo (cria o diretório se preciso). */
23
+ export declare function writeKeystore(path: string, store: PersistedKeystore): void;
24
+ /**
25
+ * Garante que o keystore exista: se ausente, cria com uma chave nova e persiste.
26
+ * Retorna o keystore (privado).
27
+ */
28
+ export declare function ensureKeystore(path: string, alg: SigningAlg): Promise<PersistedKeystore>;
29
+ /**
30
+ * Rotaciona o keystore: gera uma chave nova, coloca-a NA FRENTE (vira a chave de
31
+ * assinatura corrente) e mantém apenas as `keep` mais recentes (default 2) para
32
+ * que tokens assinados com a chave anterior continuem validando. Persiste e
33
+ * retorna o keystore atualizado.
34
+ */
35
+ export declare function rotateKeystore(path: string, alg: SigningAlg, keep?: number): Promise<{
36
+ store: PersistedKeystore;
37
+ newKid: string;
38
+ retiredKids: string[];
39
+ }>;
40
+ /** Deriva o JWKS PÚBLICO (sem `d` e demais campos privados) a partir do privado. */
41
+ export declare function toPublicJwks(store: PersistedKeystore): {
42
+ keys: Record<string, any>[];
43
+ };
@@ -0,0 +1,74 @@
1
+ import { generateKeyPair, exportJWK } from 'jose';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ /** Gera uma chave de assinatura privada como JWK (com use/alg/kid). */
6
+ export async function generateSigningJwk(alg) {
7
+ const { privateKey } = await generateKeyPair(alg, { extractable: true });
8
+ const jwk = (await exportJWK(privateKey));
9
+ jwk.use = 'sig';
10
+ jwk.alg = alg;
11
+ jwk.kid = randomUUID();
12
+ return jwk;
13
+ }
14
+ /** Lê o keystore do arquivo, ou null se não existir/for inválido. */
15
+ export function readKeystore(path) {
16
+ if (!existsSync(path))
17
+ return null;
18
+ try {
19
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
20
+ if (parsed && Array.isArray(parsed.keys))
21
+ return parsed;
22
+ return null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** Escreve o keystore no arquivo (cria o diretório se preciso). */
29
+ export function writeKeystore(path, store) {
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ writeFileSync(path, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
32
+ }
33
+ /**
34
+ * Garante que o keystore exista: se ausente, cria com uma chave nova e persiste.
35
+ * Retorna o keystore (privado).
36
+ */
37
+ export async function ensureKeystore(path, alg) {
38
+ const existing = readKeystore(path);
39
+ if (existing && existing.keys.length > 0)
40
+ return existing;
41
+ const store = { keys: [await generateSigningJwk(alg)] };
42
+ writeKeystore(path, store);
43
+ return store;
44
+ }
45
+ /**
46
+ * Rotaciona o keystore: gera uma chave nova, coloca-a NA FRENTE (vira a chave de
47
+ * assinatura corrente) e mantém apenas as `keep` mais recentes (default 2) para
48
+ * que tokens assinados com a chave anterior continuem validando. Persiste e
49
+ * retorna o keystore atualizado.
50
+ */
51
+ export async function rotateKeystore(path, alg, keep = 2) {
52
+ const current = readKeystore(path) ?? { keys: [] };
53
+ const fresh = await generateSigningJwk(alg);
54
+ const next = [fresh, ...current.keys];
55
+ const kept = next.slice(0, Math.max(1, keep));
56
+ const retiredKids = next.slice(Math.max(1, keep)).map((k) => k.kid);
57
+ const store = { keys: kept };
58
+ writeKeystore(path, store);
59
+ return { store, newKid: fresh.kid, retiredKids };
60
+ }
61
+ /** Deriva o JWKS PÚBLICO (sem `d` e demais campos privados) a partir do privado. */
62
+ export function toPublicJwks(store) {
63
+ const PRIVATE_FIELDS = ['d', 'p', 'q', 'dp', 'dq', 'qi'];
64
+ return {
65
+ keys: store.keys.map((jwk) => {
66
+ const pub = {};
67
+ for (const [k, v] of Object.entries(jwk)) {
68
+ if (!PRIVATE_FIELDS.includes(k))
69
+ pub[k] = v;
70
+ }
71
+ return pub;
72
+ }),
73
+ };
74
+ }
@@ -1,4 +1,5 @@
1
1
  import * as oidc from 'oidc-provider';
2
+ import { createDeviceSources } from './device_sources.js';
2
3
  export function buildProvider(config, options) {
3
4
  const cookieKeys = config.cookieKeys.length ? config.cookieKeys : [options.appKey];
4
5
  // OIDC Dynamic Client Registration (RFC 7591/7592). Só montamos as chaves de feature
@@ -16,6 +17,22 @@ export function buildProvider(config, options) {
16
17
  ...(dynReg.management ? { registrationManagement: { enabled: true } } : {}),
17
18
  }
18
19
  : {};
20
+ // Device Authorization Grant (RFC 8628). Quando ligado, montamos a feature com
21
+ // as três sources de UI i18n-izadas (entrada/confirmação/sucesso do user-code).
22
+ const deviceFlowFeatures = config.deviceFlow.enabled
23
+ ? { deviceFlow: { enabled: true, ...createDeviceSources(config.messages) } }
24
+ : {};
25
+ // DPoP (RFC 9449). A chave EXATA do oidc-provider v9 é `dPoP`.
26
+ const dpopFeatures = config.dpop.enabled ? { dPoP: { enabled: true } } : {};
27
+ // PAR (RFC 9126).
28
+ const parFeatures = config.par.enabled
29
+ ? {
30
+ pushedAuthorizationRequests: {
31
+ enabled: true,
32
+ requirePushedAuthorizationRequests: config.par.requirePushedAuthorizationRequests,
33
+ },
34
+ }
35
+ : {};
19
36
  const provider = new oidc.Provider(config.issuer, {
20
37
  adapter: config.AdapterClass,
21
38
  clients: config.clients.map((c) => ({
@@ -79,7 +96,13 @@ export function buildProvider(config, options) {
79
96
  revocation: { enabled: true },
80
97
  introspection: { enabled: true },
81
98
  ...registrationFeatures,
99
+ ...deviceFlowFeatures,
100
+ ...dpopFeatures,
101
+ ...parFeatures,
82
102
  },
103
+ // Step-up auth (acr_values): anuncia os acr suportados para que clients possam
104
+ // solicitá-los. A exigência efetiva do 2º fator acontece na interaction de login.
105
+ acrValues: config.stepUp.acrValues,
83
106
  ttl: {
84
107
  AccessToken: config.ttl.accessToken,
85
108
  RefreshToken: config.ttl.refreshToken,
@@ -0,0 +1,6 @@
1
+ import { type AuthMessages } from '../host/i18n.js';
2
+ export declare function createDeviceSources(messages: AuthMessages): {
3
+ userCodeInputSource(_ctx: any, form: string, _out: any, err: any): Promise<void>;
4
+ userCodeConfirmSource(_ctx: any, form: string, _client: any, _deviceInfo: any, userCode: string): Promise<void>;
5
+ successSource(_ctx: any): Promise<void>;
6
+ };
@@ -0,0 +1,65 @@
1
+ import { translate } from '../host/i18n.js';
2
+ /**
3
+ * Fontes (sources) de renderização do Device Authorization Grant (RFC 8628).
4
+ *
5
+ * O oidc-provider chama estas funções com o KOA ctx (não o HttpContext do Adonis),
6
+ * então elas NÃO têm acesso ao renderer Inertia/Edge do host. Emitimos HTML
7
+ * auto-contido e i18n-izado (mesmo idioma das demais telas), o que também silencia
8
+ * os avisos `shouldChange` dos defaults da lib. As três telas:
9
+ * - userCodeInputSource: entrada do user-code (`/device`)
10
+ * - userCodeConfirmSource: confirmação após o code casar
11
+ * - successSource: tela final pós-aprovação
12
+ */
13
+ function esc(value) {
14
+ return String(value ?? '')
15
+ .replace(/&/g, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;');
19
+ }
20
+ const STYLE = `
21
+ body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f5f5f7;margin:0;padding:48px 16px;color:#1d1d1f}
22
+ .card{max-width:340px;margin:0 auto;background:#fff;border-radius:14px;padding:32px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
23
+ h1{font-size:1.4rem;font-weight:600;margin:0 0 12px;text-align:center}
24
+ p{font-size:.95rem;line-height:1.5;text-align:center;color:#444}
25
+ p.red{color:#c0392b}
26
+ code{display:block;font-size:1.6rem;letter-spacing:.15em;text-align:center;margin:16px 0;font-weight:600}
27
+ input[type=text]{width:100%;box-sizing:border-box;height:46px;font-size:1rem;text-align:center;text-transform:uppercase;border:1px solid #d2d2d7;border-radius:10px;padding:0 12px;margin-bottom:14px}
28
+ button{width:100%;height:44px;font-size:.95rem;font-weight:600;color:#fff;background:#0071e3;border:0;border-radius:10px;cursor:pointer}
29
+ button:hover{background:#0077ed}
30
+ .abort{margin-top:12px;background:none;color:#666;font-weight:400}
31
+ .abort:hover{background:none;text-decoration:underline}
32
+ `;
33
+ function page(title, inner) {
34
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title><style>${STYLE}</style></head><body><div class="card">${inner}</div></body></html>`;
35
+ }
36
+ export function createDeviceSources(messages) {
37
+ const t = (key, params) => translate(messages, key, params);
38
+ return {
39
+ async userCodeInputSource(_ctx, form, _out, err) {
40
+ const ctx = _ctx;
41
+ let msg;
42
+ if (err && (err.userCode || err.name === 'NoCodeError')) {
43
+ msg = `<p class="red">${esc(t('device.input.error_invalid'))}</p>`;
44
+ }
45
+ else if (err && err.name === 'AbortedError') {
46
+ msg = `<p class="red">${esc(t('device.input.error_aborted'))}</p>`;
47
+ }
48
+ else if (err) {
49
+ msg = `<p class="red">${esc(t('device.input.error_generic'))}</p>`;
50
+ }
51
+ else {
52
+ msg = `<p>${esc(t('device.input.intro'))}</p>`;
53
+ }
54
+ ctx.body = page(t('device.input.title'), `<h1>${esc(t('device.input.title'))}</h1>${msg}${form}<button type="submit" form="op.deviceInputForm">${esc(t('device.input.submit'))}</button>`);
55
+ },
56
+ async userCodeConfirmSource(_ctx, form, _client, _deviceInfo, userCode) {
57
+ const ctx = _ctx;
58
+ ctx.body = page(t('device.confirm.title'), `<h1>${esc(t('device.confirm.title'))}</h1><p>${esc(t('device.confirm.body'))}</p><code>${esc(userCode)}</code>${form}<button autofocus type="submit" form="op.deviceConfirmForm">${esc(t('device.confirm.submit'))}</button><button class="abort" type="submit" form="op.deviceConfirmForm" value="yes" name="abort">${esc(t('device.confirm.abort'))}</button>`);
59
+ },
60
+ async successSource(_ctx) {
61
+ const ctx = _ctx;
62
+ ctx.body = page(t('device.success.title'), `<h1>${esc(t('device.success.title'))}</h1><p>${esc(t('device.success.body'))}</p>`);
63
+ },
64
+ };
65
+ }
@@ -4,6 +4,11 @@ export interface InteractionDeps {
4
4
  id: string;
5
5
  } | null>;
6
6
  }
7
+ /** Detalhes opcionais de login (step-up auth): acr alcançado + amr (métodos). */
8
+ export interface CompleteLoginExtra {
9
+ acr?: string;
10
+ amr?: string[];
11
+ }
7
12
  export interface InteractionActions {
8
13
  details(ctx: HttpContext): Promise<any>;
9
14
  login(ctx: HttpContext, input: {
@@ -12,7 +17,7 @@ export interface InteractionActions {
12
17
  }): Promise<{
13
18
  ok: boolean;
14
19
  }>;
15
- completeLogin(ctx: HttpContext, accountId: string): Promise<{
20
+ completeLogin(ctx: HttpContext, accountId: string, extra?: CompleteLoginExtra): Promise<{
16
21
  ok: boolean;
17
22
  }>;
18
23
  consent(ctx: HttpContext): Promise<unknown>;
@@ -14,8 +14,15 @@ export function createInteractionActions(provider, deps) {
14
14
  await provider.interactionFinished(ctx.request.request, ctx.response.response, { login: { accountId: account.id } }, { mergeWithLastSubmission: false });
15
15
  return { ok: true };
16
16
  },
17
- async completeLogin(ctx, accountId) {
18
- await provider.interactionFinished(ctx.request.request, ctx.response.response, { login: { accountId } }, { mergeWithLastSubmission: false });
17
+ async completeLogin(ctx, accountId, extra) {
18
+ // acr/amr (RFC 8176): quando um step-up de MFA foi efetivamente cumprido,
19
+ // passamos o acr alcançado + os métodos (amr) para que o id_token os carregue.
20
+ const login = { accountId };
21
+ if (extra?.acr)
22
+ login.acr = extra.acr;
23
+ if (extra?.amr && extra.amr.length)
24
+ login.amr = extra.amr;
25
+ await provider.interactionFinished(ctx.request.request, ctx.response.response, { login }, { mergeWithLastSubmission: false });
19
26
  return { ok: true };
20
27
  },
21
28
  async consent(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/adonis-authkit-server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -76,7 +76,7 @@
76
76
  "oidc-provider": "^9.8.4",
77
77
  "otplib": "^12.0.1",
78
78
  "qrcode": "^1.5.4",
79
- "@dudousxd/adonis-authkit-core": "0.1.0"
79
+ "@dudousxd/adonis-authkit-core": "0.2.0"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@adonisjs/ally": "6.3.0",