@wtfalch/auth 0.9.0 → 0.10.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 +87 -5
- package/dist/auth.js +179 -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/namespace-session.d.ts +4 -1
- package/dist/namespace-session.js +78 -8
- package/dist/next.d.ts +23 -2
- package/dist/next.js +36 -13
- package/dist/oidc.d.ts +30 -1
- package/dist/oidc.js +58 -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;
|
|
@@ -45,6 +46,8 @@ export type Gate = {
|
|
|
45
46
|
reason?: 'account_changed' | 'unavailable';
|
|
46
47
|
};
|
|
47
48
|
export type Intent = 'login' | 'register';
|
|
49
|
+
/** Allowlisted so a caller cannot smuggle an arbitrary OIDC prompt value through. */
|
|
50
|
+
export type Prompt = 'select_account' | 'login';
|
|
48
51
|
export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
|
|
49
52
|
export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
|
|
50
53
|
export type SignUpError = 'registration_closed' | 'email_taken' | 'username_taken' | 'username_invalid' | 'invalid' | 'request' | 'unavailable';
|
|
@@ -65,6 +68,33 @@ export type SignUpResult = {
|
|
|
65
68
|
error: SignUpError;
|
|
66
69
|
message: string;
|
|
67
70
|
};
|
|
71
|
+
export type EmailChangeError = 'invalid' | 'email_taken' | 'too_many' | 'unavailable';
|
|
72
|
+
export type EmailChangeResult = {
|
|
73
|
+
ok: true;
|
|
74
|
+
} | {
|
|
75
|
+
ok: false;
|
|
76
|
+
error: EmailChangeError;
|
|
77
|
+
};
|
|
78
|
+
export type ChangePasswordError = 'invalid_current_password' | 'invalid_password' | 'unavailable';
|
|
79
|
+
export type ChangePasswordResult = {
|
|
80
|
+
ok: true;
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
error: ChangePasswordError;
|
|
84
|
+
};
|
|
85
|
+
export type DeletePasskeyResult = {
|
|
86
|
+
ok: true;
|
|
87
|
+
} | {
|
|
88
|
+
ok: false;
|
|
89
|
+
error: 'invalid';
|
|
90
|
+
};
|
|
91
|
+
/** One passkey as the issuer reports it -- `ready` or still mid-registration. */
|
|
92
|
+
export interface Passkey {
|
|
93
|
+
readonly id: string;
|
|
94
|
+
readonly name: string;
|
|
95
|
+
/** `ready`, `not_ready` for a registration nobody finished, or `unknown` for a state a newer issuer added. Only `ready` signs anyone in. */
|
|
96
|
+
readonly state: string;
|
|
97
|
+
}
|
|
68
98
|
export interface PasskeyRegistrationStart {
|
|
69
99
|
passkeyId: string;
|
|
70
100
|
/** ZITADEL's `publicKeyCredentialCreationOptions`, opaque here -- `@wtfalch/auth/passkey`'s `createPasskey` knows its shape. */
|
|
@@ -127,8 +157,13 @@ export interface Auth {
|
|
|
127
157
|
start(request: Request): Promise<Response>;
|
|
128
158
|
/** GET {basePath}/callback */
|
|
129
159
|
callback(request: Request): Promise<Response>;
|
|
130
|
-
/**
|
|
131
|
-
|
|
160
|
+
/**
|
|
161
|
+
* The callback as data, for a server action that finishes the sign-in
|
|
162
|
+
* itself and sets the cookies. `requestHeaders`, when given, is the
|
|
163
|
+
* signing-in request's own headers -- namespace mode keeps its user agent
|
|
164
|
+
* and address on the new session record.
|
|
165
|
+
*/
|
|
166
|
+
complete(callbackUrl: string, cookieHeader: string | null, requestHeaders?: Headers): Promise<Completed>;
|
|
132
167
|
/** POST {basePath}/logout */
|
|
133
168
|
logout(request: Request): Promise<Response>;
|
|
134
169
|
/** GET {basePath}/verify?userId=&code= */
|
|
@@ -154,7 +189,7 @@ export interface Auth {
|
|
|
154
189
|
sessionId: string;
|
|
155
190
|
code: string;
|
|
156
191
|
next?: string;
|
|
157
|
-
}): Promise<SignedIn>;
|
|
192
|
+
}, requestHeaders?: Headers): Promise<SignedIn>;
|
|
158
193
|
/** Emails a fresh verification link. Says nothing about whether the address exists. */
|
|
159
194
|
resendVerification(email: string, next?: string): Promise<void>;
|
|
160
195
|
/** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
|
|
@@ -165,7 +200,7 @@ export interface Auth {
|
|
|
165
200
|
code: string;
|
|
166
201
|
password: string;
|
|
167
202
|
next?: string;
|
|
168
|
-
}): Promise<SignedIn>;
|
|
203
|
+
}, requestHeaders?: Headers): Promise<SignedIn>;
|
|
169
204
|
/** Emails a sign-in link. Needs a verified address. Says nothing about whether the address exists. */
|
|
170
205
|
sendLink(email: string, next?: string): Promise<void>;
|
|
171
206
|
/**
|
|
@@ -195,7 +230,8 @@ export interface Auth {
|
|
|
195
230
|
refresh?: boolean;
|
|
196
231
|
}): Promise<ReadResult>;
|
|
197
232
|
gate(request: Request, rules?: GateRules): Promise<Gate>;
|
|
198
|
-
|
|
233
|
+
/** `prompt` is allowlisted server-side too -- an unrecognised value is dropped, not forwarded. */
|
|
234
|
+
startUrl(next?: string | null, intent?: Intent, prompt?: Prompt): string;
|
|
199
235
|
/**
|
|
200
236
|
* Where to send somebody when a sign-in step failed for a reason they
|
|
201
237
|
* cannot fix by retrying it automatically: `onError` with `?auth_error=`,
|
|
@@ -230,6 +266,19 @@ export interface Auth {
|
|
|
230
266
|
* straight back in.
|
|
231
267
|
*/
|
|
232
268
|
revokeSessions(userId: string): Promise<number>;
|
|
269
|
+
/**
|
|
270
|
+
* The devices page: every live session belonging to whoever is signed in
|
|
271
|
+
* on `request`, newest first. Namespace mode only. Throws `AuthError`
|
|
272
|
+
* exactly as `startPasskeyRegistration` does for nobody signed in.
|
|
273
|
+
*/
|
|
274
|
+
sessions(request: Request): Promise<SessionRecord[]>;
|
|
275
|
+
/**
|
|
276
|
+
* Ends one of the signed-in person's own sessions. A key for somebody
|
|
277
|
+
* else, another binding, or nothing at all is refused the same way --
|
|
278
|
+
* `AuthError('request', ...)` -- so a guess cannot tell an id that exists
|
|
279
|
+
* from one that does not.
|
|
280
|
+
*/
|
|
281
|
+
endSession(request: Request, key: string): Promise<void>;
|
|
233
282
|
/** The auth request the issuer sent the browser here with. Refuses one for another application. */
|
|
234
283
|
authRequest(id: string): Promise<AuthRequest>;
|
|
235
284
|
signIn(input: {
|
|
@@ -253,6 +302,39 @@ export interface Auth {
|
|
|
253
302
|
credential: unknown;
|
|
254
303
|
name: string;
|
|
255
304
|
}): Promise<PasskeyRegistrationResult>;
|
|
305
|
+
/**
|
|
306
|
+
* Starts changing the address of whoever is signed in on `request` to
|
|
307
|
+
* `email`. A code is mailed to that NEW address, never to the old one --
|
|
308
|
+
* it is what proves they hold it -- and only `completeEmailChange` moves
|
|
309
|
+
* the live address once that code checks out. Until then the person's
|
|
310
|
+
* current address is untouched and keeps working, so a code that is lost
|
|
311
|
+
* or never used costs nothing. Same signed-in requirement as
|
|
312
|
+
* `startPasskeyRegistration`.
|
|
313
|
+
*/
|
|
314
|
+
startEmailChange(request: Request, email: string): Promise<void>;
|
|
315
|
+
/** The other half of `startEmailChange`; moves the live address once `code` checks out. */
|
|
316
|
+
completeEmailChange(request: Request, code: string): Promise<EmailChangeResult>;
|
|
317
|
+
/**
|
|
318
|
+
* Changes the password of whoever is signed in on `request`. The issuer
|
|
319
|
+
* checks `currentPassword` itself, so this never opens a session just to
|
|
320
|
+
* find out -- a wrong one comes back as its own error, distinct from a
|
|
321
|
+
* `newPassword` the issuer's own policy rejects.
|
|
322
|
+
*/
|
|
323
|
+
changePassword(request: Request, input: {
|
|
324
|
+
currentPassword: string;
|
|
325
|
+
newPassword: string;
|
|
326
|
+
}): Promise<ChangePasswordResult>;
|
|
327
|
+
/**
|
|
328
|
+
* Every passkey this person has, `ready` or not. A person with no
|
|
329
|
+
* password who deletes their only `ready` passkey can lock themselves
|
|
330
|
+
* out of their own account -- the broker does not guard this and neither
|
|
331
|
+
* does the issuer, deliberately: whether that is a problem for this app
|
|
332
|
+
* is its call to make, not the SDK's. This returns enough to make that
|
|
333
|
+
* call -- an app can warn when the list holds exactly one `ready` entry.
|
|
334
|
+
*/
|
|
335
|
+
passkeys(request: Request): Promise<Passkey[]>;
|
|
336
|
+
/** Removes one of this person's own passkeys. See `passkeys` for the lockout this does not guard against. */
|
|
337
|
+
deletePasskey(request: Request, id: string): Promise<DeletePasskeyResult>;
|
|
256
338
|
/** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
|
|
257
339
|
startPasskeySignIn(input: {
|
|
258
340
|
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,13 +74,15 @@ 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) => {
|
|
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);
|
|
84
86
|
return url.href;
|
|
85
87
|
};
|
|
86
88
|
const start = async (request) => {
|
|
@@ -95,6 +97,7 @@ export function createAuth(input) {
|
|
|
95
97
|
const codeVerifier = client.randomPKCECodeVerifier();
|
|
96
98
|
const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
|
|
97
99
|
create: params.get('intent') === 'register',
|
|
100
|
+
prompt: promptFrom(params.get('prompt')),
|
|
98
101
|
});
|
|
99
102
|
const transaction = await sealTransaction(options(), {
|
|
100
103
|
st: state,
|
|
@@ -104,7 +107,7 @@ export function createAuth(input) {
|
|
|
104
107
|
});
|
|
105
108
|
return redirect(target.href, [transaction]);
|
|
106
109
|
};
|
|
107
|
-
const complete = async (callbackUrl, cookieHeader) => {
|
|
110
|
+
const complete = async (callbackUrl, cookieHeader, requestHeaders) => {
|
|
108
111
|
if (!validCallback(callbackUrl))
|
|
109
112
|
return failure('request', []);
|
|
110
113
|
const cleared = clearTransaction(options());
|
|
@@ -123,7 +126,7 @@ export function createAuth(input) {
|
|
|
123
126
|
nonce: transaction.nc,
|
|
124
127
|
codeVerifier: transaction.cv,
|
|
125
128
|
});
|
|
126
|
-
session = await sessionCookie(tokens);
|
|
129
|
+
session = await sessionCookie(tokens, requestHeaders);
|
|
127
130
|
}
|
|
128
131
|
catch (error) {
|
|
129
132
|
return failure(reasonOf(error), [cleared]);
|
|
@@ -135,26 +138,77 @@ export function createAuth(input) {
|
|
|
135
138
|
};
|
|
136
139
|
};
|
|
137
140
|
const callback = async (request) => {
|
|
138
|
-
|
|
141
|
+
// Behind a proxy the request can arrive as http:// or on an internal host,
|
|
142
|
+
// and `validCallback` would refuse it. The browser reached this app, so the
|
|
143
|
+
// path and query are what it asked for, on the app's own origin.
|
|
144
|
+
const { pathname, search } = new URL(request.url);
|
|
145
|
+
const callbackUrl = new URL(`${pathname}${search}`, options().appUrl).href;
|
|
146
|
+
const { location, cookies } = await complete(callbackUrl, request.headers.get('cookie'), request.headers);
|
|
139
147
|
return redirect(location, cookies);
|
|
140
148
|
};
|
|
149
|
+
/**
|
|
150
|
+
* The record's own id token, read before `sessions.end` revokes it. Namespace
|
|
151
|
+
* sessions never hand the raw token back to a caller -- `sessions.read`
|
|
152
|
+
* answers with claims, not the token itself -- so logout, the one place that
|
|
153
|
+
* needs it for `id_token_hint`, opens the record the same way `end` does.
|
|
154
|
+
*/
|
|
155
|
+
const namespaceIdToken = async (value) => {
|
|
156
|
+
const store = options().sessionStore;
|
|
157
|
+
const key = store ? await sessions.keyOf(value) : null;
|
|
158
|
+
if (!store || !key)
|
|
159
|
+
return undefined;
|
|
160
|
+
const record = await store.get(key).catch(() => null);
|
|
161
|
+
const payload = record && (await openRecord(options(), key, record.payload).catch(() => null));
|
|
162
|
+
return payload?.idt;
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Where a sign-out lands: the issuer's own logout, so its session ends and
|
|
166
|
+
* the next sign-in asks who again, rather than straight back to
|
|
167
|
+
* `afterLogout`. What this function can see fail -- no id token to hint
|
|
168
|
+
* with, no `end_session_endpoint` published, the issuer not answering --
|
|
169
|
+
* falls back to the plain redirect a sign-out has always ended with.
|
|
170
|
+
* Losing the issuer's co-operation is never a reason to fail somebody's
|
|
171
|
+
* sign-out.
|
|
172
|
+
*
|
|
173
|
+
* `afterLogout` not being a registered `post_logout_redirect_uri` is a
|
|
174
|
+
* failure this function cannot see: that request reaches the issuer, which
|
|
175
|
+
* is free to answer with its own error page instead of a redirect back.
|
|
176
|
+
* Registering it (docs/adopting.md) is what prevents that, not a check
|
|
177
|
+
* here -- cookie mode has no registry to check against at runtime, and
|
|
178
|
+
* namespace mode already refuses to start with a binding whose
|
|
179
|
+
* `afterLogout` is not registered.
|
|
180
|
+
*/
|
|
181
|
+
const endIssuerSession = async (idToken, cookies) => {
|
|
182
|
+
const afterLogout = new URL(options().afterLogout, options().appUrl).href;
|
|
183
|
+
const target = idToken
|
|
184
|
+
? await oidc()
|
|
185
|
+
.endSessionUrl(idToken, afterLogout)
|
|
186
|
+
.catch(() => null)
|
|
187
|
+
: null;
|
|
188
|
+
return redirect(target?.href ?? afterLogout, cookies, 303);
|
|
189
|
+
};
|
|
141
190
|
const logout = async (request) => {
|
|
142
191
|
if (!isSameOrigin(request, options().appUrl.origin)) {
|
|
143
192
|
return new Response('Forbidden', { status: 403 });
|
|
144
193
|
}
|
|
145
194
|
const value = cookieFrom(request.headers.get('cookie'), sessionCookieName(options()));
|
|
146
195
|
if (options().namespace) {
|
|
196
|
+
const idToken = await namespaceIdToken(value);
|
|
147
197
|
// Revoked in the store first: that is what ends copies of the cookie too.
|
|
148
|
-
|
|
198
|
+
// `sessions.end` reaches the store, which can be briefly unreachable --
|
|
199
|
+
// and a sign-out that 500s over that leaves the browser's own cookie
|
|
200
|
+
// standing, which is worse than the record surviving a moment longer.
|
|
201
|
+
const status = await sessions.end(value).catch(() => 'ended');
|
|
202
|
+
if (status === 'changed')
|
|
149
203
|
return new Response('Account changed', { status: 409 });
|
|
150
|
-
return
|
|
204
|
+
return endIssuerSession(idToken, [clearSession(options())]);
|
|
151
205
|
}
|
|
152
206
|
const session = await openSession(options(), value);
|
|
153
207
|
if (session?.rt)
|
|
154
208
|
await broker()
|
|
155
209
|
.revoke(session.rt)
|
|
156
210
|
.catch(() => { });
|
|
157
|
-
return
|
|
211
|
+
return endIssuerSession(session?.idt, [clearSession(options())]);
|
|
158
212
|
};
|
|
159
213
|
const verify = async (request) => {
|
|
160
214
|
const params = new URL(request.url).searchParams;
|
|
@@ -222,8 +276,10 @@ export function createAuth(input) {
|
|
|
222
276
|
return { user: null, cookies: [] };
|
|
223
277
|
const now = Math.floor(Date.now() / 1000);
|
|
224
278
|
const exp = expiryOf(session.idt);
|
|
279
|
+
const iat = issuedAtOf(session.idt);
|
|
225
280
|
const valid = exp !== null && exp > now - 60;
|
|
226
|
-
const
|
|
281
|
+
const stale = iat !== null && now - iat >= options().freshness;
|
|
282
|
+
const due = exp === null || exp - options().refreshWindow <= now || stale;
|
|
227
283
|
if (due && refresh && session.rt) {
|
|
228
284
|
try {
|
|
229
285
|
const tokens = await oidc().refresh(session.rt);
|
|
@@ -234,9 +290,17 @@ export function createAuth(input) {
|
|
|
234
290
|
return { user: boundUser(tokens.claims), cookies: [cookie] };
|
|
235
291
|
}
|
|
236
292
|
catch (error) {
|
|
237
|
-
// A failed refresh never clears the cookie: a valid token carries on, and a dead one is replaced by the next sign-in.
|
|
238
293
|
if (error instanceof AuthError)
|
|
239
294
|
return { user: null, cookies: [clearSession(options())] };
|
|
295
|
+
// Under the issuer's own rotation nobody else can have spent this
|
|
296
|
+
// token, so a refusal is final -- the account is disabled or the
|
|
297
|
+
// grant is gone, and it stays denied on every request from here.
|
|
298
|
+
// The cookie is left alone though: a losing request in a genuine
|
|
299
|
+
// race must not blank out whatever cookie the winner's response is
|
|
300
|
+
// setting for the browser to pick up next.
|
|
301
|
+
if (isOAuthRefusal(error))
|
|
302
|
+
return { user: null, cookies: [] };
|
|
303
|
+
// A network error or a 5xx: the current token carries on, never past its own expiry.
|
|
240
304
|
if (!valid)
|
|
241
305
|
return { user: null, cookies: [] };
|
|
242
306
|
}
|
|
@@ -294,6 +358,19 @@ export function createAuth(input) {
|
|
|
294
358
|
throw new Error('@wtfalch/auth: revokeSessions needs namespace sessions');
|
|
295
359
|
return sessions.revokeSubject(userId);
|
|
296
360
|
};
|
|
361
|
+
const listSessions = async (request) => {
|
|
362
|
+
if (!options().namespace)
|
|
363
|
+
throw new Error('@wtfalch/auth: sessions needs namespace sessions');
|
|
364
|
+
const user = await currentUser(request);
|
|
365
|
+
return sessions.listSessions(user.id);
|
|
366
|
+
};
|
|
367
|
+
const endSession = async (request, key) => {
|
|
368
|
+
if (!options().namespace)
|
|
369
|
+
throw new Error('@wtfalch/auth: endSession needs namespace sessions');
|
|
370
|
+
const user = await currentUser(request);
|
|
371
|
+
if (!(await sessions.endSession(key, user.id)))
|
|
372
|
+
throw new AuthError('request', 'session does not belong to the signed-in person');
|
|
373
|
+
};
|
|
297
374
|
const authRequest = (id) => broker().authRequest(id);
|
|
298
375
|
/**
|
|
299
376
|
* An OIDC flow this app starts and finishes itself, for a person arriving
|
|
@@ -318,7 +395,7 @@ export function createAuth(input) {
|
|
|
318
395
|
return { id, state, nonce, verifier };
|
|
319
396
|
};
|
|
320
397
|
/** Exchanges a callback the service issued, and seals the cookie. */
|
|
321
|
-
const finish = async (callbackUrl, flow, next) => {
|
|
398
|
+
const finish = async (callbackUrl, flow, next, requestHeaders) => {
|
|
322
399
|
if (!validCallback(callbackUrl))
|
|
323
400
|
throw new AuthError('request', 'callback does not match namespace binding');
|
|
324
401
|
const tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
|
|
@@ -326,7 +403,7 @@ export function createAuth(input) {
|
|
|
326
403
|
nonce: flow.nonce,
|
|
327
404
|
codeVerifier: flow.verifier,
|
|
328
405
|
});
|
|
329
|
-
const cookie = await sessionCookie(tokens);
|
|
406
|
+
const cookie = await sessionCookie(tokens, requestHeaders);
|
|
330
407
|
return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
|
|
331
408
|
};
|
|
332
409
|
const hostedUrl = (id) => {
|
|
@@ -337,9 +414,9 @@ export function createAuth(input) {
|
|
|
337
414
|
const needsHostedLogin = (error) => error instanceof BrokerError && error.error === 'hosted_login_required';
|
|
338
415
|
// Email/reset flows started on the server must transfer their PKCE transaction
|
|
339
416
|
// to the browser before handing off to the issuer's full authentication UI.
|
|
340
|
-
const finishOrHandoff = async (action, flow, next) => {
|
|
417
|
+
const finishOrHandoff = async (action, flow, next, requestHeaders) => {
|
|
341
418
|
try {
|
|
342
|
-
return await finish(await action(), flow, next);
|
|
419
|
+
return await finish(await action(), flow, next, requestHeaders);
|
|
343
420
|
}
|
|
344
421
|
catch (error) {
|
|
345
422
|
if (!needsHostedLogin(error))
|
|
@@ -362,10 +439,10 @@ export function createAuth(input) {
|
|
|
362
439
|
const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
|
|
363
440
|
const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
|
|
364
441
|
const invite = (input) => broker().invite({ ...input, next: safeNextPath(input.next, '') });
|
|
365
|
-
const resetPassword = async ({ userId, code, password, next, }) => {
|
|
442
|
+
const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
|
|
366
443
|
try {
|
|
367
444
|
const flow = await serverFlow();
|
|
368
|
-
const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin));
|
|
445
|
+
const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
369
446
|
return { ok: true, ...completed };
|
|
370
447
|
}
|
|
371
448
|
catch (error) {
|
|
@@ -378,10 +455,10 @@ export function createAuth(input) {
|
|
|
378
455
|
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
379
456
|
}
|
|
380
457
|
};
|
|
381
|
-
const followLink = async ({ sessionId, code, next,
|
|
458
|
+
const followLink = async ({ sessionId, code, next }, requestHeaders) => {
|
|
382
459
|
try {
|
|
383
460
|
const flow = await serverFlow();
|
|
384
|
-
const completed = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(next, options().afterLogin));
|
|
461
|
+
const completed = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
385
462
|
return { ok: true, ...completed };
|
|
386
463
|
}
|
|
387
464
|
catch (error) {
|
|
@@ -401,7 +478,7 @@ export function createAuth(input) {
|
|
|
401
478
|
sessionId,
|
|
402
479
|
code,
|
|
403
480
|
next: params.get('next') ?? undefined,
|
|
404
|
-
});
|
|
481
|
+
}, request.headers);
|
|
405
482
|
return result.ok ? redirect(result.location, result.cookies) : failed('link', []);
|
|
406
483
|
};
|
|
407
484
|
const signIn = async ({ authRequestId, email, password, }) => {
|
|
@@ -475,6 +552,62 @@ export function createAuth(input) {
|
|
|
475
552
|
throw error;
|
|
476
553
|
}
|
|
477
554
|
};
|
|
555
|
+
const startEmailChange = async (request, email) => {
|
|
556
|
+
const user = await currentUser(request);
|
|
557
|
+
await broker().startEmailChange(user.id, email);
|
|
558
|
+
};
|
|
559
|
+
const completeEmailChange = async (request, code) => {
|
|
560
|
+
const user = await currentUser(request);
|
|
561
|
+
try {
|
|
562
|
+
await broker().completeEmailChange(user.id, code);
|
|
563
|
+
return { ok: true };
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
if (error instanceof BrokerError && error.error === 'email_taken') {
|
|
567
|
+
return { ok: false, error: 'email_taken' };
|
|
568
|
+
}
|
|
569
|
+
if (error instanceof BrokerError && error.error === 'too_many') {
|
|
570
|
+
return { ok: false, error: 'too_many' };
|
|
571
|
+
}
|
|
572
|
+
if (error instanceof BrokerError && error.status < 500) {
|
|
573
|
+
return { ok: false, error: 'invalid' };
|
|
574
|
+
}
|
|
575
|
+
return { ok: false, error: 'unavailable' };
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
const changePassword = async (request, input) => {
|
|
579
|
+
const user = await currentUser(request);
|
|
580
|
+
try {
|
|
581
|
+
await broker().changePassword({ userId: user.id, ...input });
|
|
582
|
+
return { ok: true };
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
if (error instanceof BrokerError && error.error === 'invalid_current_password') {
|
|
586
|
+
return { ok: false, error: 'invalid_current_password' };
|
|
587
|
+
}
|
|
588
|
+
if (error instanceof BrokerError && error.status < 500) {
|
|
589
|
+
return { ok: false, error: 'invalid_password' };
|
|
590
|
+
}
|
|
591
|
+
return { ok: false, error: 'unavailable' };
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const passkeys = async (request) => {
|
|
595
|
+
const user = await currentUser(request);
|
|
596
|
+
const result = await broker().passkeys(user.id);
|
|
597
|
+
return result.passkeys;
|
|
598
|
+
};
|
|
599
|
+
const deletePasskey = async (request, id) => {
|
|
600
|
+
const user = await currentUser(request);
|
|
601
|
+
try {
|
|
602
|
+
await broker().deletePasskey(user.id, id);
|
|
603
|
+
return { ok: true };
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
if (error instanceof BrokerError && error.status < 500)
|
|
607
|
+
return { ok: false, error: 'invalid' };
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
478
611
|
const startPasskeySignIn = async ({ authRequestId, email, }) => {
|
|
479
612
|
try {
|
|
480
613
|
const { sessionId, options } = await broker().startPasskeySignIn({ authRequestId, email });
|
|
@@ -543,7 +676,7 @@ export function createAuth(input) {
|
|
|
543
676
|
return { status: 'unavailable', cookies: [cleared] };
|
|
544
677
|
let session;
|
|
545
678
|
try {
|
|
546
|
-
session = await sessionCookie(outcome.tokens);
|
|
679
|
+
session = await sessionCookie(outcome.tokens, request.headers);
|
|
547
680
|
}
|
|
548
681
|
catch (error) {
|
|
549
682
|
return { status: 'failed', reason: reasonOf(error), cookies: [cleared] };
|
|
@@ -587,11 +720,18 @@ export function createAuth(input) {
|
|
|
587
720
|
people,
|
|
588
721
|
setPersonActive,
|
|
589
722
|
revokeSessions,
|
|
723
|
+
sessions: listSessions,
|
|
724
|
+
endSession,
|
|
590
725
|
authRequest,
|
|
591
726
|
signIn,
|
|
592
727
|
signUp,
|
|
593
728
|
startPasskeyRegistration,
|
|
594
729
|
finishPasskeyRegistration,
|
|
730
|
+
startEmailChange,
|
|
731
|
+
completeEmailChange,
|
|
732
|
+
changePassword,
|
|
733
|
+
passkeys,
|
|
734
|
+
deletePasskey,
|
|
595
735
|
startPasskeySignIn,
|
|
596
736
|
finishPasskeySignIn,
|
|
597
737
|
startQrSignIn,
|
|
@@ -627,6 +767,22 @@ function expiryOf(idToken) {
|
|
|
627
767
|
return null;
|
|
628
768
|
}
|
|
629
769
|
}
|
|
770
|
+
// Age, not time left: a disabled or revoked account must not ride out a
|
|
771
|
+
// long-lived token just because it still has plenty of time on the clock.
|
|
772
|
+
function issuedAtOf(idToken) {
|
|
773
|
+
try {
|
|
774
|
+
const [, payload] = idToken.split('.');
|
|
775
|
+
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
776
|
+
return typeof json.iat === 'number' ? json.iat : null;
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const PROMPTS = ['select_account', 'login'];
|
|
783
|
+
function promptFrom(raw) {
|
|
784
|
+
return PROMPTS.includes(raw ?? '') ? raw : undefined;
|
|
785
|
+
}
|
|
630
786
|
function userFrom(claims) {
|
|
631
787
|
const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
|
|
632
788
|
return {
|
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
|
}
|
package/dist/config.js
CHANGED
|
@@ -55,6 +55,7 @@ export function resolveOptions(options) {
|
|
|
55
55
|
brokerUrl: (options.brokerUrl ?? `${(options.issuer ?? ISSUER).replace(/\/$/, '')}/api`).replace(/\/$/, ''),
|
|
56
56
|
sessionMaxAge: seconds(options.sessionMaxAge, 'sessionMaxAge', 30 * 24 * 60 * 60),
|
|
57
57
|
refreshWindow: seconds(options.refreshWindow, 'refreshWindow', 5 * 60),
|
|
58
|
+
freshness: seconds(options.freshness, 'freshness', 5 * 60),
|
|
58
59
|
secure: appUrl.protocol === 'https:',
|
|
59
60
|
fetch: options.fetch ?? globalThis.fetch,
|
|
60
61
|
};
|
|
@@ -3,6 +3,7 @@ import { type Broker } from './broker.js';
|
|
|
3
3
|
import type { ResolvedOptions } from './config.js';
|
|
4
4
|
import { type SetCookie } from './cookies.js';
|
|
5
5
|
import { type Oidc, type Tokens } from './oidc.js';
|
|
6
|
+
import type { SessionRecord } from './sessions.js';
|
|
6
7
|
/** How long a binding status answer is trusted. The propagation bound for a suspension. */
|
|
7
8
|
export declare const STATUS_FRESH_MS = 60000;
|
|
8
9
|
/** How long the last good answer carries on while the service cannot be asked. */
|
|
@@ -34,9 +35,11 @@ export declare function namespaceSessions(deps: {
|
|
|
34
35
|
broker: () => Broker;
|
|
35
36
|
}): {
|
|
36
37
|
bindingActive: () => Promise<boolean>;
|
|
37
|
-
create: (tokens: Tokens) => Promise<SetCookie>;
|
|
38
|
+
create: (tokens: Tokens, requestHeaders?: Headers) => Promise<SetCookie>;
|
|
38
39
|
end: (value: string | undefined) => Promise<"ended" | "changed" | "none">;
|
|
39
40
|
read: (value: string | undefined, force?: boolean) => Promise<SessionRead>;
|
|
40
41
|
revokeSubject: (subject: string) => Promise<number>;
|
|
42
|
+
listSessions: (subject: string) => Promise<SessionRecord[]>;
|
|
43
|
+
endSession: (key: string, subject: string) => Promise<boolean>;
|
|
41
44
|
keyOf: (value: string | undefined) => Promise<string | null>;
|
|
42
45
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as client from 'openid-client';
|
|
2
2
|
import { BrokerError } from './broker.js';
|
|
3
3
|
import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
|
|
4
|
-
import { AuthError } from './oidc.js';
|
|
4
|
+
import { AuthError, isOAuthRefusal } from './oidc.js';
|
|
5
5
|
/** How long a binding status answer is trusted. The propagation bound for a suspension. */
|
|
6
6
|
export const STATUS_FRESH_MS = 60_000;
|
|
7
7
|
/** How long the last good answer carries on while the service cannot be asked. */
|
|
@@ -127,10 +127,15 @@ export function namespaceSessions(deps) {
|
|
|
127
127
|
.revoke(refreshToken)
|
|
128
128
|
.catch(() => { });
|
|
129
129
|
};
|
|
130
|
-
/**
|
|
131
|
-
|
|
130
|
+
/**
|
|
131
|
+
* A new record and the cookie that points at it. `requestHeaders` is
|
|
132
|
+
* whatever the request that signed the person in carried; omitted where
|
|
133
|
+
* none exists (there is no browser request behind a server-initiated flow).
|
|
134
|
+
*/
|
|
135
|
+
const create = async (tokens, requestHeaders) => {
|
|
132
136
|
const sid = client.randomState();
|
|
133
137
|
const key = await digest(sid);
|
|
138
|
+
const at = now();
|
|
134
139
|
await store().create({
|
|
135
140
|
key,
|
|
136
141
|
context: await contextDigest(options()),
|
|
@@ -138,8 +143,12 @@ export function namespaceSessions(deps) {
|
|
|
138
143
|
generation: 0,
|
|
139
144
|
leaseUntil: null,
|
|
140
145
|
payload: await seal(key, tokens),
|
|
141
|
-
expiresAt:
|
|
146
|
+
expiresAt: at + options().sessionMaxAge,
|
|
142
147
|
revokedAt: null,
|
|
148
|
+
createdAt: at,
|
|
149
|
+
lastSeenAt: at,
|
|
150
|
+
userAgent: requestHeaders?.get('user-agent') ?? null,
|
|
151
|
+
ip: ipFrom(requestHeaders),
|
|
143
152
|
});
|
|
144
153
|
return sealHandle(options(), sid);
|
|
145
154
|
};
|
|
@@ -156,9 +165,32 @@ export function namespaceSessions(deps) {
|
|
|
156
165
|
return 'ended';
|
|
157
166
|
};
|
|
158
167
|
const revokeSubject = async (subject) => store().revokeSubject(await contextDigest(options()), subject, now());
|
|
168
|
+
/** A subject's live sessions under this binding, newest first: the devices page. */
|
|
169
|
+
const listSessions = async (subject) => store().listSubject(await contextDigest(options()), subject);
|
|
170
|
+
/**
|
|
171
|
+
* Ends one of a subject's sessions. Resolves whether it did: false for a
|
|
172
|
+
* key that is unknown, already over, somebody else's, or another binding's
|
|
173
|
+
* -- the same answer either way, so a guess at another person's key cannot
|
|
174
|
+
* be told apart from a stranger's.
|
|
175
|
+
*/
|
|
176
|
+
const endSession = async (key, subject) => {
|
|
177
|
+
const record = await store().get(key);
|
|
178
|
+
if (!record ||
|
|
179
|
+
record.subject !== subject ||
|
|
180
|
+
record.context !== (await contextDigest(options())))
|
|
181
|
+
return false;
|
|
182
|
+
await store().revoke(key, now());
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
159
185
|
// ---- refresh ------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Under the lease nobody else can have spent the token, so any 4xx the
|
|
188
|
+
* issuer answers with is the grant itself gone -- the account disabled, the
|
|
189
|
+
* refresh token revoked -- never a rival request. This issuer does not
|
|
190
|
+
* reserve `invalid_grant` for that: a deactivated user and a dead refresh
|
|
191
|
+
* token both come back `invalid_request`, so only the status class counts.
|
|
192
|
+
*/
|
|
193
|
+
const definitive = (error) => error instanceof AuthError || isOAuthRefusal(error);
|
|
162
194
|
/**
|
|
163
195
|
* Refresh under the lease. Resolves the freshest usable tokens: new ones,
|
|
164
196
|
* another request's, or null when the session is over. While another
|
|
@@ -250,8 +282,10 @@ export function namespaceSessions(deps) {
|
|
|
250
282
|
if (!loaded)
|
|
251
283
|
return { kind: 'none' };
|
|
252
284
|
const exp = expiryOf(loaded.tokens.idt);
|
|
285
|
+
const iat = issuedAtOf(loaded.tokens.idt);
|
|
253
286
|
const valid = exp !== null && exp > now() - 60;
|
|
254
|
-
const
|
|
287
|
+
const stale = iat !== null && now() - iat >= options().freshness;
|
|
288
|
+
const due = force || exp === null || exp - options().refreshWindow <= now() || stale;
|
|
255
289
|
if (due && loaded.tokens.rt) {
|
|
256
290
|
loaded = await refresh(loaded, valid);
|
|
257
291
|
if (!loaded)
|
|
@@ -264,7 +298,31 @@ export function namespaceSessions(deps) {
|
|
|
264
298
|
return { kind: 'none' };
|
|
265
299
|
}
|
|
266
300
|
};
|
|
267
|
-
return { bindingActive, create, end, read, revokeSubject, keyOf };
|
|
301
|
+
return { bindingActive, create, end, read, revokeSubject, listSessions, endSession, keyOf };
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* These apps sit behind a proxy, so the client never reaches this process
|
|
305
|
+
* directly. `x-real-ip` first: a proxy that sets it overwrites rather than
|
|
306
|
+
* appends, so it cannot be a client-supplied value riding along underneath
|
|
307
|
+
* the real one. Otherwise the rightmost `x-forwarded-for` hop -- the one the
|
|
308
|
+
* nearest proxy itself observed and prepended, not the leftmost one, which
|
|
309
|
+
* is exactly the entry a client sends and controls, so trusting it would
|
|
310
|
+
* let anyone write whatever address they like into this record. There is no
|
|
311
|
+
* trusted-proxy config -- this value is only ever as trustworthy as
|
|
312
|
+
* whatever sits in front of the app.
|
|
313
|
+
*/
|
|
314
|
+
function ipFrom(headers) {
|
|
315
|
+
const realIp = headers?.get('x-real-ip');
|
|
316
|
+
if (realIp)
|
|
317
|
+
return realIp;
|
|
318
|
+
const forwarded = headers?.get('x-forwarded-for');
|
|
319
|
+
if (!forwarded)
|
|
320
|
+
return null;
|
|
321
|
+
const hops = forwarded
|
|
322
|
+
.split(',')
|
|
323
|
+
.map((hop) => hop.trim())
|
|
324
|
+
.filter(Boolean);
|
|
325
|
+
return hops.length > 0 ? hops[hops.length - 1] : null;
|
|
268
326
|
}
|
|
269
327
|
function expiryOf(idToken) {
|
|
270
328
|
try {
|
|
@@ -276,3 +334,15 @@ function expiryOf(idToken) {
|
|
|
276
334
|
return null;
|
|
277
335
|
}
|
|
278
336
|
}
|
|
337
|
+
// Age, not time left: a disabled or revoked account must not ride out a
|
|
338
|
+
// long-lived token just because it still has plenty of time on the clock.
|
|
339
|
+
function issuedAtOf(idToken) {
|
|
340
|
+
try {
|
|
341
|
+
const [, payload] = idToken.split('.');
|
|
342
|
+
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
343
|
+
return typeof json.iat === 'number' ? json.iat : null;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
package/dist/next.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { type Auth, type AuthRequest, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
|
|
2
|
+
import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
|
|
3
3
|
import type { AuthOptions } from './config.js';
|
|
4
|
+
import type { SessionRecord } from './sessions.js';
|
|
4
5
|
type SearchParams = Record<string, string | string[] | undefined>;
|
|
5
6
|
export interface QrSignIn {
|
|
6
7
|
url: string;
|
|
@@ -63,6 +64,13 @@ export interface NextAuth {
|
|
|
63
64
|
people: Auth['people'];
|
|
64
65
|
/** Deactivate or reactivate somebody in this app's organisation. */
|
|
65
66
|
setPersonActive: Auth['setPersonActive'];
|
|
67
|
+
/** The devices page: the signed-in person's own live sessions, newest first. Namespace mode only. */
|
|
68
|
+
sessions: () => Promise<SessionRecord[]>;
|
|
69
|
+
/**
|
|
70
|
+
* Ends one of the signed-in person's own sessions. Throws `AuthError('request', ...)`
|
|
71
|
+
* for a key that is somebody else's, another binding's, or does not exist.
|
|
72
|
+
*/
|
|
73
|
+
endSession: (key: string) => Promise<void>;
|
|
66
74
|
/** Sets the password from a reset link, signs the person in, and redirects. */
|
|
67
75
|
resetPassword: (input: {
|
|
68
76
|
userId: string;
|
|
@@ -85,6 +93,19 @@ export interface NextAuth {
|
|
|
85
93
|
credential: unknown;
|
|
86
94
|
name: string;
|
|
87
95
|
}) => Promise<PasskeyRegistrationResult>;
|
|
96
|
+
/** See `Auth['startEmailChange']`: mails a code to the new address and leaves the old one working until `completeEmailChange` checks it. */
|
|
97
|
+
startEmailChange: (email: string) => Promise<void>;
|
|
98
|
+
/** The other half of `startEmailChange`. */
|
|
99
|
+
completeEmailChange: (code: string) => Promise<EmailChangeResult>;
|
|
100
|
+
/** Changes the signed-in person's password. See `Auth['changePassword']` for how a wrong current password is told apart from a rejected new one. */
|
|
101
|
+
changePassword: (input: {
|
|
102
|
+
currentPassword: string;
|
|
103
|
+
newPassword: string;
|
|
104
|
+
}) => Promise<ChangePasswordResult>;
|
|
105
|
+
/** See `Auth['passkeys']`: enough to warn when this is the only one left. */
|
|
106
|
+
passkeys: () => Promise<Passkey[]>;
|
|
107
|
+
/** Removes one of this person's own passkeys. */
|
|
108
|
+
deletePasskey: (id: string) => Promise<DeletePasskeyResult>;
|
|
88
109
|
/** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
|
|
89
110
|
startPasskeySignIn: (input: {
|
|
90
111
|
authRequestId: string;
|
|
@@ -116,4 +137,4 @@ export interface NextAuth {
|
|
|
116
137
|
}>;
|
|
117
138
|
}
|
|
118
139
|
export declare function nextAuth(options: AuthOptions): NextAuth;
|
|
119
|
-
export type { Auth, AuthOptions, AuthRequest, GateRules, Intent, NewUser, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, User, };
|
|
140
|
+
export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, SessionRecord, User, };
|
package/dist/next.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cookies } from 'next/headers';
|
|
1
|
+
import { cookies, headers } from 'next/headers';
|
|
2
2
|
import { redirect } from 'next/navigation';
|
|
3
3
|
import { NextResponse } from 'next/server';
|
|
4
4
|
import { createAuth, } from './auth.js';
|
|
@@ -105,11 +105,8 @@ export function nextAuth(options) {
|
|
|
105
105
|
// A server action cannot redirect into a route handler, so the callback runs here and the cookies are set on the action's response.
|
|
106
106
|
const finish = async (callbackUrl) => {
|
|
107
107
|
const store = await cookies();
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
.map((c) => `${c.name}=${c.value}`)
|
|
111
|
-
.join('; ');
|
|
112
|
-
const { location, cookies: set } = await auth.complete(callbackUrl, header);
|
|
108
|
+
const request = await requestFromCookies();
|
|
109
|
+
const { location, cookies: set } = await auth.complete(callbackUrl, request.headers.get('cookie'), request.headers);
|
|
113
110
|
for (const c of set) {
|
|
114
111
|
if (c.value)
|
|
115
112
|
store.set(c.name, c.value, c.attributes);
|
|
@@ -119,20 +116,37 @@ export function nextAuth(options) {
|
|
|
119
116
|
const url = new URL(location);
|
|
120
117
|
redirect(`${url.pathname}${url.search}`);
|
|
121
118
|
};
|
|
122
|
-
// The passkey routes take a `Request`; a server action has
|
|
123
|
-
// own, only what `cookies()` already
|
|
124
|
-
//
|
|
125
|
-
//
|
|
119
|
+
// The passkey and session routes take a `Request`; a server action has
|
|
120
|
+
// none of its own, only what `cookies()`/`headers()` already expose for
|
|
121
|
+
// this invocation. `cookies()` (not the incoming `cookie` header) is the
|
|
122
|
+
// source of truth for the jar, since it reflects whatever this action has
|
|
123
|
+
// already set or cleared; the device headers `namespaceSessions` reads on
|
|
124
|
+
// sign-in come along too, so a session created from a server action still
|
|
125
|
+
// gets a real user agent and address.
|
|
126
126
|
const requestFromCookies = async () => {
|
|
127
127
|
const store = await cookies();
|
|
128
|
-
const
|
|
128
|
+
const cookieHeader = store
|
|
129
129
|
.getAll()
|
|
130
130
|
.map((c) => `${c.name}=${c.value}`)
|
|
131
131
|
.join('; ');
|
|
132
|
-
|
|
132
|
+
const requestHeaders = new Headers();
|
|
133
|
+
if (cookieHeader)
|
|
134
|
+
requestHeaders.set('cookie', cookieHeader);
|
|
135
|
+
const incoming = await headers();
|
|
136
|
+
for (const name of ['user-agent', 'x-forwarded-for', 'x-real-ip']) {
|
|
137
|
+
const value = incoming.get(name);
|
|
138
|
+
if (value)
|
|
139
|
+
requestHeaders.set(name, value);
|
|
140
|
+
}
|
|
141
|
+
return new Request('http://localhost/', { headers: requestHeaders });
|
|
133
142
|
};
|
|
134
143
|
const startPasskeyRegistration = async () => auth.startPasskeyRegistration(await requestFromCookies());
|
|
135
144
|
const finishPasskeyRegistration = async (input) => auth.finishPasskeyRegistration(await requestFromCookies(), input);
|
|
145
|
+
const startEmailChange = async (email) => auth.startEmailChange(await requestFromCookies(), email);
|
|
146
|
+
const completeEmailChange = async (code) => auth.completeEmailChange(await requestFromCookies(), code);
|
|
147
|
+
const changePassword = async (input) => auth.changePassword(await requestFromCookies(), input);
|
|
148
|
+
const passkeys = async () => auth.passkeys(await requestFromCookies());
|
|
149
|
+
const deletePasskey = async (id) => auth.deletePasskey(await requestFromCookies(), id);
|
|
136
150
|
const finishPasskeySignIn = async (input) => {
|
|
137
151
|
const result = await auth.finishPasskeySignIn(input);
|
|
138
152
|
if (result.ok) {
|
|
@@ -143,7 +157,7 @@ export function nextAuth(options) {
|
|
|
143
157
|
return { error: result.error };
|
|
144
158
|
};
|
|
145
159
|
const resetPassword = async (input) => {
|
|
146
|
-
const result = await auth.resetPassword(input);
|
|
160
|
+
const result = await auth.resetPassword(input, (await requestFromCookies()).headers);
|
|
147
161
|
if (!result.ok)
|
|
148
162
|
return { error: result.error, message: result.message };
|
|
149
163
|
const store = await cookies();
|
|
@@ -198,6 +212,8 @@ export function nextAuth(options) {
|
|
|
198
212
|
}
|
|
199
213
|
return reason ? { status, reason } : { status };
|
|
200
214
|
};
|
|
215
|
+
const sessionsList = async () => auth.sessions(await requestFromCookies());
|
|
216
|
+
const endSession = async (key) => auth.endSession(await requestFromCookies(), key);
|
|
201
217
|
return {
|
|
202
218
|
auth,
|
|
203
219
|
handlers: { GET: auth.handle, POST: auth.handle },
|
|
@@ -213,9 +229,16 @@ export function nextAuth(options) {
|
|
|
213
229
|
invite: auth.invite,
|
|
214
230
|
people: auth.people,
|
|
215
231
|
setPersonActive: auth.setPersonActive,
|
|
232
|
+
sessions: sessionsList,
|
|
233
|
+
endSession,
|
|
216
234
|
resetPassword,
|
|
217
235
|
startPasskeyRegistration,
|
|
218
236
|
finishPasskeyRegistration,
|
|
237
|
+
startEmailChange,
|
|
238
|
+
completeEmailChange,
|
|
239
|
+
changePassword,
|
|
240
|
+
passkeys,
|
|
241
|
+
deletePasskey,
|
|
219
242
|
startPasskeySignIn: auth.startPasskeySignIn,
|
|
220
243
|
finishPasskeySignIn,
|
|
221
244
|
startQrSignIn,
|
package/dist/oidc.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import * as jose from 'jose';
|
|
2
2
|
import type { ResolvedOptions } from './config.js';
|
|
3
3
|
export declare const ORG_CLAIM = "urn:zitadel:iam:user:resourceowner:id";
|
|
4
|
+
/**
|
|
5
|
+
* How long a rotated refresh token still answers with the tokens it rotated
|
|
6
|
+
* to. A request that started just before another one finished the same
|
|
7
|
+
* rotation would otherwise present the now-dead token and be refused as if
|
|
8
|
+
* the grant were revoked; this is the single-process fix. A second server,
|
|
9
|
+
* or a request more than a minute behind, still gets the real refusal.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ROTATION_CACHE_MS = 60000;
|
|
4
12
|
/** The scopes every flow asks for: the code-exchange authorization request and the QR device grant alike. */
|
|
5
13
|
export declare function scopeFor(organizationId: string): string;
|
|
6
14
|
export interface Tokens {
|
|
@@ -13,12 +21,14 @@ export declare class Oidc {
|
|
|
13
21
|
private configuration;
|
|
14
22
|
private jwks;
|
|
15
23
|
private refreshing;
|
|
24
|
+
private rotated;
|
|
16
25
|
constructor(options: ResolvedOptions);
|
|
17
26
|
private config;
|
|
18
27
|
private keys;
|
|
19
28
|
get redirectUri(): URL;
|
|
20
|
-
authorizationUrl(state: string, nonce: string, codeVerifier: string, { create }?: {
|
|
29
|
+
authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt }?: {
|
|
21
30
|
create?: boolean;
|
|
31
|
+
prompt?: 'select_account' | 'login';
|
|
22
32
|
}): Promise<URL>;
|
|
23
33
|
exchange(callbackParams: URLSearchParams, transaction: {
|
|
24
34
|
state: string;
|
|
@@ -26,6 +36,12 @@ export declare class Oidc {
|
|
|
26
36
|
codeVerifier: string;
|
|
27
37
|
}): Promise<Tokens>;
|
|
28
38
|
refresh(refreshToken: string): Promise<Tokens>;
|
|
39
|
+
/**
|
|
40
|
+
* The issuer's RP-Initiated Logout URL, or null when discovery publishes no
|
|
41
|
+
* `end_session_endpoint` -- an issuer is free not to support it, and a
|
|
42
|
+
* sign-out has to succeed either way.
|
|
43
|
+
*/
|
|
44
|
+
endSessionUrl(idToken: string, postLogoutRedirectUri: string): Promise<URL | null>;
|
|
29
45
|
private tokensFrom;
|
|
30
46
|
verify(idToken: string): Promise<jose.JWTPayload>;
|
|
31
47
|
private assertOrganization;
|
|
@@ -37,3 +53,16 @@ export declare class AuthError extends Error {
|
|
|
37
53
|
readonly reason: AuthErrorReason;
|
|
38
54
|
constructor(reason: AuthErrorReason, message: string);
|
|
39
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* A refusal from the token endpoint itself -- the grant is gone, the account
|
|
58
|
+
* is disabled -- as opposed to the issuer being unreachable, rate-limiting,
|
|
59
|
+
* or having a bad day. Keyed on the HTTP status alone, never the body: a
|
|
60
|
+
* WAF or CDN in front of the issuer can answer a genuine refusal as HTML,
|
|
61
|
+
* which oauth4webapi cannot parse as an OAuth error, but it still hands back
|
|
62
|
+
* the response that carried the status. Trusting only a parsed `error` field
|
|
63
|
+
* would treat that case as an outage and let the old bug back in through a
|
|
64
|
+
* path nobody would look at. 429 is excluded on purpose: "come back later"
|
|
65
|
+
* is never a statement that the grant is dead, and this issuer's own rate
|
|
66
|
+
* limiting must not permanently revoke a session.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isOAuthRefusal(error: unknown): boolean;
|
package/dist/oidc.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import * as jose from 'jose';
|
|
2
2
|
import * as client from 'openid-client';
|
|
3
3
|
export const ORG_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
|
|
4
|
+
/**
|
|
5
|
+
* How long a rotated refresh token still answers with the tokens it rotated
|
|
6
|
+
* to. A request that started just before another one finished the same
|
|
7
|
+
* rotation would otherwise present the now-dead token and be refused as if
|
|
8
|
+
* the grant were revoked; this is the single-process fix. A second server,
|
|
9
|
+
* or a request more than a minute behind, still gets the real refusal.
|
|
10
|
+
*/
|
|
11
|
+
export const ROTATION_CACHE_MS = 60_000;
|
|
4
12
|
/** The scopes every flow asks for: the code-exchange authorization request and the QR device grant alike. */
|
|
5
13
|
export function scopeFor(organizationId) {
|
|
6
14
|
return [
|
|
@@ -17,6 +25,7 @@ export class Oidc {
|
|
|
17
25
|
configuration = null;
|
|
18
26
|
jwks = null;
|
|
19
27
|
refreshing = new Map();
|
|
28
|
+
rotated = new Map();
|
|
20
29
|
constructor(options) {
|
|
21
30
|
this.options = options;
|
|
22
31
|
}
|
|
@@ -53,9 +62,9 @@ export class Oidc {
|
|
|
53
62
|
get redirectUri() {
|
|
54
63
|
return new URL(`${this.options.basePath}/callback`, this.options.appUrl);
|
|
55
64
|
}
|
|
56
|
-
async authorizationUrl(state, nonce, codeVerifier, { create = false } = {}) {
|
|
65
|
+
async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt } = {}) {
|
|
57
66
|
return client.buildAuthorizationUrl(await this.config(), {
|
|
58
|
-
...(create ? { prompt: 'create' } : {}),
|
|
67
|
+
...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
|
|
59
68
|
redirect_uri: this.redirectUri.href,
|
|
60
69
|
scope: scopeFor(this.options.organizationId),
|
|
61
70
|
state,
|
|
@@ -78,16 +87,41 @@ export class Oidc {
|
|
|
78
87
|
}
|
|
79
88
|
// Parallel requests holding the same refresh token share one grant; the issuer rotates it, so a second grant would fail.
|
|
80
89
|
refresh(refreshToken) {
|
|
90
|
+
const asked = Date.now();
|
|
91
|
+
for (const [token, entry] of this.rotated)
|
|
92
|
+
if (entry.until <= asked)
|
|
93
|
+
this.rotated.delete(token);
|
|
94
|
+
const cached = this.rotated.get(refreshToken);
|
|
95
|
+
if (cached)
|
|
96
|
+
return Promise.resolve(cached.tokens);
|
|
81
97
|
let pending = this.refreshing.get(refreshToken);
|
|
82
98
|
if (!pending) {
|
|
83
99
|
pending = this.config()
|
|
84
100
|
.then((config) => client.refreshTokenGrant(config, refreshToken))
|
|
85
101
|
.then((response) => this.tokensFrom(response, refreshToken))
|
|
102
|
+
.then((tokens) => {
|
|
103
|
+
this.rotated.set(refreshToken, { tokens, until: Date.now() + ROTATION_CACHE_MS });
|
|
104
|
+
return tokens;
|
|
105
|
+
})
|
|
86
106
|
.finally(() => this.refreshing.delete(refreshToken));
|
|
87
107
|
this.refreshing.set(refreshToken, pending);
|
|
88
108
|
}
|
|
89
109
|
return pending;
|
|
90
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The issuer's RP-Initiated Logout URL, or null when discovery publishes no
|
|
113
|
+
* `end_session_endpoint` -- an issuer is free not to support it, and a
|
|
114
|
+
* sign-out has to succeed either way.
|
|
115
|
+
*/
|
|
116
|
+
async endSessionUrl(idToken, postLogoutRedirectUri) {
|
|
117
|
+
const config = await this.config();
|
|
118
|
+
if (!config.serverMetadata().end_session_endpoint)
|
|
119
|
+
return null;
|
|
120
|
+
return client.buildEndSessionUrl(config, {
|
|
121
|
+
id_token_hint: idToken,
|
|
122
|
+
post_logout_redirect_uri: postLogoutRedirectUri,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
91
125
|
tokensFrom(response, previousRefreshToken) {
|
|
92
126
|
const claims = response.claims();
|
|
93
127
|
if (!response.id_token || !claims) {
|
|
@@ -130,3 +164,25 @@ export class AuthError extends Error {
|
|
|
130
164
|
this.name = 'AuthError';
|
|
131
165
|
}
|
|
132
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* A refusal from the token endpoint itself -- the grant is gone, the account
|
|
169
|
+
* is disabled -- as opposed to the issuer being unreachable, rate-limiting,
|
|
170
|
+
* or having a bad day. Keyed on the HTTP status alone, never the body: a
|
|
171
|
+
* WAF or CDN in front of the issuer can answer a genuine refusal as HTML,
|
|
172
|
+
* which oauth4webapi cannot parse as an OAuth error, but it still hands back
|
|
173
|
+
* the response that carried the status. Trusting only a parsed `error` field
|
|
174
|
+
* would treat that case as an outage and let the old bug back in through a
|
|
175
|
+
* path nobody would look at. 429 is excluded on purpose: "come back later"
|
|
176
|
+
* is never a statement that the grant is dead, and this issuer's own rate
|
|
177
|
+
* limiting must not permanently revoke a session.
|
|
178
|
+
*/
|
|
179
|
+
export function isOAuthRefusal(error) {
|
|
180
|
+
const status = statusOf(error);
|
|
181
|
+
return status !== undefined && status >= 400 && status < 500 && status !== 429;
|
|
182
|
+
}
|
|
183
|
+
function statusOf(error) {
|
|
184
|
+
if (error instanceof client.ResponseBodyError)
|
|
185
|
+
return error.status;
|
|
186
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
187
|
+
return cause instanceof Response ? cause.status : undefined;
|
|
188
|
+
}
|
package/dist/sessions.d.ts
CHANGED
|
@@ -32,19 +32,33 @@ export interface SessionRecord {
|
|
|
32
32
|
expiresAt: number;
|
|
33
33
|
/** Unix seconds, or null while the session is live. */
|
|
34
34
|
revokedAt: number | null;
|
|
35
|
+
/**
|
|
36
|
+
* Unix seconds the record was made, or null for a row written before this
|
|
37
|
+
* column existed. Nullable so an old row keeps working unmigrated.
|
|
38
|
+
*/
|
|
39
|
+
createdAt: number | null;
|
|
40
|
+
/** Unix seconds, bumped whenever `advance` succeeds. Null for the same reason as `createdAt`. */
|
|
41
|
+
lastSeenAt: number | null;
|
|
42
|
+
/** The signing-in request's `User-Agent`, or null if it sent none or the row predates this column. */
|
|
43
|
+
userAgent: string | null;
|
|
44
|
+
/** The signing-in request's address as the proxy in front reported it, or null. See `ipFrom` in namespace-session.ts for how much to trust it. */
|
|
45
|
+
ip: string | null;
|
|
35
46
|
}
|
|
36
47
|
export type SessionUpdate = Pick<SessionRecord, 'payload' | 'expiresAt' | 'leaseUntil'>;
|
|
37
48
|
export interface SessionStore {
|
|
38
49
|
create(record: SessionRecord): Promise<void>;
|
|
39
50
|
get(key: string): Promise<SessionRecord | null>;
|
|
40
51
|
/**
|
|
41
|
-
* Replace the payload, expiry and lease,
|
|
42
|
-
* record is still at `generation` and not
|
|
52
|
+
* Replace the payload, expiry and lease, bump the generation and touch
|
|
53
|
+
* `lastSeenAt`, only if the record is still at `generation` and not
|
|
54
|
+
* revoked. Resolves whether it did.
|
|
43
55
|
*/
|
|
44
56
|
advance(key: string, generation: number, next: SessionUpdate): Promise<boolean>;
|
|
45
57
|
revoke(key: string, at: number): Promise<void>;
|
|
46
58
|
/** Revoke every live record for this subject under this binding context. Resolves how many. */
|
|
47
59
|
revokeSubject(context: string, subject: string, at: number): Promise<number>;
|
|
60
|
+
/** Live (not revoked, not expired) records for this subject under this binding context, newest first: the devices page. */
|
|
61
|
+
listSubject(context: string, subject: string): Promise<SessionRecord[]>;
|
|
48
62
|
}
|
|
49
63
|
/**
|
|
50
64
|
* One process only. For tests and a single-instance development server: two
|
|
@@ -52,7 +66,13 @@ export interface SessionStore {
|
|
|
52
66
|
*/
|
|
53
67
|
export declare function createMemorySessionStore(): SessionStore;
|
|
54
68
|
/** The table `createPostgresSessionStore` expects. Run it in the app's own migrations. */
|
|
55
|
-
export declare const POSTGRES_SESSION_TABLE = "create table if not exists wtfalch_auth_sessions (\n key text primary key,\n context text not null,\n subject text not null,\n generation integer not null,\n payload text not null,\n expires_at bigint not null,\n lease_until bigint,\n revoked_at bigint\n);\ncreate index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);\ncreate index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);";
|
|
69
|
+
export declare const POSTGRES_SESSION_TABLE = "create table if not exists wtfalch_auth_sessions (\n key text primary key,\n context text not null,\n subject text not null,\n generation integer not null,\n payload text not null,\n expires_at bigint not null,\n lease_until bigint,\n revoked_at bigint,\n created_at bigint,\n last_seen_at bigint,\n user_agent text,\n ip text\n);\ncreate index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);\ncreate index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);";
|
|
70
|
+
/**
|
|
71
|
+
* `POSTGRES_SESSION_TABLE` already has these columns for a fresh table. A
|
|
72
|
+
* consumer whose table predates them runs this once, in its own migrations,
|
|
73
|
+
* to catch up; `add column if not exists` makes it safe to run again.
|
|
74
|
+
*/
|
|
75
|
+
export declare const POSTGRES_SESSION_MIGRATION_1 = "alter table wtfalch_auth_sessions add column if not exists created_at bigint;\nalter table wtfalch_auth_sessions add column if not exists last_seen_at bigint;\nalter table wtfalch_auth_sessions add column if not exists user_agent text;\nalter table wtfalch_auth_sessions add column if not exists ip text;";
|
|
56
76
|
type Row = Record<string, unknown>;
|
|
57
77
|
/**
|
|
58
78
|
* Postgres through whatever driver the app already has. `query` runs one
|
package/dist/sessions.js
CHANGED
|
@@ -24,7 +24,12 @@ export function createMemorySessionStore() {
|
|
|
24
24
|
const record = records.get(key);
|
|
25
25
|
if (!record || record.revokedAt !== null || record.generation !== generation)
|
|
26
26
|
return false;
|
|
27
|
-
records.set(key, {
|
|
27
|
+
records.set(key, {
|
|
28
|
+
...record,
|
|
29
|
+
...next,
|
|
30
|
+
generation: generation + 1,
|
|
31
|
+
lastSeenAt: Math.floor(Date.now() / 1000),
|
|
32
|
+
});
|
|
28
33
|
return true;
|
|
29
34
|
},
|
|
30
35
|
async revoke(key, at) {
|
|
@@ -41,6 +46,16 @@ export function createMemorySessionStore() {
|
|
|
41
46
|
}
|
|
42
47
|
return count;
|
|
43
48
|
},
|
|
49
|
+
async listSubject(context, subject) {
|
|
50
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
51
|
+
return [...records.values()]
|
|
52
|
+
.filter((r) => r.context === context &&
|
|
53
|
+
r.subject === subject &&
|
|
54
|
+
r.revokedAt === null &&
|
|
55
|
+
r.expiresAt > nowSeconds)
|
|
56
|
+
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
|
|
57
|
+
.map((r) => ({ ...r }));
|
|
58
|
+
},
|
|
44
59
|
};
|
|
45
60
|
}
|
|
46
61
|
/** The table `createPostgresSessionStore` expects. Run it in the app's own migrations. */
|
|
@@ -52,11 +67,25 @@ export const POSTGRES_SESSION_TABLE = `create table if not exists wtfalch_auth_s
|
|
|
52
67
|
payload text not null,
|
|
53
68
|
expires_at bigint not null,
|
|
54
69
|
lease_until bigint,
|
|
55
|
-
revoked_at bigint
|
|
70
|
+
revoked_at bigint,
|
|
71
|
+
created_at bigint,
|
|
72
|
+
last_seen_at bigint,
|
|
73
|
+
user_agent text,
|
|
74
|
+
ip text
|
|
56
75
|
);
|
|
57
76
|
create index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);
|
|
58
77
|
create index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);`;
|
|
78
|
+
/**
|
|
79
|
+
* `POSTGRES_SESSION_TABLE` already has these columns for a fresh table. A
|
|
80
|
+
* consumer whose table predates them runs this once, in its own migrations,
|
|
81
|
+
* to catch up; `add column if not exists` makes it safe to run again.
|
|
82
|
+
*/
|
|
83
|
+
export const POSTGRES_SESSION_MIGRATION_1 = `alter table wtfalch_auth_sessions add column if not exists created_at bigint;
|
|
84
|
+
alter table wtfalch_auth_sessions add column if not exists last_seen_at bigint;
|
|
85
|
+
alter table wtfalch_auth_sessions add column if not exists user_agent text;
|
|
86
|
+
alter table wtfalch_auth_sessions add column if not exists ip text;`;
|
|
59
87
|
const nullable = (value) => value === null || value === undefined ? null : Number(value);
|
|
88
|
+
const nullableString = (value) => value === null || value === undefined ? null : String(value);
|
|
60
89
|
/**
|
|
61
90
|
* Postgres through whatever driver the app already has. `query` runs one
|
|
62
91
|
* parameterised statement and resolves its rows:
|
|
@@ -77,14 +106,19 @@ export function createPostgresSessionStore(input) {
|
|
|
77
106
|
expiresAt: Number(row.expires_at),
|
|
78
107
|
leaseUntil: nullable(row.lease_until),
|
|
79
108
|
revokedAt: nullable(row.revoked_at),
|
|
109
|
+
createdAt: nullable(row.created_at),
|
|
110
|
+
lastSeenAt: nullable(row.last_seen_at),
|
|
111
|
+
userAgent: nullableString(row.user_agent),
|
|
112
|
+
ip: nullableString(row.ip),
|
|
80
113
|
});
|
|
81
114
|
return {
|
|
82
115
|
async create(r) {
|
|
83
116
|
await query(`delete from wtfalch_auth_sessions where key in
|
|
84
117
|
(select key from wtfalch_auth_sessions where expires_at <= $1 limit 100)`, [Math.floor(Date.now() / 1000)]);
|
|
85
118
|
await query(`insert into wtfalch_auth_sessions
|
|
86
|
-
(key, context, subject, generation, payload, expires_at, lease_until, revoked_at
|
|
87
|
-
|
|
119
|
+
(key, context, subject, generation, payload, expires_at, lease_until, revoked_at,
|
|
120
|
+
created_at, last_seen_at, user_agent, ip)
|
|
121
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, [
|
|
88
122
|
r.key,
|
|
89
123
|
r.context,
|
|
90
124
|
r.subject,
|
|
@@ -93,6 +127,10 @@ export function createPostgresSessionStore(input) {
|
|
|
93
127
|
r.expiresAt,
|
|
94
128
|
r.leaseUntil,
|
|
95
129
|
r.revokedAt,
|
|
130
|
+
r.createdAt,
|
|
131
|
+
r.lastSeenAt,
|
|
132
|
+
r.userAgent,
|
|
133
|
+
r.ip,
|
|
96
134
|
]);
|
|
97
135
|
},
|
|
98
136
|
async get(key) {
|
|
@@ -101,9 +139,17 @@ export function createPostgresSessionStore(input) {
|
|
|
101
139
|
},
|
|
102
140
|
async advance(key, generation, next) {
|
|
103
141
|
const rows = await query(`update wtfalch_auth_sessions
|
|
104
|
-
set payload = $3, expires_at = $4, lease_until = $5, generation = generation + 1
|
|
142
|
+
set payload = $3, expires_at = $4, lease_until = $5, generation = generation + 1,
|
|
143
|
+
last_seen_at = $6
|
|
105
144
|
where key = $1 and generation = $2 and revoked_at is null
|
|
106
|
-
returning key`, [
|
|
145
|
+
returning key`, [
|
|
146
|
+
key,
|
|
147
|
+
generation,
|
|
148
|
+
next.payload,
|
|
149
|
+
next.expiresAt,
|
|
150
|
+
next.leaseUntil,
|
|
151
|
+
Math.floor(Date.now() / 1000),
|
|
152
|
+
]);
|
|
107
153
|
return rows.length === 1;
|
|
108
154
|
},
|
|
109
155
|
async revoke(key, at) {
|
|
@@ -115,5 +161,11 @@ export function createPostgresSessionStore(input) {
|
|
|
115
161
|
returning key`, [context, subject, at]);
|
|
116
162
|
return rows.length;
|
|
117
163
|
},
|
|
164
|
+
async listSubject(context, subject) {
|
|
165
|
+
const rows = await query(`select * from wtfalch_auth_sessions
|
|
166
|
+
where context = $1 and subject = $2 and revoked_at is null and expires_at > $3
|
|
167
|
+
order by created_at desc nulls last`, [context, subject, Math.floor(Date.now() / 1000)]);
|
|
168
|
+
return rows.map(record);
|
|
169
|
+
},
|
|
118
170
|
};
|
|
119
171
|
}
|
package/package.json
CHANGED