@maronn-openid-connect/cli 0.7.0 → 0.8.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 (67) hide show
  1. package/README.md +83 -7
  2. package/dist/frameworks/hono/index.d.ts.map +1 -1
  3. package/dist/frameworks/hono/index.js +18 -9
  4. package/dist/frameworks/hono/index.js.map +1 -1
  5. package/dist/frameworks/hono/pages.d.ts +7 -6
  6. package/dist/frameworks/hono/pages.d.ts.map +1 -1
  7. package/dist/frameworks/hono/pages.js +62 -44
  8. package/dist/frameworks/hono/pages.js.map +1 -1
  9. package/dist/frameworks/hono/templates.d.ts +19 -6
  10. package/dist/frameworks/hono/templates.d.ts.map +1 -1
  11. package/dist/frameworks/hono/templates.js +439 -304
  12. package/dist/frameworks/hono/templates.js.map +1 -1
  13. package/dist/frameworks/hono/views.d.ts +3 -0
  14. package/dist/frameworks/hono/views.d.ts.map +1 -0
  15. package/dist/frameworks/hono/views.js +458 -0
  16. package/dist/frameworks/hono/views.js.map +1 -0
  17. package/dist/frameworks/nextjs/authorize.d.ts +3 -0
  18. package/dist/frameworks/nextjs/authorize.d.ts.map +1 -0
  19. package/dist/frameworks/nextjs/authorize.js +757 -0
  20. package/dist/frameworks/nextjs/authorize.js.map +1 -0
  21. package/dist/frameworks/nextjs/ciba.d.ts +8 -0
  22. package/dist/frameworks/nextjs/ciba.d.ts.map +1 -0
  23. package/dist/frameworks/nextjs/ciba.js +591 -0
  24. package/dist/frameworks/nextjs/ciba.js.map +1 -0
  25. package/dist/frameworks/nextjs/conformance.d.ts +3 -0
  26. package/dist/frameworks/nextjs/conformance.d.ts.map +1 -0
  27. package/dist/frameworks/nextjs/conformance.js +2447 -0
  28. package/dist/frameworks/nextjs/conformance.js.map +1 -0
  29. package/dist/frameworks/nextjs/device.d.ts +8 -0
  30. package/dist/frameworks/nextjs/device.d.ts.map +1 -0
  31. package/dist/frameworks/nextjs/device.js +662 -0
  32. package/dist/frameworks/nextjs/device.js.map +1 -0
  33. package/dist/frameworks/nextjs/endpoints.d.ts +7 -0
  34. package/dist/frameworks/nextjs/endpoints.d.ts.map +1 -0
  35. package/dist/frameworks/nextjs/endpoints.js +673 -0
  36. package/dist/frameworks/nextjs/endpoints.js.map +1 -0
  37. package/dist/frameworks/nextjs/index.d.ts.map +1 -1
  38. package/dist/frameworks/nextjs/index.js +135 -2
  39. package/dist/frameworks/nextjs/index.js.map +1 -1
  40. package/dist/frameworks/nextjs/interaction.d.ts +13 -0
  41. package/dist/frameworks/nextjs/interaction.d.ts.map +1 -0
  42. package/dist/frameworks/nextjs/interaction.js +864 -0
  43. package/dist/frameworks/nextjs/interaction.js.map +1 -0
  44. package/dist/frameworks/nextjs/logout.d.ts +5 -0
  45. package/dist/frameworks/nextjs/logout.d.ts.map +1 -0
  46. package/dist/frameworks/nextjs/logout.js +271 -0
  47. package/dist/frameworks/nextjs/logout.js.map +1 -0
  48. package/dist/frameworks/nextjs/metadata.d.ts +6 -0
  49. package/dist/frameworks/nextjs/metadata.d.ts.map +1 -0
  50. package/dist/frameworks/nextjs/metadata.js +339 -0
  51. package/dist/frameworks/nextjs/metadata.js.map +1 -0
  52. package/dist/frameworks/nextjs/provider.d.ts +6 -0
  53. package/dist/frameworks/nextjs/provider.d.ts.map +1 -0
  54. package/dist/frameworks/nextjs/provider.js +797 -0
  55. package/dist/frameworks/nextjs/provider.js.map +1 -0
  56. package/dist/frameworks/nextjs/token.d.ts +7 -0
  57. package/dist/frameworks/nextjs/token.d.ts.map +1 -0
  58. package/dist/frameworks/nextjs/token.js +1337 -0
  59. package/dist/frameworks/nextjs/token.js.map +1 -0
  60. package/dist/frameworks/web-standard/templates.d.ts +1 -13
  61. package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
  62. package/dist/frameworks/web-standard/templates.js +12 -1296
  63. package/dist/frameworks/web-standard/templates.js.map +1 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +38 -8
  66. package/dist/index.js.map +1 -1
  67. package/package.json +1 -1
