@wtfalch/auth 0.9.1 → 0.11.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/dist/auth.d.ts +110 -5
- package/dist/auth.js +220 -23
- package/dist/broker.d.ts +16 -0
- package/dist/broker.js +16 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/namespace-session.d.ts +4 -1
- package/dist/namespace-session.js +78 -8
- package/dist/next.d.ts +43 -2
- package/dist/next.js +68 -14
- package/dist/oidc.d.ts +34 -2
- package/dist/oidc.js +59 -2
- package/dist/sessions.d.ts +23 -3
- package/dist/sessions.js +58 -6
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type JWTPayload } from 'jose';
|
|
|
2
2
|
import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
|
|
3
3
|
import { type AuthOptions } from './config.js';
|
|
4
4
|
import { type SetCookie } from './cookies.js';
|
|
5
|
+
import type { SessionRecord } from './sessions.js';
|
|
5
6
|
export interface User {
|
|
6
7
|
/** Present only for validated namespace sessions. Membership is app-owned. */
|
|
7
8
|
namespaceId?: string;
|
|
@@ -14,6 +15,16 @@ export interface User {
|
|
|
14
15
|
familyName: string | null;
|
|
15
16
|
preferredUsername: string | null;
|
|
16
17
|
organizationId: string;
|
|
18
|
+
/**
|
|
19
|
+
* When the person last proved who they were to the issuer, in seconds since
|
|
20
|
+
* the epoch, from the id token's `auth_time`. Null when the issuer did not
|
|
21
|
+
* send it, which OIDC allows unless the request carried `max_age` -- so a
|
|
22
|
+
* null is not evidence of freshness and `fresh()` treats it as stale.
|
|
23
|
+
*
|
|
24
|
+
* A refresh does not move it. It is when they authenticated, not when the
|
|
25
|
+
* token was minted.
|
|
26
|
+
*/
|
|
27
|
+
authTime: number | null;
|
|
17
28
|
claims: JWTPayload;
|
|
18
29
|
}
|
|
19
30
|
export interface ReadResult {
|
|
@@ -45,6 +56,10 @@ export type Gate = {
|
|
|
45
56
|
reason?: 'account_changed' | 'unavailable';
|
|
46
57
|
};
|
|
47
58
|
export type Intent = 'login' | 'register';
|
|
59
|
+
/** Seconds. `0` makes the issuer re-authenticate whatever it did a moment ago. */
|
|
60
|
+
export type MaxAge = number;
|
|
61
|
+
/** Allowlisted so a caller cannot smuggle an arbitrary OIDC prompt value through. */
|
|
62
|
+
export type Prompt = 'select_account' | 'login';
|
|
48
63
|
export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
|
|
49
64
|
export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
|
|
50
65
|
export type SignUpError = 'registration_closed' | 'email_taken' | 'username_taken' | 'username_invalid' | 'invalid' | 'request' | 'unavailable';
|
|
@@ -65,6 +80,33 @@ export type SignUpResult = {
|
|
|
65
80
|
error: SignUpError;
|
|
66
81
|
message: string;
|
|
67
82
|
};
|
|
83
|
+
export type EmailChangeError = 'invalid' | 'email_taken' | 'too_many' | 'unavailable';
|
|
84
|
+
export type EmailChangeResult = {
|
|
85
|
+
ok: true;
|
|
86
|
+
} | {
|
|
87
|
+
ok: false;
|
|
88
|
+
error: EmailChangeError;
|
|
89
|
+
};
|
|
90
|
+
export type ChangePasswordError = 'invalid_current_password' | 'invalid_password' | 'unavailable';
|
|
91
|
+
export type ChangePasswordResult = {
|
|
92
|
+
ok: true;
|
|
93
|
+
} | {
|
|
94
|
+
ok: false;
|
|
95
|
+
error: ChangePasswordError;
|
|
96
|
+
};
|
|
97
|
+
export type DeletePasskeyResult = {
|
|
98
|
+
ok: true;
|
|
99
|
+
} | {
|
|
100
|
+
ok: false;
|
|
101
|
+
error: 'invalid';
|
|
102
|
+
};
|
|
103
|
+
/** One passkey as the issuer reports it -- `ready` or still mid-registration. */
|
|
104
|
+
export interface Passkey {
|
|
105
|
+
readonly id: string;
|
|
106
|
+
readonly name: string;
|
|
107
|
+
/** `ready`, `not_ready` for a registration nobody finished, or `unknown` for a state a newer issuer added. Only `ready` signs anyone in. */
|
|
108
|
+
readonly state: string;
|
|
109
|
+
}
|
|
68
110
|
export interface PasskeyRegistrationStart {
|
|
69
111
|
passkeyId: string;
|
|
70
112
|
/** ZITADEL's `publicKeyCredentialCreationOptions`, opaque here -- `@wtfalch/auth/passkey`'s `createPasskey` knows its shape. */
|
|
@@ -127,8 +169,13 @@ export interface Auth {
|
|
|
127
169
|
start(request: Request): Promise<Response>;
|
|
128
170
|
/** GET {basePath}/callback */
|
|
129
171
|
callback(request: Request): Promise<Response>;
|
|
130
|
-
/**
|
|
131
|
-
|
|
172
|
+
/**
|
|
173
|
+
* The callback as data, for a server action that finishes the sign-in
|
|
174
|
+
* itself and sets the cookies. `requestHeaders`, when given, is the
|
|
175
|
+
* signing-in request's own headers -- namespace mode keeps its user agent
|
|
176
|
+
* and address on the new session record.
|
|
177
|
+
*/
|
|
178
|
+
complete(callbackUrl: string, cookieHeader: string | null, requestHeaders?: Headers): Promise<Completed>;
|
|
132
179
|
/** POST {basePath}/logout */
|
|
133
180
|
logout(request: Request): Promise<Response>;
|
|
134
181
|
/** GET {basePath}/verify?userId=&code= */
|
|
@@ -154,7 +201,7 @@ export interface Auth {
|
|
|
154
201
|
sessionId: string;
|
|
155
202
|
code: string;
|
|
156
203
|
next?: string;
|
|
157
|
-
}): Promise<SignedIn>;
|
|
204
|
+
}, requestHeaders?: Headers): Promise<SignedIn>;
|
|
158
205
|
/** Emails a fresh verification link. Says nothing about whether the address exists. */
|
|
159
206
|
resendVerification(email: string, next?: string): Promise<void>;
|
|
160
207
|
/** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
|
|
@@ -165,7 +212,7 @@ export interface Auth {
|
|
|
165
212
|
code: string;
|
|
166
213
|
password: string;
|
|
167
214
|
next?: string;
|
|
168
|
-
}): Promise<SignedIn>;
|
|
215
|
+
}, requestHeaders?: Headers): Promise<SignedIn>;
|
|
169
216
|
/** Emails a sign-in link. Needs a verified address. Says nothing about whether the address exists. */
|
|
170
217
|
sendLink(email: string, next?: string): Promise<void>;
|
|
171
218
|
/**
|
|
@@ -195,7 +242,19 @@ export interface Auth {
|
|
|
195
242
|
refresh?: boolean;
|
|
196
243
|
}): Promise<ReadResult>;
|
|
197
244
|
gate(request: Request, rules?: GateRules): Promise<Gate>;
|
|
198
|
-
|
|
245
|
+
/**
|
|
246
|
+
* `prompt` is allowlisted server-side too -- an unrecognised value is dropped, not forwarded.
|
|
247
|
+
* `maxAge` becomes OIDC `max_age`: the issuer re-authenticates anybody whose
|
|
248
|
+
* last authentication is older than that many seconds.
|
|
249
|
+
*/
|
|
250
|
+
startUrl(next?: string | null, intent?: Intent, prompt?: Prompt, maxAge?: MaxAge): string;
|
|
251
|
+
/**
|
|
252
|
+
* Whether `user` authenticated within `maxAge` seconds. A user with no
|
|
253
|
+
* `authTime` is never fresh: the issuer only has to send the claim when the
|
|
254
|
+
* request asked for it, so its absence says nothing, and the safe reading of
|
|
255
|
+
* nothing is "send them back".
|
|
256
|
+
*/
|
|
257
|
+
fresh(user: User | null, maxAge: MaxAge): boolean;
|
|
199
258
|
/**
|
|
200
259
|
* Where to send somebody when a sign-in step failed for a reason they
|
|
201
260
|
* cannot fix by retrying it automatically: `onError` with `?auth_error=`,
|
|
@@ -230,6 +289,19 @@ export interface Auth {
|
|
|
230
289
|
* straight back in.
|
|
231
290
|
*/
|
|
232
291
|
revokeSessions(userId: string): Promise<number>;
|
|
292
|
+
/**
|
|
293
|
+
* The devices page: every live session belonging to whoever is signed in
|
|
294
|
+
* on `request`, newest first. Namespace mode only. Throws `AuthError`
|
|
295
|
+
* exactly as `startPasskeyRegistration` does for nobody signed in.
|
|
296
|
+
*/
|
|
297
|
+
sessions(request: Request): Promise<SessionRecord[]>;
|
|
298
|
+
/**
|
|
299
|
+
* Ends one of the signed-in person's own sessions. A key for somebody
|
|
300
|
+
* else, another binding, or nothing at all is refused the same way --
|
|
301
|
+
* `AuthError('request', ...)` -- so a guess cannot tell an id that exists
|
|
302
|
+
* from one that does not.
|
|
303
|
+
*/
|
|
304
|
+
endSession(request: Request, key: string): Promise<void>;
|
|
233
305
|
/** The auth request the issuer sent the browser here with. Refuses one for another application. */
|
|
234
306
|
authRequest(id: string): Promise<AuthRequest>;
|
|
235
307
|
signIn(input: {
|
|
@@ -253,6 +325,39 @@ export interface Auth {
|
|
|
253
325
|
credential: unknown;
|
|
254
326
|
name: string;
|
|
255
327
|
}): Promise<PasskeyRegistrationResult>;
|
|
328
|
+
/**
|
|
329
|
+
* Starts changing the address of whoever is signed in on `request` to
|
|
330
|
+
* `email`. A code is mailed to that NEW address, never to the old one --
|
|
331
|
+
* it is what proves they hold it -- and only `completeEmailChange` moves
|
|
332
|
+
* the live address once that code checks out. Until then the person's
|
|
333
|
+
* current address is untouched and keeps working, so a code that is lost
|
|
334
|
+
* or never used costs nothing. Same signed-in requirement as
|
|
335
|
+
* `startPasskeyRegistration`.
|
|
336
|
+
*/
|
|
337
|
+
startEmailChange(request: Request, email: string): Promise<void>;
|
|
338
|
+
/** The other half of `startEmailChange`; moves the live address once `code` checks out. */
|
|
339
|
+
completeEmailChange(request: Request, code: string): Promise<EmailChangeResult>;
|
|
340
|
+
/**
|
|
341
|
+
* Changes the password of whoever is signed in on `request`. The issuer
|
|
342
|
+
* checks `currentPassword` itself, so this never opens a session just to
|
|
343
|
+
* find out -- a wrong one comes back as its own error, distinct from a
|
|
344
|
+
* `newPassword` the issuer's own policy rejects.
|
|
345
|
+
*/
|
|
346
|
+
changePassword(request: Request, input: {
|
|
347
|
+
currentPassword: string;
|
|
348
|
+
newPassword: string;
|
|
349
|
+
}): Promise<ChangePasswordResult>;
|
|
350
|
+
/**
|
|
351
|
+
* Every passkey this person has, `ready` or not. A person with no
|
|
352
|
+
* password who deletes their only `ready` passkey can lock themselves
|
|
353
|
+
* out of their own account -- the broker does not guard this and neither
|
|
354
|
+
* does the issuer, deliberately: whether that is a problem for this app
|
|
355
|
+
* is its call to make, not the SDK's. This returns enough to make that
|
|
356
|
+
* call -- an app can warn when the list holds exactly one `ready` entry.
|
|
357
|
+
*/
|
|
358
|
+
passkeys(request: Request): Promise<Passkey[]>;
|
|
359
|
+
/** Removes one of this person's own passkeys. See `passkeys` for the lockout this does not guard against. */
|
|
360
|
+
deletePasskey(request: Request, id: string): Promise<DeletePasskeyResult>;
|
|
256
361
|
/** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
|
|
257
362
|
startPasskeySignIn(input: {
|
|
258
363
|
authRequestId: string;
|
package/dist/auth.js
CHANGED
|
@@ -2,9 +2,9 @@ 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, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
|
|
5
|
+
import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
|
|
6
6
|
import { namespaceSessions } from './namespace-session.js';
|
|
7
|
-
import { AuthError, ORG_CLAIM, Oidc, scopeFor } from './oidc.js';
|
|
7
|
+
import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
|
|
8
8
|
import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
|
|
9
9
|
import { safeNextPath } from './redirect.js';
|
|
10
10
|
export function createAuth(input) {
|
|
@@ -57,8 +57,8 @@ export function createAuth(input) {
|
|
|
57
57
|
return brokerInstance;
|
|
58
58
|
};
|
|
59
59
|
const sessions = namespaceSessions({ options, oidc, broker });
|
|
60
|
-
const sessionCookie = (tokens) => options().namespace
|
|
61
|
-
? sessions.create(tokens)
|
|
60
|
+
const sessionCookie = (tokens, requestHeaders) => options().namespace
|
|
61
|
+
? sessions.create(tokens, requestHeaders)
|
|
62
62
|
: sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
|
|
63
63
|
/**
|
|
64
64
|
* Signing in again replaces this binding's previous session outright: in
|
|
@@ -74,15 +74,27 @@ export function createAuth(input) {
|
|
|
74
74
|
.catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
|
|
75
75
|
};
|
|
76
76
|
const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
|
|
77
|
-
const startUrl = (next, intent = 'login') => {
|
|
77
|
+
const startUrl = (next, intent = 'login', prompt, maxAge) => {
|
|
78
78
|
const url = new URL(`${options().basePath}/start`, options().appUrl);
|
|
79
79
|
const path = safeNextPath(next, '');
|
|
80
80
|
if (path)
|
|
81
81
|
url.searchParams.set('next', path);
|
|
82
82
|
if (intent === 'register')
|
|
83
83
|
url.searchParams.set('intent', 'register');
|
|
84
|
+
if (prompt)
|
|
85
|
+
url.searchParams.set('prompt', prompt);
|
|
86
|
+
if (maxAge !== undefined) {
|
|
87
|
+
assertMaxAge(maxAge);
|
|
88
|
+
url.searchParams.set('max_age', String(maxAge));
|
|
89
|
+
}
|
|
84
90
|
return url.href;
|
|
85
91
|
};
|
|
92
|
+
const fresh = (user, maxAge) => {
|
|
93
|
+
assertMaxAge(maxAge);
|
|
94
|
+
if (!user || user.authTime === null)
|
|
95
|
+
return false;
|
|
96
|
+
return Date.now() / 1000 - user.authTime <= maxAge + CLOCK_TOLERANCE;
|
|
97
|
+
};
|
|
86
98
|
const start = async (request) => {
|
|
87
99
|
if (!isNavigation(request))
|
|
88
100
|
return new Response('Unauthorized', { status: 401 });
|
|
@@ -95,6 +107,8 @@ export function createAuth(input) {
|
|
|
95
107
|
const codeVerifier = client.randomPKCECodeVerifier();
|
|
96
108
|
const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
|
|
97
109
|
create: params.get('intent') === 'register',
|
|
110
|
+
prompt: promptFrom(params.get('prompt')),
|
|
111
|
+
maxAge: maxAgeFrom(params.get('max_age')),
|
|
98
112
|
});
|
|
99
113
|
const transaction = await sealTransaction(options(), {
|
|
100
114
|
st: state,
|
|
@@ -104,7 +118,7 @@ export function createAuth(input) {
|
|
|
104
118
|
});
|
|
105
119
|
return redirect(target.href, [transaction]);
|
|
106
120
|
};
|
|
107
|
-
const complete = async (callbackUrl, cookieHeader) => {
|
|
121
|
+
const complete = async (callbackUrl, cookieHeader, requestHeaders) => {
|
|
108
122
|
if (!validCallback(callbackUrl))
|
|
109
123
|
return failure('request', []);
|
|
110
124
|
const cleared = clearTransaction(options());
|
|
@@ -123,7 +137,7 @@ export function createAuth(input) {
|
|
|
123
137
|
nonce: transaction.nc,
|
|
124
138
|
codeVerifier: transaction.cv,
|
|
125
139
|
});
|
|
126
|
-
session = await sessionCookie(tokens);
|
|
140
|
+
session = await sessionCookie(tokens, requestHeaders);
|
|
127
141
|
}
|
|
128
142
|
catch (error) {
|
|
129
143
|
return failure(reasonOf(error), [cleared]);
|
|
@@ -140,26 +154,72 @@ export function createAuth(input) {
|
|
|
140
154
|
// path and query are what it asked for, on the app's own origin.
|
|
141
155
|
const { pathname, search } = new URL(request.url);
|
|
142
156
|
const callbackUrl = new URL(`${pathname}${search}`, options().appUrl).href;
|
|
143
|
-
const { location, cookies } = await complete(callbackUrl, request.headers.get('cookie'));
|
|
157
|
+
const { location, cookies } = await complete(callbackUrl, request.headers.get('cookie'), request.headers);
|
|
144
158
|
return redirect(location, cookies);
|
|
145
159
|
};
|
|
160
|
+
/**
|
|
161
|
+
* The record's own id token, read before `sessions.end` revokes it. Namespace
|
|
162
|
+
* sessions never hand the raw token back to a caller -- `sessions.read`
|
|
163
|
+
* answers with claims, not the token itself -- so logout, the one place that
|
|
164
|
+
* needs it for `id_token_hint`, opens the record the same way `end` does.
|
|
165
|
+
*/
|
|
166
|
+
const namespaceIdToken = async (value) => {
|
|
167
|
+
const store = options().sessionStore;
|
|
168
|
+
const key = store ? await sessions.keyOf(value) : null;
|
|
169
|
+
if (!store || !key)
|
|
170
|
+
return undefined;
|
|
171
|
+
const record = await store.get(key).catch(() => null);
|
|
172
|
+
const payload = record && (await openRecord(options(), key, record.payload).catch(() => null));
|
|
173
|
+
return payload?.idt;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Where a sign-out lands: the issuer's own logout, so its session ends and
|
|
177
|
+
* the next sign-in asks who again, rather than straight back to
|
|
178
|
+
* `afterLogout`. What this function can see fail -- no id token to hint
|
|
179
|
+
* with, no `end_session_endpoint` published, the issuer not answering --
|
|
180
|
+
* falls back to the plain redirect a sign-out has always ended with.
|
|
181
|
+
* Losing the issuer's co-operation is never a reason to fail somebody's
|
|
182
|
+
* sign-out.
|
|
183
|
+
*
|
|
184
|
+
* `afterLogout` not being a registered `post_logout_redirect_uri` is a
|
|
185
|
+
* failure this function cannot see: that request reaches the issuer, which
|
|
186
|
+
* is free to answer with its own error page instead of a redirect back.
|
|
187
|
+
* Registering it (docs/adopting.md) is what prevents that, not a check
|
|
188
|
+
* here -- cookie mode has no registry to check against at runtime, and
|
|
189
|
+
* namespace mode already refuses to start with a binding whose
|
|
190
|
+
* `afterLogout` is not registered.
|
|
191
|
+
*/
|
|
192
|
+
const endIssuerSession = async (idToken, cookies) => {
|
|
193
|
+
const afterLogout = new URL(options().afterLogout, options().appUrl).href;
|
|
194
|
+
const target = idToken
|
|
195
|
+
? await oidc()
|
|
196
|
+
.endSessionUrl(idToken, afterLogout)
|
|
197
|
+
.catch(() => null)
|
|
198
|
+
: null;
|
|
199
|
+
return redirect(target?.href ?? afterLogout, cookies, 303);
|
|
200
|
+
};
|
|
146
201
|
const logout = async (request) => {
|
|
147
202
|
if (!isSameOrigin(request, options().appUrl.origin)) {
|
|
148
203
|
return new Response('Forbidden', { status: 403 });
|
|
149
204
|
}
|
|
150
205
|
const value = cookieFrom(request.headers.get('cookie'), sessionCookieName(options()));
|
|
151
206
|
if (options().namespace) {
|
|
207
|
+
const idToken = await namespaceIdToken(value);
|
|
152
208
|
// Revoked in the store first: that is what ends copies of the cookie too.
|
|
153
|
-
|
|
209
|
+
// `sessions.end` reaches the store, which can be briefly unreachable --
|
|
210
|
+
// and a sign-out that 500s over that leaves the browser's own cookie
|
|
211
|
+
// standing, which is worse than the record surviving a moment longer.
|
|
212
|
+
const status = await sessions.end(value).catch(() => 'ended');
|
|
213
|
+
if (status === 'changed')
|
|
154
214
|
return new Response('Account changed', { status: 409 });
|
|
155
|
-
return
|
|
215
|
+
return endIssuerSession(idToken, [clearSession(options())]);
|
|
156
216
|
}
|
|
157
217
|
const session = await openSession(options(), value);
|
|
158
218
|
if (session?.rt)
|
|
159
219
|
await broker()
|
|
160
220
|
.revoke(session.rt)
|
|
161
221
|
.catch(() => { });
|
|
162
|
-
return
|
|
222
|
+
return endIssuerSession(session?.idt, [clearSession(options())]);
|
|
163
223
|
};
|
|
164
224
|
const verify = async (request) => {
|
|
165
225
|
const params = new URL(request.url).searchParams;
|
|
@@ -227,8 +287,10 @@ export function createAuth(input) {
|
|
|
227
287
|
return { user: null, cookies: [] };
|
|
228
288
|
const now = Math.floor(Date.now() / 1000);
|
|
229
289
|
const exp = expiryOf(session.idt);
|
|
290
|
+
const iat = issuedAtOf(session.idt);
|
|
230
291
|
const valid = exp !== null && exp > now - 60;
|
|
231
|
-
const
|
|
292
|
+
const stale = iat !== null && now - iat >= options().freshness;
|
|
293
|
+
const due = exp === null || exp - options().refreshWindow <= now || stale;
|
|
232
294
|
if (due && refresh && session.rt) {
|
|
233
295
|
try {
|
|
234
296
|
const tokens = await oidc().refresh(session.rt);
|
|
@@ -239,9 +301,17 @@ export function createAuth(input) {
|
|
|
239
301
|
return { user: boundUser(tokens.claims), cookies: [cookie] };
|
|
240
302
|
}
|
|
241
303
|
catch (error) {
|
|
242
|
-
// A failed refresh never clears the cookie: a valid token carries on, and a dead one is replaced by the next sign-in.
|
|
243
304
|
if (error instanceof AuthError)
|
|
244
305
|
return { user: null, cookies: [clearSession(options())] };
|
|
306
|
+
// Under the issuer's own rotation nobody else can have spent this
|
|
307
|
+
// token, so a refusal is final -- the account is disabled or the
|
|
308
|
+
// grant is gone, and it stays denied on every request from here.
|
|
309
|
+
// The cookie is left alone though: a losing request in a genuine
|
|
310
|
+
// race must not blank out whatever cookie the winner's response is
|
|
311
|
+
// setting for the browser to pick up next.
|
|
312
|
+
if (isOAuthRefusal(error))
|
|
313
|
+
return { user: null, cookies: [] };
|
|
314
|
+
// A network error or a 5xx: the current token carries on, never past its own expiry.
|
|
245
315
|
if (!valid)
|
|
246
316
|
return { user: null, cookies: [] };
|
|
247
317
|
}
|
|
@@ -299,6 +369,19 @@ export function createAuth(input) {
|
|
|
299
369
|
throw new Error('@wtfalch/auth: revokeSessions needs namespace sessions');
|
|
300
370
|
return sessions.revokeSubject(userId);
|
|
301
371
|
};
|
|
372
|
+
const listSessions = async (request) => {
|
|
373
|
+
if (!options().namespace)
|
|
374
|
+
throw new Error('@wtfalch/auth: sessions needs namespace sessions');
|
|
375
|
+
const user = await currentUser(request);
|
|
376
|
+
return sessions.listSessions(user.id);
|
|
377
|
+
};
|
|
378
|
+
const endSession = async (request, key) => {
|
|
379
|
+
if (!options().namespace)
|
|
380
|
+
throw new Error('@wtfalch/auth: endSession needs namespace sessions');
|
|
381
|
+
const user = await currentUser(request);
|
|
382
|
+
if (!(await sessions.endSession(key, user.id)))
|
|
383
|
+
throw new AuthError('request', 'session does not belong to the signed-in person');
|
|
384
|
+
};
|
|
302
385
|
const authRequest = (id) => broker().authRequest(id);
|
|
303
386
|
/**
|
|
304
387
|
* An OIDC flow this app starts and finishes itself, for a person arriving
|
|
@@ -323,7 +406,7 @@ export function createAuth(input) {
|
|
|
323
406
|
return { id, state, nonce, verifier };
|
|
324
407
|
};
|
|
325
408
|
/** Exchanges a callback the service issued, and seals the cookie. */
|
|
326
|
-
const finish = async (callbackUrl, flow, next) => {
|
|
409
|
+
const finish = async (callbackUrl, flow, next, requestHeaders) => {
|
|
327
410
|
if (!validCallback(callbackUrl))
|
|
328
411
|
throw new AuthError('request', 'callback does not match namespace binding');
|
|
329
412
|
const tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
|
|
@@ -331,7 +414,7 @@ export function createAuth(input) {
|
|
|
331
414
|
nonce: flow.nonce,
|
|
332
415
|
codeVerifier: flow.verifier,
|
|
333
416
|
});
|
|
334
|
-
const cookie = await sessionCookie(tokens);
|
|
417
|
+
const cookie = await sessionCookie(tokens, requestHeaders);
|
|
335
418
|
return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
|
|
336
419
|
};
|
|
337
420
|
const hostedUrl = (id) => {
|
|
@@ -342,9 +425,9 @@ export function createAuth(input) {
|
|
|
342
425
|
const needsHostedLogin = (error) => error instanceof BrokerError && error.error === 'hosted_login_required';
|
|
343
426
|
// Email/reset flows started on the server must transfer their PKCE transaction
|
|
344
427
|
// to the browser before handing off to the issuer's full authentication UI.
|
|
345
|
-
const finishOrHandoff = async (action, flow, next) => {
|
|
428
|
+
const finishOrHandoff = async (action, flow, next, requestHeaders) => {
|
|
346
429
|
try {
|
|
347
|
-
return await finish(await action(), flow, next);
|
|
430
|
+
return await finish(await action(), flow, next, requestHeaders);
|
|
348
431
|
}
|
|
349
432
|
catch (error) {
|
|
350
433
|
if (!needsHostedLogin(error))
|
|
@@ -367,10 +450,10 @@ export function createAuth(input) {
|
|
|
367
450
|
const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
|
|
368
451
|
const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
|
|
369
452
|
const invite = (input) => broker().invite({ ...input, next: safeNextPath(input.next, '') });
|
|
370
|
-
const resetPassword = async ({ userId, code, password, next, }) => {
|
|
453
|
+
const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
|
|
371
454
|
try {
|
|
372
455
|
const flow = await serverFlow();
|
|
373
|
-
const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin));
|
|
456
|
+
const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
374
457
|
return { ok: true, ...completed };
|
|
375
458
|
}
|
|
376
459
|
catch (error) {
|
|
@@ -383,10 +466,10 @@ export function createAuth(input) {
|
|
|
383
466
|
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
384
467
|
}
|
|
385
468
|
};
|
|
386
|
-
const followLink = async ({ sessionId, code, next,
|
|
469
|
+
const followLink = async ({ sessionId, code, next }, requestHeaders) => {
|
|
387
470
|
try {
|
|
388
471
|
const flow = await serverFlow();
|
|
389
|
-
const completed = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(next, options().afterLogin));
|
|
472
|
+
const completed = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
390
473
|
return { ok: true, ...completed };
|
|
391
474
|
}
|
|
392
475
|
catch (error) {
|
|
@@ -406,7 +489,7 @@ export function createAuth(input) {
|
|
|
406
489
|
sessionId,
|
|
407
490
|
code,
|
|
408
491
|
next: params.get('next') ?? undefined,
|
|
409
|
-
});
|
|
492
|
+
}, request.headers);
|
|
410
493
|
return result.ok ? redirect(result.location, result.cookies) : failed('link', []);
|
|
411
494
|
};
|
|
412
495
|
const signIn = async ({ authRequestId, email, password, }) => {
|
|
@@ -480,6 +563,62 @@ export function createAuth(input) {
|
|
|
480
563
|
throw error;
|
|
481
564
|
}
|
|
482
565
|
};
|
|
566
|
+
const startEmailChange = async (request, email) => {
|
|
567
|
+
const user = await currentUser(request);
|
|
568
|
+
await broker().startEmailChange(user.id, email);
|
|
569
|
+
};
|
|
570
|
+
const completeEmailChange = async (request, code) => {
|
|
571
|
+
const user = await currentUser(request);
|
|
572
|
+
try {
|
|
573
|
+
await broker().completeEmailChange(user.id, code);
|
|
574
|
+
return { ok: true };
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
if (error instanceof BrokerError && error.error === 'email_taken') {
|
|
578
|
+
return { ok: false, error: 'email_taken' };
|
|
579
|
+
}
|
|
580
|
+
if (error instanceof BrokerError && error.error === 'too_many') {
|
|
581
|
+
return { ok: false, error: 'too_many' };
|
|
582
|
+
}
|
|
583
|
+
if (error instanceof BrokerError && error.status < 500) {
|
|
584
|
+
return { ok: false, error: 'invalid' };
|
|
585
|
+
}
|
|
586
|
+
return { ok: false, error: 'unavailable' };
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
const changePassword = async (request, input) => {
|
|
590
|
+
const user = await currentUser(request);
|
|
591
|
+
try {
|
|
592
|
+
await broker().changePassword({ userId: user.id, ...input });
|
|
593
|
+
return { ok: true };
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
if (error instanceof BrokerError && error.error === 'invalid_current_password') {
|
|
597
|
+
return { ok: false, error: 'invalid_current_password' };
|
|
598
|
+
}
|
|
599
|
+
if (error instanceof BrokerError && error.status < 500) {
|
|
600
|
+
return { ok: false, error: 'invalid_password' };
|
|
601
|
+
}
|
|
602
|
+
return { ok: false, error: 'unavailable' };
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
const passkeys = async (request) => {
|
|
606
|
+
const user = await currentUser(request);
|
|
607
|
+
const result = await broker().passkeys(user.id);
|
|
608
|
+
return result.passkeys;
|
|
609
|
+
};
|
|
610
|
+
const deletePasskey = async (request, id) => {
|
|
611
|
+
const user = await currentUser(request);
|
|
612
|
+
try {
|
|
613
|
+
await broker().deletePasskey(user.id, id);
|
|
614
|
+
return { ok: true };
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
if (error instanceof BrokerError && error.status < 500)
|
|
618
|
+
return { ok: false, error: 'invalid' };
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
};
|
|
483
622
|
const startPasskeySignIn = async ({ authRequestId, email, }) => {
|
|
484
623
|
try {
|
|
485
624
|
const { sessionId, options } = await broker().startPasskeySignIn({ authRequestId, email });
|
|
@@ -548,7 +687,7 @@ export function createAuth(input) {
|
|
|
548
687
|
return { status: 'unavailable', cookies: [cleared] };
|
|
549
688
|
let session;
|
|
550
689
|
try {
|
|
551
|
-
session = await sessionCookie(outcome.tokens);
|
|
690
|
+
session = await sessionCookie(outcome.tokens, request.headers);
|
|
552
691
|
}
|
|
553
692
|
catch (error) {
|
|
554
693
|
return { status: 'failed', reason: reasonOf(error), cookies: [cleared] };
|
|
@@ -588,15 +727,23 @@ export function createAuth(input) {
|
|
|
588
727
|
readCookie,
|
|
589
728
|
gate,
|
|
590
729
|
startUrl,
|
|
730
|
+
fresh,
|
|
591
731
|
errorUrl,
|
|
592
732
|
people,
|
|
593
733
|
setPersonActive,
|
|
594
734
|
revokeSessions,
|
|
735
|
+
sessions: listSessions,
|
|
736
|
+
endSession,
|
|
595
737
|
authRequest,
|
|
596
738
|
signIn,
|
|
597
739
|
signUp,
|
|
598
740
|
startPasskeyRegistration,
|
|
599
741
|
finishPasskeyRegistration,
|
|
742
|
+
startEmailChange,
|
|
743
|
+
completeEmailChange,
|
|
744
|
+
changePassword,
|
|
745
|
+
passkeys,
|
|
746
|
+
deletePasskey,
|
|
600
747
|
startPasskeySignIn,
|
|
601
748
|
finishPasskeySignIn,
|
|
602
749
|
startQrSignIn,
|
|
@@ -632,6 +779,55 @@ function expiryOf(idToken) {
|
|
|
632
779
|
return null;
|
|
633
780
|
}
|
|
634
781
|
}
|
|
782
|
+
// Age, not time left: a disabled or revoked account must not ride out a
|
|
783
|
+
// long-lived token just because it still has plenty of time on the clock.
|
|
784
|
+
function issuedAtOf(idToken) {
|
|
785
|
+
try {
|
|
786
|
+
const [, payload] = idToken.split('.');
|
|
787
|
+
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
788
|
+
return typeof json.iat === 'number' ? json.iat : null;
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const PROMPTS = ['select_account', 'login'];
|
|
795
|
+
function promptFrom(raw) {
|
|
796
|
+
return PROMPTS.includes(raw ?? '') ? raw : undefined;
|
|
797
|
+
}
|
|
798
|
+
// A year. Past that the bound is doing nothing, and an absurd value is a
|
|
799
|
+
// caller getting it wrong rather than a request to honour.
|
|
800
|
+
const MAX_AGE_CEILING = 31_536_000;
|
|
801
|
+
/**
|
|
802
|
+
* The same sixty seconds `Oidc.verify` allows a token's own claims. `auth_time`
|
|
803
|
+
* comes off the issuer's clock and is compared against this server's, so a
|
|
804
|
+
* bound checked to the exact second would flip on skew alone.
|
|
805
|
+
*
|
|
806
|
+
* It also makes `maxAge: 0` terminate. Zero asks the issuer to authenticate
|
|
807
|
+
* the person again; they then spend a second or two coming back, and a check
|
|
808
|
+
* with no slack would find them already stale and send them round again,
|
|
809
|
+
* forever. The tolerance is why the round trip can ever satisfy the bound.
|
|
810
|
+
*/
|
|
811
|
+
const CLOCK_TOLERANCE = 60;
|
|
812
|
+
function maxAgeFrom(raw) {
|
|
813
|
+
if (raw === null || raw.trim() === '')
|
|
814
|
+
return undefined;
|
|
815
|
+
const value = Number(raw);
|
|
816
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING)
|
|
817
|
+
return undefined;
|
|
818
|
+
return value;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* A bad `maxAge` from app code is a mistake in the app, and the loud failure is
|
|
822
|
+
* the cheap one. Left alone it is silent and expensive: `startUrl` would drop
|
|
823
|
+
* the value, the issuer would be asked for nothing, the person would come back
|
|
824
|
+
* exactly as stale, and the gate would send them again.
|
|
825
|
+
*/
|
|
826
|
+
function assertMaxAge(value) {
|
|
827
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING) {
|
|
828
|
+
throw new AuthError('request', `maxAge must be whole seconds from 0 to ${MAX_AGE_CEILING}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
635
831
|
function userFrom(claims) {
|
|
636
832
|
const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
|
|
637
833
|
return {
|
|
@@ -643,6 +839,7 @@ function userFrom(claims) {
|
|
|
643
839
|
familyName: str('family_name'),
|
|
644
840
|
preferredUsername: str('preferred_username'),
|
|
645
841
|
organizationId: String(claims[ORG_CLAIM]),
|
|
842
|
+
authTime: typeof claims.auth_time === 'number' ? claims.auth_time : null,
|
|
646
843
|
claims,
|
|
647
844
|
};
|
|
648
845
|
}
|
package/dist/broker.d.ts
CHANGED
|
@@ -123,6 +123,22 @@ export declare class Broker {
|
|
|
123
123
|
revoked?: string[];
|
|
124
124
|
}>;
|
|
125
125
|
verifyEmail(userId: string, code: string): Promise<unknown>;
|
|
126
|
+
/** Mails a code to `email`, never to the address it would replace -- see `Auth['startEmailChange']`. */
|
|
127
|
+
startEmailChange(userId: string, email: string): Promise<unknown>;
|
|
128
|
+
completeEmailChange(userId: string, code: string): Promise<unknown>;
|
|
129
|
+
changePassword(input: {
|
|
130
|
+
userId: string;
|
|
131
|
+
currentPassword: string;
|
|
132
|
+
newPassword: string;
|
|
133
|
+
}): Promise<unknown>;
|
|
134
|
+
passkeys(userId: string): Promise<{
|
|
135
|
+
passkeys: {
|
|
136
|
+
id: string;
|
|
137
|
+
name: string;
|
|
138
|
+
state: string;
|
|
139
|
+
}[];
|
|
140
|
+
}>;
|
|
141
|
+
deletePasskey(userId: string, passkeyId: string): Promise<unknown>;
|
|
126
142
|
/** A public client revoking its own token: no key, no service. */
|
|
127
143
|
revoke(refreshToken: string): Promise<void>;
|
|
128
144
|
}
|
package/dist/broker.js
CHANGED
|
@@ -123,6 +123,22 @@ export class Broker {
|
|
|
123
123
|
verifyEmail(userId, code) {
|
|
124
124
|
return this.call('POST', '/verify', { userId, code });
|
|
125
125
|
}
|
|
126
|
+
/** Mails a code to `email`, never to the address it would replace -- see `Auth['startEmailChange']`. */
|
|
127
|
+
startEmailChange(userId, email) {
|
|
128
|
+
return this.call('POST', '/email/change/send', { userId, email });
|
|
129
|
+
}
|
|
130
|
+
completeEmailChange(userId, code) {
|
|
131
|
+
return this.call('POST', '/email/change/complete', { userId, code });
|
|
132
|
+
}
|
|
133
|
+
changePassword(input) {
|
|
134
|
+
return this.call('POST', '/password/change', input);
|
|
135
|
+
}
|
|
136
|
+
passkeys(userId) {
|
|
137
|
+
return this.call('GET', '/passkeys', { userId });
|
|
138
|
+
}
|
|
139
|
+
deletePasskey(userId, passkeyId) {
|
|
140
|
+
return this.call('POST', '/passkeys/delete', { userId, passkeyId });
|
|
141
|
+
}
|
|
126
142
|
/** A public client revoking its own token: no key, no service. */
|
|
127
143
|
async revoke(refreshToken) {
|
|
128
144
|
await this.options.fetch(`${this.options.issuer}/oauth/v2/revoke`, {
|
package/dist/config.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export interface AuthOptions {
|
|
|
43
43
|
sessionMaxAge?: number;
|
|
44
44
|
/** Refresh when the id token has fewer seconds left than this. Default 300. */
|
|
45
45
|
refreshWindow?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Refresh when the id token is older than this, however far it is from
|
|
48
|
+
* expiry -- the propagation bound for a disabled or revoked account.
|
|
49
|
+
* Default 300. Both modes.
|
|
50
|
+
*/
|
|
51
|
+
freshness?: number;
|
|
46
52
|
fetch?: typeof fetch;
|
|
47
53
|
}
|
|
48
54
|
export interface ResolvedOptions {
|
|
@@ -74,6 +80,7 @@ export interface ResolvedOptions {
|
|
|
74
80
|
brokerUrl: string;
|
|
75
81
|
sessionMaxAge: number;
|
|
76
82
|
refreshWindow: number;
|
|
83
|
+
freshness: number;
|
|
77
84
|
secure: boolean;
|
|
78
85
|
fetch: typeof fetch;
|
|
79
86
|
}
|