@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.
- package/README.md +83 -7
- package/dist/frameworks/hono/index.d.ts.map +1 -1
- package/dist/frameworks/hono/index.js +18 -9
- package/dist/frameworks/hono/index.js.map +1 -1
- package/dist/frameworks/hono/pages.d.ts +7 -6
- package/dist/frameworks/hono/pages.d.ts.map +1 -1
- package/dist/frameworks/hono/pages.js +62 -44
- package/dist/frameworks/hono/pages.js.map +1 -1
- package/dist/frameworks/hono/templates.d.ts +19 -6
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +439 -304
- package/dist/frameworks/hono/templates.js.map +1 -1
- package/dist/frameworks/hono/views.d.ts +3 -0
- package/dist/frameworks/hono/views.d.ts.map +1 -0
- package/dist/frameworks/hono/views.js +458 -0
- package/dist/frameworks/hono/views.js.map +1 -0
- package/dist/frameworks/nextjs/authorize.d.ts +3 -0
- package/dist/frameworks/nextjs/authorize.d.ts.map +1 -0
- package/dist/frameworks/nextjs/authorize.js +757 -0
- package/dist/frameworks/nextjs/authorize.js.map +1 -0
- package/dist/frameworks/nextjs/ciba.d.ts +8 -0
- package/dist/frameworks/nextjs/ciba.d.ts.map +1 -0
- package/dist/frameworks/nextjs/ciba.js +591 -0
- package/dist/frameworks/nextjs/ciba.js.map +1 -0
- package/dist/frameworks/nextjs/conformance.d.ts +3 -0
- package/dist/frameworks/nextjs/conformance.d.ts.map +1 -0
- package/dist/frameworks/nextjs/conformance.js +2447 -0
- package/dist/frameworks/nextjs/conformance.js.map +1 -0
- package/dist/frameworks/nextjs/device.d.ts +8 -0
- package/dist/frameworks/nextjs/device.d.ts.map +1 -0
- package/dist/frameworks/nextjs/device.js +662 -0
- package/dist/frameworks/nextjs/device.js.map +1 -0
- package/dist/frameworks/nextjs/endpoints.d.ts +7 -0
- package/dist/frameworks/nextjs/endpoints.d.ts.map +1 -0
- package/dist/frameworks/nextjs/endpoints.js +673 -0
- package/dist/frameworks/nextjs/endpoints.js.map +1 -0
- package/dist/frameworks/nextjs/index.d.ts.map +1 -1
- package/dist/frameworks/nextjs/index.js +135 -2
- package/dist/frameworks/nextjs/index.js.map +1 -1
- package/dist/frameworks/nextjs/interaction.d.ts +13 -0
- package/dist/frameworks/nextjs/interaction.d.ts.map +1 -0
- package/dist/frameworks/nextjs/interaction.js +864 -0
- package/dist/frameworks/nextjs/interaction.js.map +1 -0
- package/dist/frameworks/nextjs/logout.d.ts +5 -0
- package/dist/frameworks/nextjs/logout.d.ts.map +1 -0
- package/dist/frameworks/nextjs/logout.js +271 -0
- package/dist/frameworks/nextjs/logout.js.map +1 -0
- package/dist/frameworks/nextjs/metadata.d.ts +6 -0
- package/dist/frameworks/nextjs/metadata.d.ts.map +1 -0
- package/dist/frameworks/nextjs/metadata.js +339 -0
- package/dist/frameworks/nextjs/metadata.js.map +1 -0
- package/dist/frameworks/nextjs/provider.d.ts +6 -0
- package/dist/frameworks/nextjs/provider.d.ts.map +1 -0
- package/dist/frameworks/nextjs/provider.js +797 -0
- package/dist/frameworks/nextjs/provider.js.map +1 -0
- package/dist/frameworks/nextjs/token.d.ts +7 -0
- package/dist/frameworks/nextjs/token.d.ts.map +1 -0
- package/dist/frameworks/nextjs/token.js +1337 -0
- package/dist/frameworks/nextjs/token.js.map +1 -0
- package/dist/frameworks/web-standard/templates.d.ts +1 -13
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
- package/dist/frameworks/web-standard/templates.js +12 -1296
- package/dist/frameworks/web-standard/templates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -8
- package/dist/index.js.map +1 -1
- 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
|