@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,864 @@
1
+ import { DEFAULT_FEATURES } from '../../features.js';
2
+ import { EXPERIMENTAL_PACKAGE, GOOGLE_LOGIN_PACKAGE } from '../hono/templates.js';
3
+ export function nextJsLoginSessionTemplate(corePkg, features = DEFAULT_FEATURES) {
4
+ const loginMethods = features.googleLogin
5
+ ? `shared
6
+ * by every login method (the password form's Server Action and the Google
7
+ * callback in login/google/route.ts).`
8
+ : `called by
9
+ * the login Server Action (actions.ts). A login method of your own (WebAuthn,
10
+ * an upstream IdP) calls it the same way once it has authenticated the user.`;
11
+ return `/**
12
+ * Starting the OP browser session once the End-User is authenticated — ${loginMethods}
13
+ */
14
+ import { cookies } from 'next/headers';
15
+ import { generateRandomString, type AuthTransaction } from '${corePkg}';
16
+ import { stores } from '../_oidc-provider/provider';
17
+ import { SESSION_COOKIE_NAME } from '../_oidc-provider/store';
18
+
19
+ /**
20
+ * Start the OP session for subject and hand it to the consent step of this
21
+ * transaction.
22
+ *
23
+ * The session (OIDC Core 1.0 §3.1.2.3) is what SSO, prompt=none and max_age
24
+ * read back on later authorization requests: an opaque id in an HttpOnly
25
+ * cookie, with the same attributes as buildSessionCookie() in store.ts —
26
+ * Secure, and SameSite=Lax because Strict would drop it on the cross-site
27
+ * redirect back from the client.
28
+ */
29
+ export async function startSession(
30
+ transactionId: string,
31
+ transaction: AuthTransaction,
32
+ subject: string,
33
+ ): Promise<void> {
34
+ const cookieStore = await cookies();
35
+
36
+ // prompt=login / select_account require fresh authentication: discard any
37
+ // existing hand-off for this transaction AND the browser's current session
38
+ // (OIDC Core 1.0 §3.1.2.1 — prompt is a space-delimited list).
39
+ const promptValues = transaction.prompt?.trim().split(/\\s+/).filter(Boolean) ?? [];
40
+ if (promptValues.includes('login') || promptValues.includes('select_account')) {
41
+ await stores.authSessionStore.delete(transactionId);
42
+ const existingSessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value;
43
+ if (existingSessionId) await stores.browserSessionStore.delete(existingSessionId);
44
+ }
45
+
46
+ const authTime = Math.floor(Date.now() / 1000);
47
+ const sessionId = generateRandomString(32);
48
+ await stores.browserSessionStore.set(sessionId, { subject, authTime });
49
+
50
+ // The per-transaction hand-off to the consent step. sessionId も渡すのは online
51
+ // refresh token のため。consent 経由で発行する認可コードにこのセッションを引き継ぎ、
52
+ // ログアウトで使えなくなる RT を作る。
53
+ await stores.authSessionStore.set(transactionId, { subject, authTime, sessionId });
54
+
55
+ cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
56
+ httpOnly: true,
57
+ secure: true,
58
+ sameSite: 'lax',
59
+ path: '/',
60
+ });
61
+ }
62
+ `;
63
+ }
64
+ export function nextJsTransactionTemplate(corePkg, features = DEFAULT_FEATURES) {
65
+ const binding = features.transactionBinding;
66
+ const navigationImport = binding
67
+ ? `import { notFound, redirect } from 'next/navigation';
68
+ import { cookies } from 'next/headers';`
69
+ : `import { notFound } from 'next/navigation';`;
70
+ const coreImports = [
71
+ 'AuthTransactionError',
72
+ 'getAuthTransaction',
73
+ ...(binding ? ['validateTransactionBinding'] : []),
74
+ 'type AuthTransaction',
75
+ ];
76
+ const bindingImports = binding
77
+ ? `
78
+ import { errorPagePath } from './http';
79
+ import { TRANSACTION_BINDING_COOKIE_PREFIX } from './store';`
80
+ : '';
81
+ const bindingDoc = binding
82
+ ? `
83
+ *
84
+ * OIDC Core 1.0 §3.1.2.3 / §3.1.2.4: transaction_id rides in the URL and can
85
+ * leak, so it proves nothing about the browser presenting it. Only the browser
86
+ * holding the transaction's binding cookie may continue it (see
87
+ * buildTransactionBindingCookie() in store.ts); any other is sent to the OP's
88
+ * error page before it sees a form or reaches a decision.`
89
+ : '';
90
+ const body = binding
91
+ ? ` let transaction: AuthTransaction;
92
+ try {
93
+ transaction = await getAuthTransaction(transactionId, stores.transactionStore);
94
+ } catch (error) {
95
+ if (error instanceof AuthTransactionError) notFound();
96
+ throw error;
97
+ }
98
+
99
+ try {
100
+ await validateTransactionBinding(
101
+ transaction,
102
+ (await cookies()).get(TRANSACTION_BINDING_COOKIE_PREFIX + transactionId)?.value,
103
+ );
104
+ } catch (error) {
105
+ if (error instanceof AuthTransactionError) redirect(errorPagePath(error.code, error.message));
106
+ throw error;
107
+ }
108
+ return transaction;`
109
+ : ` try {
110
+ return await getAuthTransaction(transactionId, stores.transactionStore);
111
+ } catch (error) {
112
+ if (error instanceof AuthTransactionError) notFound();
113
+ throw error;
114
+ }`;
115
+ return `/**
116
+ * Looking up the authorization transaction the login and consent steps continue.
117
+ */
118
+ ${navigationImport}
119
+ import {
120
+ ${coreImports.map((name) => ` ${name},`).join('\n')}
121
+ } from '${corePkg}';
122
+ import { stores } from './provider';${bindingImports}
123
+
124
+ /**
125
+ * The authorization transaction transaction_id names, for the login and consent
126
+ * pages and their Server Actions. When there is none — unknown, already
127
+ * finished, or expired — the request ends in notFound(), which renders the
128
+ * not-found.tsx beside the page: the End-User has to start over from the client
129
+ * application.${bindingDoc}
130
+ */
131
+ export async function requireTransaction(transactionId: string): Promise<AuthTransaction> {
132
+ ${body}
133
+ }
134
+ `;
135
+ }
136
+ export function nextJsErrorViewTemplate() {
137
+ return `/**
138
+ * The layout of the OP's error screens: the error page (oidc-error/page.tsx)
139
+ * and the not-found.tsx / error.tsx of the login and consent pages all render
140
+ * it, so every way the browser stops on the OP looks the same. Restyle it here.
141
+ */
142
+ import type { ReactNode } from 'react';
143
+
144
+ interface ErrorViewProps {
145
+ /** The error code: an OAuth error, or one of the OP's own. */
146
+ error: string;
147
+ /** Text for the End-User. React escapes it, so it cannot inject markup. */
148
+ description?: string;
149
+ /** Anything the screen adds below the message, such as a retry button. */
150
+ children?: ReactNode;
151
+ }
152
+
153
+ export function ErrorView({ error, description, children }: ErrorViewProps) {
154
+ return (
155
+ <main>
156
+ <h1>Error</h1>
157
+ <p>{error}</p>
158
+ {description ? <p>{description}</p> : null}
159
+ {children}
160
+ </main>
161
+ );
162
+ }
163
+ `;
164
+ }
165
+ const PAGE_LABELS = {
166
+ login: { component: 'Login', action: 'loginAction' },
167
+ consent: { component: 'Consent', action: 'consentAction' },
168
+ };
169
+ export function nextJsNotFoundTemplate(page) {
170
+ const { component, action } = PAGE_LABELS[page];
171
+ return `import { ErrorView } from '../_oidc-provider/error-view';
172
+
173
+ /**
174
+ * Not-found screen of the ${page} page (Next.js not-found.js): rendered when the
175
+ * page or ${action} calls notFound() because transaction_id names no
176
+ * authorization request — unknown, already finished, or expired. Next.js
177
+ * answers it with HTTP 404. The End-User can only start over from the client
178
+ * application.
179
+ */
180
+ export default function ${component}NotFound() {
181
+ return (
182
+ <ErrorView
183
+ error="transaction_not_found"
184
+ description="This sign-in request was not found or has expired. Start again from the application."
185
+ />
186
+ );
187
+ }
188
+ `;
189
+ }
190
+ export function nextJsErrorBoundaryTemplate(page) {
191
+ const { component, action } = PAGE_LABELS[page];
192
+ return `'use client'; // Error boundaries must be Client Components.
193
+
194
+ import { ErrorView } from '../_oidc-provider/error-view';
195
+
196
+ /**
197
+ * Error boundary of the ${page} page (Next.js error.js): the screen for an
198
+ * exception nobody expected while rendering the page or running ${action} —
199
+ * a store outage, a bug. Expected outcomes never land here: they redirect to the
200
+ * OP's error page or call notFound().
201
+ *
202
+ * In production Next.js withholds the error message from the browser and passes
203
+ * error.digest instead, which matches the entry in the server log. retry()
204
+ * fetches and renders the page again.
205
+ */
206
+ export default function ${component}Error({
207
+ error,
208
+ retry,
209
+ }: {
210
+ error: Error & { digest?: string };
211
+ retry: () => void;
212
+ }) {
213
+ return (
214
+ <ErrorView error="server_error" description={error.digest ? 'Reference: ' + error.digest : undefined}>
215
+ <button type="button" onClick={() => retry()}>
216
+ Try again
217
+ </button>
218
+ </ErrorView>
219
+ );
220
+ }
221
+ `;
222
+ }
223
+ export function nextJsLoginPageTemplate(features = DEFAULT_FEATURES) {
224
+ const binding = features.transactionBinding;
225
+ const google = features.googleLogin;
226
+ const googleImports = google
227
+ ? `
228
+ import Script from 'next/script';
229
+ import { issueGoogleLoginNonce } from '${GOOGLE_LOGIN_PACKAGE}';
230
+ import {
231
+ buildGoogleSignInAttributes,
232
+ GOOGLE_GSI_CLIENT_SCRIPT_URL,
233
+ } from '${GOOGLE_LOGIN_PACKAGE}/sign-in';
234
+ import { config, stores } from '../_oidc-provider/provider';`
235
+ : '';
236
+ const bindingDoc = binding
237
+ ? ` A browser that did not
238
+ * start the transaction never sees the form: it is sent to the OP's error page.`
239
+ : '';
240
+ const googleSignIn = google
241
+ ? `
242
+ // EXTENSION (google-login): the GIS configuration (g_id_onload attributes),
243
+ // built only when config.googleLogin is set. Each render issues a fresh nonce
244
+ // bound to this transaction; Google echoes it in the ID token, which is how
245
+ // login/google/route.ts finds its way back to this authorization request.
246
+ const googleLogin = config.googleLogin;
247
+ const googleSignIn = googleLogin
248
+ ? buildGoogleSignInAttributes({
249
+ clientId: googleLogin.clientId,
250
+ // Must equal an authorized redirect URI of the Google OAuth client. Built
251
+ // on config.issuer, like every URL the OP builds for itself.
252
+ loginUri: new URL('/login/google', config.issuer).toString(),
253
+ nonce: await issueGoogleLoginNonce({
254
+ transactionId,
255
+ expiresAt: transaction.expiresAt,
256
+ store: stores.googleLoginNonceStore,
257
+ }),
258
+ // OIDC Core 1.0 §3.1.2.1: pass login_hint on so Google can preselect the account.
259
+ loginHint: transaction.loginHint,
260
+ hostedDomain:
261
+ typeof googleLogin.hostedDomain === 'string' ? googleLogin.hostedDomain : undefined,
262
+ })
263
+ : undefined;
264
+ `
265
+ : '';
266
+ const googleSignInJsx = google
267
+ ? `
268
+ {/*
269
+ EXTENSION (google-login): the three elements GIS needs for redirect mode.
270
+ googleSignIn holds the g_id_onload attributes (data-ux_mode="redirect",
271
+ data-login_uri, data-nonce, ...) and spreads straight onto the element;
272
+ GIS replaces .g_id_signin with the button — style it through the GIS
273
+ button attributes (data-theme, data-size, data-text, ...).
274
+ */}
275
+ {googleSignIn ? (
276
+ <section aria-label="Sign in with Google">
277
+ <Script src={GOOGLE_GSI_CLIENT_SCRIPT_URL} strategy="afterInteractive" />
278
+ <div {...googleSignIn} />
279
+ <div className="g_id_signin" data-type="standard" />
280
+ </section>
281
+ ) : null}`
282
+ : '';
283
+ return `import { notFound } from 'next/navigation';${googleImports}
284
+ import { requireTransaction } from '../_oidc-provider/transaction';
285
+ import { loginAction } from './actions';
286
+
287
+ // /authorize redirects here with a per-request transaction_id, so the page must
288
+ // always render dynamically (never from a static cache).
289
+ export const dynamic = 'force-dynamic';
290
+
291
+ interface LoginPageProps {
292
+ searchParams: Promise<{
293
+ transaction_id?: string;
294
+ error?: string;
295
+ remaining?: string;
296
+ }>;
297
+ }
298
+
299
+ /**
300
+ * Login page (React Server Component).
301
+ *
302
+ * A real Next.js page, so the UI can be built with JSX, components, CSS modules
303
+ * and the rest of the React ecosystem. The form posts to the loginAction Server
304
+ * Action (actions.ts), which checks the credentials and starts the OP session.
305
+ * Keep the hidden transaction_id / csrf_token fields when customizing it.
306
+ *
307
+ * A transaction_id that names no transaction renders not-found.tsx (see
308
+ * requireTransaction() in _oidc-provider/transaction.ts).${bindingDoc}
309
+ */
310
+ export default async function LoginPage({ searchParams }: LoginPageProps) {
311
+ const { transaction_id: transactionId, error, remaining } = await searchParams;
312
+ if (!transactionId) notFound();
313
+ const transaction = await requireTransaction(transactionId);
314
+ ${googleSignIn}
315
+ const errorMessage =
316
+ error === 'invalid_credentials'
317
+ ? \`Invalid credentials\${remaining ? \`. Attempts remaining: \${remaining}\` : ''}\`
318
+ : null;
319
+
320
+ return (
321
+ <main>
322
+ <h1>Login</h1>
323
+ {errorMessage ? (
324
+ <p role="alert" style={{ color: 'red' }}>
325
+ {errorMessage}
326
+ </p>
327
+ ) : null}
328
+ <form action={loginAction}>
329
+ <input type="hidden" name="transaction_id" value={transactionId} />
330
+ <input type="hidden" name="csrf_token" value={transaction.csrfToken} />
331
+ <div>
332
+ <label htmlFor="username">Username:</label>
333
+ {/* OIDC Core 1.0 §3.1.2.1: login_hint MAY pre-fill the username. It is
334
+ an untrusted hint, used for the initial value only. */}
335
+ <input
336
+ type="text"
337
+ id="username"
338
+ name="username"
339
+ defaultValue={transaction.loginHint}
340
+ required
341
+ />
342
+ </div>
343
+ <div>
344
+ <label htmlFor="password">Password:</label>
345
+ <input type="password" id="password" name="password" required />
346
+ </div>
347
+ <button type="submit">Login</button>
348
+ </form>${googleSignInJsx}
349
+ </main>
350
+ );
351
+ }
352
+ `;
353
+ }
354
+ export function nextJsLoginActionTemplate(corePkg) {
355
+ return `'use server';
356
+
357
+ import { redirect } from 'next/navigation';
358
+ import {
359
+ AuthTransactionError,
360
+ AuthTransactionErrorCode,
361
+ handleLoginFailure,
362
+ validateCsrfToken,
363
+ } from '${corePkg}';
364
+ import { errorPagePath } from '../_oidc-provider/http';
365
+ import { stores } from '../_oidc-provider/provider';
366
+ import { requireTransaction } from '../_oidc-provider/transaction';
367
+ import { startSession } from './session';
368
+
369
+ /**
370
+ * Login Server Action.
371
+ *
372
+ * Checks the credentials, starts the OP session and continues to the consent
373
+ * step. A wrong password goes back to the form with the error in the query, so
374
+ * the page renders the message. A submission the OP refuses ends on the OP's
375
+ * error page (oidc-error/page.tsx), never at the client.
376
+ */
377
+ export async function loginAction(formData: FormData): Promise<void> {
378
+ const transactionId = String(formData.get('transaction_id') ?? '');
379
+ const transaction = await requireTransaction(transactionId);
380
+
381
+ // The CSRF token proves the submission came from the form this browser was shown.
382
+ try {
383
+ validateCsrfToken(transaction, String(formData.get('csrf_token') ?? ''));
384
+ } catch (error) {
385
+ if (error instanceof AuthTransactionError) redirect(errorPagePath(error.code, error.message));
386
+ throw error;
387
+ }
388
+
389
+ // Swap point: replace this with your own credential check (LDAP, WebAuthn, an
390
+ // upstream IdP) without touching anything above or below it.
391
+ const user = await stores.userStore.authenticate(
392
+ String(formData.get('username') ?? ''),
393
+ String(formData.get('password') ?? ''),
394
+ );
395
+ if (!user) {
396
+ const failure = await handleLoginFailure(transactionId, transaction, stores.transactionStore);
397
+ if (!failure.canRetry) {
398
+ // handleLoginFailure() deleted the transaction: this sign-in cannot continue.
399
+ redirect(
400
+ errorPagePath(
401
+ AuthTransactionErrorCode.MaxAttemptsExceeded,
402
+ 'Too many login attempts. Start again from the application.',
403
+ ),
404
+ );
405
+ }
406
+ const remaining = failure.maxAttempts - failure.failedAttempts;
407
+ redirect(
408
+ \`/login?transaction_id=\${encodeURIComponent(transactionId)}&error=invalid_credentials&remaining=\${remaining}\`,
409
+ );
410
+ }
411
+
412
+ await startSession(transactionId, transaction, user.sub);
413
+ redirect(\`/consent?transaction_id=\${encodeURIComponent(transactionId)}\`);
414
+ }
415
+ `;
416
+ }
417
+ export function nextJsGoogleLoginRouteTemplate(corePkg) {
418
+ return `/**
419
+ * EXTENSION (google-login) — the Google login callback (login_uri).
420
+ *
421
+ * Sign in with Google (redirect mode) posts the ID token here once the user
422
+ * picks an account. A Route Handler rather than a Server Action: the POST comes
423
+ * from Google's page and carries no Server Action id. After the callback checks
424
+ * it continues exactly like a password login: same session, same consent step.
425
+ *
426
+ * Transaction binding is deliberately NOT checked here: Google's POST is a
427
+ * cross-site navigation, so the browser withholds SameSite=Lax cookies. The
428
+ * single-use nonce stands in for it — it was issued to the login page, which
429
+ * only the bound browser could load.
430
+ *
431
+ * A failed callback ends on the OP's error page (oidc-error/page.tsx), like
432
+ * every error that must not reach a client.
433
+ */
434
+ import { notFound } from 'next/navigation';
435
+ import { NextResponse } from 'next/server';
436
+ import { AuthTransactionError, getAuthTransaction } from '${corePkg}';
437
+ import {
438
+ getDefaultGoogleIdTokenVerifier,
439
+ GoogleLoginError,
440
+ handleGoogleLoginRedirect,
441
+ resolveGoogleLoginSubject,
442
+ type GoogleAccountResolver,
443
+ type GoogleIdTokenPayload,
444
+ type GoogleIdTokenVerifier,
445
+ } from '${GOOGLE_LOGIN_PACKAGE}';
446
+ import { readFormFields, redirectToErrorPage } from '../../_oidc-provider/http';
447
+ import { config, stores } from '../../_oidc-provider/provider';
448
+ import { startSession } from '../session';
449
+
450
+ export const dynamic = 'force-dynamic';
451
+ export const runtime = 'nodejs';
452
+
453
+ /**
454
+ * Verifies the ID token Google posts: google-auth-library
455
+ * (OAuth2Client.verifyIdToken) with a process-wide certificate cache. Replace
456
+ * it for tests or a proxied environment.
457
+ */
458
+ const googleIdTokenVerifier: GoogleIdTokenVerifier = getDefaultGoogleIdTokenVerifier();
459
+
460
+ /**
461
+ * Maps a verified Google account to the OP subject: just-in-time provisioning
462
+ * through the user store (userStore.linkGoogleAccount), keyed by the Google sub.
463
+ */
464
+ const googleAccountResolver: GoogleAccountResolver = {
465
+ resolveSubject: async (account: GoogleIdTokenPayload) =>
466
+ (await stores.userStore.linkGoogleAccount(account)).sub,
467
+ };
468
+
469
+ export async function POST(request: Request): Promise<Response> {
470
+ const googleLogin = config.googleLogin;
471
+ // Without config.googleLogin there is no Google login to call back into.
472
+ if (!googleLogin) notFound();
473
+
474
+ let transactionId: string;
475
+ let subject: string;
476
+ try {
477
+ // Double Submit Cookie -> google-auth-library verification -> nonce lookup,
478
+ // in the order Google's server-side verification guide prescribes.
479
+ const login = await handleGoogleLoginRedirect({
480
+ params: await readFormFields(request),
481
+ cookieHeader: request.headers.get('Cookie'),
482
+ clientId: googleLogin.clientId,
483
+ verifier: googleIdTokenVerifier,
484
+ nonceStore: stores.googleLoginNonceStore,
485
+ hostedDomain: googleLogin.hostedDomain,
486
+ requireVerifiedEmail: googleLogin.requireVerifiedEmail,
487
+ });
488
+ transactionId = login.transactionId;
489
+ subject = await resolveGoogleLoginSubject(login.account, googleAccountResolver);
490
+ } catch (error) {
491
+ // Until the nonce is verified the OP cannot tell whose transaction this is,
492
+ // so a failed callback can only end on the OP.
493
+ if (!(error instanceof GoogleLoginError)) throw error;
494
+ return redirectToErrorPage(error.code, error.message);
495
+ }
496
+
497
+ let transaction;
498
+ try {
499
+ transaction = await getAuthTransaction(transactionId, stores.transactionStore);
500
+ } catch (error) {
501
+ if (!(error instanceof AuthTransactionError)) throw error;
502
+ return redirectToErrorPage(error.code, error.message);
503
+ }
504
+
505
+ await startSession(transactionId, transaction, subject);
506
+ const consentUrl = new URL('/consent', config.issuer);
507
+ consentUrl.searchParams.set('transaction_id', transactionId);
508
+ return NextResponse.redirect(consentUrl, 302);
509
+ }
510
+ `;
511
+ }
512
+ export function nextJsConsentPageTemplate(features = DEFAULT_FEATURES, scopes = []) {
513
+ const binding = features.transactionBinding;
514
+ const customScopesDeclared = scopes.length > 0;
515
+ const scopeImports = customScopesDeclared
516
+ ? `
517
+ import { stores } from '../_oidc-provider/provider';
518
+ import { resolveGrantableScopes } from '../_oidc-provider/scopes';`
519
+ : '';
520
+ const bindingDoc = binding
521
+ ? ` A browser that did not
522
+ * start the transaction never sees the form: it is sent to the OP's error page.`
523
+ : '';
524
+ const displayedScopes = customScopesDeclared
525
+ ? `
526
+ // Show only what THIS End-User can actually grant (resolveGrantableScopes in
527
+ // scopes.ts). The subject comes from the login step's hand-off; without one
528
+ // the request is shown as-is, and the Server Action stops on the same missing
529
+ // session.
530
+ const requestedScopes = transaction.scope.split(' ').filter(Boolean);
531
+ const consentSession = await stores.authSessionStore.get(transactionId);
532
+ const scopes = consentSession
533
+ ? await resolveGrantableScopes(requestedScopes, consentSession.subject)
534
+ : requestedScopes;
535
+ `
536
+ : `
537
+ const scopes = transaction.scope.split(' ').filter(Boolean);
538
+ `;
539
+ return `import { notFound } from 'next/navigation';${scopeImports}
540
+ import { requireTransaction } from '../_oidc-provider/transaction';
541
+ import { consentAction } from './actions';
542
+
543
+ export const dynamic = 'force-dynamic';
544
+
545
+ interface ConsentPageProps {
546
+ searchParams: Promise<{ transaction_id?: string }>;
547
+ }
548
+
549
+ /**
550
+ * Consent page (React Server Component).
551
+ *
552
+ * A real Next.js page, so the consent UI can be built with JSX and React
553
+ * components. The form posts to the consentAction Server Action (actions.ts).
554
+ *
555
+ * A transaction_id that names no transaction renders not-found.tsx (see
556
+ * requireTransaction() in _oidc-provider/transaction.ts).${bindingDoc}
557
+ */
558
+ export default async function ConsentPage({ searchParams }: ConsentPageProps) {
559
+ const { transaction_id: transactionId } = await searchParams;
560
+ if (!transactionId) notFound();
561
+ const transaction = await requireTransaction(transactionId);
562
+ ${displayedScopes}
563
+ return (
564
+ <main>
565
+ <h1>Authorize Application</h1>
566
+ <p>
567
+ Client <strong>{transaction.clientId}</strong> is requesting access to the
568
+ following scopes:
569
+ </p>
570
+ <ul>
571
+ {scopes.map((scope) => (
572
+ <li key={scope}>{scope}</li>
573
+ ))}
574
+ </ul>
575
+ {/*
576
+ The submit buttons carry the authorization decision (OIDC Core 1.0
577
+ §3.1.2.4). consentAction accepts exactly 'approve' and 'deny' and rejects
578
+ everything else, so keep both values when customizing this markup:
579
+ renaming 'approve' makes every approval fail with an error page.
580
+ */}
581
+ <form action={consentAction}>
582
+ <input type="hidden" name="transaction_id" value={transactionId} />
583
+ <input type="hidden" name="csrf_token" value={transaction.csrfToken} />
584
+ <button type="submit" name="action" value="approve">
585
+ Approve
586
+ </button>
587
+ <button type="submit" name="action" value="deny">
588
+ Deny
589
+ </button>
590
+ </form>
591
+ </main>
592
+ );
593
+ }
594
+ `;
595
+ }
596
+ export function nextJsConsentActionTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
597
+ const binding = features.transactionBinding;
598
+ const jarm = features.jarm;
599
+ const customScopesDeclared = scopes.length > 0;
600
+ const coreImports = [
601
+ 'AuthTransactionError',
602
+ 'completeAuthTransaction',
603
+ 'createAuthorizationCode',
604
+ 'validateCsrfToken',
605
+ ...(jarm ? ['selectSigningKeyByAlg'] : []),
606
+ 'type AuthTransaction',
607
+ ...(jarm ? ['type SigningKey'] : []),
608
+ ];
609
+ const nextImports = binding
610
+ ? `import { cookies } from 'next/headers';
611
+ import { redirect } from 'next/navigation';`
612
+ : `import { redirect } from 'next/navigation';`;
613
+ const jarmImports = jarm
614
+ ? `
615
+ import {
616
+ buildJarmRedirectUrl,
617
+ createJarmResponseJwt,
618
+ type JarmAuthTransactionFields,
619
+ } from '${EXPERIMENTAL_PACKAGE}/jarm';`
620
+ : '';
621
+ const providerImport = jarm
622
+ ? `import { errorPagePath } from '../_oidc-provider/http';
623
+ import { config, loadSigningKeys, resolvers, stores } from '../_oidc-provider/provider';`
624
+ : `import { errorPagePath } from '../_oidc-provider/http';
625
+ import { config, resolvers, stores } from '../_oidc-provider/provider';`;
626
+ const bindingStoreImport = binding
627
+ ? `
628
+ import { TRANSACTION_BINDING_COOKIE_PREFIX } from '../_oidc-provider/store';`
629
+ : '';
630
+ const jarmConfigImport = jarm
631
+ ? `
632
+ import { jarmConfig } from '../_oidc-provider/jarm';`
633
+ : '';
634
+ const customScopeImport = customScopesDeclared
635
+ ? `
636
+ import { resolveGrantableScopes } from '../_oidc-provider/scopes';`
637
+ : '';
638
+ const clearBinding = (indent) => binding
639
+ ? `${indent}// The transaction is over; drop its binding cookie.
640
+ ${indent}(await cookies()).delete(TRANSACTION_BINDING_COOKIE_PREFIX + transactionId);
641
+ `
642
+ : '';
643
+ const grantedScope = customScopesDeclared
644
+ ? ` // Apply the scope policy (resolveGrantableScopes in scopes.ts — the place for
645
+ // per-user filtering). A dropped scope narrows the grant rather than failing
646
+ // the request: RFC 6749 §3.3 lets the authorization server issue a narrower
647
+ // scope, and the token response reports what was granted.
648
+ const grantedScope = await resolveGrantableScopes(
649
+ transaction.scope.split(' ').filter(Boolean),
650
+ session.subject,
651
+ );`
652
+ : ` // transaction.scope は認可リクエスト検証時に applyOfflineAccessPolicy を通した後の値。
653
+ // offline_access の可否(OIDC Core 1.0 §11 の prompt=consent と、クライアント登録
654
+ // grant_types に refresh_token があるか)はそこで確定しているので再フィルタしない。
655
+ const grantedScope = transaction.scope.split(' ').filter(Boolean);`;
656
+ const responseUrlHelper = jarm
657
+ ? `/**
658
+ * EXPERIMENTAL — JARM §3: the key a response JWT is signed with, for a
659
+ * transaction on which the authorize step recorded response_mode=query.jwt (the
660
+ * transaction store MUST persist fields it does not know about); undefined for
661
+ * the plain query response. The JWT declares alg RS256, so the key is picked by
662
+ * alg from the registered set — the key /.well-known/jwks.json publishes.
663
+ */
664
+ async function jarmSigningKeyFor(
665
+ transaction: AuthTransaction & JarmAuthTransactionFields,
666
+ ): Promise<SigningKey | undefined> {
667
+ if (transaction.jarmResponseMode !== 'query.jwt') return undefined;
668
+ return selectSigningKeyByAlg((await loadSigningKeys()).general.registered, 'RS256');
669
+ }
670
+
671
+ /**
672
+ * The authorization response URL: the parameters in the query with iss
673
+ * (RFC 9207 §2) — or, EXPERIMENTAL JARM §2.3.1, with a JARM signing key, one
674
+ * signed JWT in the \`response\` parameter. In JARM mode the JWT's iss claim
675
+ * replaces the iss parameter.
676
+ */
677
+ async function authorizationResponseUrl(
678
+ transaction: AuthTransaction,
679
+ parameters: Record<string, string | undefined>,
680
+ jarmSigningKey: SigningKey | undefined,
681
+ ): Promise<string> {
682
+ if (jarmSigningKey) {
683
+ return buildJarmRedirectUrl(
684
+ transaction.redirectUri,
685
+ await createJarmResponseJwt({
686
+ issuer: config.issuer,
687
+ clientId: transaction.clientId,
688
+ parameters,
689
+ signingKey: jarmSigningKey,
690
+ lifetimeSeconds: jarmConfig.jarmResponseLifetimeSeconds,
691
+ }),
692
+ );
693
+ }
694
+ const url = new URL(transaction.redirectUri);
695
+ for (const [name, value] of Object.entries(parameters)) {
696
+ if (value !== undefined) url.searchParams.set(name, value);
697
+ }
698
+ url.searchParams.set('iss', config.issuer);
699
+ return url.toString();
700
+ }`
701
+ : `/**
702
+ * The authorization response URL: the parameters in the query, with iss on
703
+ * every response, success and error alike (RFC 9207 §2).
704
+ */
705
+ function authorizationResponseUrl(
706
+ transaction: AuthTransaction,
707
+ parameters: Record<string, string | undefined>,
708
+ ): string {
709
+ const url = new URL(transaction.redirectUri);
710
+ for (const [name, value] of Object.entries(parameters)) {
711
+ if (value !== undefined) url.searchParams.set(name, value);
712
+ }
713
+ url.searchParams.set('iss', config.issuer);
714
+ return url.toString();
715
+ }`;
716
+ const responseAwait = jarm ? 'await ' : '';
717
+ const jarmKeyArgument = jarm ? ', jarmSigningKey' : '';
718
+ const jarmKeyStep = jarm
719
+ ? `
720
+ // EXPERIMENTAL — JARM: load the response signing key before anything is
721
+ // recorded, so a key outage stops here instead of after the code and the
722
+ // consent were stored.
723
+ let jarmSigningKey: SigningKey | undefined;
724
+ try {
725
+ jarmSigningKey = await jarmSigningKeyFor(transaction);
726
+ } catch {
727
+ redirect(errorPagePath('server_error', 'Failed to load the response signing key'));
728
+ }
729
+ `
730
+ : '';
731
+ return `'use server';
732
+
733
+ ${nextImports}
734
+ import {
735
+ ${coreImports.map((name) => ` ${name},`).join('\n')}
736
+ } from '${corePkg}';${jarmImports}
737
+ ${providerImport}${bindingStoreImport}${jarmConfigImport}${customScopeImport}
738
+ import { requireTransaction } from '../_oidc-provider/transaction';
739
+
740
+ /**
741
+ * Consent Server Action: records the End-User's decision and sends the browser
742
+ * back to the client with the authorization response (OIDC Core 1.0 §3.1.2.5 /
743
+ * §3.1.2.6).
744
+ */
745
+ export async function consentAction(formData: FormData): Promise<void> {
746
+ const transactionId = String(formData.get('transaction_id') ?? '');
747
+ const action = String(formData.get('action') ?? '');
748
+ const transaction${jarm ? ': AuthTransaction & JarmAuthTransactionFields' : ''} = await requireTransaction(transactionId);
749
+
750
+ // The CSRF token proves the decision came from the form this browser was
751
+ // shown. Checked before any decision is acted on: this step mints the
752
+ // authorization code.
753
+ try {
754
+ validateCsrfToken(transaction, String(formData.get('csrf_token') ?? ''));
755
+ } catch (error) {
756
+ if (error instanceof AuthTransactionError) redirect(errorPagePath(error.code, error.message));
757
+ throw error;
758
+ }
759
+ ${jarmKeyStep}
760
+ if (action === 'deny') {
761
+ await stores.transactionStore.delete('auth_txn:' + transactionId);
762
+ await stores.authSessionStore.delete(transactionId);
763
+ ${clearBinding(' ')} redirect(${responseAwait}authorizationResponseUrl(transaction, {
764
+ error: 'access_denied',
765
+ state: transaction.state,
766
+ }${jarmKeyArgument}));
767
+ }
768
+
769
+ // OIDC Core 1.0 §3.1.2.4: "the Authorization Server MUST obtain an
770
+ // authorization decision before releasing information to the Relying Party."
771
+ // This action mints the authorization code, so the affirmative decision is
772
+ // detected on an allowlist: a missing, empty or unknown 'action' means no
773
+ // decision was obtained and must not approve. 'approve' MUST stay in sync with
774
+ // the Approve button in page.tsx.
775
+ //
776
+ // §3.1.2.6: access_denied means the End-User denied the request, which is not
777
+ // the same as no decision at all — an unrecognized value therefore goes to the
778
+ // OP's own error page instead of back to the client.
779
+ if (action !== 'approve') {
780
+ redirect(
781
+ errorPagePath('invalid_request', 'Invalid consent decision. Please use the Approve or Deny button.'),
782
+ );
783
+ }
784
+
785
+ // The login step's hand-off: no authenticated subject means the login step
786
+ // was skipped or has expired.
787
+ const session = await stores.authSessionStore.get(transactionId);
788
+ if (!session) {
789
+ redirect(\`/login?transaction_id=\${encodeURIComponent(transactionId)}\`);
790
+ }
791
+
792
+ const responseParams = await completeAuthTransaction(
793
+ transactionId,
794
+ transaction,
795
+ stores.transactionStore,
796
+ );
797
+
798
+ ${grantedScope}
799
+
800
+ // OIDC Core 1.0 §3.1.3.1: TTL is configurable via ProviderConfig (300 seconds by default).
801
+ const authCodeData = await createAuthorizationCode({
802
+ authorizationResponse: { ...responseParams, scope: grantedScope },
803
+ subject: session.subject,
804
+ authTime: session.authTime,
805
+ // online refresh token をこのログインセッションへ束縛する(login step が
806
+ // authSessionStore へ載せた値)。ログアウトすれば RT も使えなくなる。
807
+ sessionId: session.sessionId,
808
+ ttlSeconds: config.authorizationCodeTtl,
809
+ });
810
+ await stores.authCodeStore.set(authCodeData.code, authCodeData);
811
+
812
+ // Record the consent so a later prompt=none (or SSO) request can confirm it
813
+ // without UI (OIDC Core 1.0 §3.1.2.1 / §3.1.2.4), and the grant, so
814
+ // withdrawing that consent revokes the tokens issued from it. Only the
815
+ // per-transaction hand-off is cleared; the OP session persists for SSO.
816
+ await resolvers.consentResolver.recordConsent?.(session.subject, transaction.clientId, grantedScope);
817
+ await resolvers.consentResolver.recordGrant(session.subject, transaction.clientId, authCodeData.grantId);
818
+ await stores.authSessionStore.delete(transactionId);
819
+
820
+ ${clearBinding(' ')} redirect(${responseAwait}authorizationResponseUrl(transaction, {
821
+ code: authCodeData.code,
822
+ state: responseParams.state,
823
+ }${jarmKeyArgument}));
824
+ }
825
+
826
+ ${responseUrlHelper}
827
+ `;
828
+ }
829
+ export function nextJsErrorPageTemplate() {
830
+ return `import type { Metadata } from 'next';
831
+ import { ErrorView } from '../_oidc-provider/error-view';
832
+
833
+ // The Authorization Endpoint, the login / consent steps and the Google callback
834
+ // send the browser here with error / error_description in the query, so the
835
+ // page renders per request.
836
+ export const dynamic = 'force-dynamic';
837
+
838
+ export const metadata: Metadata = {
839
+ title: 'Error',
840
+ // An error screen is never a search result.
841
+ robots: { index: false },
842
+ };
843
+
844
+ interface OidcErrorPageProps {
845
+ searchParams: Promise<{ error?: string; error_description?: string }>;
846
+ }
847
+
848
+ /**
849
+ * The OP's own error page (OIDC Core 1.0 §3.1.2.2).
850
+ *
851
+ * Errors that must not be redirected to the client end up here: an unknown
852
+ * client_id, an unregistered redirect_uri, a transaction this browser may not
853
+ * continue, a consent POST without a decision, a failed Google callback. It is
854
+ * drawn by ErrorView (_oidc-provider/error-view.tsx), like the not-found and
855
+ * error screens of the login and consent pages.
856
+ */
857
+ export default async function OidcErrorPage({ searchParams }: OidcErrorPageProps) {
858
+ const { error, error_description: errorDescription } = await searchParams;
859
+
860
+ return <ErrorView error={error ?? 'invalid_request'} description={errorDescription} />;
861
+ }
862
+ `;
863
+ }
864
+ //# sourceMappingURL=interaction.js.map