@adonis-agora/authkit-server 0.76.0 → 0.78.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 (88) hide show
  1. package/README.md +149 -0
  2. package/build/host/views/agents/consent.edge +13 -2
  3. package/build/host/views/agents/done.edge +3 -0
  4. package/build/host/views/mfa-challenge.edge +42 -3
  5. package/build/index.d.ts +19 -1
  6. package/build/index.js +13 -1
  7. package/build/providers/authkit_server_provider.js +15 -1
  8. package/build/src/accounts/account_store.d.ts +2 -0
  9. package/build/src/accounts/lucid_account_store.js +2 -0
  10. package/build/src/adapters/adapter_contract.d.ts +2 -0
  11. package/build/src/adapters/database_adapter.d.ts +1 -0
  12. package/build/src/adapters/database_adapter.js +26 -0
  13. package/build/src/adapters/redis_adapter.d.ts +1 -0
  14. package/build/src/adapters/redis_adapter.js +30 -0
  15. package/build/src/agents/config.d.ts +20 -3
  16. package/build/src/agents/config.js +19 -4
  17. package/build/src/agents/delegation_service.js +18 -8
  18. package/build/src/agents/middleware.js +2 -1
  19. package/build/src/agents/poppy/client_registry.d.ts +45 -0
  20. package/build/src/agents/poppy/client_registry.js +226 -0
  21. package/build/src/agents/poppy/config.d.ts +362 -0
  22. package/build/src/agents/poppy/config.js +201 -0
  23. package/build/src/agents/poppy/dpop.d.ts +61 -0
  24. package/build/src/agents/poppy/dpop.js +158 -0
  25. package/build/src/agents/poppy/errors.d.ts +20 -0
  26. package/build/src/agents/poppy/errors.js +22 -0
  27. package/build/src/agents/poppy/replay.d.ts +25 -0
  28. package/build/src/agents/poppy/replay.js +106 -0
  29. package/build/src/agents/poppy/resource.d.ts +95 -0
  30. package/build/src/agents/poppy/resource.js +125 -0
  31. package/build/src/agents/poppy/safe_fetch.d.ts +48 -0
  32. package/build/src/agents/poppy/safe_fetch.js +256 -0
  33. package/build/src/agents/poppy/service.d.ts +280 -0
  34. package/build/src/agents/poppy/service.js +1452 -0
  35. package/build/src/agents/poppy/store.d.ts +109 -0
  36. package/build/src/agents/poppy/store.js +260 -0
  37. package/build/src/agents/poppy/web_session.d.ts +32 -0
  38. package/build/src/agents/poppy/web_session.js +104 -0
  39. package/build/src/agents/protocol.d.ts +8 -4
  40. package/build/src/agents/runtime.d.ts +17 -1
  41. package/build/src/agents/runtime.js +138 -13
  42. package/build/src/define_config.d.ts +24 -0
  43. package/build/src/define_config.js +14 -0
  44. package/build/src/host/account_api/account_api_controller.d.ts +12 -1
  45. package/build/src/host/account_api/account_api_controller.js +10 -5
  46. package/build/src/host/admin_sessions_service.js +8 -5
  47. package/build/src/host/auth_host_config.d.ts +2 -1
  48. package/build/src/host/controllers/account_apps_controller.js +13 -3
  49. package/build/src/host/controllers/account_security_controller.js +8 -4
  50. package/build/src/host/controllers/interaction_controller.d.ts +13 -1
  51. package/build/src/host/controllers/interaction_controller.js +352 -24
  52. package/build/src/host/controllers/poppy_consent_controller.d.ts +19 -0
  53. package/build/src/host/controllers/poppy_consent_controller.js +173 -0
  54. package/build/src/host/controllers/poppy_controller.d.ts +25 -0
  55. package/build/src/host/controllers/poppy_controller.js +194 -0
  56. package/build/src/host/csrf.d.ts +7 -0
  57. package/build/src/host/csrf.js +16 -1
  58. package/build/src/host/custom_login.d.ts +36 -0
  59. package/build/src/host/custom_login.js +76 -0
  60. package/build/src/host/custom_login_completion.d.ts +4 -0
  61. package/build/src/host/custom_login_completion.js +33 -0
  62. package/build/src/host/custom_mfa.d.ts +77 -0
  63. package/build/src/host/custom_mfa.js +268 -0
  64. package/build/src/host/i18n.d.ts +8 -0
  65. package/build/src/host/i18n.js +14 -6
  66. package/build/src/host/idp_session_bridge.d.ts +5 -3
  67. package/build/src/host/idp_session_bridge.js +5 -3
  68. package/build/src/host/oidc_rp_guard.d.ts +5 -1
  69. package/build/src/host/oidc_rp_guard.js +15 -2
  70. package/build/src/host/persistent_rp_session.d.ts +8 -0
  71. package/build/src/host/persistent_rp_session.js +74 -0
  72. package/build/src/host/poppy_session_key.d.ts +9 -0
  73. package/build/src/host/poppy_session_key.js +9 -0
  74. package/build/src/host/register_auth_host.js +72 -0
  75. package/build/src/host/whatsapp_code_sender.d.ts +20 -0
  76. package/build/src/host/whatsapp_code_sender.js +9 -0
  77. package/build/src/host/whatsapp_senders/http.d.ts +5 -0
  78. package/build/src/host/whatsapp_senders/http.js +33 -0
  79. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
  80. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
  81. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
  82. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
  83. package/build/src/provider/oidc_service.d.ts +5 -0
  84. package/build/src/provider/oidc_service.js +15 -4
  85. package/build/src/schema/ensure.js +124 -0
  86. package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
  87. package/package.json +2 -1
  88. package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
