@wtfalch/auth 0.23.0 → 0.25.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 +7 -5
- package/dist/auth.js +69 -101
- package/dist/broker.js +44 -13
- package/dist/browser.d.ts +6 -3
- package/dist/browser.js +14 -26
- package/dist/config.d.ts +15 -1
- package/dist/config.js +20 -0
- package/dist/cookies.d.ts +1 -2
- package/dist/cookies.js +3 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/namespace-browser.js +6 -10
- package/dist/namespace-session.d.ts +1 -5
- package/dist/namespace-session.js +11 -27
- package/dist/next.d.ts +2 -1
- package/dist/next.js +34 -15
- package/dist/oidc.d.ts +4 -1
- package/dist/oidc.js +16 -1
- package/dist/person-token.js +4 -1
- package/dist/qr.js +3 -0
- package/dist/redirect.d.ts +7 -0
- package/dist/redirect.js +11 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +57 -0
- package/dist/util.d.ts +13 -0
- package/dist/util.js +44 -0
- package/package.json +1 -1
package/dist/cookies.d.ts
CHANGED
|
@@ -32,10 +32,9 @@ export interface LinkPayload extends JWTPayload {
|
|
|
32
32
|
stk: string;
|
|
33
33
|
ar: string;
|
|
34
34
|
}
|
|
35
|
-
/** The QR device-grant transaction: the device_code, never handed to the caller, and
|
|
35
|
+
/** The QR device-grant transaction: the device_code, never handed to the caller, and nothing else: the issuer answers `slow_down` to a client that polls too fast, and `pollQrSignIn` passes that on. Its own `exp` -- set to the issuer's `expires_in` -- is what makes an unsealed-past-expiry cookie fail open with the others below. */
|
|
36
36
|
export interface QrTransactionPayload extends JWTPayload {
|
|
37
37
|
dc: string;
|
|
38
|
-
iv: number;
|
|
39
38
|
}
|
|
40
39
|
export interface SetCookie {
|
|
41
40
|
name: string;
|
package/dist/cookies.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EncryptJWT, jwtDecrypt } from 'jose';
|
|
2
2
|
import { deriveCookieKey } from './config.js';
|
|
3
3
|
import { AuthError } from './oidc.js';
|
|
4
|
+
import { base64url } from './util.js';
|
|
4
5
|
// `options.cookieKey` decodes once per `ResolvedOptions` (config.ts) and is
|
|
5
6
|
// always the same `Uint8Array` reference after that, so keying the derived
|
|
6
7
|
// key's own cache off it means every seal/open after the first reuses it
|
|
@@ -78,10 +79,7 @@ export async function contextDigest(options) {
|
|
|
78
79
|
/** SHA-256, base64url. */
|
|
79
80
|
export async function digest(value) {
|
|
80
81
|
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
81
|
-
return
|
|
82
|
-
.replaceAll('+', '-')
|
|
83
|
-
.replaceAll('/', '_')
|
|
84
|
-
.replace(/=+$/, '');
|
|
82
|
+
return base64url(bytes);
|
|
85
83
|
}
|
|
86
84
|
export async function sealTransaction(options, payload) {
|
|
87
85
|
const value = await seal(options, 'transaction', payload, TRANSACTION_TTL);
|
|
@@ -125,9 +123,7 @@ export async function sealQrTransaction(options, payload, ttl) {
|
|
|
125
123
|
}
|
|
126
124
|
export async function openQrTransaction(options, value) {
|
|
127
125
|
const payload = await open(options, 'qr', value);
|
|
128
|
-
return payload && typeof payload.dc === 'string'
|
|
129
|
-
? payload
|
|
130
|
-
: null;
|
|
126
|
+
return payload && typeof payload.dc === 'string' ? payload : null;
|
|
131
127
|
}
|
|
132
128
|
export function clearQrTransaction(options) {
|
|
133
129
|
return setCookie(qrTransactionCookieName(options), '', 0, options.secure);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasswordChangeRequired, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, STEP_UP_MAX_AGE, createAuth, } from './auth.js';
|
|
1
|
+
export { type Auth, type AuthRequest, type ChangePasswordError, type ChangePasswordResult, type Completed, type DeletePasskeyResult, type EmailChangeError, type EmailChangeResult, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasswordChangeRequired, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, STEP_UP_MAX_AGE, createAuth, } from './auth.js';
|
|
2
2
|
export type { AccessTokenReason, AccessTokenResult, AuthEvent, AuthOptions } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
|
5
5
|
export type { PersonTokenConfig, PersonTokenVerifier } from './person-token.js';
|
|
6
|
-
export { BrokerError } from './broker.js';
|
|
6
|
+
export { BrokerError, type PeoplePage, type Person } from './broker.js';
|
|
7
7
|
export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
|
|
8
|
-
export { safeNextPath } from './redirect.js';
|
|
8
|
+
export { safeLoginHint, safeNextPath } from './redirect.js';
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
2
|
import { resolveNamespaceContext } from './namespaces.js';
|
|
3
|
-
import { safeNextPath } from './redirect.js';
|
|
3
|
+
import { safeLoginHint, safeNextPath } from './redirect.js';
|
|
4
|
+
import { base64url } from './util.js';
|
|
4
5
|
const OWNER = 'urn:zitadel:iam:user:resourceowner:id';
|
|
5
6
|
const ttl = 10 * 60_000;
|
|
6
7
|
const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
7
|
-
function base64url(bytes) {
|
|
8
|
-
return btoa(String.fromCharCode(...bytes))
|
|
9
|
-
.replaceAll('+', '-')
|
|
10
|
-
.replaceAll('/', '_')
|
|
11
|
-
.replace(/=+$/, '');
|
|
12
|
-
}
|
|
13
8
|
function read(storage, key) {
|
|
14
9
|
try {
|
|
15
10
|
return JSON.parse(storage.getItem(key) ?? 'null');
|
|
@@ -75,7 +70,7 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
75
70
|
: null;
|
|
76
71
|
};
|
|
77
72
|
const silentSignInAvailable = () => !accountChanged() && !currentSession() && !sessionStorage.getItem(triedKey);
|
|
78
|
-
const authorize = async (prompt, returnTo) => {
|
|
73
|
+
const authorize = async (prompt, returnTo, loginHint) => {
|
|
79
74
|
const transaction = {
|
|
80
75
|
context: binding.context,
|
|
81
76
|
state: random(),
|
|
@@ -99,6 +94,7 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
99
94
|
code_challenge: base64url(new Uint8Array(digest)),
|
|
100
95
|
code_challenge_method: 'S256',
|
|
101
96
|
...(prompt ? { prompt } : {}),
|
|
97
|
+
...(safeLoginHint(loginHint) ? { login_hint: safeLoginHint(loginHint) } : {}),
|
|
102
98
|
}).toString();
|
|
103
99
|
if (!ownsPending(transaction.state))
|
|
104
100
|
throw new Error('Another sign-in replaced this attempt');
|
|
@@ -220,9 +216,9 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
220
216
|
await authorize('none', returnTo);
|
|
221
217
|
return true;
|
|
222
218
|
},
|
|
223
|
-
async signIn(returnTo = window.location.pathname) {
|
|
219
|
+
async signIn(returnTo = window.location.pathname, opts) {
|
|
224
220
|
sessionStorage.removeItem(triedKey);
|
|
225
|
-
await authorize(undefined, returnTo);
|
|
221
|
+
await authorize(undefined, returnTo, opts?.loginHint);
|
|
226
222
|
},
|
|
227
223
|
// Matches BrowserAuth.forget()'s Promise<void> signature; this path's
|
|
228
224
|
// storage is always synchronous, so there is nothing to await.
|
|
@@ -11,10 +11,6 @@ export declare const STATUS_STALE_IF_ERROR_MS: number;
|
|
|
11
11
|
export type SessionRead = {
|
|
12
12
|
kind: 'none';
|
|
13
13
|
}
|
|
14
|
-
/** A cookie for another binding, revision or format. */
|
|
15
|
-
| {
|
|
16
|
-
kind: 'changed';
|
|
17
|
-
}
|
|
18
14
|
/** The binding is suspended, or its status cannot be confirmed. */
|
|
19
15
|
| {
|
|
20
16
|
kind: 'unavailable';
|
|
@@ -36,7 +32,7 @@ export declare function namespaceSessions(deps: {
|
|
|
36
32
|
}): {
|
|
37
33
|
bindingActive: () => Promise<boolean>;
|
|
38
34
|
create: (tokens: Tokens, requestHeaders?: Headers) => Promise<SetCookie>;
|
|
39
|
-
end: (value: string | undefined) => Promise<"ended" | "
|
|
35
|
+
end: (value: string | undefined) => Promise<"ended" | "none">;
|
|
40
36
|
read: (value: string | undefined, force?: boolean) => Promise<SessionRead>;
|
|
41
37
|
revokeSubject: (subject: string) => Promise<number>;
|
|
42
38
|
listSessions: (subject: string) => Promise<SessionRecord[]>;
|
|
@@ -2,7 +2,8 @@ import { sleep } from '@wtfalch/utils/datetime';
|
|
|
2
2
|
import * as client from 'openid-client';
|
|
3
3
|
import { BrokerError } from './broker.js';
|
|
4
4
|
import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
|
|
5
|
-
import { AuthError, isOAuthRefusal } from './oidc.js';
|
|
5
|
+
import { AuthError, isOAuthRefusal, rejected } from './oidc.js';
|
|
6
|
+
import { expiryOf, issuedAtOf } from './util.js';
|
|
6
7
|
/** How long a binding status answer is trusted. The propagation bound for a suspension. */
|
|
7
8
|
export const STATUS_FRESH_MS = 60_000;
|
|
8
9
|
/** How long the last good answer carries on while the service cannot be asked. */
|
|
@@ -158,7 +159,7 @@ export function namespaceSessions(deps) {
|
|
|
158
159
|
return 'none';
|
|
159
160
|
const key = await keyOf(value);
|
|
160
161
|
if (!key)
|
|
161
|
-
return '
|
|
162
|
+
return 'none';
|
|
162
163
|
const loaded = await load(key);
|
|
163
164
|
await store().revoke(key, now());
|
|
164
165
|
revokeToken(loaded?.tokens.rt);
|
|
@@ -423,8 +424,9 @@ export function namespaceSessions(deps) {
|
|
|
423
424
|
if (!value)
|
|
424
425
|
return { kind: 'none' };
|
|
425
426
|
const key = await keyOf(value);
|
|
427
|
+
// A handle that does not open (another binding's, an older revision's, or damaged) is nobody: sign in again replaces it.
|
|
426
428
|
if (!key)
|
|
427
|
-
return { kind: '
|
|
429
|
+
return { kind: 'none' };
|
|
428
430
|
if (!(await bindingActive()))
|
|
429
431
|
return { kind: 'unavailable' };
|
|
430
432
|
let loaded = await load(key);
|
|
@@ -443,8 +445,12 @@ export function namespaceSessions(deps) {
|
|
|
443
445
|
try {
|
|
444
446
|
return { kind: 'user', claims: await deps.oidc().verify(loaded.tokens.idt) };
|
|
445
447
|
}
|
|
446
|
-
catch {
|
|
447
|
-
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (rejected(error))
|
|
450
|
+
return { kind: 'none' };
|
|
451
|
+
// The issuer's keys could not be fetched: not a verdict on this session.
|
|
452
|
+
console.error('@wtfalch/auth: could not verify the session id token', error instanceof Error ? error.name : 'unknown');
|
|
453
|
+
return { kind: 'unavailable' };
|
|
448
454
|
}
|
|
449
455
|
};
|
|
450
456
|
return {
|
|
@@ -483,25 +489,3 @@ function ipFrom(headers) {
|
|
|
483
489
|
.filter(Boolean);
|
|
484
490
|
return hops.length > 0 ? hops[hops.length - 1] : null;
|
|
485
491
|
}
|
|
486
|
-
function expiryOf(idToken) {
|
|
487
|
-
try {
|
|
488
|
-
const [, payload] = idToken.split('.');
|
|
489
|
-
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
490
|
-
return typeof json.exp === 'number' ? json.exp : null;
|
|
491
|
-
}
|
|
492
|
-
catch {
|
|
493
|
-
return null;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
// Age, not time left: a disabled or revoked account must not ride out a
|
|
497
|
-
// long-lived token just because it still has plenty of time on the clock.
|
|
498
|
-
function issuedAtOf(idToken) {
|
|
499
|
-
try {
|
|
500
|
-
const [, payload] = idToken.split('.');
|
|
501
|
-
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
502
|
-
return typeof json.iat === 'number' ? json.iat : null;
|
|
503
|
-
}
|
|
504
|
-
catch {
|
|
505
|
-
return null;
|
|
506
|
-
}
|
|
507
|
-
}
|
package/dist/next.d.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface NextAuth {
|
|
|
49
49
|
next?: string;
|
|
50
50
|
intent?: Intent;
|
|
51
51
|
prompt?: Prompt;
|
|
52
|
+
loginHint?: string;
|
|
52
53
|
}) => Promise<AuthRequest>;
|
|
53
54
|
/** For a server action: redirects into the app on success, returns the error otherwise. */
|
|
54
55
|
signIn: (input: {
|
|
@@ -127,7 +128,7 @@ export interface NextAuth {
|
|
|
127
128
|
/**
|
|
128
129
|
* Registers a passkey on this app's own domain for the person signed in
|
|
129
130
|
* here. Throws `AuthError('unauthenticated', ...)` for nobody, exactly as
|
|
130
|
-
* `requireUser` throws `
|
|
131
|
+
* `requireUser` throws `unavailable` for a suspended binding.
|
|
131
132
|
*/
|
|
132
133
|
startPasskeyRegistration: () => Promise<PasskeyRegistrationStart>;
|
|
133
134
|
/** The second half of `startPasskeyRegistration`. Same signed-in requirement. */
|
package/dist/next.js
CHANGED
|
@@ -31,13 +31,9 @@ export function nextAuth(options) {
|
|
|
31
31
|
response = NextResponse.redirect(gate.location, 303);
|
|
32
32
|
}
|
|
33
33
|
else if (gate.kind === 'deny') {
|
|
34
|
-
response = new NextResponse(gate.reason === '
|
|
35
|
-
? '
|
|
36
|
-
: gate.reason === 'unavailable'
|
|
37
|
-
? 'Sign-in is unavailable for this workspace'
|
|
38
|
-
: 'Unauthorized', {
|
|
39
|
-
status: gate.reason === 'account_changed' ? 409 : gate.reason === 'unavailable' ? 503 : 401,
|
|
40
|
-
});
|
|
34
|
+
response = new NextResponse(gate.reason === 'unavailable'
|
|
35
|
+
? 'Sign-in is unavailable for this workspace'
|
|
36
|
+
: 'Unauthorized', { status: gate.reason === 'unavailable' ? 503 : 401 });
|
|
41
37
|
}
|
|
42
38
|
else if (gate.cookies.length > 0) {
|
|
43
39
|
const headers = new Headers(request.headers);
|
|
@@ -60,9 +56,9 @@ export function nextAuth(options) {
|
|
|
60
56
|
};
|
|
61
57
|
const requireUser = async (next) => {
|
|
62
58
|
const store = await cookies();
|
|
63
|
-
const { user,
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
const { user, unavailable } = await auth.readCookie(store.get(auth.sessionCookieName)?.value, {
|
|
60
|
+
refresh: false,
|
|
61
|
+
});
|
|
66
62
|
if (unavailable)
|
|
67
63
|
throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
|
|
68
64
|
if (!user)
|
|
@@ -123,7 +119,7 @@ export function nextAuth(options) {
|
|
|
123
119
|
// Nobody has started a flow yet. This cannot loop: the issuer always
|
|
124
120
|
// sends the browser back carrying an id.
|
|
125
121
|
if (!id)
|
|
126
|
-
redirect(auth.startUrl(opts.next, opts.intent, opts.prompt));
|
|
122
|
+
redirect(auth.startUrl(opts.next, opts.intent, opts.prompt, undefined, opts.loginHint));
|
|
127
123
|
try {
|
|
128
124
|
return await auth.authRequest(id);
|
|
129
125
|
}
|
|
@@ -213,10 +209,16 @@ export function nextAuth(options) {
|
|
|
213
209
|
};
|
|
214
210
|
const setNewPassword = async (input) => {
|
|
215
211
|
// Named fields: a Server Action's argument is whatever the client sent.
|
|
212
|
+
// A field that is not text is refused here and never reaches the broker.
|
|
213
|
+
if (typeof input?.authRequestId !== 'string' || typeof input.changeToken !== 'string') {
|
|
214
|
+
return { error: 'invalid_token' };
|
|
215
|
+
}
|
|
216
|
+
if (typeof input.newPassword !== 'string')
|
|
217
|
+
return { error: 'invalid_password' };
|
|
216
218
|
const result = await auth.setNewPassword({
|
|
217
|
-
authRequestId:
|
|
218
|
-
changeToken:
|
|
219
|
-
newPassword:
|
|
219
|
+
authRequestId: input.authRequestId,
|
|
220
|
+
changeToken: input.changeToken,
|
|
221
|
+
newPassword: input.newPassword,
|
|
220
222
|
});
|
|
221
223
|
if (result.ok) {
|
|
222
224
|
if (result.hosted)
|
|
@@ -226,7 +228,24 @@ export function nextAuth(options) {
|
|
|
226
228
|
return { error: result.error };
|
|
227
229
|
};
|
|
228
230
|
const signUp = async (input) => {
|
|
229
|
-
|
|
231
|
+
// Named fields: a Server Action's argument is whatever the client sent, so
|
|
232
|
+
// `changeRequired` and `personalEmail`, which only a server may set, are not read.
|
|
233
|
+
if (typeof input?.authRequestId !== 'string' ||
|
|
234
|
+
typeof input.email !== 'string' ||
|
|
235
|
+
typeof input.password !== 'string' ||
|
|
236
|
+
typeof input.givenName !== 'string' ||
|
|
237
|
+
typeof input.familyName !== 'string' ||
|
|
238
|
+
(input.username !== undefined && typeof input.username !== 'string')) {
|
|
239
|
+
return { error: 'invalid', message: 'Fill in every field.' };
|
|
240
|
+
}
|
|
241
|
+
const result = await auth.signUp({
|
|
242
|
+
authRequestId: input.authRequestId,
|
|
243
|
+
email: input.email,
|
|
244
|
+
password: input.password,
|
|
245
|
+
givenName: input.givenName,
|
|
246
|
+
familyName: input.familyName,
|
|
247
|
+
username: input.username,
|
|
248
|
+
});
|
|
230
249
|
if (result.ok) {
|
|
231
250
|
if (result.hosted)
|
|
232
251
|
redirect(result.redirectTo);
|
package/dist/oidc.d.ts
CHANGED
|
@@ -42,10 +42,11 @@ export declare class Oidc {
|
|
|
42
42
|
private config;
|
|
43
43
|
private keys;
|
|
44
44
|
get redirectUri(): URL;
|
|
45
|
-
authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt, maxAge, }?: {
|
|
45
|
+
authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt, maxAge, loginHint, }?: {
|
|
46
46
|
create?: boolean;
|
|
47
47
|
prompt?: 'select_account' | 'login' | 'none';
|
|
48
48
|
maxAge?: number;
|
|
49
|
+
loginHint?: string;
|
|
49
50
|
}): Promise<URL>;
|
|
50
51
|
exchange(callbackParams: URLSearchParams, transaction: {
|
|
51
52
|
state: string;
|
|
@@ -85,4 +86,6 @@ export declare class AuthError extends Error {
|
|
|
85
86
|
* is never a statement that the grant is dead, and this issuer's own rate
|
|
86
87
|
* limiting must not permanently revoke a session.
|
|
87
88
|
*/
|
|
89
|
+
/** An id token the issuer's keys or claims refuse, as opposed to keys that could not be fetched. */
|
|
90
|
+
export declare function rejected(error: unknown): boolean;
|
|
88
91
|
export declare function isOAuthRefusal(error: unknown): boolean;
|
package/dist/oidc.js
CHANGED
|
@@ -81,10 +81,11 @@ export class Oidc {
|
|
|
81
81
|
get redirectUri() {
|
|
82
82
|
return new URL(`${this.options.basePath}/callback`, this.options.appUrl);
|
|
83
83
|
}
|
|
84
|
-
async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt, maxAge, } = {}) {
|
|
84
|
+
async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt, maxAge, loginHint, } = {}) {
|
|
85
85
|
return client.buildAuthorizationUrl(await this.config(), {
|
|
86
86
|
...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
|
|
87
87
|
...(maxAge === undefined ? {} : { max_age: String(maxAge) }),
|
|
88
|
+
...(loginHint ? { login_hint: loginHint } : {}),
|
|
88
89
|
redirect_uri: this.redirectUri.href,
|
|
89
90
|
scope: scopeFor(this.options.organizationId, this.options.extraScopes),
|
|
90
91
|
state,
|
|
@@ -106,6 +107,10 @@ export class Oidc {
|
|
|
106
107
|
return this.tokensFrom(response);
|
|
107
108
|
}
|
|
108
109
|
// Parallel requests holding the same refresh token share one grant; the issuer rotates it, so a second grant would fail.
|
|
110
|
+
// The rotated result is kept for ROTATION_CACHE_MS and keyed by the OLD refresh token, so a request that lost the race gets the
|
|
111
|
+
// winner's tokens. The cost: a copy of the old cookie (same refresh token) gets those tokens too in that window. Keying on
|
|
112
|
+
// the session does not help, since the copy is the same session; the only fix is a shorter window, which would break the race
|
|
113
|
+
// it exists for. The copy still needs the sealed cookie, so this exposes nothing a live cookie did not already hold.
|
|
109
114
|
refresh(refreshToken) {
|
|
110
115
|
const asked = Date.now();
|
|
111
116
|
for (const [token, entry] of this.rotated)
|
|
@@ -220,6 +225,16 @@ export class AuthError extends Error {
|
|
|
220
225
|
* is never a statement that the grant is dead, and this issuer's own rate
|
|
221
226
|
* limiting must not permanently revoke a session.
|
|
222
227
|
*/
|
|
228
|
+
/** An id token the issuer's keys or claims refuse, as opposed to keys that could not be fetched. */
|
|
229
|
+
export function rejected(error) {
|
|
230
|
+
return (error instanceof AuthError ||
|
|
231
|
+
error instanceof jose.errors.JWTExpired ||
|
|
232
|
+
error instanceof jose.errors.JWTClaimValidationFailed ||
|
|
233
|
+
error instanceof jose.errors.JWSSignatureVerificationFailed ||
|
|
234
|
+
error instanceof jose.errors.JWSInvalid ||
|
|
235
|
+
error instanceof jose.errors.JWTInvalid ||
|
|
236
|
+
error instanceof jose.errors.JOSEAlgNotAllowed);
|
|
237
|
+
}
|
|
223
238
|
export function isOAuthRefusal(error) {
|
|
224
239
|
const status = statusOf(error);
|
|
225
240
|
return status !== undefined && status >= 400 && status < 500 && status !== 429;
|
package/dist/person-token.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
|
|
2
|
+
import { timeoutSignal } from './util.js';
|
|
2
3
|
/** A JWT is three base64url segments; a ZITADEL opaque token is not shaped
|
|
3
4
|
* like that at all, so this is enough to route between `jwtVerify` and
|
|
4
5
|
* introspection without ever trying to parse either kind as the other. */
|
|
@@ -23,7 +24,7 @@ function sameOriginEndpoint(value, name, issuerOrigin) {
|
|
|
23
24
|
async function discoverDocument(issuer, fetchImpl) {
|
|
24
25
|
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
25
26
|
const issuerOrigin = new URL(issuer).origin;
|
|
26
|
-
const response = await fetchImpl(discoveryUrl);
|
|
27
|
+
const response = await fetchImpl(discoveryUrl, { signal: timeoutSignal() });
|
|
27
28
|
if (!response.ok) {
|
|
28
29
|
throw new Error(`person-token: discovery document request failed with ${response.status}`);
|
|
29
30
|
}
|
|
@@ -108,6 +109,7 @@ export function createPersonTokenVerifier(config) {
|
|
|
108
109
|
audience: config.audience,
|
|
109
110
|
algorithms: ['RS256'],
|
|
110
111
|
clockTolerance: 60,
|
|
112
|
+
requiredClaims: ['exp', 'sub'],
|
|
111
113
|
});
|
|
112
114
|
return payload;
|
|
113
115
|
}
|
|
@@ -150,6 +152,7 @@ export function createPersonTokenVerifier(config) {
|
|
|
150
152
|
authorization: `Basic ${credentials}`,
|
|
151
153
|
},
|
|
152
154
|
body: new URLSearchParams({ token: accessToken }).toString(),
|
|
155
|
+
signal: timeoutSignal(),
|
|
153
156
|
});
|
|
154
157
|
}
|
|
155
158
|
catch {
|
package/dist/qr.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthError } from './oidc.js';
|
|
2
|
+
import { timeoutSignal } from './util.js';
|
|
2
3
|
/**
|
|
3
4
|
* The OAuth device authorization grant (RFC 8628), for signing in a new
|
|
4
5
|
* device from a QR code a phone that is already signed in approves. Raw
|
|
@@ -11,6 +12,7 @@ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
|
11
12
|
export async function requestDeviceAuthorization(options, scope) {
|
|
12
13
|
const response = await options.fetch(`${options.issuer}/oauth/v2/device_authorization`, {
|
|
13
14
|
method: 'POST',
|
|
15
|
+
signal: timeoutSignal(),
|
|
14
16
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
15
17
|
body: new URLSearchParams({ client_id: options.clientId, scope }),
|
|
16
18
|
});
|
|
@@ -36,6 +38,7 @@ export async function requestDeviceAuthorization(options, scope) {
|
|
|
36
38
|
export async function pollDeviceToken(options, oidc, deviceCode) {
|
|
37
39
|
const response = await options.fetch(`${options.issuer}/oauth/v2/token`, {
|
|
38
40
|
method: 'POST',
|
|
41
|
+
signal: timeoutSignal(),
|
|
39
42
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
40
43
|
body: new URLSearchParams({
|
|
41
44
|
grant_type: DEVICE_GRANT_TYPE,
|
package/dist/redirect.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
/** A path on this app, or the fallback. Refuses absolute, scheme-relative and backslash forms. */
|
|
2
2
|
export declare function safeNextPath(next: string | null | undefined, fallback: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* An email address fit to send as the OIDC `login_hint`: at most 254
|
|
5
|
+
* characters, one `@`, a dotted domain, no whitespace or control characters.
|
|
6
|
+
* Anything else is `undefined` -- a bad hint is dropped, never a failure,
|
|
7
|
+
* because the person can still type their address at the issuer.
|
|
8
|
+
*/
|
|
9
|
+
export declare function safeLoginHint(raw: string | null | undefined): string | undefined;
|
package/dist/redirect.js
CHANGED
|
@@ -21,3 +21,14 @@ export function safeNextPath(next, fallback) {
|
|
|
21
21
|
}
|
|
22
22
|
return path;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* An email address fit to send as the OIDC `login_hint`: at most 254
|
|
26
|
+
* characters, one `@`, a dotted domain, no whitespace or control characters.
|
|
27
|
+
* Anything else is `undefined` -- a bad hint is dropped, never a failure,
|
|
28
|
+
* because the person can still type their address at the issuer.
|
|
29
|
+
*/
|
|
30
|
+
export function safeLoginHint(raw) {
|
|
31
|
+
if (typeof raw !== 'string' || raw.length > 254)
|
|
32
|
+
return undefined;
|
|
33
|
+
return /^[^\s@\p{Cc}]+@[^\s@\p{Cc}]+\.[^\s@\p{Cc}]+$/u.test(raw) ? raw : undefined;
|
|
34
|
+
}
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Auth, User } from './auth.js';
|
|
2
|
+
import type { NextAuth } from './next.js';
|
|
2
3
|
export declare function setSessionUser(user: User | null): void;
|
|
3
4
|
/** A User shaped like the SDK's, for `setSessionUser`. */
|
|
4
5
|
export declare function testUser(id: string, overrides?: Partial<User>): User;
|
|
5
6
|
export declare function getUser(): Promise<User | null>;
|
|
6
7
|
export declare function requireUser(_next?: string): Promise<User>;
|
|
8
|
+
export declare const requireFreshUser: NextAuth['requireFreshUser'];
|
|
7
9
|
export declare const handlers: () => never;
|
|
8
10
|
export declare const proxy: () => never;
|
|
9
11
|
export declare const authRequest: () => never;
|
|
@@ -13,6 +15,24 @@ export declare const requestPasswordReset: () => never;
|
|
|
13
15
|
export declare const resetPassword: () => never;
|
|
14
16
|
export declare const sendLink: () => never;
|
|
15
17
|
export declare const resendVerification: () => never;
|
|
18
|
+
export declare const setNewPassword: () => never;
|
|
19
|
+
export declare const invite: () => never;
|
|
20
|
+
export declare const people: () => never;
|
|
21
|
+
export declare const setPersonActive: () => never;
|
|
22
|
+
export declare const adminResetPassword: () => never;
|
|
23
|
+
export declare const sessions: () => never;
|
|
24
|
+
export declare const endSession: () => never;
|
|
25
|
+
export declare const startPasskeyRegistration: () => never;
|
|
26
|
+
export declare const finishPasskeyRegistration: () => never;
|
|
27
|
+
export declare const startEmailChange: () => never;
|
|
28
|
+
export declare const completeEmailChange: () => never;
|
|
29
|
+
export declare const changePassword: () => never;
|
|
30
|
+
export declare const passkeys: () => never;
|
|
31
|
+
export declare const deletePasskey: () => never;
|
|
32
|
+
export declare const startPasskeySignIn: () => never;
|
|
33
|
+
export declare const finishPasskeySignIn: () => never;
|
|
34
|
+
export declare const startQrSignIn: () => never;
|
|
35
|
+
export declare const pollQrSignIn: () => never;
|
|
16
36
|
/**
|
|
17
37
|
* The core SDK instance, as far as a device sign-in module reaches into it.
|
|
18
38
|
*
|
|
@@ -29,3 +49,9 @@ export declare const sentLinks: {
|
|
|
29
49
|
export declare function setFollowLinkResult(result: unknown): void;
|
|
30
50
|
export declare function setCookieUser(user: User | null): void;
|
|
31
51
|
export declare const auth: Auth;
|
|
52
|
+
/**
|
|
53
|
+
* Every member of `NextAuth`, so the compiler fails here when the real
|
|
54
|
+
* surface gains one this stub lacks. `export const { getUser } = nextAuthStub`
|
|
55
|
+
* is the same as importing the named exports above.
|
|
56
|
+
*/
|
|
57
|
+
export declare const nextAuthStub: NextAuth;
|
package/dist/testing.js
CHANGED
|
@@ -39,6 +39,7 @@ export async function requireUser(_next) {
|
|
|
39
39
|
throw new Error('requireUser: no session set; call setSessionUser first');
|
|
40
40
|
return current;
|
|
41
41
|
}
|
|
42
|
+
export const requireFreshUser = async () => requireUser();
|
|
42
43
|
function unavailable(name) {
|
|
43
44
|
return () => {
|
|
44
45
|
throw new Error(`${name} is not available in the integration tier`);
|
|
@@ -53,6 +54,24 @@ export const requestPasswordReset = unavailable('requestPasswordReset');
|
|
|
53
54
|
export const resetPassword = unavailable('resetPassword');
|
|
54
55
|
export const sendLink = unavailable('sendLink');
|
|
55
56
|
export const resendVerification = unavailable('resendVerification');
|
|
57
|
+
export const setNewPassword = unavailable('setNewPassword');
|
|
58
|
+
export const invite = unavailable('invite');
|
|
59
|
+
export const people = unavailable('people');
|
|
60
|
+
export const setPersonActive = unavailable('setPersonActive');
|
|
61
|
+
export const adminResetPassword = unavailable('adminResetPassword');
|
|
62
|
+
export const sessions = unavailable('sessions');
|
|
63
|
+
export const endSession = unavailable('endSession');
|
|
64
|
+
export const startPasskeyRegistration = unavailable('startPasskeyRegistration');
|
|
65
|
+
export const finishPasskeyRegistration = unavailable('finishPasskeyRegistration');
|
|
66
|
+
export const startEmailChange = unavailable('startEmailChange');
|
|
67
|
+
export const completeEmailChange = unavailable('completeEmailChange');
|
|
68
|
+
export const changePassword = unavailable('changePassword');
|
|
69
|
+
export const passkeys = unavailable('passkeys');
|
|
70
|
+
export const deletePasskey = unavailable('deletePasskey');
|
|
71
|
+
export const startPasskeySignIn = unavailable('startPasskeySignIn');
|
|
72
|
+
export const finishPasskeySignIn = unavailable('finishPasskeySignIn');
|
|
73
|
+
export const startQrSignIn = unavailable('startQrSignIn');
|
|
74
|
+
export const pollQrSignIn = unavailable('pollQrSignIn');
|
|
56
75
|
/**
|
|
57
76
|
* The core SDK instance, as far as a device sign-in module reaches into it.
|
|
58
77
|
*
|
|
@@ -79,3 +98,41 @@ export const auth = {
|
|
|
79
98
|
followLink: async () => linkResult,
|
|
80
99
|
readCookie: async () => ({ user: cookieUser, cookies: [] }),
|
|
81
100
|
};
|
|
101
|
+
/**
|
|
102
|
+
* Every member of `NextAuth`, so the compiler fails here when the real
|
|
103
|
+
* surface gains one this stub lacks. `export const { getUser } = nextAuthStub`
|
|
104
|
+
* is the same as importing the named exports above.
|
|
105
|
+
*/
|
|
106
|
+
export const nextAuthStub = {
|
|
107
|
+
auth,
|
|
108
|
+
handlers: handlers,
|
|
109
|
+
proxy,
|
|
110
|
+
getUser,
|
|
111
|
+
requireUser,
|
|
112
|
+
requireFreshUser,
|
|
113
|
+
authRequest,
|
|
114
|
+
signIn,
|
|
115
|
+
setNewPassword,
|
|
116
|
+
signUp,
|
|
117
|
+
resendVerification,
|
|
118
|
+
requestPasswordReset,
|
|
119
|
+
sendLink,
|
|
120
|
+
invite,
|
|
121
|
+
people,
|
|
122
|
+
setPersonActive,
|
|
123
|
+
adminResetPassword,
|
|
124
|
+
sessions,
|
|
125
|
+
endSession,
|
|
126
|
+
resetPassword,
|
|
127
|
+
startPasskeyRegistration,
|
|
128
|
+
finishPasskeyRegistration,
|
|
129
|
+
startEmailChange,
|
|
130
|
+
completeEmailChange,
|
|
131
|
+
changePassword,
|
|
132
|
+
passkeys,
|
|
133
|
+
deletePasskey,
|
|
134
|
+
startPasskeySignIn,
|
|
135
|
+
finishPasskeySignIn,
|
|
136
|
+
startQrSignIn,
|
|
137
|
+
pollQrSignIn,
|
|
138
|
+
};
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Helpers shared by the server and browser entry points. No Node-only imports: the browser bundle uses this file too. */
|
|
2
|
+
/** How long an SDK request to the broker or issuer may take before it is abandoned. */
|
|
3
|
+
export declare const FETCH_TIMEOUT_MS = 15000;
|
|
4
|
+
/** `signal` if the caller brought one, otherwise a 15 second timeout. */
|
|
5
|
+
export declare function timeoutSignal(signal?: AbortSignal): AbortSignal;
|
|
6
|
+
/** Bytes as unpadded base64url. */
|
|
7
|
+
export declare function base64url(bytes: ArrayBuffer | Uint8Array): string;
|
|
8
|
+
/** The payload of a JWT, read without verifying it. Only for tokens that were verified, or came straight from the token endpoint. */
|
|
9
|
+
export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
|
|
10
|
+
/** The `exp` claim of an ID token, or null. */
|
|
11
|
+
export declare function expiryOf(idToken: string): number | null;
|
|
12
|
+
/** The `iat` claim of an ID token, or null. */
|
|
13
|
+
export declare function issuedAtOf(idToken: string): number | null;
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Helpers shared by the server and browser entry points. No Node-only imports: the browser bundle uses this file too. */
|
|
2
|
+
/** How long an SDK request to the broker or issuer may take before it is abandoned. */
|
|
3
|
+
export const FETCH_TIMEOUT_MS = 15_000;
|
|
4
|
+
/** `signal` if the caller brought one, otherwise a 15 second timeout. */
|
|
5
|
+
export function timeoutSignal(signal) {
|
|
6
|
+
return signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
7
|
+
}
|
|
8
|
+
/** Bytes as unpadded base64url. */
|
|
9
|
+
export function base64url(bytes) {
|
|
10
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
11
|
+
return btoa(String.fromCharCode(...view))
|
|
12
|
+
.replaceAll('+', '-')
|
|
13
|
+
.replaceAll('/', '_')
|
|
14
|
+
.replace(/=+$/, '');
|
|
15
|
+
}
|
|
16
|
+
/** The payload of a JWT, read without verifying it. Only for tokens that were verified, or came straight from the token endpoint. */
|
|
17
|
+
export function decodeJwtPayload(jwt) {
|
|
18
|
+
try {
|
|
19
|
+
const part = jwt.split('.')[1];
|
|
20
|
+
if (!part)
|
|
21
|
+
return null;
|
|
22
|
+
const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
23
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
24
|
+
const json = JSON.parse(atob(padded));
|
|
25
|
+
return json && typeof json === 'object' && !Array.isArray(json)
|
|
26
|
+
? json
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The `exp` claim of an ID token, or null. */
|
|
34
|
+
export function expiryOf(idToken) {
|
|
35
|
+
const exp = decodeJwtPayload(idToken)?.exp;
|
|
36
|
+
return typeof exp === 'number' ? exp : null;
|
|
37
|
+
}
|
|
38
|
+
// Age, not time left: a disabled or revoked account must not ride out a
|
|
39
|
+
// long-lived token just because it still has plenty of time on the clock.
|
|
40
|
+
/** The `iat` claim of an ID token, or null. */
|
|
41
|
+
export function issuedAtOf(idToken) {
|
|
42
|
+
const iat = decodeJwtPayload(idToken)?.iat;
|
|
43
|
+
return typeof iat === 'number' ? iat : null;
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Sign in against a self-hosted ZITADEL issuer: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|