@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/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
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
|
|
1
|
+
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
|
|
2
2
|
export { type AuthOptions, ISSUER } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
|
@@ -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 MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, 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;
|
|
@@ -21,6 +22,25 @@ export interface NextAuth {
|
|
|
21
22
|
getUser: () => Promise<User | null>;
|
|
22
23
|
/** The user, or a redirect to sign in with `next` to come back to. */
|
|
23
24
|
requireUser: (next?: string) => Promise<User>;
|
|
25
|
+
/**
|
|
26
|
+
* The user, but only if they authenticated within `maxAge` seconds;
|
|
27
|
+
* otherwise a redirect that makes the issuer ask them again. Put it in front
|
|
28
|
+
* of an action somebody holding a borrowed laptop must not be able to take:
|
|
29
|
+
* changing the password, changing the email, removing a passkey.
|
|
30
|
+
*
|
|
31
|
+
* Somebody whose id token carries no `auth_time` is sent back too. The
|
|
32
|
+
* issuer only has to send that claim when the request asked for it, so its
|
|
33
|
+
* absence proves nothing, and the first round trip fixes it for good.
|
|
34
|
+
*
|
|
35
|
+
* **Pass the page's `searchParams`.** They carry the mark that says this
|
|
36
|
+
* person has already been sent once; without them a person the issuer will
|
|
37
|
+
* not re-authenticate is sent round the same loop for ever. See the
|
|
38
|
+
* implementation for why that is not hypothetical here.
|
|
39
|
+
*/
|
|
40
|
+
requireFreshUser: (maxAge: MaxAge, opts?: {
|
|
41
|
+
next?: string;
|
|
42
|
+
searchParams?: SearchParams | Promise<SearchParams>;
|
|
43
|
+
}) => Promise<User>;
|
|
24
44
|
/**
|
|
25
45
|
* For the app's login and register pages: the auth request the issuer sent
|
|
26
46
|
* the browser with, or a redirect that starts one. Pass the page's searchParams.
|
|
@@ -28,6 +48,7 @@ export interface NextAuth {
|
|
|
28
48
|
authRequest: (searchParams: SearchParams | Promise<SearchParams>, opts?: {
|
|
29
49
|
next?: string;
|
|
30
50
|
intent?: Intent;
|
|
51
|
+
prompt?: Prompt;
|
|
31
52
|
}) => Promise<AuthRequest>;
|
|
32
53
|
/** For a server action: redirects into the app on success, returns the error otherwise. */
|
|
33
54
|
signIn: (input: {
|
|
@@ -63,6 +84,13 @@ export interface NextAuth {
|
|
|
63
84
|
people: Auth['people'];
|
|
64
85
|
/** Deactivate or reactivate somebody in this app's organisation. */
|
|
65
86
|
setPersonActive: Auth['setPersonActive'];
|
|
87
|
+
/** The devices page: the signed-in person's own live sessions, newest first. Namespace mode only. */
|
|
88
|
+
sessions: () => Promise<SessionRecord[]>;
|
|
89
|
+
/**
|
|
90
|
+
* Ends one of the signed-in person's own sessions. Throws `AuthError('request', ...)`
|
|
91
|
+
* for a key that is somebody else's, another binding's, or does not exist.
|
|
92
|
+
*/
|
|
93
|
+
endSession: (key: string) => Promise<void>;
|
|
66
94
|
/** Sets the password from a reset link, signs the person in, and redirects. */
|
|
67
95
|
resetPassword: (input: {
|
|
68
96
|
userId: string;
|
|
@@ -85,6 +113,19 @@ export interface NextAuth {
|
|
|
85
113
|
credential: unknown;
|
|
86
114
|
name: string;
|
|
87
115
|
}) => Promise<PasskeyRegistrationResult>;
|
|
116
|
+
/** See `Auth['startEmailChange']`: mails a code to the new address and leaves the old one working until `completeEmailChange` checks it. */
|
|
117
|
+
startEmailChange: (email: string) => Promise<void>;
|
|
118
|
+
/** The other half of `startEmailChange`. */
|
|
119
|
+
completeEmailChange: (code: string) => Promise<EmailChangeResult>;
|
|
120
|
+
/** Changes the signed-in person's password. See `Auth['changePassword']` for how a wrong current password is told apart from a rejected new one. */
|
|
121
|
+
changePassword: (input: {
|
|
122
|
+
currentPassword: string;
|
|
123
|
+
newPassword: string;
|
|
124
|
+
}) => Promise<ChangePasswordResult>;
|
|
125
|
+
/** See `Auth['passkeys']`: enough to warn when this is the only one left. */
|
|
126
|
+
passkeys: () => Promise<Passkey[]>;
|
|
127
|
+
/** Removes one of this person's own passkeys. */
|
|
128
|
+
deletePasskey: (id: string) => Promise<DeletePasskeyResult>;
|
|
88
129
|
/** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
|
|
89
130
|
startPasskeySignIn: (input: {
|
|
90
131
|
authRequestId: string;
|
|
@@ -116,4 +157,4 @@ export interface NextAuth {
|
|
|
116
157
|
}>;
|
|
117
158
|
}
|
|
118
159
|
export declare function nextAuth(options: AuthOptions): NextAuth;
|
|
119
|
-
export type { Auth, AuthOptions, AuthRequest, GateRules, Intent, NewUser, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, User, };
|
|
160
|
+
export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
|
package/dist/next.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
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';
|
|
5
5
|
import { BrokerError } from './broker.js';
|
|
6
6
|
import { withCookies } from './cookies.js';
|
|
7
7
|
import { AuthError } from './oidc.js';
|
|
8
|
+
/** On the page a step-up returns to: this person has been sent once already. */
|
|
9
|
+
const STEP_UP_PARAM = 'auth_stepup';
|
|
10
|
+
function marked(next) {
|
|
11
|
+
const path = next ?? '/';
|
|
12
|
+
return `${path}${path.includes('?') ? '&' : '?'}${STEP_UP_PARAM}=1`;
|
|
13
|
+
}
|
|
8
14
|
/**
|
|
9
15
|
* Which of the two failures it was, in the vocabulary `auth_error` already
|
|
10
16
|
* uses. A refusal under 500 is about this request — the broker says unknown,
|
|
@@ -63,6 +69,30 @@ export function nextAuth(options) {
|
|
|
63
69
|
redirect(auth.startUrl(next));
|
|
64
70
|
return user;
|
|
65
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* One redirect, never two. The second one is the loop.
|
|
74
|
+
*
|
|
75
|
+
* The gate has exactly the shape `authRequest` warns about below, and the
|
|
76
|
+
* same price: send somebody to the issuer because they are stale, and if
|
|
77
|
+
* they come back no fresher, the next render sends them again. Nothing
|
|
78
|
+
* errors and nothing is logged. An issuer that declines to honour `max_age`,
|
|
79
|
+
* or one that omits `auth_time` from the token it mints afterwards, turns
|
|
80
|
+
* the gate into that loop for every person who reaches it.
|
|
81
|
+
*
|
|
82
|
+
* So the redirect marks the return address, and a person who arrives already
|
|
83
|
+
* marked gets an `AuthError` instead of another redirect. The app shows them
|
|
84
|
+
* something and they click. A human click cannot loop.
|
|
85
|
+
*/
|
|
86
|
+
const requireFreshUser = async (maxAge, opts = {}) => {
|
|
87
|
+
const user = await requireUser(opts.next);
|
|
88
|
+
if (auth.fresh(user, maxAge))
|
|
89
|
+
return user;
|
|
90
|
+
const params = opts.searchParams ? await opts.searchParams : undefined;
|
|
91
|
+
if (params?.[STEP_UP_PARAM] !== undefined) {
|
|
92
|
+
throw new AuthError('stale', 'Could not confirm a recent sign-in');
|
|
93
|
+
}
|
|
94
|
+
redirect(auth.startUrl(marked(opts.next), 'login', undefined, maxAge));
|
|
95
|
+
};
|
|
66
96
|
/**
|
|
67
97
|
* The auth request the issuer sent the browser here with.
|
|
68
98
|
*
|
|
@@ -93,7 +123,7 @@ export function nextAuth(options) {
|
|
|
93
123
|
// Nobody has started a flow yet. This cannot loop: the issuer always
|
|
94
124
|
// sends the browser back carrying an id.
|
|
95
125
|
if (!id)
|
|
96
|
-
redirect(auth.startUrl(opts.next, opts.intent));
|
|
126
|
+
redirect(auth.startUrl(opts.next, opts.intent, opts.prompt));
|
|
97
127
|
try {
|
|
98
128
|
return await auth.authRequest(id);
|
|
99
129
|
}
|
|
@@ -105,11 +135,8 @@ export function nextAuth(options) {
|
|
|
105
135
|
// 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
136
|
const finish = async (callbackUrl) => {
|
|
107
137
|
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);
|
|
138
|
+
const request = await requestFromCookies();
|
|
139
|
+
const { location, cookies: set } = await auth.complete(callbackUrl, request.headers.get('cookie'), request.headers);
|
|
113
140
|
for (const c of set) {
|
|
114
141
|
if (c.value)
|
|
115
142
|
store.set(c.name, c.value, c.attributes);
|
|
@@ -119,20 +146,37 @@ export function nextAuth(options) {
|
|
|
119
146
|
const url = new URL(location);
|
|
120
147
|
redirect(`${url.pathname}${url.search}`);
|
|
121
148
|
};
|
|
122
|
-
// The passkey routes take a `Request`; a server action has
|
|
123
|
-
// own, only what `cookies()` already
|
|
124
|
-
//
|
|
125
|
-
//
|
|
149
|
+
// The passkey and session routes take a `Request`; a server action has
|
|
150
|
+
// none of its own, only what `cookies()`/`headers()` already expose for
|
|
151
|
+
// this invocation. `cookies()` (not the incoming `cookie` header) is the
|
|
152
|
+
// source of truth for the jar, since it reflects whatever this action has
|
|
153
|
+
// already set or cleared; the device headers `namespaceSessions` reads on
|
|
154
|
+
// sign-in come along too, so a session created from a server action still
|
|
155
|
+
// gets a real user agent and address.
|
|
126
156
|
const requestFromCookies = async () => {
|
|
127
157
|
const store = await cookies();
|
|
128
|
-
const
|
|
158
|
+
const cookieHeader = store
|
|
129
159
|
.getAll()
|
|
130
160
|
.map((c) => `${c.name}=${c.value}`)
|
|
131
161
|
.join('; ');
|
|
132
|
-
|
|
162
|
+
const requestHeaders = new Headers();
|
|
163
|
+
if (cookieHeader)
|
|
164
|
+
requestHeaders.set('cookie', cookieHeader);
|
|
165
|
+
const incoming = await headers();
|
|
166
|
+
for (const name of ['user-agent', 'x-forwarded-for', 'x-real-ip']) {
|
|
167
|
+
const value = incoming.get(name);
|
|
168
|
+
if (value)
|
|
169
|
+
requestHeaders.set(name, value);
|
|
170
|
+
}
|
|
171
|
+
return new Request('http://localhost/', { headers: requestHeaders });
|
|
133
172
|
};
|
|
134
173
|
const startPasskeyRegistration = async () => auth.startPasskeyRegistration(await requestFromCookies());
|
|
135
174
|
const finishPasskeyRegistration = async (input) => auth.finishPasskeyRegistration(await requestFromCookies(), input);
|
|
175
|
+
const startEmailChange = async (email) => auth.startEmailChange(await requestFromCookies(), email);
|
|
176
|
+
const completeEmailChange = async (code) => auth.completeEmailChange(await requestFromCookies(), code);
|
|
177
|
+
const changePassword = async (input) => auth.changePassword(await requestFromCookies(), input);
|
|
178
|
+
const passkeys = async () => auth.passkeys(await requestFromCookies());
|
|
179
|
+
const deletePasskey = async (id) => auth.deletePasskey(await requestFromCookies(), id);
|
|
136
180
|
const finishPasskeySignIn = async (input) => {
|
|
137
181
|
const result = await auth.finishPasskeySignIn(input);
|
|
138
182
|
if (result.ok) {
|
|
@@ -143,7 +187,7 @@ export function nextAuth(options) {
|
|
|
143
187
|
return { error: result.error };
|
|
144
188
|
};
|
|
145
189
|
const resetPassword = async (input) => {
|
|
146
|
-
const result = await auth.resetPassword(input);
|
|
190
|
+
const result = await auth.resetPassword(input, (await requestFromCookies()).headers);
|
|
147
191
|
if (!result.ok)
|
|
148
192
|
return { error: result.error, message: result.message };
|
|
149
193
|
const store = await cookies();
|
|
@@ -198,12 +242,15 @@ export function nextAuth(options) {
|
|
|
198
242
|
}
|
|
199
243
|
return reason ? { status, reason } : { status };
|
|
200
244
|
};
|
|
245
|
+
const sessionsList = async () => auth.sessions(await requestFromCookies());
|
|
246
|
+
const endSession = async (key) => auth.endSession(await requestFromCookies(), key);
|
|
201
247
|
return {
|
|
202
248
|
auth,
|
|
203
249
|
handlers: { GET: auth.handle, POST: auth.handle },
|
|
204
250
|
proxy,
|
|
205
251
|
getUser,
|
|
206
252
|
requireUser,
|
|
253
|
+
requireFreshUser,
|
|
207
254
|
authRequest,
|
|
208
255
|
signIn,
|
|
209
256
|
signUp,
|
|
@@ -213,9 +260,16 @@ export function nextAuth(options) {
|
|
|
213
260
|
invite: auth.invite,
|
|
214
261
|
people: auth.people,
|
|
215
262
|
setPersonActive: auth.setPersonActive,
|
|
263
|
+
sessions: sessionsList,
|
|
264
|
+
endSession,
|
|
216
265
|
resetPassword,
|
|
217
266
|
startPasskeyRegistration,
|
|
218
267
|
finishPasskeyRegistration,
|
|
268
|
+
startEmailChange,
|
|
269
|
+
completeEmailChange,
|
|
270
|
+
changePassword,
|
|
271
|
+
passkeys,
|
|
272
|
+
deletePasskey,
|
|
219
273
|
startPasskeySignIn: auth.startPasskeySignIn,
|
|
220
274
|
finishPasskeySignIn,
|
|
221
275
|
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,15 @@ 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, maxAge, }?: {
|
|
21
30
|
create?: boolean;
|
|
31
|
+
prompt?: 'select_account' | 'login';
|
|
32
|
+
maxAge?: number;
|
|
22
33
|
}): Promise<URL>;
|
|
23
34
|
exchange(callbackParams: URLSearchParams, transaction: {
|
|
24
35
|
state: string;
|
|
@@ -26,14 +37,35 @@ export declare class Oidc {
|
|
|
26
37
|
codeVerifier: string;
|
|
27
38
|
}): Promise<Tokens>;
|
|
28
39
|
refresh(refreshToken: string): Promise<Tokens>;
|
|
40
|
+
/**
|
|
41
|
+
* The issuer's RP-Initiated Logout URL, or null when discovery publishes no
|
|
42
|
+
* `end_session_endpoint` -- an issuer is free not to support it, and a
|
|
43
|
+
* sign-out has to succeed either way.
|
|
44
|
+
*/
|
|
45
|
+
endSessionUrl(idToken: string, postLogoutRedirectUri: string): Promise<URL | null>;
|
|
29
46
|
private tokensFrom;
|
|
30
47
|
verify(idToken: string): Promise<jose.JWTPayload>;
|
|
31
48
|
private assertOrganization;
|
|
32
49
|
}
|
|
33
50
|
export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable'
|
|
34
51
|
/** No session at all -- distinct from `account_changed`/`unavailable`, which `requireUser` already covers by redirecting instead. */
|
|
35
|
-
| 'unauthenticated'
|
|
52
|
+
| 'unauthenticated'
|
|
53
|
+
/** Came back from a step-up no fresher than it went. Never redirect on this: that is the loop. */
|
|
54
|
+
| 'stale';
|
|
36
55
|
export declare class AuthError extends Error {
|
|
37
56
|
readonly reason: AuthErrorReason;
|
|
38
57
|
constructor(reason: AuthErrorReason, message: string);
|
|
39
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* A refusal from the token endpoint itself -- the grant is gone, the account
|
|
61
|
+
* is disabled -- as opposed to the issuer being unreachable, rate-limiting,
|
|
62
|
+
* or having a bad day. Keyed on the HTTP status alone, never the body: a
|
|
63
|
+
* WAF or CDN in front of the issuer can answer a genuine refusal as HTML,
|
|
64
|
+
* which oauth4webapi cannot parse as an OAuth error, but it still hands back
|
|
65
|
+
* the response that carried the status. Trusting only a parsed `error` field
|
|
66
|
+
* would treat that case as an outage and let the old bug back in through a
|
|
67
|
+
* path nobody would look at. 429 is excluded on purpose: "come back later"
|
|
68
|
+
* is never a statement that the grant is dead, and this issuer's own rate
|
|
69
|
+
* limiting must not permanently revoke a session.
|
|
70
|
+
*/
|
|
71
|
+
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,10 @@ 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, maxAge, } = {}) {
|
|
57
66
|
return client.buildAuthorizationUrl(await this.config(), {
|
|
58
|
-
...(create ? { prompt: 'create' } : {}),
|
|
67
|
+
...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
|
|
68
|
+
...(maxAge === undefined ? {} : { max_age: String(maxAge) }),
|
|
59
69
|
redirect_uri: this.redirectUri.href,
|
|
60
70
|
scope: scopeFor(this.options.organizationId),
|
|
61
71
|
state,
|
|
@@ -78,16 +88,41 @@ export class Oidc {
|
|
|
78
88
|
}
|
|
79
89
|
// Parallel requests holding the same refresh token share one grant; the issuer rotates it, so a second grant would fail.
|
|
80
90
|
refresh(refreshToken) {
|
|
91
|
+
const asked = Date.now();
|
|
92
|
+
for (const [token, entry] of this.rotated)
|
|
93
|
+
if (entry.until <= asked)
|
|
94
|
+
this.rotated.delete(token);
|
|
95
|
+
const cached = this.rotated.get(refreshToken);
|
|
96
|
+
if (cached)
|
|
97
|
+
return Promise.resolve(cached.tokens);
|
|
81
98
|
let pending = this.refreshing.get(refreshToken);
|
|
82
99
|
if (!pending) {
|
|
83
100
|
pending = this.config()
|
|
84
101
|
.then((config) => client.refreshTokenGrant(config, refreshToken))
|
|
85
102
|
.then((response) => this.tokensFrom(response, refreshToken))
|
|
103
|
+
.then((tokens) => {
|
|
104
|
+
this.rotated.set(refreshToken, { tokens, until: Date.now() + ROTATION_CACHE_MS });
|
|
105
|
+
return tokens;
|
|
106
|
+
})
|
|
86
107
|
.finally(() => this.refreshing.delete(refreshToken));
|
|
87
108
|
this.refreshing.set(refreshToken, pending);
|
|
88
109
|
}
|
|
89
110
|
return pending;
|
|
90
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* The issuer's RP-Initiated Logout URL, or null when discovery publishes no
|
|
114
|
+
* `end_session_endpoint` -- an issuer is free not to support it, and a
|
|
115
|
+
* sign-out has to succeed either way.
|
|
116
|
+
*/
|
|
117
|
+
async endSessionUrl(idToken, postLogoutRedirectUri) {
|
|
118
|
+
const config = await this.config();
|
|
119
|
+
if (!config.serverMetadata().end_session_endpoint)
|
|
120
|
+
return null;
|
|
121
|
+
return client.buildEndSessionUrl(config, {
|
|
122
|
+
id_token_hint: idToken,
|
|
123
|
+
post_logout_redirect_uri: postLogoutRedirectUri,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
91
126
|
tokensFrom(response, previousRefreshToken) {
|
|
92
127
|
const claims = response.claims();
|
|
93
128
|
if (!response.id_token || !claims) {
|
|
@@ -130,3 +165,25 @@ export class AuthError extends Error {
|
|
|
130
165
|
this.name = 'AuthError';
|
|
131
166
|
}
|
|
132
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* A refusal from the token endpoint itself -- the grant is gone, the account
|
|
170
|
+
* is disabled -- as opposed to the issuer being unreachable, rate-limiting,
|
|
171
|
+
* or having a bad day. Keyed on the HTTP status alone, never the body: a
|
|
172
|
+
* WAF or CDN in front of the issuer can answer a genuine refusal as HTML,
|
|
173
|
+
* which oauth4webapi cannot parse as an OAuth error, but it still hands back
|
|
174
|
+
* the response that carried the status. Trusting only a parsed `error` field
|
|
175
|
+
* would treat that case as an outage and let the old bug back in through a
|
|
176
|
+
* path nobody would look at. 429 is excluded on purpose: "come back later"
|
|
177
|
+
* is never a statement that the grant is dead, and this issuer's own rate
|
|
178
|
+
* limiting must not permanently revoke a session.
|
|
179
|
+
*/
|
|
180
|
+
export function isOAuthRefusal(error) {
|
|
181
|
+
const status = statusOf(error);
|
|
182
|
+
return status !== undefined && status >= 400 && status < 500 && status !== 429;
|
|
183
|
+
}
|
|
184
|
+
function statusOf(error) {
|
|
185
|
+
if (error instanceof client.ResponseBodyError)
|
|
186
|
+
return error.status;
|
|
187
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
188
|
+
return cause instanceof Response ? cause.status : undefined;
|
|
189
|
+
}
|
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
|