@maronn-openid-connect/cli 0.5.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -5
- package/dist/features.d.ts +5 -1
- package/dist/features.d.ts.map +1 -1
- package/dist/features.js +17 -2
- package/dist/features.js.map +1 -1
- package/dist/frameworks/hono/index.d.ts.map +1 -1
- package/dist/frameworks/hono/index.js +17 -1
- package/dist/frameworks/hono/index.js.map +1 -1
- package/dist/frameworks/hono/pages.d.ts +10 -0
- package/dist/frameworks/hono/pages.d.ts.map +1 -0
- package/dist/frameworks/hono/pages.js +806 -0
- package/dist/frameworks/hono/pages.js.map +1 -0
- package/dist/frameworks/hono/templates.d.ts +6 -1
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +2462 -712
- package/dist/frameworks/hono/templates.js.map +1 -1
- package/dist/frameworks/web-standard/templates.d.ts +1 -1
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
- package/dist/frameworks/web-standard/templates.js +179 -30
- package/dist/frameworks/web-standard/templates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
import { DEFAULT_FEATURES } from '../../features.js';
|
|
2
|
+
import { EXPERIMENTAL_PACKAGE } from './templates.js';
|
|
3
|
+
export function respondTemplate() {
|
|
4
|
+
return `/**
|
|
5
|
+
* Response helpers shared by the screen routes (pages/).
|
|
6
|
+
*
|
|
7
|
+
* The logic layer (routes/) describes what to do — which screen to show, where
|
|
8
|
+
* to send the browser, which cookies to set — and never builds a Response. The
|
|
9
|
+
* helpers below are how a page turns that description into HTTP.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Attach Set-Cookie headers to a Response a view already produced.
|
|
14
|
+
*
|
|
15
|
+
* renderView() builds its own Response, so headers staged on the framework
|
|
16
|
+
* context never reach it; rebuilding the Response is the framework-neutral way
|
|
17
|
+
* to add cookies without making views cookie-aware.
|
|
18
|
+
*/
|
|
19
|
+
export function withCookies(response: Response, cookies: readonly string[]): Response {
|
|
20
|
+
if (cookies.length === 0) return response;
|
|
21
|
+
const headers = new Headers(response.headers);
|
|
22
|
+
for (const cookie of cookies) {
|
|
23
|
+
headers.append('Set-Cookie', cookie);
|
|
24
|
+
}
|
|
25
|
+
return new Response(response.body, {
|
|
26
|
+
status: response.status,
|
|
27
|
+
statusText: response.statusText,
|
|
28
|
+
headers,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Send the browser to location (302 unless told otherwise) with the given
|
|
34
|
+
* cookies attached.
|
|
35
|
+
*/
|
|
36
|
+
export function redirectWithCookies(
|
|
37
|
+
location: string,
|
|
38
|
+
cookies: readonly string[] = [],
|
|
39
|
+
status = 302,
|
|
40
|
+
): Response {
|
|
41
|
+
const headers = new Headers({ Location: location });
|
|
42
|
+
for (const cookie of cookies) {
|
|
43
|
+
headers.append('Set-Cookie', cookie);
|
|
44
|
+
}
|
|
45
|
+
return new Response(null, { status, headers });
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
export function errorPageTemplate() {
|
|
50
|
+
return `/**
|
|
51
|
+
* Error screen (screen routing layer).
|
|
52
|
+
*
|
|
53
|
+
* Whenever a page has to stop the browser on the OP's own error page it calls
|
|
54
|
+
* renderErrorPage() here. Customize the error UI in views.ts (errorPage), or
|
|
55
|
+
* change how it is delivered in this file — for example by redirecting to a
|
|
56
|
+
* page of your own.
|
|
57
|
+
*/
|
|
58
|
+
import { defaultProviderConfig } from '../config.js';
|
|
59
|
+
import { defaultViews, renderView, type ErrorPageParams } from '../views.js';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render the OP's error page.
|
|
63
|
+
*
|
|
64
|
+
* params.statusCode is both the HTTP status of the response and the value the
|
|
65
|
+
* view receives, so a custom view can show it and the status never diverges
|
|
66
|
+
* from the message (a 429 lockout page answers 429, a 403 binding failure 403).
|
|
67
|
+
*/
|
|
68
|
+
export function renderErrorPage(c: any, params: ErrorPageParams): Response {
|
|
69
|
+
const views = c.get('views') ?? defaultViews;
|
|
70
|
+
return renderView(views.errorPage(params), { status: params.statusCode });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Deliver a non-redirectable authorization error (OIDC Core 1.0 §3.1.2.2) to
|
|
75
|
+
* the browser.
|
|
76
|
+
*
|
|
77
|
+
* An unknown client_id, an unregistered redirect_uri or a redirect_uri with a
|
|
78
|
+
* fragment leaves the OP without a redirect target it may trust, so the error
|
|
79
|
+
* MUST stay on the OP (RFC 6749 §4.1.2.1). Two deliveries are supported:
|
|
80
|
+
*
|
|
81
|
+
* - config.authorizationErrorRedirectPath set: 303 to that path of the OP with
|
|
82
|
+
* error / error_description in the query, for deployments whose error screen
|
|
83
|
+
* is a framework-native page (the generated Next.js output uses /oidc-error).
|
|
84
|
+
* That page answers 200, so the HTTP 400 is traded for the framework's own
|
|
85
|
+
* error UI; the browser still sees an error screen (the OIDF Conformance
|
|
86
|
+
* Suite screenshots it for oidcc-ensure-registered-redirect-uri). Only an
|
|
87
|
+
* OP-internal root-relative path is honored: an absolute URL or a
|
|
88
|
+
* protocol-relative '//host' would turn this into an open redirect, so those
|
|
89
|
+
* fall back to the inline page below.
|
|
90
|
+
* - otherwise: the error view as an HTML 400 response.
|
|
91
|
+
*/
|
|
92
|
+
export function renderAuthorizationErrorPage(
|
|
93
|
+
c: any,
|
|
94
|
+
params: { error: string; errorDescription?: string },
|
|
95
|
+
): Response {
|
|
96
|
+
const config = c.get('config') ?? defaultProviderConfig;
|
|
97
|
+
const errorPagePath = config.authorizationErrorRedirectPath;
|
|
98
|
+
if (errorPagePath && errorPagePath.startsWith('/') && !errorPagePath.startsWith('//')) {
|
|
99
|
+
const query = new URLSearchParams({ error: params.error });
|
|
100
|
+
if (params.errorDescription) {
|
|
101
|
+
query.set('error_description', params.errorDescription);
|
|
102
|
+
}
|
|
103
|
+
return c.redirect(\`\${errorPagePath}?\${query.toString()}\`, 303);
|
|
104
|
+
}
|
|
105
|
+
return renderErrorPage(c, {
|
|
106
|
+
error: params.error,
|
|
107
|
+
errorDescription: params.errorDescription,
|
|
108
|
+
statusCode: 400,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
export function authorizePageTemplate() {
|
|
114
|
+
return `/**
|
|
115
|
+
* Authorization endpoint (screen routing layer).
|
|
116
|
+
*
|
|
117
|
+
* GET|POST /authorize is the browser's entry into the flow, and every answer it
|
|
118
|
+
* gives is a redirect or a screen: back to the client with the authorization
|
|
119
|
+
* response, on to /login or /consent, or the OP's own error page. Deciding
|
|
120
|
+
* WHICH of those applies — the whole OIDC Core 1.0 §3.1.2 validation pipeline,
|
|
121
|
+
* SSO, prompt=none — is processAuthorizationRequest() in routes/authorize.ts;
|
|
122
|
+
* this file only turns its outcome into HTTP.
|
|
123
|
+
*/
|
|
124
|
+
import { Hono } from 'hono';
|
|
125
|
+
import { defaultProviderConfig } from '../config.js';
|
|
126
|
+
import {
|
|
127
|
+
processAuthorizationRequest,
|
|
128
|
+
type AuthorizationOutcome,
|
|
129
|
+
} from '../routes/authorize.js';
|
|
130
|
+
import { renderAuthorizationErrorPage } from './errors.js';
|
|
131
|
+
import { redirectWithCookies } from './respond.js';
|
|
132
|
+
|
|
133
|
+
export const authorizePage = new Hono<{ Variables: Record<string, any> }>();
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* URL of one of the OP's own screens.
|
|
137
|
+
*
|
|
138
|
+
* Built on config.issuer, never on the request URL: some runtimes derive the
|
|
139
|
+
* request URL from the Host header, which would let the sender pick the
|
|
140
|
+
* redirect origin and receive transaction_id there (RFC 9700 §2.1: redirect
|
|
141
|
+
* only to trusted URIs). OIDC Discovery 1.0 §3 makes the advertised issuer the
|
|
142
|
+
* source of truth for URLs that point at the OP itself. A subpath issuer
|
|
143
|
+
* contributes only its origin — the screen paths are absolute — so subpath
|
|
144
|
+
* mounting is not supported by the generated routes.
|
|
145
|
+
*/
|
|
146
|
+
function screenUrl(c: any, path: '/login' | '/consent', transactionId: string): string {
|
|
147
|
+
const config = c.get('config') ?? defaultProviderConfig;
|
|
148
|
+
const url = new URL(path, config.issuer);
|
|
149
|
+
url.searchParams.set('transaction_id', transactionId);
|
|
150
|
+
return url.toString();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Turn the outcome of the authorization request into the HTTP response. */
|
|
154
|
+
function respond(c: any, outcome: AuthorizationOutcome): Response {
|
|
155
|
+
if (outcome.kind === 'bad_request') {
|
|
156
|
+
// Malformed transport (wrong POST Content-Type, repeated parameter, no
|
|
157
|
+
// client_id): OAuth error JSON — there is no transaction to show a screen for.
|
|
158
|
+
return c.json({ error: outcome.error, error_description: outcome.errorDescription }, 400);
|
|
159
|
+
}
|
|
160
|
+
if (outcome.kind === 'authorization_response') {
|
|
161
|
+
// Back to the client: the authorization code, a redirectable error, or
|
|
162
|
+
// (EXPERIMENTAL JARM) the signed response JWT — all already in the URL.
|
|
163
|
+
return c.redirect(outcome.location);
|
|
164
|
+
}
|
|
165
|
+
if (outcome.kind === 'login') {
|
|
166
|
+
return redirectWithCookies(screenUrl(c, '/login', outcome.transactionId), outcome.cookies);
|
|
167
|
+
}
|
|
168
|
+
if (outcome.kind === 'consent') {
|
|
169
|
+
return redirectWithCookies(screenUrl(c, '/consent', outcome.transactionId), outcome.cookies);
|
|
170
|
+
}
|
|
171
|
+
if (outcome.kind === 'error') {
|
|
172
|
+
// OIDC Core 1.0 §3.1.2.2: an error that cannot be redirected (unknown
|
|
173
|
+
// client_id, unregistered redirect_uri, redirect_uri with a fragment, a
|
|
174
|
+
// request_uri that does not resolve) stays on the OP. Programmatic callers
|
|
175
|
+
// that ask for JSON via the Accept header get the OAuth error JSON; browsers
|
|
176
|
+
// get the OP's error page (the OIDF Conformance Suite screenshots it for
|
|
177
|
+
// oidcc-ensure-registered-redirect-uri).
|
|
178
|
+
const acceptsJson = (c.req.header('Accept') ?? '').includes('application/json');
|
|
179
|
+
if (acceptsJson) {
|
|
180
|
+
return c.json({ error: outcome.error, error_description: outcome.errorDescription }, 400);
|
|
181
|
+
}
|
|
182
|
+
return renderAuthorizationErrorPage(c, outcome);
|
|
183
|
+
}
|
|
184
|
+
return c.json({ error: 'server_error' }, 500);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const handleAuthorizationRequest = async (c: any): Promise<Response> =>
|
|
188
|
+
respond(c, await processAuthorizationRequest(c));
|
|
189
|
+
|
|
190
|
+
// OIDC Core 1.0 Section 3.1.2.1: Authorization Endpoint must support both GET and POST.
|
|
191
|
+
authorizePage.get('/', handleAuthorizationRequest);
|
|
192
|
+
authorizePage.post('/', handleAuthorizationRequest);
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
export function loginPageTemplate(features = DEFAULT_FEATURES) {
|
|
196
|
+
const googleSignInParam = features.googleLogin
|
|
197
|
+
? `
|
|
198
|
+
// EXTENSION (google-login): undefined until config.googleLogin is set.
|
|
199
|
+
googleSignIn: screen.googleSignIn,`
|
|
200
|
+
: '';
|
|
201
|
+
const googleLoginImport = features.googleLogin ? ', completeGoogleLogin' : '';
|
|
202
|
+
const googleLoginRoute = features.googleLogin
|
|
203
|
+
? `
|
|
204
|
+
/**
|
|
205
|
+
* EXTENSION (google-login) — Google login callback (login_uri) - POST
|
|
206
|
+
*
|
|
207
|
+
* Sign in with Google (redirect mode) posts the ID token here once the user
|
|
208
|
+
* picks an account. completeGoogleLogin() (routes/login.ts) verifies it and
|
|
209
|
+
* establishes the OP session exactly like a password login; this handler only
|
|
210
|
+
* continues to the consent step or shows the error page.
|
|
211
|
+
*/
|
|
212
|
+
loginPage.post('/google', async (c) => {
|
|
213
|
+
const outcome = await completeGoogleLogin(c);
|
|
214
|
+
if (outcome.kind === 'not_configured') {
|
|
215
|
+
return renderErrorPage(c, {
|
|
216
|
+
error: 'not_found',
|
|
217
|
+
errorDescription: 'Google login is not configured',
|
|
218
|
+
statusCode: 404,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (outcome.kind === 'error') return renderErrorPage(c, outcome);
|
|
222
|
+
return redirectWithCookies(consentScreenUrl(c, outcome.transactionId), outcome.cookies);
|
|
223
|
+
});
|
|
224
|
+
`
|
|
225
|
+
: '';
|
|
226
|
+
return `/**
|
|
227
|
+
* Login screen (screen routing layer).
|
|
228
|
+
*
|
|
229
|
+
* GET /login renders the form and POST /login submits it. Neither handler
|
|
230
|
+
* holds OIDC logic: prepareLogin() and submitLogin() in routes/login.ts load
|
|
231
|
+
* the transaction, check the User-Agent binding, verify the credentials and
|
|
232
|
+
* mint the OP session, and report what happened as an outcome. This file turns
|
|
233
|
+
* each outcome into a screen or a redirect. To customize the login UI, edit
|
|
234
|
+
* this file or the loginPage view in views.ts; routes/login.ts never has to
|
|
235
|
+
* change.
|
|
236
|
+
*/
|
|
237
|
+
import { Hono } from 'hono';
|
|
238
|
+
import { defaultProviderConfig } from '../config.js';
|
|
239
|
+
import { prepareLogin, submitLogin${googleLoginImport}, type LoginScreen } from '../routes/login.js';
|
|
240
|
+
import { defaultViews, renderView, type LoginPageParams } from '../views.js';
|
|
241
|
+
import { renderErrorPage } from './errors.js';
|
|
242
|
+
import { redirectWithCookies } from './respond.js';
|
|
243
|
+
|
|
244
|
+
export const loginPage = new Hono<{ Variables: Record<string, any> }>();
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Render the login form. Swap the view, return a framework-rendered Response,
|
|
248
|
+
* or redirect to a UI of your own — here only.
|
|
249
|
+
*/
|
|
250
|
+
export function renderLoginPage(c: any, params: LoginPageParams): Response {
|
|
251
|
+
const views = c.get('views') ?? defaultViews;
|
|
252
|
+
return renderView(views.loginPage(params));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Map what the logic prepared for the form onto the view's parameters. Extend
|
|
257
|
+
* this when your login view needs more than the defaults.
|
|
258
|
+
*/
|
|
259
|
+
function loginPageParams(screen: LoginScreen): LoginPageParams {
|
|
260
|
+
return {
|
|
261
|
+
transactionId: screen.transactionId,
|
|
262
|
+
csrfToken: screen.csrfToken,
|
|
263
|
+
// OIDC Core 1.0 §3.1.2.1: pre-fill the login form with login_hint (RECOMMENDED).
|
|
264
|
+
loginHint: screen.loginHint,${googleSignInParam}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Where a signed-in End-User continues: the consent screen. Built on
|
|
270
|
+
* config.issuer, not the request URL — some runtimes derive the request URL
|
|
271
|
+
* from the Host header, which would let the sender pick where transaction_id
|
|
272
|
+
* lands (OIDC Discovery 1.0 §3 / RFC 9700 §2.1).
|
|
273
|
+
*/
|
|
274
|
+
function consentScreenUrl(c: any, transactionId: string): string {
|
|
275
|
+
const config = c.get('config') ?? defaultProviderConfig;
|
|
276
|
+
const url = new URL('/consent', config.issuer);
|
|
277
|
+
url.searchParams.set('transaction_id', transactionId);
|
|
278
|
+
return url.toString();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Login Page - GET
|
|
283
|
+
* Displays the login form for user authentication.
|
|
284
|
+
*/
|
|
285
|
+
loginPage.get('/', async (c) => {
|
|
286
|
+
const transactionId = c.req.query('transaction_id');
|
|
287
|
+
if (!transactionId) {
|
|
288
|
+
return c.text('Missing transaction_id', 400);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const screen = await prepareLogin(c, transactionId);
|
|
292
|
+
if (screen.kind === 'error') return renderErrorPage(c, screen);
|
|
293
|
+
return renderLoginPage(c, loginPageParams(screen));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Login Handler - POST
|
|
298
|
+
* Submits the login form and shows the next screen.
|
|
299
|
+
*/
|
|
300
|
+
loginPage.post('/', async (c) => {
|
|
301
|
+
const body = await c.req.parseBody();
|
|
302
|
+
const outcome = await submitLogin(c, {
|
|
303
|
+
transactionId: String(body['transaction_id'] ?? ''),
|
|
304
|
+
csrfToken: String(body['csrf_token'] ?? ''),
|
|
305
|
+
username: String(body['username'] ?? ''),
|
|
306
|
+
password: String(body['password'] ?? ''),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (outcome.kind === 'error') return renderErrorPage(c, outcome);
|
|
310
|
+
if (outcome.kind === 'locked_out') {
|
|
311
|
+
return renderErrorPage(c, {
|
|
312
|
+
error: 'Too many login attempts',
|
|
313
|
+
statusCode: 429,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (outcome.kind === 'invalid_credentials') {
|
|
317
|
+
// The same form again, with the failure shown.
|
|
318
|
+
return renderLoginPage(c, {
|
|
319
|
+
...loginPageParams(outcome.screen),
|
|
320
|
+
error: 'Invalid credentials',
|
|
321
|
+
remainingAttempts: outcome.remainingAttempts,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
// Signed in: set the OP session cookie and continue to the consent step.
|
|
325
|
+
return redirectWithCookies(consentScreenUrl(c, outcome.transactionId), outcome.cookies);
|
|
326
|
+
});
|
|
327
|
+
${googleLoginRoute}`;
|
|
328
|
+
}
|
|
329
|
+
export function consentPageTemplate() {
|
|
330
|
+
return `/**
|
|
331
|
+
* Consent screen (screen routing layer).
|
|
332
|
+
*
|
|
333
|
+
* GET /consent renders the approve / deny form and POST /consent submits it.
|
|
334
|
+
* Neither handler holds OIDC logic: prepareConsent() and submitConsent() in
|
|
335
|
+
* routes/consent.ts load the transaction, check the User-Agent binding, record
|
|
336
|
+
* the decision, mint the authorization code and build the authorization
|
|
337
|
+
* response URL, and report what happened as an outcome. This file turns each
|
|
338
|
+
* outcome into a screen or a redirect. To customize the consent UI, edit this
|
|
339
|
+
* file or the consentPage view in views.ts; routes/consent.ts never has to
|
|
340
|
+
* change. Keep the two button values ('approve' / 'deny') as they are: the
|
|
341
|
+
* logic accepts exactly those.
|
|
342
|
+
*/
|
|
343
|
+
import { Hono } from 'hono';
|
|
344
|
+
import { prepareConsent, submitConsent } from '../routes/consent.js';
|
|
345
|
+
import { defaultViews, renderView, type ConsentPageParams } from '../views.js';
|
|
346
|
+
import { renderErrorPage } from './errors.js';
|
|
347
|
+
import { redirectWithCookies } from './respond.js';
|
|
348
|
+
|
|
349
|
+
export const consentPage = new Hono<{ Variables: Record<string, any> }>();
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Render the consent form. Swap the view, return a framework-rendered Response,
|
|
353
|
+
* or redirect to a UI of your own — here only.
|
|
354
|
+
*/
|
|
355
|
+
export function renderConsentPage(c: any, params: ConsentPageParams): Response {
|
|
356
|
+
const views = c.get('views') ?? defaultViews;
|
|
357
|
+
return renderView(views.consentPage(params));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Consent Page - GET
|
|
362
|
+
* Displays the consent form for scope authorization.
|
|
363
|
+
*/
|
|
364
|
+
consentPage.get('/', async (c) => {
|
|
365
|
+
const transactionId = c.req.query('transaction_id');
|
|
366
|
+
if (!transactionId) {
|
|
367
|
+
return c.text('Missing transaction_id', 400);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const screen = await prepareConsent(c, transactionId);
|
|
371
|
+
if (screen.kind === 'error') return renderErrorPage(c, screen);
|
|
372
|
+
return renderConsentPage(c, {
|
|
373
|
+
transactionId: screen.transactionId,
|
|
374
|
+
csrfToken: screen.csrfToken,
|
|
375
|
+
scopes: screen.scopes,
|
|
376
|
+
clientId: screen.clientId,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Consent Handler - POST
|
|
382
|
+
* Submits the consent decision and sends the browser on.
|
|
383
|
+
*/
|
|
384
|
+
consentPage.post('/', async (c) => {
|
|
385
|
+
const body = await c.req.parseBody();
|
|
386
|
+
const outcome = await submitConsent(c, {
|
|
387
|
+
transactionId: String(body['transaction_id'] ?? ''),
|
|
388
|
+
csrfToken: String(body['csrf_token'] ?? ''),
|
|
389
|
+
action: String(body['action'] ?? ''),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
if (outcome.kind === 'error') return renderErrorPage(c, outcome);
|
|
393
|
+
if (outcome.kind === 'invalid_decision') {
|
|
394
|
+
// OIDC Core 1.0 Section 3.1.2.4 / 3.1.2.6: no decision was obtained, which
|
|
395
|
+
// is not the same as the End-User denying — so the browser stays on the OP's
|
|
396
|
+
// own error page instead of being sent back to the client. 'approve' and
|
|
397
|
+
// 'deny' are the values the logic accepts; the buttons in views.ts
|
|
398
|
+
// consentPage() must keep sending exactly those.
|
|
399
|
+
return renderErrorPage(c, {
|
|
400
|
+
error: 'Invalid consent decision. Please use the Approve or Deny button.',
|
|
401
|
+
statusCode: 400,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (outcome.kind === 'session_missing') {
|
|
405
|
+
return renderErrorPage(c, {
|
|
406
|
+
error: 'Authentication session not found. Please restart login.',
|
|
407
|
+
statusCode: 400,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// Approved or denied: the authorization response is already in the URL.
|
|
411
|
+
return redirectWithCookies(outcome.location, outcome.cookies);
|
|
412
|
+
});
|
|
413
|
+
`;
|
|
414
|
+
}
|
|
415
|
+
export function devicePageTemplate() {
|
|
416
|
+
return `/**
|
|
417
|
+
* EXPERIMENTAL — Device Authorization Grant verification screens
|
|
418
|
+
* (RFC 8628 §3.3), screen routing layer.
|
|
419
|
+
*
|
|
420
|
+
* The end user opens /device on a second device, types the user_code the first
|
|
421
|
+
* device is showing, signs in, and approves or denies. All four routes of that
|
|
422
|
+
* UI are here; none of them holds logic. submitDeviceUserCode(),
|
|
423
|
+
* submitDeviceLogin() and submitDeviceDecision() in routes/device.ts match the
|
|
424
|
+
* code, mint the browser binding, check the credentials and record the
|
|
425
|
+
* decision, and report what happened as an outcome. This file turns each
|
|
426
|
+
* outcome into a screen, with the cookies the outcome carries. To customize
|
|
427
|
+
* the device UI, edit this file or the device* views in views.ts;
|
|
428
|
+
* routes/device.ts never has to change.
|
|
429
|
+
*
|
|
430
|
+
* Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable.
|
|
431
|
+
*/
|
|
432
|
+
import { Hono } from 'hono';
|
|
433
|
+
import { INVALID_USER_CODE_MESSAGE } from '${EXPERIMENTAL_PACKAGE}/device-authorization-grant';
|
|
434
|
+
import {
|
|
435
|
+
submitDeviceDecision,
|
|
436
|
+
submitDeviceLogin,
|
|
437
|
+
submitDeviceUserCode,
|
|
438
|
+
type DeviceOutcome,
|
|
439
|
+
} from '../routes/device.js';
|
|
440
|
+
import {
|
|
441
|
+
defaultViews,
|
|
442
|
+
renderView,
|
|
443
|
+
type DeviceApprovalPageParams,
|
|
444
|
+
type DeviceCompletedPageParams,
|
|
445
|
+
type DeviceLoginPageParams,
|
|
446
|
+
type DeviceVerificationPageParams,
|
|
447
|
+
} from '../views.js';
|
|
448
|
+
import { renderErrorPage } from './errors.js';
|
|
449
|
+
import { withCookies } from './respond.js';
|
|
450
|
+
|
|
451
|
+
export const devicePage = new Hono<{ Variables: Record<string, any> }>();
|
|
452
|
+
|
|
453
|
+
/** Render the user_code entry form (RFC 8628 §3.3). */
|
|
454
|
+
export function renderDeviceVerificationPage(
|
|
455
|
+
c: any,
|
|
456
|
+
params: DeviceVerificationPageParams,
|
|
457
|
+
): Response {
|
|
458
|
+
const views = c.get('views') ?? defaultViews;
|
|
459
|
+
return renderView(views.deviceVerificationPage(params));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Re-render the code entry form with the single, reason-free failure message.
|
|
464
|
+
*
|
|
465
|
+
* RFC 8628 §5.1: unknown, expired and already-used codes must be
|
|
466
|
+
* indistinguishable, otherwise the response itself confirms which codes exist.
|
|
467
|
+
*/
|
|
468
|
+
export function renderInvalidUserCode(c: any, userCode: string): Response {
|
|
469
|
+
const views = c.get('views') ?? defaultViews;
|
|
470
|
+
return renderView(
|
|
471
|
+
views.deviceVerificationPage({ userCode, error: INVALID_USER_CODE_MESSAGE }),
|
|
472
|
+
{ status: 400 },
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Render the sign-in form of the device flow. */
|
|
477
|
+
export function renderDeviceLoginPage(c: any, params: DeviceLoginPageParams): Response {
|
|
478
|
+
const views = c.get('views') ?? defaultViews;
|
|
479
|
+
return renderView(views.deviceLoginPage(params));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Render the approve / deny screen (RFC 8628 §5.4: the user_code is repeated). */
|
|
483
|
+
export function renderDeviceApprovalPage(c: any, params: DeviceApprovalPageParams): Response {
|
|
484
|
+
const views = c.get('views') ?? defaultViews;
|
|
485
|
+
return renderView(views.deviceApprovalPage(params));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Render the "go back to your device" screen. */
|
|
489
|
+
export function renderDeviceCompletedPage(c: any, params: DeviceCompletedPageParams): Response {
|
|
490
|
+
const views = c.get('views') ?? defaultViews;
|
|
491
|
+
return renderView(views.deviceCompletedPage(params));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Turn the outcome of a verification step into the screen that follows it. */
|
|
495
|
+
function respond(c: any, outcome: DeviceOutcome): Response {
|
|
496
|
+
if (outcome.kind === 'invalid_user_code') return renderInvalidUserCode(c, outcome.userCode);
|
|
497
|
+
if (outcome.kind === 'error') return renderErrorPage(c, outcome);
|
|
498
|
+
if (outcome.kind === 'session_required') {
|
|
499
|
+
return renderErrorPage(c, {
|
|
500
|
+
error: 'Sign in again to approve this device',
|
|
501
|
+
statusCode: 401,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (outcome.kind === 'locked_out') {
|
|
505
|
+
// The record is now denied: the device gets access_denied on its next poll.
|
|
506
|
+
return renderErrorPage(c, {
|
|
507
|
+
error: 'Too many login attempts',
|
|
508
|
+
statusCode: 429,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
if (outcome.kind === 'login') {
|
|
512
|
+
return withCookies(renderDeviceLoginPage(c, {
|
|
513
|
+
userCode: outcome.userCode,
|
|
514
|
+
csrfToken: outcome.csrfToken,
|
|
515
|
+
}), outcome.cookies);
|
|
516
|
+
}
|
|
517
|
+
if (outcome.kind === 'invalid_credentials') {
|
|
518
|
+
return renderDeviceLoginPage(c, {
|
|
519
|
+
userCode: outcome.userCode,
|
|
520
|
+
csrfToken: outcome.csrfToken,
|
|
521
|
+
error: 'Invalid credentials',
|
|
522
|
+
remainingAttempts: outcome.remainingAttempts,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (outcome.kind === 'approval') {
|
|
526
|
+
return withCookies(renderDeviceApprovalPage(c, {
|
|
527
|
+
userCode: outcome.userCode,
|
|
528
|
+
csrfToken: outcome.csrfToken,
|
|
529
|
+
clientId: outcome.clientId,
|
|
530
|
+
scopes: outcome.scopes,
|
|
531
|
+
}), outcome.cookies);
|
|
532
|
+
}
|
|
533
|
+
return withCookies(renderDeviceCompletedPage(c, {
|
|
534
|
+
approved: outcome.approved,
|
|
535
|
+
clientId: outcome.clientId,
|
|
536
|
+
}), outcome.cookies);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* User code entry form - GET
|
|
541
|
+
* RFC 8628 §3.3 / §3.3.1
|
|
542
|
+
*
|
|
543
|
+
* Unauthenticated and side-effect free. A user_code in the query string
|
|
544
|
+
* (verification_uri_complete) only pre-fills the field: nothing is looked up or
|
|
545
|
+
* mutated until the form is submitted, so following the complete URI never
|
|
546
|
+
* consumes or reveals anything.
|
|
547
|
+
*/
|
|
548
|
+
devicePage.get('/', (c) =>
|
|
549
|
+
renderDeviceVerificationPage(c, { userCode: c.req.query('user_code') ?? '' }),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
/** User code submission - POST (RFC 8628 §3.3) */
|
|
553
|
+
devicePage.post('/', async (c) => {
|
|
554
|
+
const body = await c.req.parseBody();
|
|
555
|
+
return respond(c, await submitDeviceUserCode(c, String(body['user_code'] ?? '')));
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
/** Device login - POST (RFC 8628 §3.3) */
|
|
559
|
+
devicePage.post('/login', async (c) => {
|
|
560
|
+
const body = await c.req.parseBody();
|
|
561
|
+
return respond(c, await submitDeviceLogin(c, {
|
|
562
|
+
userCode: String(body['user_code'] ?? ''),
|
|
563
|
+
csrfToken: String(body['csrf_token'] ?? ''),
|
|
564
|
+
username: String(body['username'] ?? ''),
|
|
565
|
+
password: String(body['password'] ?? ''),
|
|
566
|
+
}));
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
/** Approve or deny - POST (RFC 8628 §3.3) */
|
|
570
|
+
devicePage.post('/approve', async (c) => {
|
|
571
|
+
const body = await c.req.parseBody();
|
|
572
|
+
return respond(c, await submitDeviceDecision(c, {
|
|
573
|
+
userCode: String(body['user_code'] ?? ''),
|
|
574
|
+
csrfToken: String(body['csrf_token'] ?? ''),
|
|
575
|
+
decision: String(body['decision'] ?? ''),
|
|
576
|
+
}));
|
|
577
|
+
});
|
|
578
|
+
`;
|
|
579
|
+
}
|
|
580
|
+
export function cibaPageTemplate() {
|
|
581
|
+
return `/**
|
|
582
|
+
* EXPERIMENTAL — CIBA authentication device screens (CIBA Core 1.0 §7.1),
|
|
583
|
+
* screen routing layer.
|
|
584
|
+
*
|
|
585
|
+
* The user signs in at /ciba, reviews the pending requests addressed to them
|
|
586
|
+
* (client, scopes, binding_message) and approves or denies. All three routes of
|
|
587
|
+
* that UI are here; none of them holds logic. prepareCibaDevice(),
|
|
588
|
+
* submitCibaLogin() and submitCibaDecision() in routes/ciba-verification.ts
|
|
589
|
+
* mint the login transaction, check the credentials, list the requests and
|
|
590
|
+
* record the decision, and report what happened as an outcome. This file turns
|
|
591
|
+
* each outcome into a screen, with the cookies the outcome carries. To
|
|
592
|
+
* customize the CIBA UI, edit this file or the ciba* views in views.ts; the
|
|
593
|
+
* route module never has to change.
|
|
594
|
+
*
|
|
595
|
+
* Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable.
|
|
596
|
+
*/
|
|
597
|
+
import { Hono } from 'hono';
|
|
598
|
+
import {
|
|
599
|
+
prepareCibaDevice,
|
|
600
|
+
submitCibaDecision,
|
|
601
|
+
submitCibaLogin,
|
|
602
|
+
type CibaOutcome,
|
|
603
|
+
} from '../routes/ciba-verification.js';
|
|
604
|
+
import {
|
|
605
|
+
defaultViews,
|
|
606
|
+
renderView,
|
|
607
|
+
type CibaCompletedPageParams,
|
|
608
|
+
type CibaLoginPageParams,
|
|
609
|
+
type CibaPendingRequestsPageParams,
|
|
610
|
+
} from '../views.js';
|
|
611
|
+
import { renderErrorPage } from './errors.js';
|
|
612
|
+
import { withCookies } from './respond.js';
|
|
613
|
+
|
|
614
|
+
export const cibaPage = new Hono<{ Variables: Record<string, any> }>();
|
|
615
|
+
|
|
616
|
+
/** Render the sign-in form of the authentication device UI. */
|
|
617
|
+
export function renderCibaLoginPage(c: any, params: CibaLoginPageParams): Response {
|
|
618
|
+
const views = c.get('views') ?? defaultViews;
|
|
619
|
+
return renderView(views.cibaLoginPage(params));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Render the pending-requests approval screen (CIBA Core 1.0 §7.1 binding_message). */
|
|
623
|
+
export function renderCibaPendingRequestsPage(
|
|
624
|
+
c: any,
|
|
625
|
+
params: CibaPendingRequestsPageParams,
|
|
626
|
+
): Response {
|
|
627
|
+
const views = c.get('views') ?? defaultViews;
|
|
628
|
+
return renderView(views.cibaPendingRequestsPage(params));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Render the decision-recorded screen. */
|
|
632
|
+
export function renderCibaCompletedPage(c: any, params: CibaCompletedPageParams): Response {
|
|
633
|
+
const views = c.get('views') ?? defaultViews;
|
|
634
|
+
return renderView(views.cibaCompletedPage(params));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Turn the outcome of a step into the screen that follows it. */
|
|
638
|
+
function respond(c: any, outcome: CibaOutcome): Response {
|
|
639
|
+
if (outcome.kind === 'error') return renderErrorPage(c, outcome);
|
|
640
|
+
if (outcome.kind === 'session_required') {
|
|
641
|
+
return renderErrorPage(c, {
|
|
642
|
+
error: 'Sign in again to review this request',
|
|
643
|
+
statusCode: 401,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (outcome.kind === 'invalid_decision') {
|
|
647
|
+
return renderErrorPage(c, {
|
|
648
|
+
error: 'invalid_request',
|
|
649
|
+
errorDescription: 'decision must be approve or deny',
|
|
650
|
+
statusCode: 400,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (outcome.kind === 'locked_out') {
|
|
654
|
+
// The login transaction is gone: this form cannot be retried at all.
|
|
655
|
+
return renderErrorPage(c, {
|
|
656
|
+
error: 'Too many login attempts',
|
|
657
|
+
statusCode: 429,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (outcome.kind === 'login') {
|
|
661
|
+
return withCookies(renderCibaLoginPage(c, {
|
|
662
|
+
loginTransactionId: outcome.loginTransactionId,
|
|
663
|
+
csrfToken: outcome.csrfToken,
|
|
664
|
+
}), outcome.cookies);
|
|
665
|
+
}
|
|
666
|
+
if (outcome.kind === 'invalid_credentials') {
|
|
667
|
+
return renderCibaLoginPage(c, {
|
|
668
|
+
loginTransactionId: outcome.loginTransactionId,
|
|
669
|
+
csrfToken: outcome.csrfToken,
|
|
670
|
+
error: 'Invalid credentials',
|
|
671
|
+
remainingAttempts: outcome.remainingAttempts,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
if (outcome.kind === 'pending_requests') {
|
|
675
|
+
return withCookies(
|
|
676
|
+
renderCibaPendingRequestsPage(c, { requests: outcome.requests }),
|
|
677
|
+
outcome.cookies,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
return renderCibaCompletedPage(c, {
|
|
681
|
+
approved: outcome.approved,
|
|
682
|
+
clientId: outcome.clientId,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Listing / login form - GET
|
|
688
|
+
*
|
|
689
|
+
* With an OP session: the pending requests addressed to the signed-in user.
|
|
690
|
+
* Without one: the sign-in form, with the binding cookie its submission needs.
|
|
691
|
+
*/
|
|
692
|
+
cibaPage.get('/', async (c) => respond(c, await prepareCibaDevice(c)));
|
|
693
|
+
|
|
694
|
+
/** Sign in - POST */
|
|
695
|
+
cibaPage.post('/login', async (c) => {
|
|
696
|
+
const body = await c.req.parseBody();
|
|
697
|
+
return respond(c, await submitCibaLogin(c, {
|
|
698
|
+
loginTransactionId: String(body['login_transaction_id'] ?? ''),
|
|
699
|
+
csrfToken: String(body['csrf_token'] ?? ''),
|
|
700
|
+
username: String(body['username'] ?? ''),
|
|
701
|
+
password: String(body['password'] ?? ''),
|
|
702
|
+
}));
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
/** Approve or deny - POST */
|
|
706
|
+
cibaPage.post('/approve', async (c) => {
|
|
707
|
+
const body = await c.req.parseBody();
|
|
708
|
+
return respond(c, await submitCibaDecision(c, {
|
|
709
|
+
authReqId: String(body['auth_req_id'] ?? ''),
|
|
710
|
+
csrfToken: String(body['csrf_token'] ?? ''),
|
|
711
|
+
decision: String(body['decision'] ?? ''),
|
|
712
|
+
}));
|
|
713
|
+
});
|
|
714
|
+
`;
|
|
715
|
+
}
|
|
716
|
+
export function logoutPageTemplate() {
|
|
717
|
+
return `/**
|
|
718
|
+
* EXPERIMENTAL — RP-Initiated Logout screens (RP-Initiated Logout 1.0 §2),
|
|
719
|
+
* screen routing layer.
|
|
720
|
+
*
|
|
721
|
+
* The RP sends the user agent to GET|POST /logout; the browser then sees one
|
|
722
|
+
* of three things: the confirmation screen, the logged-out screen, or a
|
|
723
|
+
* redirect to the RP's registered post_logout_redirect_uri. Deciding which —
|
|
724
|
+
* verifying id_token_hint, matching the session, resolving the redirect — is
|
|
725
|
+
* processEndSessionRequest() and approveLogout() in routes/logout.ts; this file
|
|
726
|
+
* only turns their outcome into HTTP, with the cookies the outcome carries. To
|
|
727
|
+
* customize the logout UI, edit this file or the logout* views in views.ts;
|
|
728
|
+
* the route module never has to change. The confirmation form must keep
|
|
729
|
+
* posting csrf_token to /logout/approve: it is paired with the HttpOnly cookie
|
|
730
|
+
* the logic mints.
|
|
731
|
+
*
|
|
732
|
+
* Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable.
|
|
733
|
+
*/
|
|
734
|
+
import { Hono } from 'hono';
|
|
735
|
+
import { approveLogout, processEndSessionRequest, type LogoutOutcome } from '../routes/logout.js';
|
|
736
|
+
import {
|
|
737
|
+
defaultViews,
|
|
738
|
+
renderView,
|
|
739
|
+
type LogoutCompletedPageParams,
|
|
740
|
+
type LogoutConfirmationPageParams,
|
|
741
|
+
} from '../views.js';
|
|
742
|
+
import { renderErrorPage } from './errors.js';
|
|
743
|
+
import { redirectWithCookies, withCookies } from './respond.js';
|
|
744
|
+
|
|
745
|
+
export const logoutPage = new Hono<{ Variables: Record<string, any> }>();
|
|
746
|
+
|
|
747
|
+
/** Render the logout confirmation screen (§2 MUST when no valid hint is presented). */
|
|
748
|
+
export function renderLogoutConfirmationPage(
|
|
749
|
+
c: any,
|
|
750
|
+
params: LogoutConfirmationPageParams,
|
|
751
|
+
): Response {
|
|
752
|
+
const views = c.get('views') ?? defaultViews;
|
|
753
|
+
return renderView(views.logoutConfirmationPage(params));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Render the logged-out screen. */
|
|
757
|
+
export function renderLogoutCompletedPage(c: any, params: LogoutCompletedPageParams): Response {
|
|
758
|
+
const views = c.get('views') ?? defaultViews;
|
|
759
|
+
return renderView(views.logoutCompletedPage(params));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Turn the outcome of a logout step into the HTTP response. */
|
|
763
|
+
function respond(c: any, outcome: LogoutOutcome): Response {
|
|
764
|
+
if (outcome.kind === 'invalid_confirmation') {
|
|
765
|
+
// Forged, replayed or expired confirmation: nothing was deleted. This is a
|
|
766
|
+
// browser surface, so the answer is the error page, not OAuth error JSON.
|
|
767
|
+
return renderErrorPage(c, { error: 'Invalid logout confirmation', statusCode: 400 });
|
|
768
|
+
}
|
|
769
|
+
if (outcome.kind === 'confirmation') {
|
|
770
|
+
return withCookies(
|
|
771
|
+
renderLogoutConfirmationPage(c, { csrfToken: outcome.csrfToken }),
|
|
772
|
+
outcome.cookies,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
if (outcome.kind === 'redirect') {
|
|
776
|
+
// §3: the registered post_logout_redirect_uri, state already appended.
|
|
777
|
+
return redirectWithCookies(outcome.location, outcome.cookies);
|
|
778
|
+
}
|
|
779
|
+
return withCookies(renderLogoutCompletedPage(c, {}), outcome.cookies);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** end_session_endpoint - GET (§2: the OP MUST support GET and POST). */
|
|
783
|
+
logoutPage.get('/', async (c) =>
|
|
784
|
+
respond(c, await processEndSessionRequest(c, new URL(c.req.url).searchParams)),
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
/** end_session_endpoint - POST, application/x-www-form-urlencoded body (§2). */
|
|
788
|
+
logoutPage.post('/', async (c) => {
|
|
789
|
+
const body = await c.req.parseBody();
|
|
790
|
+
const params = new URLSearchParams();
|
|
791
|
+
for (const [key, value] of Object.entries(body)) {
|
|
792
|
+
if (typeof value === 'string') {
|
|
793
|
+
params.append(key, value);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return respond(c, await processEndSessionRequest(c, params));
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
/** Confirmation approve - POST */
|
|
800
|
+
logoutPage.post('/approve', async (c) => {
|
|
801
|
+
const body = await c.req.parseBody();
|
|
802
|
+
return respond(c, await approveLogout(c, String(body['csrf_token'] ?? '')));
|
|
803
|
+
});
|
|
804
|
+
`;
|
|
805
|
+
}
|
|
806
|
+
//# sourceMappingURL=pages.js.map
|