@@ -0,0 +1,173 @@
1
+ import '../augmentations.js';
2
+ import { personalAgentsFor } from '../../agents/runtime.js';
3
+ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
4
+ import { impersonationState } from '../impersonation_session.js';
5
+ import { redirectExact } from '../redirect_exact.js';
6
+ /** A tela nunca pode ser emoldurada (clickjacking — ver `agent_consent_controller.ts`). */
7
+ function denyFraming(ctx) {
8
+ ctx.response.header('X-Frame-Options', 'DENY');
9
+ ctx.response.header('Content-Security-Policy', "frame-ancestors 'none'");
10
+ }
11
+ function chosenScopes(ctx) {
12
+ const raw = ctx.request.input('scope');
13
+ return (Array.isArray(raw) ? raw : raw ? [raw] : []).map(String);
14
+ }
15
+ /**
16
+ * Telas do usuário no Poppy: o consentimento do Direct Sign-In (o
17
+ * `authorization_endpoint`, §4.5) e a do Device Sign-In (o `verification_uri`,
18
+ * §4.6). Montadas atrás do `accountGuard`: sem sessão, o usuário passa pelo login
19
+ * DESTE app e volta. Abrir o link nunca aprova sozinho — a aprovação é um POST
20
+ * com CSRF. Recusadas durante impersonation.
21
+ */
22
+ export default class PoppyConsentController {
23
+ /** GET {prefix}/oauth/authorize */
24
+ async authorize(ctx) {
25
+ const runtime = await personalAgentsFor(ctx);
26
+ const poppy = runtime?.poppy;
27
+ if (!poppy?.cfg.signIn.direct)
28
+ return ctx.response.notFound();
29
+ denyFraming(ctx);
30
+ const service = await ctx.containerResolver.make('authkit.server');
31
+ const render = service.config.render;
32
+ if (impersonationState(ctx).active) {
33
+ return render(ctx, 'agents/consent', {
34
+ csrfToken: ctx.request.csrfToken,
35
+ action: poppy.urls.authorization,
36
+ request: null,
37
+ account: null,
38
+ error: 'agents.consent.impersonating',
39
+ });
40
+ }
41
+ const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
42
+ const outcome = await poppy.startAuthorization(ctx.request.qs(), accountId);
43
+ if (outcome.kind === 'redirect')
44
+ return redirectExact(ctx.response, outcome.url);
45
+ if (outcome.kind === 'error') {
46
+ ctx.response.status(400);
47
+ return render(ctx, 'agents/done', { status: 'invalid', agentName: null, scopes: [] });
48
+ }
49
+ const account = await service.config.accountStore.findById(accountId);
50
+ return render(ctx, 'agents/consent', {
51
+ csrfToken: ctx.request.csrfToken,
52
+ action: poppy.urls.authorization,
53
+ request: {
54
+ agentName: outcome.request.agentName,
55
+ agentOrigin: outcome.request.agentOrigin,
56
+ logoUri: outcome.request.logoUri,
57
+ scopes: outcome.request.scopes,
58
+ hidden: [{ name: 'request_id', value: outcome.request.id }],
59
+ },
60
+ account: { email: account?.email ?? null },
61
+ error: null,
62
+ });
63
+ }
64
+ /** POST {prefix}/oauth/authorize — `request_id`, `decision=allow|deny`, `scope[]`. */
65
+ async decideAuthorization(ctx) {
66
+ const runtime = await personalAgentsFor(ctx);
67
+ const poppy = runtime?.poppy;
68
+ if (!poppy?.cfg.signIn.direct)
69
+ return ctx.response.notFound();
70
+ denyFraming(ctx);
71
+ const service = await ctx.containerResolver.make('authkit.server');
72
+ const cfg = service.config;
73
+ const render = cfg.render;
74
+ if (impersonationState(ctx).active) {
75
+ return render(ctx, 'agents/done', { status: 'expired', agentName: null, scopes: [] });
76
+ }
77
+ const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
78
+ const decided = await poppy.decideAuthorization({
79
+ requestId: String(ctx.request.input('request_id') ?? ''),
80
+ accountId,
81
+ allow: ctx.request.input('decision') === 'allow',
82
+ scopes: chosenScopes(ctx),
83
+ });
84
+ if (!decided) {
85
+ return render(ctx, 'agents/done', { status: 'expired', agentName: null, scopes: [] });
86
+ }
87
+ await cfg.audit?.record({
88
+ type: decided.approved ? 'agent.delegation_approved' : 'agent.delegation_denied',
89
+ accountId,
90
+ clientId: decided.clientId,
91
+ ip: ctx.request.ip?.() ?? null,
92
+ metadata: { protocol: 'poppy', signIn: 'direct', scopes: decided.approved ?? [] },
93
+ });
94
+ return redirectExact(ctx.response, decided.url);
95
+ }
96
+ /** GET {prefix}/device?user_code=XXXX-XXXX */
97
+ async device(ctx) {
98
+ const runtime = await personalAgentsFor(ctx);
99
+ const poppy = runtime?.poppy;
100
+ if (!poppy?.cfg.signIn.device)
101
+ return ctx.response.notFound();
102
+ denyFraming(ctx);
103
+ const service = await ctx.containerResolver.make('authkit.server');
104
+ const render = service.config.render;
105
+ const base = {
106
+ csrfToken: ctx.request.csrfToken,
107
+ action: poppy.urls.device,
108
+ request: null,
109
+ account: null,
110
+ error: null,
111
+ };
112
+ if (impersonationState(ctx).active) {
113
+ return render(ctx, 'agents/consent', { ...base, error: 'agents.consent.impersonating' });
114
+ }
115
+ const input = ctx.request.input('user_code');
116
+ if (!input)
117
+ return render(ctx, 'agents/consent', base);
118
+ const pending = await poppy.pendingDevice(input);
119
+ if (!pending) {
120
+ return render(ctx, 'agents/consent', { ...base, error: 'agents.consent.invalid_code' });
121
+ }
122
+ const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
123
+ const account = await service.config.accountStore.findById(accountId);
124
+ return render(ctx, 'agents/consent', {
125
+ ...base,
126
+ request: {
127
+ userCode: pending.userCode,
128
+ agentName: pending.agentName,
129
+ agentOrigin: pending.agentOrigin,
130
+ scopes: pending.scopes,
131
+ },
132
+ account: { email: account?.email ?? null },
133
+ });
134
+ }
135
+ /** POST {prefix}/device — `user_code`, `decision`, `scope[]`. */
136
+ async decideDevice(ctx) {
137
+ const runtime = await personalAgentsFor(ctx);
138
+ const poppy = runtime?.poppy;
139
+ if (!poppy?.cfg.signIn.device)
140
+ return ctx.response.notFound();
141
+ denyFraming(ctx);
142
+ const service = await ctx.containerResolver.make('authkit.server');
143
+ const cfg = service.config;
144
+ const render = cfg.render;
145
+ if (impersonationState(ctx).active) {
146
+ return render(ctx, 'agents/done', { status: 'expired', agentName: null, scopes: [] });
147
+ }
148
+ const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
149
+ const userCode = String(ctx.request.input('user_code') ?? '');
150
+ const pending = await poppy.pendingDevice(userCode);
151
+ const decided = await poppy.decideDevice({
152
+ userCode,
153
+ accountId,
154
+ allow: ctx.request.input('decision') === 'allow',
155
+ scopes: chosenScopes(ctx),
156
+ });
157
+ if (!decided || !pending) {
158
+ return render(ctx, 'agents/done', { status: 'expired', agentName: null, scopes: [] });
159
+ }
160
+ await cfg.audit?.record({
161
+ type: decided.approved ? 'agent.delegation_approved' : 'agent.delegation_denied',
162
+ accountId,
163
+ clientId: decided.clientId,
164
+ ip: ctx.request.ip?.() ?? null,
165
+ metadata: { protocol: 'poppy', signIn: 'device', scopes: decided.approved ?? [] },
166
+ });
167
+ return render(ctx, 'agents/done', {
168
+ status: decided.approved ? 'approved' : 'denied',
169
+ agentName: pending.agentName,
170
+ scopes: decided.approved ? poppy.describe(decided.approved) : [],
171
+ });
172
+ }
173
+ }
@@ -0,0 +1,25 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * Endpoints server-to-server do Poppy (§3, §4): descoberta, metadata RFC 8414,
4
+ * token, revogação, device authorization, Mediated Sign-In e a sessão de
5
+ * navegador (§5). Todos isentos de CSRF — ver `authkitCsrfExceptions`.
6
+ */
7
+ export default class PoppyController {
8
+ /** `GET /.well-known/poppy.json` — só no domínio da empresa (ignorando `www.`). */
9
+ discovery(ctx: HttpContext): Promise<void>;
10
+ /** `GET /.well-known/oauth-authorization-server{prefix}` (RFC 8414 + `poppy_domains`). */
11
+ metadata(ctx: HttpContext): Promise<void>;
12
+ token(ctx: HttpContext): Promise<void>;
13
+ deviceAuthorization(ctx: HttpContext): Promise<void>;
14
+ /** RFC 7009: 200 também para token desconhecido. */
15
+ revoke(ctx: HttpContext): Promise<void>;
16
+ /** `POST auth.mediated.endpoint` (§4.7) — com o Session Token (DPoP). */
17
+ mediatedStart(ctx: HttpContext): Promise<void>;
18
+ /** `POST {mediated}/{sign_in_id}` (§4.7). */
19
+ mediatedCode(ctx: HttpContext): Promise<void>;
20
+ /**
21
+ * `POST web.browser_session_endpoint` (§5): asserção no corpo → cookie do app
22
+ * para a Session e 303 para `return_to`; qualquer falha → 400, sem cookie.
23
+ */
24
+ browserSession(ctx: HttpContext): Promise<void>;
25
+ }
@@ -0,0 +1,194 @@
1
+ import { PoppyError } from '../../agents/poppy/errors.js';
2
+ import { publicRequestUrl, writePoppyAuthError } from '../../agents/poppy/resource.js';
3
+ import { loginPoppyBrowser } from '../../agents/poppy/web_session.js';
4
+ import { personalAgentsFor } from '../../agents/runtime.js';
5
+ function noStore(ctx) {
6
+ ctx.response.header('Cache-Control', 'no-store');
7
+ ctx.response.header('Pragma', 'no-cache');
8
+ }
9
+ function withNonce(ctx, poppy) {
10
+ const nonce = poppy.nonce();
11
+ if (nonce)
12
+ ctx.response.header('DPoP-Nonce', nonce);
13
+ }
14
+ function oauthError(ctx, error) {
15
+ noStore(ctx);
16
+ for (const [k, v] of Object.entries(error.headers))
17
+ ctx.response.header(k, v);
18
+ if (error.code === 'invalid_dpop_proof' || error.code === 'use_dpop_nonce') {
19
+ ctx.response.header('WWW-Authenticate', `DPoP error="${error.code}"`);
20
+ }
21
+ return ctx.response.status(error.status).json(error.toJSON());
22
+ }
23
+ async function poppyOr404(ctx) {
24
+ const poppy = (await personalAgentsFor(ctx))?.poppy ?? null;
25
+ if (!poppy)
26
+ ctx.response.notFound();
27
+ return poppy;
28
+ }
29
+ /** Formulário (`application/x-www-form-urlencoded`) da request. */
30
+ function form(ctx) {
31
+ return ctx.request.all();
32
+ }
33
+ /**
34
+ * Endpoints server-to-server do Poppy (§3, §4): descoberta, metadata RFC 8414,
35
+ * token, revogação, device authorization, Mediated Sign-In e a sessão de
36
+ * navegador (§5). Todos isentos de CSRF — ver `authkitCsrfExceptions`.
37
+ */
38
+ export default class PoppyController {
39
+ /** `GET /.well-known/poppy.json` — só no domínio da empresa (ignorando `www.`). */
40
+ async discovery(ctx) {
41
+ const poppy = await poppyOr404(ctx);
42
+ if (!poppy)
43
+ return;
44
+ // `host()` mantém a porta (dev em `localhost:3333`): o `browser_session_endpoint` sai dele.
45
+ const doc = poppy.discoveryDocument(ctx.request.host() ?? ctx.request.hostname() ?? '');
46
+ if (!doc)
47
+ return ctx.response.notFound();
48
+ ctx.response.header('Access-Control-Allow-Origin', '*');
49
+ ctx.response.header('Cache-Control', 'public, max-age=300');
50
+ return ctx.response.json(doc);
51
+ }
52
+ /** `GET /.well-known/oauth-authorization-server{prefix}` (RFC 8414 + `poppy_domains`). */
53
+ async metadata(ctx) {
54
+ const poppy = await poppyOr404(ctx);
55
+ if (!poppy)
56
+ return;
57
+ ctx.response.header('Access-Control-Allow-Origin', '*');
58
+ ctx.response.header('Cache-Control', 'public, max-age=300');
59
+ return ctx.response.json(poppy.authorizationServerMetadata());
60
+ }
61
+ async token(ctx) {
62
+ const poppy = await poppyOr404(ctx);
63
+ if (!poppy)
64
+ return;
65
+ withNonce(ctx, poppy);
66
+ try {
67
+ const body = await poppy.token(form(ctx), ctx.request.header('dpop'));
68
+ noStore(ctx);
69
+ return ctx.response.json(body);
70
+ }
71
+ catch (error) {
72
+ if (error instanceof PoppyError)
73
+ return oauthError(ctx, error);
74
+ throw error;
75
+ }
76
+ }
77
+ async deviceAuthorization(ctx) {
78
+ const poppy = await poppyOr404(ctx);
79
+ if (!poppy)
80
+ return;
81
+ try {
82
+ const body = await poppy.deviceAuthorization(form(ctx));
83
+ noStore(ctx);
84
+ return ctx.response.json(body);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof PoppyError)
88
+ return oauthError(ctx, error);
89
+ throw error;
90
+ }
91
+ }
92
+ /** RFC 7009: 200 também para token desconhecido. */
93
+ async revoke(ctx) {
94
+ const poppy = await poppyOr404(ctx);
95
+ if (!poppy)
96
+ return;
97
+ try {
98
+ await poppy.revoke(form(ctx));
99
+ noStore(ctx);
100
+ return ctx.response.status(200).send('');
101
+ }
102
+ catch (error) {
103
+ if (error instanceof PoppyError)
104
+ return oauthError(ctx, error);
105
+ throw error;
106
+ }
107
+ }
108
+ /** `POST auth.mediated.endpoint` (§4.7) — com o Session Token (DPoP). */
109
+ async mediatedStart(ctx) {
110
+ const poppy = await poppyOr404(ctx);
111
+ if (!poppy?.cfg.signIn.mediated)
112
+ return poppy ? ctx.response.notFound() : undefined;
113
+ const auth = await poppy.verifyAccess({
114
+ method: ctx.request.method(),
115
+ url: publicRequestUrl(ctx, poppy),
116
+ headers: ctx.request.headers(),
117
+ });
118
+ if (!auth.ok) {
119
+ const { ok: _ok, ...rest } = auth;
120
+ return writePoppyAuthError(ctx, { ok: false, ...rest });
121
+ }
122
+ try {
123
+ const body = await poppy.mediatedStart(auth.principal, auth.jkt, ctx.request.body(), ctx);
124
+ noStore(ctx);
125
+ return ctx.response.json(body);
126
+ }
127
+ catch (error) {
128
+ if (error instanceof PoppyError)
129
+ return oauthError(ctx, error);
130
+ throw error;
131
+ }
132
+ }
133
+ /** `POST {mediated}/{sign_in_id}` (§4.7). */
134
+ async mediatedCode(ctx) {
135
+ const poppy = await poppyOr404(ctx);
136
+ if (!poppy?.cfg.signIn.mediated)
137
+ return poppy ? ctx.response.notFound() : undefined;
138
+ const auth = await poppy.verifyAccess({
139
+ method: ctx.request.method(),
140
+ url: publicRequestUrl(ctx, poppy),
141
+ headers: ctx.request.headers(),
142
+ });
143
+ if (!auth.ok) {
144
+ const { ok: _ok, ...rest } = auth;
145
+ return writePoppyAuthError(ctx, { ok: false, ...rest });
146
+ }
147
+ try {
148
+ const body = await poppy.mediatedCode(auth.principal, auth.jkt, String(ctx.request.param('id') ?? ''), ctx.request.body());
149
+ noStore(ctx);
150
+ if (!body) {
151
+ return ctx.response
152
+ .status(404)
153
+ .json({ error: 'invalid_request', error_description: 'Unknown sign_in_id' });
154
+ }
155
+ return ctx.response.json(body);
156
+ }
157
+ catch (error) {
158
+ if (error instanceof PoppyError)
159
+ return oauthError(ctx, error);
160
+ throw error;
161
+ }
162
+ }
163
+ /**
164
+ * `POST web.browser_session_endpoint` (§5): asserção no corpo → cookie do app
165
+ * para a Session e 303 para `return_to`; qualquer falha → 400, sem cookie.
166
+ */
167
+ async browserSession(ctx) {
168
+ const poppy = await poppyOr404(ctx);
169
+ if (!poppy?.cfg.web?.browserSession)
170
+ return poppy ? ctx.response.notFound() : undefined;
171
+ noStore(ctx);
172
+ // A asserção vai NO CORPO, nunca na URL (§5).
173
+ if (ctx.request.qs().assertion !== undefined)
174
+ return badBrowserRequest(ctx);
175
+ let result;
176
+ try {
177
+ result = await poppy.verifyBrowserAssertion(ctx.request.input('assertion'), new URL(publicRequestUrl(ctx, poppy)).origin);
178
+ }
179
+ catch (error) {
180
+ if (error instanceof PoppyError)
181
+ return badBrowserRequest(ctx);
182
+ throw error;
183
+ }
184
+ await loginPoppyBrowser(ctx, result);
185
+ ctx.response.header('Location', result.returnTo);
186
+ return ctx.response.status(303).send('');
187
+ }
188
+ }
189
+ function badBrowserRequest(ctx) {
190
+ ctx.response.header('Content-Type', 'text/html; charset=utf-8');
191
+ return ctx.response
192
+ .status(400)
193
+ .send('<!doctype html><html><head><meta charset="utf-8"><title>Bad request</title></head><body><h1>Invalid browser session request</h1></body></html>');
194
+ }
@@ -10,6 +10,13 @@ export interface AuthkitCsrfOptions {
10
10
  * consentimento (`{prefix}/consent`) continua protegida.
11
11
  */
12
12
  personalAgentsPrefix?: string | false;
13
+ /**
14
+ * Prefixo do Poppy: isenta token/revoke/device authorization, o Mediated
15
+ * Sign-In e a sessão de navegador (POST cross-site autenticado pela asserção).
16
+ * Default: o do config. As telas `{prefix}/oauth/authorize` e `{prefix}/device`
17
+ * continuam protegidas. `false` desliga.
18
+ */
19
+ poppyPrefix?: string | false;
13
20
  }
