@wtfalch/auth 0.16.0 → 0.19.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 +93 -7
- package/dist/auth.d.ts +58 -3
- package/dist/auth.js +142 -14
- package/dist/broker.d.ts +17 -1
- package/dist/broker.js +11 -2
- package/dist/browser.d.ts +11 -9
- package/dist/browser.js +163 -71
- package/dist/config.d.ts +2 -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 +24 -2
- package/dist/next.js +28 -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
|
|
@@ -70,7 +104,7 @@ across the estate's subdomains**.
|
|
|
70
104
|
import { createBrowserAuth } from '@wtfalch/auth/browser';
|
|
71
105
|
|
|
72
106
|
const auth = createBrowserAuth({
|
|
73
|
-
issuer: 'https://auth.
|
|
107
|
+
issuer: 'https://auth.example.com',
|
|
74
108
|
clientId: '…', // this app's client id at the issuer
|
|
75
109
|
callbackPath: '/signed-in', // when `/auth/callback` is not yours to host
|
|
76
110
|
});
|
|
@@ -108,7 +142,7 @@ behaviour above exactly, so a caller that sets none of them sees no change.
|
|
|
108
142
|
|
|
109
143
|
```ts
|
|
110
144
|
const auth = createBrowserAuth({
|
|
111
|
-
issuer: 'https://auth.
|
|
145
|
+
issuer: 'https://auth.example.com',
|
|
112
146
|
clientId: '…',
|
|
113
147
|
open: (url) => Browser.open({ url }), // Capacitor's system-browser opener
|
|
114
148
|
store: capacitorSecureStore, // see "Storage stays synchronous" below
|
|
@@ -122,9 +156,11 @@ const auth = createBrowserAuth({
|
|
|
122
156
|
- **`store`** replaces the `sessionStorage` wrapper that holds the token,
|
|
123
157
|
verifier, return path and silent-tried mark, with a plain
|
|
124
158
|
`{ get, set, drop }` shape — `get` synchronous, `set`/`drop` allowed to
|
|
125
|
-
return a promise (see "Storage: `get` stays synchronous" below).
|
|
126
|
-
|
|
127
|
-
|
|
159
|
+
return a promise (see "Storage: `get` stays synchronous" below). Custom
|
|
160
|
+
stores retain the existing `wtfalch.auth.*` keys. Supply separate
|
|
161
|
+
backing storage per client context; different wrappers around the same
|
|
162
|
+
backing keys share values. Reuse the same store object for instances that
|
|
163
|
+
should coordinate pending operations.
|
|
128
164
|
- **`refresh()`**, a new method on the returned client, posts the
|
|
129
165
|
`refresh_token` grant and stores the session it gets back — awaiting the
|
|
130
166
|
store's write before it resolves, so an app that awaits `refresh()` knows
|
|
@@ -147,7 +183,36 @@ const auth = createBrowserAuth({
|
|
|
147
183
|
sign-in, so this only matters there.
|
|
148
184
|
- **`forget()`** is now `Promise<void>` rather than `void`, the one signature
|
|
149
185
|
change here: it awaits the store's writes, the same as `completeSignIn()`
|
|
150
|
-
and `refresh()`.
|
|
186
|
+
and `refresh()`. It also cancels pending authorization, completion and
|
|
187
|
+
refresh operations in the same store/context before cleanup starts.
|
|
188
|
+
|
|
189
|
+
**Default storage and clients on one origin.** Legacy browser clients now use
|
|
190
|
+
versioned `sessionStorage` keys bound to issuer, client ID, origin, callback URI
|
|
191
|
+
and the requested scope set (including extra scopes). Matching contexts share
|
|
192
|
+
sessions, pending proof/return path and the silent-attempt mark; different
|
|
193
|
+
contexts keep each of those separate. Scope order and repeated scopes do not
|
|
194
|
+
create a new context. Old unbound `wtfalch.auth.*` default values are ignored,
|
|
195
|
+
with no automatic migration: users sign in once after upgrading. Custom stores
|
|
196
|
+
keep their established keys and need separate backing storage per context.
|
|
197
|
+
|
|
198
|
+
Instances using the same default context or the same custom store object share
|
|
199
|
+
write coordination and refresh requests within this loaded SDK. `forget()`
|
|
200
|
+
invalidates pending generations immediately, removes the token and pending
|
|
201
|
+
proof/return path, and sets the silent-attempt mark. Network responses are
|
|
202
|
+
checked before committing. If an async store write has already started,
|
|
203
|
+
`forget()` waits for it and then removes its result; await `forget()` before
|
|
204
|
+
reading the final state. A cancelled completion returns `kind: 'error'`; a
|
|
205
|
+
cancelled refresh returns `kind: 'refused'` without deleting a newer session.
|
|
206
|
+
A deliberate new sign-in replaces the previous pending generation. Custom
|
|
207
|
+
stores must resolve their write promises only after the writes have landed;
|
|
208
|
+
a stalled store can also stall cleanup. This is local cancellation; issuer
|
|
209
|
+
requests already sent may still complete upstream.
|
|
210
|
+
|
|
211
|
+
Namespace clients keep their existing coordination: per-context session keys,
|
|
212
|
+
a shared service marker and pending transaction state across tabs. A late
|
|
213
|
+
response must still own that pending state. Forgetting a stale namespace
|
|
214
|
+
context preserves a different active context. Namespace mode continues to
|
|
215
|
+
reject custom stores and retain no refresh token.
|
|
151
216
|
|
|
152
217
|
**Storage: `get` stays synchronous; `set` and `drop` may be async.**
|
|
153
218
|
`currentSession()` and `silentSignInAvailable()` are synchronous methods on
|
|
@@ -250,3 +315,24 @@ against a real issuer.
|
|
|
250
315
|
## Licence
|
|
251
316
|
|
|
252
317
|
Private to the wtfalch estate.
|
|
318
|
+
|
|
319
|
+
## Checking HTTPS transaction removal
|
|
320
|
+
|
|
321
|
+
From the repository root, build the SDK and run the isolated browser fixture:
|
|
322
|
+
|
|
323
|
+
```sh
|
|
324
|
+
pnpm --filter @wtfalch/auth build
|
|
325
|
+
pnpm --filter @wtfalch/auth test:cookie-browser
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
This needs OpenSSL and an installed Chromium browser. The fixture uses Google
|
|
329
|
+
Chrome on macOS when available, otherwise Playwright's installed Chromium.
|
|
330
|
+
Set `AUTH_COOKIE_BROWSER_EXECUTABLE` to use another Chromium executable. It
|
|
331
|
+
creates a temporary self-signed certificate, listens only on loopback and
|
|
332
|
+
maps a test hostname to that server in the test browser. Certificate errors
|
|
333
|
+
are ignored only in the isolated browser context. Pages can contact only the
|
|
334
|
+
fixture origin; no issuer or deployed account is used. The adapter and Next
|
|
335
|
+
cookie serializer are real, while authentication results use dummy fixtures.
|
|
336
|
+
Legacy, namespace and ordinary-cookie cases check transaction removal and
|
|
337
|
+
session preservation. The browser, server and temporary certificate files
|
|
338
|
+
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. */
|
|
@@ -330,6 +358,23 @@ export interface Auth {
|
|
|
330
358
|
email: string;
|
|
331
359
|
password: string;
|
|
332
360
|
}): Promise<SignInResult>;
|
|
361
|
+
/**
|
|
362
|
+
* After `signIn` answered `password_change_required`: sets the person's own
|
|
363
|
+
* password from that state, with no old password, then signs them in
|
|
364
|
+
* exactly as `signIn` does. Only a `changeToken` from a sign-in this app
|
|
365
|
+
* just saw succeed works.
|
|
366
|
+
*/
|
|
367
|
+
setNewPassword(input: {
|
|
368
|
+
authRequestId: string;
|
|
369
|
+
changeToken: string;
|
|
370
|
+
newPassword: string;
|
|
371
|
+
}): Promise<SetNewPasswordResult>;
|
|
372
|
+
/** `setNewPassword` for a caller with no browser, as `signInWithPassword` is to `signIn`. */
|
|
373
|
+
setNewPasswordHeadless(input: {
|
|
374
|
+
changeToken: string;
|
|
375
|
+
newPassword: string;
|
|
376
|
+
next?: string;
|
|
377
|
+
}, requestHeaders?: Headers): Promise<SignedIn<SetNewPasswordError>>;
|
|
333
378
|
signUp(input: {
|
|
334
379
|
authRequestId: string;
|
|
335
380
|
} & NewUser): Promise<SignUpResult>;
|
|
@@ -440,4 +485,14 @@ export interface Auth {
|
|
|
440
485
|
accessToken(cookieValue: string): Promise<AccessTokenResult>;
|
|
441
486
|
}
|
|
442
487
|
export declare function createAuth(input: AuthOptions): Auth;
|
|
488
|
+
/**
|
|
489
|
+
* The freshness bound `startEmailChange`, `completeEmailChange`,
|
|
490
|
+
* `changePassword`, `startPasskeyRegistration`, `finishPasskeyRegistration`
|
|
491
|
+
* and `deletePasskey` enforce internally (`currentFreshUser`) -- the same
|
|
492
|
+
* five minutes docs/adopting.md's Step-up section demonstrates for
|
|
493
|
+
* `requireFreshUser`. Exported so a page gating one of these calls with its
|
|
494
|
+
* own `requireFreshUser` can pass the identical bound rather than a second
|
|
495
|
+
* number that might drift from this one.
|
|
496
|
+
*/
|
|
497
|
+
export declare const STEP_UP_MAX_AGE: MaxAge;
|
|
443
498
|
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))
|
|
@@ -150,6 +162,15 @@ export function createAuth(input) {
|
|
|
150
162
|
return failure('state', options().namespace ? [] : [cleared]);
|
|
151
163
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
152
164
|
return failure('unavailable', [cleared]);
|
|
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, {
|
|
@@ -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);
|
|
@@ -508,6 +562,9 @@ export function createAuth(input) {
|
|
|
508
562
|
return { ok: true, ...completed };
|
|
509
563
|
}
|
|
510
564
|
catch (error) {
|
|
565
|
+
const change = changeRequired(error);
|
|
566
|
+
if (change)
|
|
567
|
+
return { ...change, message: 'choose a new password to continue' };
|
|
511
568
|
if (error instanceof BrokerError && error.status < 500) {
|
|
512
569
|
return { ok: false, error: 'invalid_code', message: error.detail ?? error.error };
|
|
513
570
|
}
|
|
@@ -525,7 +582,12 @@ export function createAuth(input) {
|
|
|
525
582
|
code,
|
|
526
583
|
next: params.get('next') ?? undefined,
|
|
527
584
|
}, request.headers);
|
|
528
|
-
|
|
585
|
+
if (result.ok)
|
|
586
|
+
return redirect(result.location, result.cookies);
|
|
587
|
+
// A redirect cannot carry the token (it would sit in a URL), so the
|
|
588
|
+
// person is sent to the error page to sign in with their password, which
|
|
589
|
+
// answers with the token as data.
|
|
590
|
+
return failed(result.error === 'password_change_required' ? result.error : 'link', []);
|
|
529
591
|
};
|
|
530
592
|
const signIn = async ({ authRequestId, email, password, }) => {
|
|
531
593
|
try {
|
|
@@ -538,9 +600,40 @@ export function createAuth(input) {
|
|
|
538
600
|
emit({ type: 'signIn', email });
|
|
539
601
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
540
602
|
}
|
|
603
|
+
const change = changeRequired(error);
|
|
604
|
+
if (change)
|
|
605
|
+
return change;
|
|
541
606
|
return { ok: false, error: signInError(error) };
|
|
542
607
|
}
|
|
543
608
|
};
|
|
609
|
+
const setNewPassword = async ({ authRequestId, changeToken, newPassword, }) => {
|
|
610
|
+
try {
|
|
611
|
+
const redirectTo = await broker().setNewPassword({ authRequestId, changeToken, newPassword });
|
|
612
|
+
return { ok: true, redirectTo };
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
if (needsHostedLogin(error))
|
|
616
|
+
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
617
|
+
return { ok: false, error: setNewPasswordError(error) };
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
const setNewPasswordHeadless = async ({ changeToken, newPassword, next }, requestHeaders) => {
|
|
621
|
+
try {
|
|
622
|
+
const flow = await serverFlow();
|
|
623
|
+
const completed = await finishOrHandoff(() => broker().setNewPassword({ authRequestId: flow.id, changeToken, newPassword }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
624
|
+
return { ok: true, ...completed };
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
const code = setNewPasswordError(error);
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
error: code,
|
|
631
|
+
message: code !== 'unavailable' && error instanceof BrokerError
|
|
632
|
+
? (error.detail ?? error.error)
|
|
633
|
+
: 'the sign-in service did not answer',
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
};
|
|
544
637
|
const signInWithPassword = async ({ email, password, next }, requestHeaders) => {
|
|
545
638
|
try {
|
|
546
639
|
const flow = await serverFlow();
|
|
@@ -549,6 +642,9 @@ export function createAuth(input) {
|
|
|
549
642
|
return { ok: true, ...completed };
|
|
550
643
|
}
|
|
551
644
|
catch (error) {
|
|
645
|
+
const change = changeRequired(error);
|
|
646
|
+
if (change)
|
|
647
|
+
return { ...change, message: 'choose a new password to continue' };
|
|
552
648
|
const code = signInError(error);
|
|
553
649
|
// `signInError` collapses everything the broker can say into one of
|
|
554
650
|
// four codes; only a real `BrokerError` carries a sentence worth
|
|
@@ -571,6 +667,9 @@ export function createAuth(input) {
|
|
|
571
667
|
emit({ type: 'signUp', email: user.email });
|
|
572
668
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
573
669
|
}
|
|
670
|
+
const change = changeRequired(error);
|
|
671
|
+
if (change)
|
|
672
|
+
return { ...change, message: 'choose a new password to continue' };
|
|
574
673
|
if (error instanceof BrokerError && error.error === 'email_taken') {
|
|
575
674
|
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
576
675
|
}
|
|
@@ -594,11 +693,11 @@ export function createAuth(input) {
|
|
|
594
693
|
}
|
|
595
694
|
};
|
|
596
695
|
const startPasskeyRegistration = async (request) => {
|
|
597
|
-
const user = await
|
|
696
|
+
const user = await currentFreshUser(request);
|
|
598
697
|
return broker().startPasskeyRegistration(user.id);
|
|
599
698
|
};
|
|
600
699
|
const finishPasskeyRegistration = async (request, input) => {
|
|
601
|
-
const user = await
|
|
700
|
+
const user = await currentFreshUser(request);
|
|
602
701
|
// Built from named fields, never spread: `input` crosses a trust
|
|
603
702
|
// boundary (a Next.js Server Action does not enforce its TS parameter
|
|
604
703
|
// types on the wire), and a spread after `userId: user.id` would let a
|
|
@@ -626,11 +725,11 @@ export function createAuth(input) {
|
|
|
626
725
|
}
|
|
627
726
|
};
|
|
628
727
|
const startEmailChange = async (request, email) => {
|
|
629
|
-
const user = await
|
|
728
|
+
const user = await currentFreshUser(request);
|
|
630
729
|
await broker().startEmailChange(user.id, email);
|
|
631
730
|
};
|
|
632
731
|
const completeEmailChange = async (request, code) => {
|
|
633
|
-
const user = await
|
|
732
|
+
const user = await currentFreshUser(request);
|
|
634
733
|
try {
|
|
635
734
|
await broker().completeEmailChange(user.id, code);
|
|
636
735
|
return { ok: true };
|
|
@@ -649,7 +748,7 @@ export function createAuth(input) {
|
|
|
649
748
|
}
|
|
650
749
|
};
|
|
651
750
|
const changePassword = async (request, input) => {
|
|
652
|
-
const user = await
|
|
751
|
+
const user = await currentFreshUser(request);
|
|
653
752
|
try {
|
|
654
753
|
await broker().changePassword({ userId: user.id, ...input });
|
|
655
754
|
return { ok: true };
|
|
@@ -670,7 +769,7 @@ export function createAuth(input) {
|
|
|
670
769
|
return result.passkeys;
|
|
671
770
|
};
|
|
672
771
|
const deletePasskey = async (request, id) => {
|
|
673
|
-
const user = await
|
|
772
|
+
const user = await currentFreshUser(request);
|
|
674
773
|
try {
|
|
675
774
|
await broker().deletePasskey(user.id, id);
|
|
676
775
|
return { ok: true };
|
|
@@ -700,7 +799,7 @@ export function createAuth(input) {
|
|
|
700
799
|
catch (error) {
|
|
701
800
|
if (needsHostedLogin(error))
|
|
702
801
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
703
|
-
return { ok: false, error: signInError(error) };
|
|
802
|
+
return changeRequired(error) ?? { ok: false, error: signInError(error) };
|
|
704
803
|
}
|
|
705
804
|
};
|
|
706
805
|
const startQrSignIn = async () => {
|
|
@@ -833,6 +932,8 @@ export function createAuth(input) {
|
|
|
833
932
|
endSession,
|
|
834
933
|
authRequest,
|
|
835
934
|
signIn,
|
|
935
|
+
setNewPassword,
|
|
936
|
+
setNewPasswordHeadless,
|
|
836
937
|
signInWithPassword,
|
|
837
938
|
signUp,
|
|
838
939
|
startPasskeyRegistration,
|
|
@@ -908,6 +1009,16 @@ const MAX_AGE_CEILING = 31_536_000;
|
|
|
908
1009
|
* forever. The tolerance is why the round trip can ever satisfy the bound.
|
|
909
1010
|
*/
|
|
910
1011
|
const CLOCK_TOLERANCE = 60;
|
|
1012
|
+
/**
|
|
1013
|
+
* The freshness bound `startEmailChange`, `completeEmailChange`,
|
|
1014
|
+
* `changePassword`, `startPasskeyRegistration`, `finishPasskeyRegistration`
|
|
1015
|
+
* and `deletePasskey` enforce internally (`currentFreshUser`) -- the same
|
|
1016
|
+
* five minutes docs/adopting.md's Step-up section demonstrates for
|
|
1017
|
+
* `requireFreshUser`. Exported so a page gating one of these calls with its
|
|
1018
|
+
* own `requireFreshUser` can pass the identical bound rather than a second
|
|
1019
|
+
* number that might drift from this one.
|
|
1020
|
+
*/
|
|
1021
|
+
export const STEP_UP_MAX_AGE = 300;
|
|
911
1022
|
function maxAgeFrom(raw) {
|
|
912
1023
|
if (raw === null || raw.trim() === '')
|
|
913
1024
|
return undefined;
|
|
@@ -975,6 +1086,23 @@ async function quietly(act) {
|
|
|
975
1086
|
throw error;
|
|
976
1087
|
}
|
|
977
1088
|
}
|
|
1089
|
+
/** The broker's refusal for a verified one-time password, with the proof of that to send back. */
|
|
1090
|
+
function changeRequired(error) {
|
|
1091
|
+
return error instanceof BrokerError &&
|
|
1092
|
+
error.error === 'password_change_required' &&
|
|
1093
|
+
error.changeToken
|
|
1094
|
+
? { ok: false, error: 'password_change_required', changeToken: error.changeToken }
|
|
1095
|
+
: null;
|
|
1096
|
+
}
|
|
1097
|
+
function setNewPasswordError(error) {
|
|
1098
|
+
if (error instanceof BrokerError && error.error === 'invalid_token')
|
|
1099
|
+
return 'invalid_token';
|
|
1100
|
+
if (error instanceof BrokerError && error.error === 'invalid_password')
|
|
1101
|
+
return 'invalid_password';
|
|
1102
|
+
if (error instanceof BrokerError && error.status === 401)
|
|
1103
|
+
return 'invalid_token';
|
|
1104
|
+
return 'unavailable';
|
|
1105
|
+
}
|
|
978
1106
|
function signInError(error) {
|
|
979
1107
|
if (error instanceof BrokerError && error.error === 'request')
|
|
980
1108
|
return 'request';
|
package/dist/broker.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface NewUser {
|
|
|
15
15
|
* and words it will not have. Without it the email is the login name.
|
|
16
16
|
*/
|
|
17
17
|
username?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Creates the account with `password` as a one-time password: the person
|
|
20
|
+
* is signed in on nothing until they choose their own (`signUp` answers
|
|
21
|
+
* `password_change_required`, then `setNewPassword`).
|
|
22
|
+
*/
|
|
23
|
+
changeRequired?: boolean;
|
|
18
24
|
}
|
|
19
25
|
/** One of the organisation's people, as the sign-in service reports them. */
|
|
20
26
|
export interface Person {
|
|
@@ -35,7 +41,11 @@ export declare class BrokerError extends Error {
|
|
|
35
41
|
readonly status: number;
|
|
36
42
|
readonly error: string;
|
|
37
43
|
readonly detail?: string | undefined;
|
|
38
|
-
|
|
44
|
+
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
45
|
+
readonly changeToken?: string | undefined;
|
|
46
|
+
constructor(status: number, error: string, detail?: string | undefined,
|
|
47
|
+
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
48
|
+
changeToken?: string | undefined);
|
|
39
49
|
}
|
|
40
50
|
/**
|
|
41
51
|
* The privileged half, over HTTP. The app holds a key that reaches its own
|
|
@@ -60,6 +70,12 @@ export declare class Broker {
|
|
|
60
70
|
email: string;
|
|
61
71
|
password: string;
|
|
62
72
|
}): Promise<string>;
|
|
73
|
+
/** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
|
|
74
|
+
setNewPassword(input: {
|
|
75
|
+
authRequestId: string;
|
|
76
|
+
changeToken: string;
|
|
77
|
+
newPassword: string;
|
|
78
|
+
}): Promise<string>;
|
|
63
79
|
signUp(input: {
|
|
64
80
|
authRequestId: string;
|
|
65
81
|
} & NewUser): Promise<string>;
|
package/dist/broker.js
CHANGED
|
@@ -2,11 +2,15 @@ export class BrokerError extends Error {
|
|
|
2
2
|
status;
|
|
3
3
|
error;
|
|
4
4
|
detail;
|
|
5
|
-
|
|
5
|
+
changeToken;
|
|
6
|
+
constructor(status, error, detail,
|
|
7
|
+
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
8
|
+
changeToken) {
|
|
6
9
|
super(detail ?? error);
|
|
7
10
|
this.status = status;
|
|
8
11
|
this.error = error;
|
|
9
12
|
this.detail = detail;
|
|
13
|
+
this.changeToken = changeToken;
|
|
10
14
|
this.name = 'BrokerError';
|
|
11
15
|
}
|
|
12
16
|
}
|
|
@@ -48,7 +52,7 @@ export class Broker {
|
|
|
48
52
|
throw new BrokerError(response.status, 'unavailable', `non-JSON response from ${path}`);
|
|
49
53
|
}
|
|
50
54
|
if (!response.ok) {
|
|
51
|
-
throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message);
|
|
55
|
+
throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message, typeof data.changeToken === 'string' ? data.changeToken : undefined);
|
|
52
56
|
}
|
|
53
57
|
return data;
|
|
54
58
|
}
|
|
@@ -70,6 +74,11 @@ export class Broker {
|
|
|
70
74
|
const { callbackUrl } = await this.call('POST', '/sign-in', input);
|
|
71
75
|
return callbackUrl;
|
|
72
76
|
}
|
|
77
|
+
/** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
|
|
78
|
+
async setNewPassword(input) {
|
|
79
|
+
const { callbackUrl } = await this.call('POST', '/password/set', input);
|
|
80
|
+
return callbackUrl;
|
|
81
|
+
}
|
|
73
82
|
async signUp(input) {
|
|
74
83
|
const { callbackUrl } = await this.call('POST', '/sign-up', {
|
|
75
84
|
...input,
|