@wtfalch/auth 0.16.0 → 0.20.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 +102 -7
- package/dist/auth.d.ts +70 -3
- package/dist/auth.js +165 -18
- package/dist/broker.d.ts +30 -1
- package/dist/broker.js +15 -2
- package/dist/browser.d.ts +11 -9
- package/dist/browser.js +163 -71
- package/dist/config.d.ts +7 -3
- package/dist/config.js +4 -4
- package/dist/cookies.d.ts +7 -0
- package/dist/cookies.js +14 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -2
- package/dist/names.d.ts +3 -2
- package/dist/names.reserved.js +2 -0
- package/dist/next.d.ts +28 -2
- package/dist/next.js +29 -16
- package/dist/oidc.d.ts +2 -1
- package/dist/oidc.js +21 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @wtfalch/auth
|
|
2
2
|
|
|
3
|
-
Sign in against
|
|
3
|
+
Sign in against a self-hosted ZITADEL issuer from a Next.js app.
|
|
4
4
|
|
|
5
5
|
A thin OIDC client for one self-hosted ZITADEL instance, plus a client for the
|
|
6
6
|
sign-in service that sits in front of it. Apps host their own password sign-in
|
|
@@ -25,6 +25,7 @@ import { nextAuth } from '@wtfalch/auth/next';
|
|
|
25
25
|
|
|
26
26
|
export const { handlers, proxy, getUser, requireUser, signIn } = nextAuth({
|
|
27
27
|
appUrl: process.env.APP_URL,
|
|
28
|
+
issuer: process.env.AUTH_ISSUER,
|
|
28
29
|
clientId: process.env.AUTH_CLIENT_ID,
|
|
29
30
|
organizationId: process.env.AUTH_ORGANIZATION_ID,
|
|
30
31
|
cookieSecret: process.env.AUTH_COOKIE_SECRET,
|
|
@@ -40,6 +41,39 @@ middleware for the paths that need a session, and read the person with
|
|
|
40
41
|
`createAuth` from `@wtfalch/auth` is the same thing without the framework, for
|
|
41
42
|
a CLI or a worker that has no router to import.
|
|
42
43
|
|
|
44
|
+
## One-time passwords
|
|
45
|
+
|
|
46
|
+
An account created with `changeRequired: true` holds a one-time password.
|
|
47
|
+
Signing in with it gives no session and no token. `signIn`,
|
|
48
|
+
`signInWithPassword` and `signUp` answer
|
|
49
|
+
`{ ok: false, error: 'password_change_required', changeToken }` (in `nextAuth`,
|
|
50
|
+
`{ error, changeToken }`). Show a "choose a new password" form and call
|
|
51
|
+
`setNewPassword({ authRequestId, changeToken, newPassword })` (or
|
|
52
|
+
`setNewPasswordHeadless({ changeToken, newPassword })`, as `signInWithPassword`
|
|
53
|
+
is to `signIn`). It needs no old password and signs the person in. The
|
|
54
|
+
`changeToken` is good for 5 minutes. The one-time password itself stops working
|
|
55
|
+
72 hours after it was set (or after the account was created, when the issuer
|
|
56
|
+
reports no password date): sign-in then answers `invalid_credentials`. The
|
|
57
|
+
person recovers through an owner or Archon reset, which sets the flag again, or
|
|
58
|
+
their own forgot-password reset. The limit covers every sign-in that goes
|
|
59
|
+
through the broker, including hosted-login handoffs; a sign-in made at the
|
|
60
|
+
issuer itself, which never reaches the broker, is not covered. `changeToken`s
|
|
61
|
+
live in the broker's memory, so the broker must run as one replica. The browser `SignInOutcome` is unchanged: it never
|
|
62
|
+
sees the password.
|
|
63
|
+
|
|
64
|
+
## Clearing cookies in Server Actions
|
|
65
|
+
|
|
66
|
+
The Next adapter writes every core cookie description with its attributes,
|
|
67
|
+
including an empty value and `maxAge: 0` for clearing. Transaction, QR, email-link
|
|
68
|
+
and session cookies keep their root path, host-only scope, HttpOnly and SameSite
|
|
69
|
+
settings. HTTPS cookies also keep Secure and the `__Host-` prefix; local HTTP
|
|
70
|
+
cookies use ordinary names without Secure. This applies to callback completion,
|
|
71
|
+
password reset and QR actions, including namespace sessions.
|
|
72
|
+
|
|
73
|
+
Next supports expiring cookies through
|
|
74
|
+
[`cookies().set`](https://nextjs.org/docs/app/api-reference/functions/cookies#deleting-cookies).
|
|
75
|
+
The adapter passes the core attributes through for clearing as well as writes.
|
|
76
|
+
|
|
43
77
|
## MFA and hosted login
|
|
44
78
|
|
|
45
79
|
The broker checks the organisation's login policy and enrolled authentication
|
|
@@ -60,6 +94,15 @@ Auth-request lookup failures lead to the configured error page with
|
|
|
60
94
|
`auth_error=request` or `auth_error=unavailable`. That page should show a
|
|
61
95
|
retry link; it must not automatically restart authorization.
|
|
62
96
|
|
|
97
|
+
When a sign-in fails after it started (reasons such as `state`, `unavailable`,
|
|
98
|
+
`exchange`, `denied`, `organization`, `cookie`), the error URL is
|
|
99
|
+
`<onError>?auth_error=<reason>&next=<path>`. `next` is the page the person came
|
|
100
|
+
for, always a path starting with one `/`, URL-encoded as a single parameter, and
|
|
101
|
+
present only when the sealed value is a safe same-origin path. An `onError` that
|
|
102
|
+
already has a query string keeps it. `expired` and `request` carry no `next`:
|
|
103
|
+
there is no transaction to read it from. Use it for the retry link
|
|
104
|
+
(`/auth/start?next=<path>`); a page that ignores it behaves as before.
|
|
105
|
+
|
|
63
106
|
## In a browser app
|
|
64
107
|
|
|
65
108
|
`@wtfalch/auth/browser` is the other half: a public PKCE client for an app that
|
|
@@ -70,7 +113,7 @@ across the estate's subdomains**.
|
|
|
70
113
|
import { createBrowserAuth } from '@wtfalch/auth/browser';
|
|
71
114
|
|
|
72
115
|
const auth = createBrowserAuth({
|
|
73
|
-
issuer: 'https://auth.
|
|
116
|
+
issuer: 'https://auth.example.com',
|
|
74
117
|
clientId: '…', // this app's client id at the issuer
|
|
75
118
|
callbackPath: '/signed-in', // when `/auth/callback` is not yours to host
|
|
76
119
|
});
|
|
@@ -108,7 +151,7 @@ behaviour above exactly, so a caller that sets none of them sees no change.
|
|
|
108
151
|
|
|
109
152
|
```ts
|
|
110
153
|
const auth = createBrowserAuth({
|
|
111
|
-
issuer: 'https://auth.
|
|
154
|
+
issuer: 'https://auth.example.com',
|
|
112
155
|
clientId: '…',
|
|
113
156
|
open: (url) => Browser.open({ url }), // Capacitor's system-browser opener
|
|
114
157
|
store: capacitorSecureStore, // see "Storage stays synchronous" below
|
|
@@ -122,9 +165,11 @@ const auth = createBrowserAuth({
|
|
|
122
165
|
- **`store`** replaces the `sessionStorage` wrapper that holds the token,
|
|
123
166
|
verifier, return path and silent-tried mark, with a plain
|
|
124
167
|
`{ get, set, drop }` shape — `get` synchronous, `set`/`drop` allowed to
|
|
125
|
-
return a promise (see "Storage: `get` stays synchronous" below).
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
return a promise (see "Storage: `get` stays synchronous" below). Custom
|
|
169
|
+
stores retain the existing `wtfalch.auth.*` keys. Supply separate
|
|
170
|
+
backing storage per client context; different wrappers around the same
|
|
171
|
+
backing keys share values. Reuse the same store object for instances that
|
|
172
|
+
should coordinate pending operations.
|
|
128
173
|
- **`refresh()`**, a new method on the returned client, posts the
|
|
129
174
|
`refresh_token` grant and stores the session it gets back — awaiting the
|
|
130
175
|
store's write before it resolves, so an app that awaits `refresh()` knows
|
|
@@ -147,7 +192,36 @@ const auth = createBrowserAuth({
|
|
|
147
192
|
sign-in, so this only matters there.
|
|
148
193
|
- **`forget()`** is now `Promise<void>` rather than `void`, the one signature
|
|
149
194
|
change here: it awaits the store's writes, the same as `completeSignIn()`
|
|
150
|
-
and `refresh()`.
|
|
195
|
+
and `refresh()`. It also cancels pending authorization, completion and
|
|
196
|
+
refresh operations in the same store/context before cleanup starts.
|
|
197
|
+
|
|
198
|
+
**Default storage and clients on one origin.** Legacy browser clients now use
|
|
199
|
+
versioned `sessionStorage` keys bound to issuer, client ID, origin, callback URI
|
|
200
|
+
and the requested scope set (including extra scopes). Matching contexts share
|
|
201
|
+
sessions, pending proof/return path and the silent-attempt mark; different
|
|
202
|
+
contexts keep each of those separate. Scope order and repeated scopes do not
|
|
203
|
+
create a new context. Old unbound `wtfalch.auth.*` default values are ignored,
|
|
204
|
+
with no automatic migration: users sign in once after upgrading. Custom stores
|
|
205
|
+
keep their established keys and need separate backing storage per context.
|
|
206
|
+
|
|
207
|
+
Instances using the same default context or the same custom store object share
|
|
208
|
+
write coordination and refresh requests within this loaded SDK. `forget()`
|
|
209
|
+
invalidates pending generations immediately, removes the token and pending
|
|
210
|
+
proof/return path, and sets the silent-attempt mark. Network responses are
|
|
211
|
+
checked before committing. If an async store write has already started,
|
|
212
|
+
`forget()` waits for it and then removes its result; await `forget()` before
|
|
213
|
+
reading the final state. A cancelled completion returns `kind: 'error'`; a
|
|
214
|
+
cancelled refresh returns `kind: 'refused'` without deleting a newer session.
|
|
215
|
+
A deliberate new sign-in replaces the previous pending generation. Custom
|
|
216
|
+
stores must resolve their write promises only after the writes have landed;
|
|
217
|
+
a stalled store can also stall cleanup. This is local cancellation; issuer
|
|
218
|
+
requests already sent may still complete upstream.
|
|
219
|
+
|
|
220
|
+
Namespace clients keep their existing coordination: per-context session keys,
|
|
221
|
+
a shared service marker and pending transaction state across tabs. A late
|
|
222
|
+
response must still own that pending state. Forgetting a stale namespace
|
|
223
|
+
context preserves a different active context. Namespace mode continues to
|
|
224
|
+
reject custom stores and retain no refresh token.
|
|
151
225
|
|
|
152
226
|
**Storage: `get` stays synchronous; `set` and `drop` may be async.**
|
|
153
227
|
`currentSession()` and `silentSignInAvailable()` are synchronous methods on
|
|
@@ -250,3 +324,24 @@ against a real issuer.
|
|
|
250
324
|
## Licence
|
|
251
325
|
|
|
252
326
|
Private to the wtfalch estate.
|
|
327
|
+
|
|
328
|
+
## Checking HTTPS transaction removal
|
|
329
|
+
|
|
330
|
+
From the repository root, build the SDK and run the isolated browser fixture:
|
|
331
|
+
|
|
332
|
+
```sh
|
|
333
|
+
pnpm --filter @wtfalch/auth build
|
|
334
|
+
pnpm --filter @wtfalch/auth test:cookie-browser
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
This needs OpenSSL and an installed Chromium browser. The fixture uses Google
|
|
338
|
+
Chrome on macOS when available, otherwise Playwright's installed Chromium.
|
|
339
|
+
Set `AUTH_COOKIE_BROWSER_EXECUTABLE` to use another Chromium executable. It
|
|
340
|
+
creates a temporary self-signed certificate, listens only on loopback and
|
|
341
|
+
maps a test hostname to that server in the test browser. Certificate errors
|
|
342
|
+
are ignored only in the isolated browser context. Pages can contact only the
|
|
343
|
+
fixture origin; no issuer or deployed account is used. The adapter and Next
|
|
344
|
+
cookie serializer are real, while authentication results use dummy fixtures.
|
|
345
|
+
Legacy, namespace and ordinary-cookie cases check transaction removal and
|
|
346
|
+
session preservation. The browser, server and temporary certificate files
|
|
347
|
+
are cleaned up after the run. This browser check is separate from `pnpm check`.
|
package/dist/auth.d.ts
CHANGED
|
@@ -63,6 +63,18 @@ export type Prompt = 'select_account' | 'login';
|
|
|
63
63
|
export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
|
|
64
64
|
export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
|
|
65
65
|
export type SignUpError = 'registration_closed' | 'email_taken' | 'username_taken' | 'username_invalid' | 'invalid' | 'request' | 'unavailable';
|
|
66
|
+
/**
|
|
67
|
+
* The credential was right, but it is a one-time password: the person holds
|
|
68
|
+
* no session and no token, and nothing else is let through, until
|
|
69
|
+
* `setNewPassword` (or `setNewPasswordHeadless`) is called with `changeToken`.
|
|
70
|
+
* The token proves the issuer just verified that password. It is single use,
|
|
71
|
+
* bound to this app and good for a few minutes.
|
|
72
|
+
*/
|
|
73
|
+
export interface PasswordChangeRequired {
|
|
74
|
+
ok: false;
|
|
75
|
+
error: 'password_change_required';
|
|
76
|
+
changeToken: string;
|
|
77
|
+
}
|
|
66
78
|
export type SignInResult = {
|
|
67
79
|
ok: true;
|
|
68
80
|
redirectTo: string;
|
|
@@ -70,7 +82,7 @@ export type SignInResult = {
|
|
|
70
82
|
} | {
|
|
71
83
|
ok: false;
|
|
72
84
|
error: SignInError;
|
|
73
|
-
};
|
|
85
|
+
} | PasswordChangeRequired;
|
|
74
86
|
export type SignUpResult = {
|
|
75
87
|
ok: true;
|
|
76
88
|
redirectTo: string;
|
|
@@ -79,6 +91,18 @@ export type SignUpResult = {
|
|
|
79
91
|
ok: false;
|
|
80
92
|
error: SignUpError;
|
|
81
93
|
message: string;
|
|
94
|
+
} | (PasswordChangeRequired & {
|
|
95
|
+
message: string;
|
|
96
|
+
});
|
|
97
|
+
/** `invalid_token`: unknown, spent, expired or another app's. `invalid_password`: the issuer refused the new one; the token still works for a few more tries. */
|
|
98
|
+
export type SetNewPasswordError = 'invalid_token' | 'invalid_password' | 'unavailable';
|
|
99
|
+
export type SetNewPasswordResult = {
|
|
100
|
+
ok: true;
|
|
101
|
+
redirectTo: string;
|
|
102
|
+
hosted?: true;
|
|
103
|
+
} | {
|
|
104
|
+
ok: false;
|
|
105
|
+
error: SetNewPasswordError;
|
|
82
106
|
};
|
|
83
107
|
export type EmailChangeError = 'invalid' | 'email_taken' | 'too_many' | 'unavailable';
|
|
84
108
|
export type EmailChangeResult = {
|
|
@@ -207,7 +231,9 @@ export interface Auth {
|
|
|
207
231
|
sessionId: string;
|
|
208
232
|
code: string;
|
|
209
233
|
next?: string;
|
|
210
|
-
}, requestHeaders?: Headers): Promise<SignedIn
|
|
234
|
+
}, requestHeaders?: Headers): Promise<SignedIn | (PasswordChangeRequired & {
|
|
235
|
+
message: string;
|
|
236
|
+
})>;
|
|
211
237
|
/**
|
|
212
238
|
* `signIn` as data, for a caller with no browser to carry through the OIDC
|
|
213
239
|
* redirect -- a CLI, a desktop app, or a server signing in on a person's
|
|
@@ -222,7 +248,9 @@ export interface Auth {
|
|
|
222
248
|
email: string;
|
|
223
249
|
password: string;
|
|
224
250
|
next?: string;
|
|
225
|
-
}, requestHeaders?: Headers): Promise<SignedIn<SignInError
|
|
251
|
+
}, requestHeaders?: Headers): Promise<SignedIn<SignInError> | (PasswordChangeRequired & {
|
|
252
|
+
message: string;
|
|
253
|
+
})>;
|
|
226
254
|
/** Emails a fresh verification link. Says nothing about whether the address exists. */
|
|
227
255
|
resendVerification(email: string, next?: string): Promise<void>;
|
|
228
256
|
/** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
|
|
@@ -250,6 +278,7 @@ export interface Auth {
|
|
|
250
278
|
}): Promise<{
|
|
251
279
|
userId: string;
|
|
252
280
|
invited: boolean;
|
|
281
|
+
personalEmailStored: boolean;
|
|
253
282
|
}>;
|
|
254
283
|
handle(request: Request): Promise<Response>;
|
|
255
284
|
/**
|
|
@@ -303,6 +332,17 @@ export interface Auth {
|
|
|
303
332
|
userId: string;
|
|
304
333
|
state: string;
|
|
305
334
|
}>;
|
|
335
|
+
/**
|
|
336
|
+
* An admin's reset for somebody in this app's own organisation whom
|
|
337
|
+
* `requestPasswordReset` cannot otherwise get back in -- returns a fresh
|
|
338
|
+
* one-time password the caller is trusted to deliver some other way,
|
|
339
|
+
* rather than mailing it. Refused for a user outside this app's
|
|
340
|
+
* organisation.
|
|
341
|
+
*/
|
|
342
|
+
adminResetPassword(userId: string): Promise<{
|
|
343
|
+
userId: string;
|
|
344
|
+
password: string;
|
|
345
|
+
}>;
|
|
306
346
|
/**
|
|
307
347
|
* Namespace mode: end every session this person has in this service, on
|
|
308
348
|
* every replica, at their next request. Deactivating someone does this too.
|
|
@@ -330,6 +370,23 @@ export interface Auth {
|
|
|
330
370
|
email: string;
|
|
331
371
|
password: string;
|
|
332
372
|
}): Promise<SignInResult>;
|
|
373
|
+
/**
|
|
374
|
+
* After `signIn` answered `password_change_required`: sets the person's own
|
|
375
|
+
* password from that state, with no old password, then signs them in
|
|
376
|
+
* exactly as `signIn` does. Only a `changeToken` from a sign-in this app
|
|
377
|
+
* just saw succeed works.
|
|
378
|
+
*/
|
|
379
|
+
setNewPassword(input: {
|
|
380
|
+
authRequestId: string;
|
|
381
|
+
changeToken: string;
|
|
382
|
+
newPassword: string;
|
|
383
|
+
}): Promise<SetNewPasswordResult>;
|
|
384
|
+
/** `setNewPassword` for a caller with no browser, as `signInWithPassword` is to `signIn`. */
|
|
385
|
+
setNewPasswordHeadless(input: {
|
|
386
|
+
changeToken: string;
|
|
387
|
+
newPassword: string;
|
|
388
|
+
next?: string;
|
|
389
|
+
}, requestHeaders?: Headers): Promise<SignedIn<SetNewPasswordError>>;
|
|
333
390
|
signUp(input: {
|
|
334
391
|
authRequestId: string;
|
|
335
392
|
} & NewUser): Promise<SignUpResult>;
|
|
@@ -440,4 +497,14 @@ export interface Auth {
|
|
|
440
497
|
accessToken(cookieValue: string): Promise<AccessTokenResult>;
|
|
441
498
|
}
|
|
442
499
|
export declare function createAuth(input: AuthOptions): Auth;
|
|
500
|
+
/**
|
|
501
|
+
* The freshness bound `startEmailChange`, `completeEmailChange`,
|
|
502
|
+
* `changePassword`, `startPasskeyRegistration`, `finishPasskeyRegistration`
|
|
503
|
+
* and `deletePasskey` enforce internally (`currentFreshUser`) -- the same
|
|
504
|
+
* five minutes docs/adopting.md's Step-up section demonstrates for
|
|
505
|
+
* `requireFreshUser`. Exported so a page gating one of these calls with its
|
|
506
|
+
* own `requireFreshUser` can pass the identical bound rather than a second
|
|
507
|
+
* number that might drift from this one.
|
|
508
|
+
*/
|
|
509
|
+
export declare const STEP_UP_MAX_AGE: MaxAge;
|
|
443
510
|
export type { AuthRequest, NewUser, SetCookie };
|
package/dist/auth.js
CHANGED
|
@@ -2,7 +2,7 @@ import { errors } from 'jose';
|
|
|
2
2
|
import * as client from 'openid-client';
|
|
3
3
|
import { Broker, BrokerError } from './broker.js';
|
|
4
4
|
import { resolveOptions, } from './config.js';
|
|
5
|
-
import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
|
|
5
|
+
import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openSilent, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealSilent, sealTransaction, sessionCookieName, silentCookieName, transactionCookieName, } from './cookies.js';
|
|
6
6
|
import { namespaceSessions } from './namespace-session.js';
|
|
7
7
|
import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
|
|
8
8
|
import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
|
|
@@ -122,12 +122,19 @@ export function createAuth(input) {
|
|
|
122
122
|
return unavailable();
|
|
123
123
|
const params = new URL(request.url).searchParams;
|
|
124
124
|
const next = safeNextPath(params.get('next'), options().afterLogin);
|
|
125
|
+
// Only the gate can ask for a silent pass: its sealed marker is the sole way
|
|
126
|
+
// in, and the query's own `prompt` never reaches `none`. An explicit choice
|
|
127
|
+
// (register, a prompt, max_age) is interactive by definition.
|
|
128
|
+
const silent = (await openSilent(options(), cookieFrom(request.headers.get('cookie'), silentCookieName(options())))) === 'try' &&
|
|
129
|
+
!params.has('intent') &&
|
|
130
|
+
!params.has('prompt') &&
|
|
131
|
+
!params.has('max_age');
|
|
125
132
|
const state = client.randomState();
|
|
126
133
|
const nonce = client.randomNonce();
|
|
127
134
|
const codeVerifier = client.randomPKCECodeVerifier();
|
|
128
135
|
const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
|
|
129
136
|
create: params.get('intent') === 'register',
|
|
130
|
-
prompt: promptFrom(params.get('prompt')),
|
|
137
|
+
prompt: silent ? 'none' : promptFrom(params.get('prompt')),
|
|
131
138
|
maxAge: maxAgeFrom(params.get('max_age')),
|
|
132
139
|
});
|
|
133
140
|
const transaction = await sealTransaction(options(), {
|
|
@@ -135,8 +142,13 @@ export function createAuth(input) {
|
|
|
135
142
|
nc: nonce,
|
|
136
143
|
cv: codeVerifier,
|
|
137
144
|
nx: next,
|
|
145
|
+
...(silent ? { sl: true } : {}),
|
|
138
146
|
});
|
|
139
|
-
|
|
147
|
+
// Whatever a silent pass returns, it is the gate's last: `done` sends the next sign-in to the interactive path.
|
|
148
|
+
return redirect(target.href, [
|
|
149
|
+
transaction,
|
|
150
|
+
...(silent ? [await sealSilent(options(), 'done')] : []),
|
|
151
|
+
]);
|
|
140
152
|
};
|
|
141
153
|
const complete = async (callbackUrl, cookieHeader, requestHeaders) => {
|
|
142
154
|
if (!validCallback(callbackUrl))
|
|
@@ -147,9 +159,18 @@ export function createAuth(input) {
|
|
|
147
159
|
return failure('expired', options().namespace ? [] : [cleared]);
|
|
148
160
|
const params = new URL(callbackUrl).searchParams;
|
|
149
161
|
if (params.get('state') !== transaction.st)
|
|
150
|
-
return failure('state', options().namespace ? [] : [cleared]);
|
|
162
|
+
return failure('state', options().namespace ? [] : [cleared], transaction.nx);
|
|
151
163
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
152
|
-
return failure('unavailable', [cleared]);
|
|
164
|
+
return failure('unavailable', [cleared], transaction.nx);
|
|
165
|
+
// A silent pass the issuer refused (login_required, interaction_required,
|
|
166
|
+
// consent_required, or any other error) is not a failure: sign in the normal
|
|
167
|
+
// way, once. `done` is already set, so `/start` will not go silent again.
|
|
168
|
+
if (transaction.sl && params.has('error')) {
|
|
169
|
+
return {
|
|
170
|
+
location: startUrl(transaction.nx),
|
|
171
|
+
cookies: [...(options().namespace ? [] : [cleared]), await sealSilent(options(), 'done')],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
153
174
|
let session;
|
|
154
175
|
try {
|
|
155
176
|
const tokens = await oidc().exchange(params, {
|
|
@@ -160,7 +181,7 @@ export function createAuth(input) {
|
|
|
160
181
|
session = await sessionCookie(tokens, requestHeaders);
|
|
161
182
|
}
|
|
162
183
|
catch (error) {
|
|
163
|
-
return failure(reasonOf(error), [cleared]);
|
|
184
|
+
return failure(reasonOf(error), [cleared], transaction.nx);
|
|
164
185
|
}
|
|
165
186
|
await replacePreviousSession(cookieHeader);
|
|
166
187
|
return {
|
|
@@ -221,13 +242,15 @@ export function createAuth(input) {
|
|
|
221
242
|
*/
|
|
222
243
|
const endIssuerSession = async (idToken, cookies) => {
|
|
223
244
|
const afterLogout = new URL(options().afterLogout, options().appUrl).href;
|
|
245
|
+
// A signed-out person is never silently signed back in: `done` keeps the gate interactive for the marker's life, even if the issuer's own logout fails.
|
|
246
|
+
const all = [...cookies, await sealSilent(options(), 'done')];
|
|
224
247
|
if (!idToken) {
|
|
225
|
-
return redirect(new URL('/logout', options().issuer).href,
|
|
248
|
+
return redirect(new URL('/logout', options().issuer).href, all, 303);
|
|
226
249
|
}
|
|
227
250
|
const target = await oidc()
|
|
228
251
|
.endSessionUrl(idToken, afterLogout)
|
|
229
252
|
.catch(() => null);
|
|
230
|
-
return redirect(target?.href ?? afterLogout,
|
|
253
|
+
return redirect(target?.href ?? afterLogout, all, 303);
|
|
231
254
|
};
|
|
232
255
|
const logout = async (request) => {
|
|
233
256
|
if (!isSameOrigin(request, options().appUrl.origin)) {
|
|
@@ -370,7 +393,15 @@ export function createAuth(input) {
|
|
|
370
393
|
return { kind: 'deny', cookies, reason: 'unavailable' };
|
|
371
394
|
if (!isNavigation(request))
|
|
372
395
|
return { kind: 'deny', cookies };
|
|
373
|
-
|
|
396
|
+
// First visit without a session: ask for one silent pass. The marker is
|
|
397
|
+
// sealed, so only the gate can set it, and any marker (even a spent one)
|
|
398
|
+
// keeps the next redirect interactive.
|
|
399
|
+
const tried = await openSilent(options(), cookieFrom(request.headers.get('cookie'), silentCookieName(options())));
|
|
400
|
+
return {
|
|
401
|
+
kind: 'redirect',
|
|
402
|
+
location: startUrl(`${url.pathname}${url.search}`),
|
|
403
|
+
cookies: tried ? cookies : [...cookies, await sealSilent(options(), 'try')],
|
|
404
|
+
};
|
|
374
405
|
};
|
|
375
406
|
/**
|
|
376
407
|
* The person signed in on `request`, for the passkey routes -- never a
|
|
@@ -388,6 +419,29 @@ export function createAuth(input) {
|
|
|
388
419
|
throw new AuthError('unauthenticated', 'sign in to use a passkey');
|
|
389
420
|
return user;
|
|
390
421
|
};
|
|
422
|
+
/**
|
|
423
|
+
* `currentUser`, plus the same freshness bound `requireFreshUser` enforces
|
|
424
|
+
* at the page level -- `STEP_UP_MAX_AGE` old at most. The six calls below
|
|
425
|
+
* that change a credential or an enrolled factor take this instead of
|
|
426
|
+
* `currentUser`, because a leaked-but-still-live session is enough to
|
|
427
|
+
* reach any of them directly (a Server Action, or any other route that
|
|
428
|
+
* never rendered a page gated by `requireFreshUser`) -- only the app's own
|
|
429
|
+
* page code stood between that session and the change, and nothing here
|
|
430
|
+
* required it to.
|
|
431
|
+
*
|
|
432
|
+
* `requireFreshUser`'s redirect-then-retry only makes sense with a browser
|
|
433
|
+
* navigation behind it, which a direct call has none of, so a stale
|
|
434
|
+
* session here is `AuthError('stale', ...)` outright rather than a
|
|
435
|
+
* redirect: the app shows something to click, exactly as it must already
|
|
436
|
+
* do for `unauthenticated`.
|
|
437
|
+
*/
|
|
438
|
+
const currentFreshUser = async (request) => {
|
|
439
|
+
const user = await currentUser(request);
|
|
440
|
+
if (!fresh(user, STEP_UP_MAX_AGE)) {
|
|
441
|
+
throw new AuthError('stale', 'Confirm your identity again before this change');
|
|
442
|
+
}
|
|
443
|
+
return user;
|
|
444
|
+
};
|
|
391
445
|
const people = (options) => broker().people(options);
|
|
392
446
|
const setPersonActive = async (userId, active) => {
|
|
393
447
|
const result = await broker().setPersonActive(userId, active);
|
|
@@ -485,6 +539,20 @@ export function createAuth(input) {
|
|
|
485
539
|
emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
|
|
486
540
|
return result;
|
|
487
541
|
};
|
|
542
|
+
/**
|
|
543
|
+
* An admin's reset for somebody in this app's own organisation whom
|
|
544
|
+
* `requestPasswordReset` cannot otherwise get back in -- nobody at the
|
|
545
|
+
* mailbox a personal address or the login address itself would reach.
|
|
546
|
+
* Refused for a user outside this app's organisation, exactly as
|
|
547
|
+
* `setPersonActive` is. The password is a one-time one: the person must
|
|
548
|
+
* choose their own at next sign-in, same as an invite's. Audited with the
|
|
549
|
+
* target's id and never the password itself.
|
|
550
|
+
*/
|
|
551
|
+
const adminResetPassword = async (userId) => {
|
|
552
|
+
const result = await broker().adminResetPassword(userId);
|
|
553
|
+
emit({ type: 'adminResetPassword', userId: result.userId });
|
|
554
|
+
return result;
|
|
555
|
+
};
|
|
488
556
|
const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
|
|
489
557
|
try {
|
|
490
558
|
const flow = await serverFlow();
|
|
@@ -508,6 +576,9 @@ export function createAuth(input) {
|
|
|
508
576
|
return { ok: true, ...completed };
|
|
509
577
|
}
|
|
510
578
|
catch (error) {
|
|
579
|
+
const change = changeRequired(error);
|
|
580
|
+
if (change)
|
|
581
|
+
return { ...change, message: 'choose a new password to continue' };
|
|
511
582
|
if (error instanceof BrokerError && error.status < 500) {
|
|
512
583
|
return { ok: false, error: 'invalid_code', message: error.detail ?? error.error };
|
|
513
584
|
}
|
|
@@ -525,7 +596,12 @@ export function createAuth(input) {
|
|
|
525
596
|
code,
|
|
526
597
|
next: params.get('next') ?? undefined,
|
|
527
598
|
}, request.headers);
|
|
528
|
-
|
|
599
|
+
if (result.ok)
|
|
600
|
+
return redirect(result.location, result.cookies);
|
|
601
|
+
// A redirect cannot carry the token (it would sit in a URL), so the
|
|
602
|
+
// person is sent to the error page to sign in with their password, which
|
|
603
|
+
// answers with the token as data.
|
|
604
|
+
return failed(result.error === 'password_change_required' ? result.error : 'link', []);
|
|
529
605
|
};
|
|
530
606
|
const signIn = async ({ authRequestId, email, password, }) => {
|
|
531
607
|
try {
|
|
@@ -538,9 +614,40 @@ export function createAuth(input) {
|
|
|
538
614
|
emit({ type: 'signIn', email });
|
|
539
615
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
540
616
|
}
|
|
617
|
+
const change = changeRequired(error);
|
|
618
|
+
if (change)
|
|
619
|
+
return change;
|
|
541
620
|
return { ok: false, error: signInError(error) };
|
|
542
621
|
}
|
|
543
622
|
};
|
|
623
|
+
const setNewPassword = async ({ authRequestId, changeToken, newPassword, }) => {
|
|
624
|
+
try {
|
|
625
|
+
const redirectTo = await broker().setNewPassword({ authRequestId, changeToken, newPassword });
|
|
626
|
+
return { ok: true, redirectTo };
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
if (needsHostedLogin(error))
|
|
630
|
+
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
631
|
+
return { ok: false, error: setNewPasswordError(error) };
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
const setNewPasswordHeadless = async ({ changeToken, newPassword, next }, requestHeaders) => {
|
|
635
|
+
try {
|
|
636
|
+
const flow = await serverFlow();
|
|
637
|
+
const completed = await finishOrHandoff(() => broker().setNewPassword({ authRequestId: flow.id, changeToken, newPassword }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
638
|
+
return { ok: true, ...completed };
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
const code = setNewPasswordError(error);
|
|
642
|
+
return {
|
|
643
|
+
ok: false,
|
|
644
|
+
error: code,
|
|
645
|
+
message: code !== 'unavailable' && error instanceof BrokerError
|
|
646
|
+
? (error.detail ?? error.error)
|
|
647
|
+
: 'the sign-in service did not answer',
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
};
|
|
544
651
|
const signInWithPassword = async ({ email, password, next }, requestHeaders) => {
|
|
545
652
|
try {
|
|
546
653
|
const flow = await serverFlow();
|
|
@@ -549,6 +656,9 @@ export function createAuth(input) {
|
|
|
549
656
|
return { ok: true, ...completed };
|
|
550
657
|
}
|
|
551
658
|
catch (error) {
|
|
659
|
+
const change = changeRequired(error);
|
|
660
|
+
if (change)
|
|
661
|
+
return { ...change, message: 'choose a new password to continue' };
|
|
552
662
|
const code = signInError(error);
|
|
553
663
|
// `signInError` collapses everything the broker can say into one of
|
|
554
664
|
// four codes; only a real `BrokerError` carries a sentence worth
|
|
@@ -571,6 +681,9 @@ export function createAuth(input) {
|
|
|
571
681
|
emit({ type: 'signUp', email: user.email });
|
|
572
682
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
573
683
|
}
|
|
684
|
+
const change = changeRequired(error);
|
|
685
|
+
if (change)
|
|
686
|
+
return { ...change, message: 'choose a new password to continue' };
|
|
574
687
|
if (error instanceof BrokerError && error.error === 'email_taken') {
|
|
575
688
|
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
576
689
|
}
|
|
@@ -594,11 +707,11 @@ export function createAuth(input) {
|
|
|
594
707
|
}
|
|
595
708
|
};
|
|
596
709
|
const startPasskeyRegistration = async (request) => {
|
|
597
|
-
const user = await
|
|
710
|
+
const user = await currentFreshUser(request);
|
|
598
711
|
return broker().startPasskeyRegistration(user.id);
|
|
599
712
|
};
|
|
600
713
|
const finishPasskeyRegistration = async (request, input) => {
|
|
601
|
-
const user = await
|
|
714
|
+
const user = await currentFreshUser(request);
|
|
602
715
|
// Built from named fields, never spread: `input` crosses a trust
|
|
603
716
|
// boundary (a Next.js Server Action does not enforce its TS parameter
|
|
604
717
|
// types on the wire), and a spread after `userId: user.id` would let a
|
|
@@ -626,11 +739,11 @@ export function createAuth(input) {
|
|
|
626
739
|
}
|
|
627
740
|
};
|
|
628
741
|
const startEmailChange = async (request, email) => {
|
|
629
|
-
const user = await
|
|
742
|
+
const user = await currentFreshUser(request);
|
|
630
743
|
await broker().startEmailChange(user.id, email);
|
|
631
744
|
};
|
|
632
745
|
const completeEmailChange = async (request, code) => {
|
|
633
|
-
const user = await
|
|
746
|
+
const user = await currentFreshUser(request);
|
|
634
747
|
try {
|
|
635
748
|
await broker().completeEmailChange(user.id, code);
|
|
636
749
|
return { ok: true };
|
|
@@ -649,7 +762,7 @@ export function createAuth(input) {
|
|
|
649
762
|
}
|
|
650
763
|
};
|
|
651
764
|
const changePassword = async (request, input) => {
|
|
652
|
-
const user = await
|
|
765
|
+
const user = await currentFreshUser(request);
|
|
653
766
|
try {
|
|
654
767
|
await broker().changePassword({ userId: user.id, ...input });
|
|
655
768
|
return { ok: true };
|
|
@@ -670,7 +783,7 @@ export function createAuth(input) {
|
|
|
670
783
|
return result.passkeys;
|
|
671
784
|
};
|
|
672
785
|
const deletePasskey = async (request, id) => {
|
|
673
|
-
const user = await
|
|
786
|
+
const user = await currentFreshUser(request);
|
|
674
787
|
try {
|
|
675
788
|
await broker().deletePasskey(user.id, id);
|
|
676
789
|
return { ok: true };
|
|
@@ -700,7 +813,7 @@ export function createAuth(input) {
|
|
|
700
813
|
catch (error) {
|
|
701
814
|
if (needsHostedLogin(error))
|
|
702
815
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
703
|
-
return { ok: false, error: signInError(error) };
|
|
816
|
+
return changeRequired(error) ?? { ok: false, error: signInError(error) };
|
|
704
817
|
}
|
|
705
818
|
};
|
|
706
819
|
const startQrSignIn = async () => {
|
|
@@ -792,9 +905,13 @@ export function createAuth(input) {
|
|
|
792
905
|
cookies,
|
|
793
906
|
};
|
|
794
907
|
};
|
|
795
|
-
|
|
908
|
+
/** `next`, when it is a safe same-origin path, rides on the error URL so the error page can offer a retry to it. */
|
|
909
|
+
const failure = (reason, cookies, next) => {
|
|
796
910
|
const url = new URL(options().onError, options().appUrl);
|
|
797
911
|
url.searchParams.set('auth_error', reason);
|
|
912
|
+
const retry = safeNextPath(next, '');
|
|
913
|
+
if (retry)
|
|
914
|
+
url.searchParams.set('next', retry);
|
|
798
915
|
return { location: url.href, cookies };
|
|
799
916
|
};
|
|
800
917
|
/** `failure`'s location on its own, for a caller that redirects rather than answering. */
|
|
@@ -828,11 +945,14 @@ export function createAuth(input) {
|
|
|
828
945
|
errorUrl,
|
|
829
946
|
people,
|
|
830
947
|
setPersonActive,
|
|
948
|
+
adminResetPassword,
|
|
831
949
|
revokeSessions,
|
|
832
950
|
sessions: listSessions,
|
|
833
951
|
endSession,
|
|
834
952
|
authRequest,
|
|
835
953
|
signIn,
|
|
954
|
+
setNewPassword,
|
|
955
|
+
setNewPasswordHeadless,
|
|
836
956
|
signInWithPassword,
|
|
837
957
|
signUp,
|
|
838
958
|
startPasskeyRegistration,
|
|
@@ -908,6 +1028,16 @@ const MAX_AGE_CEILING = 31_536_000;
|
|
|
908
1028
|
* forever. The tolerance is why the round trip can ever satisfy the bound.
|
|
909
1029
|
*/
|
|
910
1030
|
const CLOCK_TOLERANCE = 60;
|
|
1031
|
+
/**
|
|
1032
|
+
* The freshness bound `startEmailChange`, `completeEmailChange`,
|
|
1033
|
+
* `changePassword`, `startPasskeyRegistration`, `finishPasskeyRegistration`
|
|
1034
|
+
* and `deletePasskey` enforce internally (`currentFreshUser`) -- the same
|
|
1035
|
+
* five minutes docs/adopting.md's Step-up section demonstrates for
|
|
1036
|
+
* `requireFreshUser`. Exported so a page gating one of these calls with its
|
|
1037
|
+
* own `requireFreshUser` can pass the identical bound rather than a second
|
|
1038
|
+
* number that might drift from this one.
|
|
1039
|
+
*/
|
|
1040
|
+
export const STEP_UP_MAX_AGE = 300;
|
|
911
1041
|
function maxAgeFrom(raw) {
|
|
912
1042
|
if (raw === null || raw.trim() === '')
|
|
913
1043
|
return undefined;
|
|
@@ -975,6 +1105,23 @@ async function quietly(act) {
|
|
|
975
1105
|
throw error;
|
|
976
1106
|
}
|
|
977
1107
|
}
|
|
1108
|
+
/** The broker's refusal for a verified one-time password, with the proof of that to send back. */
|
|
1109
|
+
function changeRequired(error) {
|
|
1110
|
+
return error instanceof BrokerError &&
|
|
1111
|
+
error.error === 'password_change_required' &&
|
|
1112
|
+
error.changeToken
|
|
1113
|
+
? { ok: false, error: 'password_change_required', changeToken: error.changeToken }
|
|
1114
|
+
: null;
|
|
1115
|
+
}
|
|
1116
|
+
function setNewPasswordError(error) {
|
|
1117
|
+
if (error instanceof BrokerError && error.error === 'invalid_token')
|
|
1118
|
+
return 'invalid_token';
|
|
1119
|
+
if (error instanceof BrokerError && error.error === 'invalid_password')
|
|
1120
|
+
return 'invalid_password';
|
|
1121
|
+
if (error instanceof BrokerError && error.status === 401)
|
|
1122
|
+
return 'invalid_token';
|
|
1123
|
+
return 'unavailable';
|
|
1124
|
+
}
|
|
978
1125
|
function signInError(error) {
|
|
979
1126
|
if (error instanceof BrokerError && error.error === 'request')
|
|
980
1127
|
return 'request';
|