14
21
  /**
15
22
  * Retorna `true` quando `url` é uma rota AuthKit que deve ser ISENTA de CSRF
@@ -16,6 +16,7 @@
16
16
  * ```
17
17
  */
18
18
  import { normalizePersonalAgentsPrefix } from '../agents/config.js';
19
+ import { normalizePoppyPrefix } from '../agents/poppy/config.js';
19
20
  import { getAuthHostConfig } from './auth_host_config.js';
20
21
  /**
21
22
  * Retorna `true` quando `url` é uma rota AuthKit que deve ser ISENTA de CSRF
@@ -33,8 +34,22 @@ export function authkitCsrfExceptions(url, options = {}) {
33
34
  ? normalizePersonalAgentsPrefix(options.personalAgentsPrefix)
34
35
  : getAuthHostConfig()?.personalAgents?.prefix;
35
36
  const agentsOAuth = agentsPrefix ? `${agentsPrefix}/oauth/` : null;
37
+ const poppyPrefix = options.poppyPrefix === false
38
+ ? undefined
39
+ : options.poppyPrefix !== undefined
40
+ ? normalizePoppyPrefix(options.poppyPrefix)
41
+ : getAuthHostConfig()?.personalAgents?.poppyPrefix;
42
+ const path = url.split('?')[0];
43
+ const poppyExempt = !!poppyPrefix &&
44
+ (path === `${poppyPrefix}/oauth/token` ||
45
+ path === `${poppyPrefix}/oauth/revoke` ||
46
+ path === `${poppyPrefix}/oauth/device` ||
47
+ path === `${poppyPrefix}/sign-in` ||
48
+ path.startsWith(`${poppyPrefix}/sign-in/`) ||
49
+ path === `${poppyPrefix}/browser-session`);
36
50
  return (url.includes(mountPath) ||
37
51
  url.includes('/authkit/pat') ||
38
52
  (backchannel !== null && url === backchannel) ||
39
- (agentsOAuth !== null && url.startsWith(agentsOAuth)));
53
+ (agentsOAuth !== null && url.startsWith(agentsOAuth)) ||
54
+ poppyExempt);
40
55
  }
@@ -0,0 +1,36 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /** A verified identity, obtained from trusted host logic rather than request.accountId. */
3
+ export interface CustomLoginIdentity {
4
+ accountId: string;
5
+ /** Host-validated remember-me choice, subject to the runtime session policy. */
6
+ remember?: boolean;
7
+ }
8
+ /** Host-defined primary authentication. AuthKit owns policies, MFA and session completion. */
9
+ export interface CustomLoginMethod {
10
+ /** Credential group, used to prevent repeating it as an additional MFA factor. */
11
+ readonly factorId?: string;
12
+ /** Set true only for proofs that do not authenticate with the account password. */
13
+ readonly passwordless?: boolean;
14
+ /** Optional challenge initiation; response shape, validation and delivery are host-owned. */
15
+ begin?(ctx: HttpContext): Promise<unknown>;
16
+ /** Validate/consume the primary proof, bound to this interaction. Null denies login. */
17
+ authenticate(ctx: HttpContext): Promise<CustomLoginIdentity | null>;
18
+ }
19
+ /** Constructors are resolved through the request container, including @inject dependencies. */
20
+ export type CustomLoginMethodConstructor = new (...args: never[]) => CustomLoginMethod;
21
+ export type CustomLoginMethodBinding = CustomLoginMethodConstructor | CustomLoginMethod;
22
+ export type CustomLoginMethods = Readonly<Record<string, CustomLoginMethodBinding>>;
23
+ export declare function isCustomLoginMethodId(method: string): boolean;
24
+ /** Start a registered method's optional challenge within a valid OIDC login interaction. */
25
+ export declare function beginCustomLogin(ctx: HttpContext, method: string): Promise<unknown>;
26
+ /** Verify using a registered class/instance, then pass through the shared login gates. */
27
+ export declare function authenticateCustomLogin(ctx: HttpContext, method: string): Promise<unknown>;
28
+ /**
29
+ * Trusted host escape hatch for arbitrary flows. Call ONLY after validating a primary proof.
30
+ * Never forward an account ID supplied by the browser. Prefer a registered CustomLoginMethod.
31
+ */
32
+ export declare function completeCustomLogin(ctx: HttpContext, input: CustomLoginIdentity & {
33
+ method: string;
34
+ factorId?: string;
35
+ passwordless?: boolean;
36
+ }): Promise<unknown>;
@@ -0,0 +1,76 @@
1
+ import { Exception } from '@adonisjs/core/exceptions';
2
+ import AuthInteractionController from './controllers/interaction_controller.js';
3
+ export function isCustomLoginMethodId(method) {
4
+ return (/^[a-z][a-z0-9:_-]{0,63}$/.test(method) &&
5
+ !['pwd', 'email', 'mfa', 'totp', 'webauthn', 'recovery'].includes(method));
6
+ }
7
+ function failure(message, code, status = 422) {
8
+ return new Exception(message, { code, status });
9
+ }
10
+ async function resolveMethod(ctx, method) {
11
+ if (!isCustomLoginMethodId(method))
12
+ throw failure('Invalid custom login method', 'E_CUSTOM_LOGIN_METHOD');
13
+ const service = await ctx.containerResolver.make('authkit.server');
14
+ const details = await service.interactions.details(ctx);
15
+ if (details.uid !== ctx.request.param('uid') || details.prompt.name !== 'login') {
16
+ throw failure('Invalid login interaction', 'E_CUSTOM_LOGIN_INTERACTION');
17
+ }
18
+ const registry = service.config.customLoginMethods;
19
+ if (!registry || !Object.hasOwn(registry, method)) {
20
+ throw failure('Unknown custom login method', 'E_CUSTOM_LOGIN_METHOD');
21
+ }
22
+ const binding = registry[method];
23
+ return typeof binding === 'function' ? ctx.containerResolver.make(binding) : binding;
24
+ }
25
+ /** Start a registered method's optional challenge within a valid OIDC login interaction. */
26
+ export async function beginCustomLogin(ctx, method) {
27
+ const handler = await resolveMethod(ctx, method);
28
+ if (!handler.begin)
29
+ throw failure('This method has no challenge initiation', 'E_CUSTOM_LOGIN_BEGIN');
30
+ return handler.begin(ctx);
31
+ }
32
+ /** Verify using a registered class/instance, then pass through the shared login gates. */
33
+ export async function authenticateCustomLogin(ctx, method) {
34
+ const handler = await resolveMethod(ctx, method);
35
+ let identity;
36
+ try {
37
+ identity = await handler.authenticate(ctx);
38
+ }
39
+ catch (error) {
40
+ await recordFailure(ctx, method, 'method_error');
41
+ throw error;
42
+ }
43
+ if (!identity || typeof identity.accountId !== 'string' || !identity.accountId.trim()) {
44
+ await recordFailure(ctx, method, 'invalid_proof');
45
+ throw failure('Authentication failed', 'E_CUSTOM_LOGIN_FAILED');
46
+ }
47
+ return completeCustomLogin(ctx, {
48
+ ...identity,
49
+ method,
50
+ factorId: handler.factorId,
51
+ passwordless: handler.passwordless === true,
52
+ });
53
+ }
54
+ /**
55
+ * Trusted host escape hatch for arbitrary flows. Call ONLY after validating a primary proof.
56
+ * Never forward an account ID supplied by the browser. Prefer a registered CustomLoginMethod.
57
+ */
58
+ export function completeCustomLogin(ctx, input) {
59
+ return new AuthInteractionController().completeCustomLogin(ctx, input);
60
+ }
61
+ async function recordFailure(ctx, method, reason) {
62
+ const service = await ctx.containerResolver.make('authkit.server');
63
+ const details = await service.interactions.details(ctx);
64
+ try {
65
+ await service.config.audit?.record({
66
+ type: 'login.failure',
67
+ accountId: null,
68
+ ip: ctx.request.ip(),
69
+ clientId: details.params.client_id ?? null,
70
+ metadata: { method, reason },
71
+ });
72
+ }
73
+ catch {
74
+ ctx.logger.warn('AuthKit custom login failure audit could not be recorded');
75
+ }
76
+ }
@@ -0,0 +1,4 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { CompleteLoginExtra } from '../provider/interaction_actions.js';
3
+ /** Shared by direct custom completion and its TOTP/passkey continuation. */
4
+ export declare function completeCustomLoginSession(ctx: HttpContext, accountId: string, extra: CompleteLoginExtra, rememberRequested?: boolean): Promise<unknown>;
@@ -0,0 +1,33 @@
1
+ import { AdminSessionsService } from './admin_sessions_service.js';
2
+ import { resolveRuntimeSettingsOrNoop } from './runtime_settings.js';
3
+ import { resolveEffectiveSessionPolicy } from './runtime_toggles.js';
4
+ /** Shared by direct custom completion and its TOTP/passkey continuation. */
5
+ export async function completeCustomLoginSession(ctx, accountId, extra, rememberRequested = false) {
6
+ const service = await ctx.containerResolver.make('authkit.server');
7
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
8
+ const policy = await resolveEffectiveSessionPolicy(settings, service.config.ttl?.session ? Math.ceil(service.config.ttl.session / 3600) : undefined);
9
+ const sessions = policy.singleSession ? new AdminSessionsService(service) : undefined;
10
+ const previous = sessions?.canList ? await sessions.listSessions(accountId) : [];
11
+ const details = sessions?.canList ? await service.interactions.details(ctx) : undefined;
12
+ const currentIdpSession = details?.session?.uid
13
+ ? await service.provider.Session.findByUid(details.session.uid)
14
+ : undefined;
15
+ const result = await service.interactions.completeLogin(ctx, accountId, {
16
+ ...extra,
17
+ remember: policy.rememberEnabled && rememberRequested,
18
+ });
19
+ const denied = result !== null && typeof result === 'object' && 'ok' in result && result.ok === false;
20
+ if (!denied && sessions?.canList && previous.length) {
21
+ const previousIds = new Set(previous.map((session) => session.id));
22
+ const current = await sessions.listSessions(accountId);
23
+ const created = current.find((session) => !previousIds.has(session.id));
24
+ const revoked = await sessions.revokeAllExcept(accountId, created?.id ?? currentIdpSession?.id ?? '__none__');
25
+ await service.config.audit?.record({
26
+ type: 'session.single_enforced',
27
+ accountId,
28
+ ip: ctx.request.ip(),
29
+ metadata: { revokedCount: revoked.sessions },
30
+ });
31
+ }
32
+ return result;
33
+ }
@@ -0,0 +1,77 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { AuthAccount } from '../accounts/account_store.js';
3
+ export interface CustomMfaContext {
4
+ ctx: HttpContext;
5
+ account: AuthAccount;
6
+ accountId: string;
7
+ uid: string;
8
+ primaryMethod: string;
9
+ challengeId: string;
10
+ }
11
+ export interface CustomMfaField {
12
+ name: string;
13
+ label: string;
14
+ type?: 'text' | 'password';
15
+ inputMode?: 'numeric' | 'text';
16
+ autoComplete?: string;
17
+ }
18
+ export interface CustomMfaDescription {
19
+ label: string;
20
+ fields?: CustomMfaField[];
21
+ }
22
+ /** A host-defined enrolled factor. Verify and consume its proof within this challenge. */
23
+ export interface CustomMfaMethod {
24
+ /** Methods sharing the same credential/channel must share a factorId. */
25
+ readonly factorId?: string;
26
+ isEnabled(context: CustomMfaContext): Promise<boolean>;
27
+ describe(context: CustomMfaContext): Promise<CustomMfaDescription>;
28
+ begin?(context: CustomMfaContext): Promise<void>;
29
+ verify(context: CustomMfaContext): Promise<boolean>;
30
+ }
31
+ export type CustomMfaMethodConstructor = new (...args: never[]) => CustomMfaMethod;
32
+ export type CustomMfaMethodBinding = CustomMfaMethod | CustomMfaMethodConstructor;
33
+ export type CustomMfaMethods = Readonly<Record<string, CustomMfaMethodBinding>>;
34
+ export interface RuntimeMfaFlow {
35
+ uid: string;
36
+ accountId: string;
37
+ primaryMethod: string;
38
+ primaryFactorId: string;
39
+ challengeId: string;
40
+ /** Total distinct factors, including the authenticated primary factor. */
41
+ requiredFactors: number;
42
+ remember?: boolean;
43
+ passwordless?: boolean;
44
+ completed: Array<{
45
+ method: string;
46
+ factorId: string;
47
+ }>;
48
+ startedMethods: string[];
49
+ createdAt: number;
50
+ attempts: number;
51
+ }
52
+ export interface CustomMfaDescriptor extends CustomMfaDescription {
53
+ id: string;
54
+ factorId: string;
55
+ requiresBegin: boolean;
56
+ }
57
+ export declare function isCustomMfaMethodId(method: string): boolean;
58
+ export declare function initializeMfaFlow(ctx: HttpContext, input: Pick<RuntimeMfaFlow, 'uid' | 'accountId' | 'primaryMethod' | 'primaryFactorId' | 'requiredFactors' | 'remember' | 'passwordless'>): void;
59
+ export declare function clearMfaFlow(ctx: HttpContext): void;
60
+ export declare function getMfaFlow(ctx: HttpContext): RuntimeMfaFlow | null;
61
+ export declare function resolveCustomMfaMethods(ctx: HttpContext, accountId: string, uid: string, primaryMethod: string, challengeId: string): Promise<CustomMfaDescriptor[]>;
62
+ export declare function customMfaViewProps(ctx: HttpContext): Promise<{
63
+ customMfaMethods: Array<CustomMfaDescriptor & {
64
+ beginUrl: string;
65
+ verifyUrl: string;
66
+ started: boolean;
67
+ }>;
68
+ completedMfaMethods: string[];
69
+ requiredMfaFactors: number;
70
+ }>;
71
+ export declare function beginCustomMfa(ctx: HttpContext, method: string): Promise<void>;
72
+ export declare function verifyCustomMfa(ctx: HttpContext, method: string): Promise<boolean>;
73
+ /** Call only after a trusted verifier consumes a proof; this does not finish the login. */
74
+ export declare function recordVerifiedMfaFactor(ctx: HttpContext, method: string, factorId?: string): {
75
+ complete: boolean;
76
+ amr: string[];
77
+ };