@maronn-openid-connect/cli 0.5.0 → 0.7.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.
@@ -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