@@ -0,0 +1,662 @@
1
+ import { DEFAULT_FEATURES } from '../../features.js';
2
+ import { EXPERIMENTAL_PACKAGE } from '../hono/templates.js';
3
+ const DEVICE_PACKAGE = `${EXPERIMENTAL_PACKAGE}/device-authorization-grant`;
4
+ export function nextJsDeviceAuthorizationConfigTemplate() {
5
+ return `/**
6
+ * EXPERIMENTAL — Device Authorization Grant settings (RFC 8628).
7
+ *
8
+ * Read by the device authorization endpoint and the verification UI (/device).
9
+ *
10
+ * - deviceCodeExpiresIn: §3.2 expires_in, in seconds. Keep it short: it is the
11
+ * window in which a user_code can be guessed (§5.1) or phished (§5.4).
12
+ * - pollInterval: §3.2 interval, in seconds. The token endpoint raises a
13
+ * record's own interval by 5 every time it answers slow_down.
14
+ * - maxLoginAttempts: failed device logins allowed per record before it is
15
+ * denied. Per-record only — see device/login/route.ts.
16
+ *
17
+ * Not configurable: the user_code charset (RFC 8628 §6.1 base-20) and length
18
+ * (8). They carry the entropy claim, so they are constants in the experimental
19
+ * package rather than something a config typo can weaken.
20
+ */
21
+ export const deviceAuthorizationConfig = {
22
+ deviceCodeExpiresIn: 600,
23
+ pollInterval: 5,
24
+ maxLoginAttempts: 5,
25
+ };
26
+ `;
27
+ }
28
+ export function nextJsDeviceAuthorizationRouteTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
29
+ const customScopesDeclared = scopes.length > 0;
30
+ const customScopeImport = customScopesDeclared
31
+ ? `
32
+ import { findUnsupportedScopes } from '../_oidc-provider/scopes';`
33
+ : '';
34
+ const customScopeStep = customScopesDeclared
35
+ ? `
36
+ // RFC 6749 §3.3: reject a scope this OP never declared, the same way
37
+ // /authorize does. Checked after applyOfflineAccessPolicy so an
38
+ // offline_access the policy already dropped stays ignored rather than
39
+ // becoming invalid_scope (OIDC Core 1.0 §11).
40
+ const unsupportedScopes = findUnsupportedScopes(scope);
41
+ if (unsupportedScopes.length > 0) {
42
+ throw new DeviceAuthorizationError(
43
+ 'invalid_scope',
44
+ 'Unsupported scope: ' + unsupportedScopes.join(' '),
45
+ );
46
+ }
47
+ `
48
+ : '';
49
+ return `/**
50
+ * EXPERIMENTAL — Device Authorization Endpoint (RFC 8628 §3.1 / §3.2).
51
+ *
52
+ * Generated because the OP was created with \`--enable device-authorization-grant\`.
53
+ * Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable: it may change in a
54
+ * breaking way between releases. Do not build production code on it without
55
+ * pinning the version.
56
+ *
57
+ * The device (a TV app, a CLI, an IoT box) POSTs here — back channel,
58
+ * client-authenticated — and receives a device_code it polls the token endpoint
59
+ * with, plus a short user_code the End-User types into /device on another
60
+ * device's browser.
61
+ *
62
+ * NOTE (RFC 8628 §5.1): rate limiting the user_code guess surface is left to
63
+ * the deployment layer (reverse proxy / platform). An in-process counter cannot
64
+ * work across instances, so putting one here would give a false sense of
65
+ * protection. The in-band defenses are the 20^8 user_code entropy, the short
66
+ * TTL, and answering every failed match identically.
67
+ */
68
+ import {
69
+ DeviceAuthorizationError,
70
+ applyOfflineAccessPolicy,
71
+ buildDeviceAuthorizationResponse,
72
+ createDeviceAuthorizationRecord,
73
+ validateDeviceAuthorizationScope,
74
+ validateDeviceGrantAllowed,
75
+ } from '${DEVICE_PACKAGE}';
76
+ import {
77
+ TokenError,
78
+ extractClientCredentials,
79
+ resolveAuthenticatedTokenClient,
80
+ sanitizeErrorDescription,
81
+ validateClientAuthMethod,
82
+ verifyClientSecret,
83
+ } from '${corePkg}';
84
+ import { clientResolver, config, deviceAuthorizationStore } from '../_oidc-provider/provider';
85
+ import {
86
+ clientCors,
87
+ corsPreflight,
88
+ isFormUrlEncoded,
89
+ noStoreJson,
90
+ oauthError,
91
+ uniqueParams,
92
+ withCors,
93
+ } from '../_oidc-provider/http';${customScopeImport}
94
+ import { deviceAuthorizationConfig } from './config';
95
+
96
+ export const dynamic = 'force-dynamic';
97
+ export const runtime = 'nodejs';
98
+
99
+ export async function POST(request: Request): Promise<Response> {
100
+ return withCors(request, clientCors, await deviceAuthorization(request));
101
+ }
102
+
103
+ export function OPTIONS(request: Request): Response {
104
+ return corsPreflight(request, clientCors);
105
+ }
106
+
107
+ /**
108
+ * device_code is a credential, so every answer follows the token response rules
109
+ * of RFC 6749 §5.1 / §5.2 (no-store), although RFC 8628 §3.2 has no explicit rule.
110
+ */
111
+ async function deviceAuthorization(request: Request): Promise<Response> {
112
+ // RFC 8628 §3.1: the body MUST be application/x-www-form-urlencoded.
113
+ if (!isFormUrlEncoded(request)) {
114
+ return oauthError('invalid_request', 'Device authorization requests must use application/x-www-form-urlencoded');
115
+ }
116
+
117
+ // RFC 6749 §3.1: request parameters MUST NOT be repeated.
118
+ const { params, duplicateKey } = uniqueParams(new URLSearchParams(await request.text()));
119
+ if (duplicateKey !== undefined) {
120
+ return oauthError('invalid_request', \`Parameter "\${sanitizeErrorDescription(duplicateKey)}" must not be repeated\`);
121
+ }
122
+
123
+ try {
124
+ // --- Client authentication pipeline -------------------------------------
125
+ // RFC 8628 §3.1: "The client authentication requirements of Section 3.2.1 of
126
+ // [RFC6749] apply" — the same pipeline the token endpoint runs. Public
127
+ // clients present only client_id.
128
+ const presentedCredentials = extractClientCredentials({
129
+ params,
130
+ authorizationHeader: request.headers.get('Authorization') ?? '',
131
+ });
132
+ const client = await resolveAuthenticatedTokenClient(
133
+ presentedCredentials.clientId,
134
+ clientResolver,
135
+ );
136
+ validateClientAuthMethod(client, presentedCredentials);
137
+ await verifyClientSecret(client, presentedCredentials.clientSecret);
138
+
139
+ // --- Device authorization pipeline --------------------------------------
140
+ // Each step below is an independent function from
141
+ // ${DEVICE_PACKAGE}, called in RFC 8628 §3.1 order. Delete a call to drop
142
+ // that validation, or insert your own logic between steps.
143
+
144
+ // RFC 6749 §5.2: the client must be registered for the device_code grant.
145
+ validateDeviceGrantAllowed(client);
146
+
147
+ // RFC 8628 §3.1 leaves scope OPTIONAL, but this OP requires scope and openid
148
+ // everywhere (the same rule as /authorize), so a request without scope is
149
+ // rejected: a known, deliberate profile restriction.
150
+ const requestedScope = validateDeviceAuthorizationScope(params['scope']);
151
+
152
+ // OIDC Core 1.0 §11: drop offline_access when it could never be granted.
153
+ const scope = applyOfflineAccessPolicy(requestedScope, {
154
+ client,
155
+ refreshTokenFeatureEnabled: ${features.refreshToken ? 'true' : 'false'},
156
+ });
157
+ ${customScopeStep}
158
+ // RFC 8628 §3.2 / §5.2: mint a 256-bit device_code and a collision-checked
159
+ // base-20 user_code, then store the pending record under both.
160
+ const record = await createDeviceAuthorizationRecord({
161
+ clientId: client.clientId,
162
+ scope,
163
+ store: deviceAuthorizationStore,
164
+ expiresIn: deviceAuthorizationConfig.deviceCodeExpiresIn,
165
+ interval: deviceAuthorizationConfig.pollInterval,
166
+ });
167
+
168
+ // Never log device_code or user_code: both are live credentials for the
169
+ // lifetime of the record (RFC 8628 §5.1 / §5.2).
170
+
171
+ return noStoreJson(buildDeviceAuthorizationResponse(record, config.issuer));
172
+ } catch (error) {
173
+ if (error instanceof DeviceAuthorizationError) {
174
+ // RFC 6749 §5.2 shape. Authentication failures are core TokenErrors (401).
175
+ return oauthError(error.code, error.errorDescription, error.statusCode);
176
+ }
177
+ if (error instanceof TokenError) {
178
+ return oauthError(
179
+ error.error,
180
+ error.errorDescription,
181
+ error.statusCode,
182
+ error.wwwAuthenticate ? { 'WWW-Authenticate': error.wwwAuthenticate } : undefined,
183
+ );
184
+ }
185
+ return oauthError('server_error', undefined, 500);
186
+ }
187
+ }
188
+ `;
189
+ }
190
+ export function nextJsDeviceScreensTemplate() {
191
+ return `/**
192
+ * EXPERIMENTAL — the screens of the device verification UI (RFC 8628 §3.3).
193
+ *
194
+ * Restyle the UI here. Keep the form actions, field names and decision values:
195
+ * the Route Handlers next to this file read exactly those.
196
+ */
197
+ import {
198
+ DeviceAuthorizationError,
199
+ DeviceVerificationError,
200
+ INVALID_USER_CODE_MESSAGE,
201
+ } from '${DEVICE_PACKAGE}';
202
+ import { errorPage, escapeHtml, htmlResponse } from '../_oidc-provider/html';
203
+
204
+ /** The user_code entry form. userCode is untrusted input (query or a previous submission). */
205
+ export function verificationScreen(userCode: string, error?: string): Response {
206
+ const errorHtml = error ? \`<p style="color: red;">\${escapeHtml(error)}</p>\` : '';
207
+ return htmlResponse(
208
+ \`<!DOCTYPE html>
209
+ <html>
210
+ <head><title>Device Activation</title></head>
211
+ <body>
212
+ <h1>Device Activation</h1>
213
+ <p>Enter the code shown on your device.</p>
214
+ \${errorHtml}
215
+ <form method="POST" action="/device">
216
+ <div>
217
+ <label for="user_code">Code:</label>
218
+ <input type="text" id="user_code" name="user_code" value="\${escapeHtml(userCode)}" required />
219
+ </div>
220
+ <button type="submit">Continue</button>
221
+ </form>
222
+ </body>
223
+ </html>\`,
224
+ { status: error ? 400 : 200 },
225
+ );
226
+ }
227
+
228
+ /**
229
+ * The code entry form again, with the one reason-free failure message. RFC 8628
230
+ * §5.1: unknown, expired and already-used codes must be indistinguishable, or
231
+ * the response itself confirms which codes exist.
232
+ */
233
+ export function invalidUserCodeScreen(userCode: string): Response {
234
+ return verificationScreen(userCode, INVALID_USER_CODE_MESSAGE);
235
+ }
236
+
237
+ /** The sign-in form of the device flow; cookies carry the browser binding. */
238
+ export function loginScreen(
239
+ params: { userCode: string; csrfToken: string; error?: string; remainingAttempts?: number },
240
+ cookies: readonly string[] = [],
241
+ ): Response {
242
+ const errorHtml = params.error
243
+ ? \`<p style="color: red;">\${escapeHtml(params.error)}\${
244
+ params.remainingAttempts !== undefined
245
+ ? \`. Attempts remaining: \${params.remainingAttempts}\`
246
+ : ''
247
+ }</p>\`
248
+ : '';
249
+ return htmlResponse(
250
+ \`<!DOCTYPE html>
251
+ <html>
252
+ <head><title>Login</title></head>
253
+ <body>
254
+ <h1>Login</h1>
255
+ <p>Activating device code <strong>\${escapeHtml(params.userCode)}</strong></p>
256
+ \${errorHtml}
257
+ <form method="POST" action="/device/login">
258
+ <input type="hidden" name="user_code" value="\${escapeHtml(params.userCode)}" />
259
+ <input type="hidden" name="csrf_token" value="\${escapeHtml(params.csrfToken)}" />
260
+ <div>
261
+ <label for="username">Username:</label>
262
+ <input type="text" id="username" name="username" required />
263
+ </div>
264
+ <div>
265
+ <label for="password">Password:</label>
266
+ <input type="password" id="password" name="password" required />
267
+ </div>
268
+ <button type="submit">Login</button>
269
+ </form>
270
+ </body>
271
+ </html>\`,
272
+ { cookies },
273
+ );
274
+ }
275
+
276
+ /**
277
+ * The approve / deny screen. RFC 8628 §5.4: the user_code is repeated so the
278
+ * End-User can check it against the device in front of them — the only defense
279
+ * against being lured into approving someone else's device.
280
+ */
281
+ export function approvalScreen(
282
+ params: { userCode: string; csrfToken: string; clientId: string; scopes: readonly string[] },
283
+ cookies: readonly string[] = [],
284
+ ): Response {
285
+ const scopeListHtml = params.scopes.map((scope) => \` <li>\${escapeHtml(scope)}</li>\`).join('\\n');
286
+ return htmlResponse(
287
+ \`<!DOCTYPE html>
288
+ <html>
289
+ <head><title>Authorize Device</title></head>
290
+ <body>
291
+ <h1>Authorize Device</h1>
292
+ <p>Confirm that your device is showing this code: <strong>\${escapeHtml(params.userCode)}</strong></p>
293
+ <p>Do not continue if the code does not match.</p>
294
+ <p>Client <strong>\${escapeHtml(params.clientId)}</strong> is requesting access to the following scopes:</p>
295
+ <ul>
296
+ \${scopeListHtml}
297
+ </ul>
298
+ <form method="POST" action="/device/approve">
299
+ <input type="hidden" name="user_code" value="\${escapeHtml(params.userCode)}" />
300
+ <input type="hidden" name="csrf_token" value="\${escapeHtml(params.csrfToken)}" />
301
+ <button type="submit" name="decision" value="approve">Approve</button>
302
+ <button type="submit" name="decision" value="deny">Deny</button>
303
+ </form>
304
+ </body>
305
+ </html>\`,
306
+ { cookies },
307
+ );
308
+ }
309
+
310
+ /** The "go back to your device" screen. */
311
+ export function completedScreen(
312
+ params: { approved: boolean; clientId: string },
313
+ cookies: readonly string[] = [],
314
+ ): Response {
315
+ const outcome = params.approved
316
+ ? \`<p>You approved <strong>\${escapeHtml(params.clientId)}</strong>.</p>\`
317
+ : \`<p>You denied <strong>\${escapeHtml(params.clientId)}</strong>.</p>\`;
318
+ return htmlResponse(
319
+ \`<!DOCTYPE html>
320
+ <html>
321
+ <head><title>Device Activation</title></head>
322
+ <body>
323
+ <h1>Device Activation</h1>
324
+ \${outcome}
325
+ <p>You can close this page and go back to your device.</p>
326
+ </body>
327
+ </html>\`,
328
+ { cookies },
329
+ );
330
+ }
331
+
332
+ /**
333
+ * A failed binding or CSRF check, shown on the OP's error page with its status
334
+ * (403 for a missing binding). Anything that is not a verification failure is
335
+ * rethrown.
336
+ */
337
+ export function verificationFailureScreen(error: unknown): Response {
338
+ if (error instanceof DeviceVerificationError) {
339
+ return errorPage(error.message, error.statusCode);
340
+ }
341
+ if (error instanceof DeviceAuthorizationError) {
342
+ return errorPage(error.errorDescription, 400);
343
+ }
344
+ throw error;
345
+ }
346
+ `;
347
+ }
348
+ export function nextJsDeviceRouteTemplate(scopes = []) {
349
+ const customScopesDeclared = scopes.length > 0;
350
+ const customScopeImport = customScopesDeclared
351
+ ? `
352
+ import { resolveGrantableScopes } from '../_oidc-provider/scopes';`
353
+ : '';
354
+ const approvalScopes = customScopesDeclared
355
+ ? 'await resolveGrantableScopes(record.scope, session.subject)'
356
+ : 'record.scope';
357
+ return `/**
358
+ * EXPERIMENTAL — device verification UI, step 1 (RFC 8628 §3.3).
359
+ *
360
+ * The End-User opens /device on a second device and types the user_code the
361
+ * first device shows. On a match this step binds the verification to this
362
+ * browser: the user_code is known to whoever started the flow — possibly an
363
+ * attacker — so a CSRF token on the record alone would be no defense. The
364
+ * binding cookie minted here is what every later step requires (see
365
+ * buildDeviceBindingCookie() in store.ts for the full model).
366
+ *
367
+ * Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable.
368
+ */
369
+ import type { NextRequest } from 'next/server';
370
+ import {
371
+ findPendingRecordByUserCode,
372
+ issueVerificationBinding,
373
+ type DeviceAuthorizationRecord,
374
+ } from '${DEVICE_PACKAGE}';
375
+ import { deviceAuthorizationStore, stores } from '../_oidc-provider/provider';
376
+ import { buildDeviceBindingCookie, parseSessionId } from '../_oidc-provider/store';
377
+ import { readFormFields } from '../_oidc-provider/http';${customScopeImport}
378
+ import {
379
+ approvalScreen,
380
+ invalidUserCodeScreen,
381
+ loginScreen,
382
+ verificationScreen,
383
+ } from './screens';
384
+
385
+ export const dynamic = 'force-dynamic';
386
+ export const runtime = 'nodejs';
387
+
388
+ /**
389
+ * The user_code entry form (RFC 8628 §3.3 / §3.3.1). Unauthenticated and free
390
+ * of side effects: a user_code in the query (verification_uri_complete) only
391
+ * pre-fills the field, so following the complete URI consumes or reveals
392
+ * nothing.
393
+ */
394
+ export function GET(request: NextRequest): Response {
395
+ return verificationScreen(request.nextUrl.searchParams.get('user_code') ?? '');
396
+ }
397
+
398
+ /** User code submission: on a match, the sign-in form or — signed in already — the approval screen. */
399
+ export async function POST(request: Request): Promise<Response> {
400
+ const form = await readFormFields(request);
401
+ const submittedUserCode = String(form.get('user_code') ?? '');
402
+
403
+ const record = await findPendingRecordByUserCode(submittedUserCode, deviceAuthorizationStore);
404
+ if (!record) {
405
+ return invalidUserCodeScreen(submittedUserCode);
406
+ }
407
+
408
+ // Rotate the binding secret and the csrf token together. A second browser
409
+ // submitting the same user_code takes the binding over (last writer wins);
410
+ // that is inherent to a flow whose identifier is shareable by design.
411
+ const { bindingSecret, csrfToken } = await issueVerificationBinding(
412
+ record,
413
+ deviceAuthorizationStore,
414
+ );
415
+ const bindingCookie = buildDeviceBindingCookie(
416
+ record.userCode,
417
+ bindingSecret,
418
+ remainingTtlSeconds(record),
419
+ );
420
+
421
+ const sessionId = parseSessionId(request.headers.get('Cookie'));
422
+ const session = sessionId ? await stores.browserSessionStore.get(sessionId) : undefined;
423
+ if (session) {
424
+ return approvalScreen(
425
+ {
426
+ userCode: record.userCodeDisplay,
427
+ csrfToken,
428
+ clientId: record.clientId,
429
+ scopes: ${approvalScopes},
430
+ },
431
+ [bindingCookie],
432
+ );
433
+ }
434
+ return loginScreen({ userCode: record.userCodeDisplay, csrfToken }, [bindingCookie]);
435
+ }
436
+
437
+ /**
438
+ * Remaining lifetime of a record in whole seconds, rounded up so the binding
439
+ * cookie always outlives the record it binds (a cookie that expired first
440
+ * would turn a still-valid verification into an unexplained 403).
441
+ */
442
+ function remainingTtlSeconds(record: DeviceAuthorizationRecord): number {
443
+ return Math.max(0, Math.ceil((record.expiresAt.getTime() - Date.now()) / 1000));
444
+ }
445
+ `;
446
+ }
447
+ export function nextJsDeviceLoginRouteTemplate(corePkg, scopes = []) {
448
+ const customScopesDeclared = scopes.length > 0;
449
+ const customScopeImport = customScopesDeclared
450
+ ? `
451
+ import { resolveGrantableScopes } from '../../_oidc-provider/scopes';`
452
+ : '';
453
+ const approvalScopes = customScopesDeclared
454
+ ? 'await resolveGrantableScopes(record.scope, user.sub)'
455
+ : 'record.scope';
456
+ return `/**
457
+ * EXPERIMENTAL — device verification UI, step 2: sign in (RFC 8628 §3.3).
458
+ *
459
+ * Binding first, then CSRF, then credentials: the binding proves this is the
460
+ * browser that submitted the user_code, and it must gate the step that would
461
+ * otherwise let a forged POST plant an OP session in the victim's browser
462
+ * (login CSRF).
463
+ *
464
+ * Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable.
465
+ */
466
+ import {
467
+ findPendingRecordByUserCode,
468
+ recordDeviceLoginFailure,
469
+ validateVerificationBinding,
470
+ validateVerificationCsrfToken,
471
+ } from '${DEVICE_PACKAGE}';
472
+ import { generateRandomString } from '${corePkg}';
473
+ import { errorPage } from '../../_oidc-provider/html';
474
+ import { readFormFields } from '../../_oidc-provider/http';
475
+ import { deviceAuthorizationStore, stores } from '../../_oidc-provider/provider';
476
+ import { buildSessionCookie, parseDeviceBindingSecret } from '../../_oidc-provider/store';${customScopeImport}
477
+ import { deviceAuthorizationConfig } from '../../device_authorization/config';
478
+ import {
479
+ approvalScreen,
480
+ invalidUserCodeScreen,
481
+ loginScreen,
482
+ verificationFailureScreen,
483
+ } from '../screens';
484
+
485
+ export const dynamic = 'force-dynamic';
486
+ export const runtime = 'nodejs';
487
+
488
+ export async function POST(request: Request): Promise<Response> {
489
+ const form = await readFormFields(request);
490
+ const submittedUserCode = String(form.get('user_code') ?? '');
491
+ const csrfToken = String(form.get('csrf_token') ?? '');
492
+
493
+ const record = await findPendingRecordByUserCode(submittedUserCode, deviceAuthorizationStore);
494
+ if (!record) {
495
+ return invalidUserCodeScreen(submittedUserCode);
496
+ }
497
+
498
+ try {
499
+ await validateVerificationBinding(
500
+ record,
501
+ parseDeviceBindingSecret(request.headers.get('Cookie'), record.userCode),
502
+ );
503
+ validateVerificationCsrfToken(record, csrfToken);
504
+ } catch (error) {
505
+ return verificationFailureScreen(error);
506
+ }
507
+
508
+ // Swap point: replace this with your own credential check (LDAP, WebAuthn, an
509
+ // upstream IdP) without touching anything above or below it.
510
+ const user = await stores.userStore.authenticate(
511
+ String(form.get('username') ?? ''),
512
+ String(form.get('password') ?? ''),
513
+ );
514
+ if (!user) {
515
+ // Per-record throttling only. An attacker holding a device-grant client can
516
+ // mint unlimited records, so the aggregate password-guess budget is the same
517
+ // as the one on /login. Subject-scoped throttling is a separate concern.
518
+ const failure = await recordDeviceLoginFailure(
519
+ record,
520
+ deviceAuthorizationStore,
521
+ deviceAuthorizationConfig.maxLoginAttempts,
522
+ );
523
+ if (!failure.canRetry) {
524
+ // The record is now denied: the device gets access_denied on its next poll.
525
+ return errorPage('Too many login attempts', 429);
526
+ }
527
+ return loginScreen({
528
+ userCode: record.userCodeDisplay,
529
+ csrfToken,
530
+ error: 'Invalid credentials',
531
+ remainingAttempts: failure.remainingAttempts,
532
+ });
533
+ }
534
+
535
+ const sessionId = generateRandomString(32);
536
+ await stores.browserSessionStore.set(sessionId, {
537
+ subject: user.sub,
538
+ authTime: Math.floor(Date.now() / 1000),
539
+ });
540
+
541
+ // The new OP session travels with the approval screen; the approval step has
542
+ // to present the binding cookie again as well.
543
+ return approvalScreen(
544
+ {
545
+ userCode: record.userCodeDisplay,
546
+ csrfToken,
547
+ clientId: record.clientId,
548
+ scopes: ${approvalScopes},
549
+ },
550
+ [buildSessionCookie(sessionId)],
551
+ );
552
+ }
553
+ `;
554
+ }
555
+ export function nextJsDeviceApproveRouteTemplate(scopes = []) {
556
+ const customScopesDeclared = scopes.length > 0;
557
+ const customScopeImport = customScopesDeclared
558
+ ? `
559
+ import { resolveGrantableScopes } from '../../_oidc-provider/scopes';`
560
+ : '';
561
+ const approveNarrowStep = customScopesDeclared
562
+ ? `
563
+ // Apply the scope policy to what was approved. approveDeviceAuthorization()
564
+ // copies the requested scope into approvedScope, so the policy is applied
565
+ // afterwards and persisted; the token endpoint reads approvedScope, and
566
+ // RFC 6749 §3.3 allows a granted scope narrower than the request. Write the
567
+ // policy in resolveGrantableScopes() (scopes.ts).
568
+ approved.approvedScope = await resolveGrantableScopes(
569
+ approved.approvedScope ?? approved.scope,
570
+ session.subject,
571
+ );
572
+ await deviceAuthorizationStore.update(approved);
573
+ `
574
+ : '';
575
+ return `/**
576
+ * EXPERIMENTAL — device verification UI, step 3: approve or deny
577
+ * (RFC 8628 §3.3).
578
+ *
579
+ * The only state-changing step of the UI, so it demands all three: an OP
580
+ * session, the binding cookie, and the csrf_token. The device learns the
581
+ * outcome by polling the token endpoint.
582
+ *
583
+ * Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable.
584
+ */
585
+ import {
586
+ approveDeviceAuthorization,
587
+ denyDeviceAuthorization,
588
+ findPendingRecordByUserCode,
589
+ validateVerificationBinding,
590
+ } from '${DEVICE_PACKAGE}';
591
+ import { errorPage } from '../../_oidc-provider/html';
592
+ import { readFormFields } from '../../_oidc-provider/http';
593
+ import { deviceAuthorizationStore, resolvers, stores } from '../../_oidc-provider/provider';
594
+ import {
595
+ buildClearedDeviceBindingCookie,
596
+ parseDeviceBindingSecret,
597
+ parseSessionId,
598
+ } from '../../_oidc-provider/store';${customScopeImport}
599
+ import { completedScreen, invalidUserCodeScreen, verificationFailureScreen } from '../screens';
600
+
601
+ export const dynamic = 'force-dynamic';
602
+ export const runtime = 'nodejs';
603
+
604
+ export async function POST(request: Request): Promise<Response> {
605
+ const form = await readFormFields(request);
606
+ const submittedUserCode = String(form.get('user_code') ?? '');
607
+ const csrfToken = String(form.get('csrf_token') ?? '');
608
+
609
+ const record = await findPendingRecordByUserCode(submittedUserCode, deviceAuthorizationStore);
610
+ if (!record) {
611
+ return invalidUserCodeScreen(submittedUserCode);
612
+ }
613
+
614
+ const sessionId = parseSessionId(request.headers.get('Cookie'));
615
+ const session = sessionId ? await stores.browserSessionStore.get(sessionId) : undefined;
616
+ if (!session) {
617
+ return errorPage('Sign in again to approve this device', 401);
618
+ }
619
+
620
+ // The decision ends the verification; drop its binding cookie.
621
+ const clearBindingCookie = buildClearedDeviceBindingCookie(record.userCode);
622
+ try {
623
+ await validateVerificationBinding(
624
+ record,
625
+ parseDeviceBindingSecret(request.headers.get('Cookie'), record.userCode),
626
+ );
627
+
628
+ if (String(form.get('decision') ?? '') === 'approve') {
629
+ // csrf_token is validated inside; the record moves to approved with the
630
+ // subject, auth_time, scope and a fresh grantId the token endpoint reads.
631
+ const approved = await approveDeviceAuthorization({
632
+ record,
633
+ store: deviceAuthorizationStore,
634
+ csrfToken,
635
+ subject: session.subject,
636
+ authTime: session.authTime,
637
+ });
638
+ ${approveNarrowStep}
639
+ // Record the consent the way /consent does, so a later Authorization Code
640
+ // Flow for this client skips the consent screen (OIDC Core 1.0 §3.1.2.4),
641
+ // and the grant, so withdrawing that consent revokes these tokens too.
642
+ await resolvers.consentResolver.recordConsent?.(
643
+ session.subject,
644
+ approved.clientId,
645
+ approved.approvedScope ?? approved.scope,
646
+ );
647
+ if (approved.grantId) {
648
+ await resolvers.consentResolver.recordGrant(session.subject, approved.clientId, approved.grantId);
649
+ }
650
+ return completedScreen({ approved: true, clientId: approved.clientId }, [clearBindingCookie]);
651
+ }
652
+
653
+ // Anything but 'approve' denies.
654
+ await denyDeviceAuthorization({ record, store: deviceAuthorizationStore, csrfToken });
655
+ return completedScreen({ approved: false, clientId: record.clientId }, [clearBindingCookie]);
656
+ } catch (error) {
657
+ return verificationFailureScreen(error);
658
+ }
659
+ }
660
+ `;
661
+ }
662
+ //# sourceMappingURL=device.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device.js","sourceRoot":"","sources":["../../../src/frameworks/nextjs/device.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import type { OidcFeatureConfig } from '../../features.js';
2
+ export declare function nextJsUserinfoRouteTemplate(corePkg: string): string;
3
+ export declare function nextJsIntrospectionRouteTemplate(corePkg: string, features?: OidcFeatureConfig): string;
4
+ export declare function nextJsRevocationRouteTemplate(corePkg: string): string;
5
+ export declare function nextJsParConfigTemplate(): string;
6
+ export declare function nextJsParRouteTemplate(corePkg: string): string;
7
+ //# sourceMappingURL=endpoints.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"endpoints.d.ts","sourceRoot":"","sources":["../../../src/frameworks/nextjs/endpoints.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAI3D,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CA0MnE;AAGD,wBAAgB,gCAAgC,CAC9C,OAAO,EAAE,MAAM,EACf,QAAQ,GAAE,iBAAoC,GAC7C,MAAM,CA+LR;AAGD,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAwHrE;AAGD,wBAAgB,uBAAuB,IAAI,MAAM,CAsBhD;AAGD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAkI9D"}