@wtfalch/auth 0.20.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/auth.d.ts +26 -0
- package/dist/auth.js +37 -0
- package/dist/broker.d.ts +9 -0
- package/dist/broker.js +4 -0
- package/dist/browser.js +64 -3
- package/dist/config.d.ts +15 -0
- package/dist/config.js +26 -0
- package/dist/cookies.d.ts +3 -1
- package/dist/cookies.js +22 -2
- package/dist/index.d.ts +1 -0
- package/dist/names.d.ts +8 -0
- package/dist/names.js +11 -3
- package/dist/namespace-server.js +2 -2
- package/dist/namespace-session.js +1 -1
- package/dist/person-token.d.ts +61 -0
- package/dist/person-token.js +191 -0
- package/dist/qr-door-scene.d.ts +66 -0
- package/dist/qr-door-scene.js +404 -0
- package/dist/qr-door.d.ts +15 -0
- package/dist/qr-door.js +71 -0
- package/package.json +18 -3
package/README.md
CHANGED
|
@@ -257,7 +257,7 @@ refresh token to begin with.
|
|
|
257
257
|
| `appUrl` | This app's origin. Every URL the SDK builds starts here, never from the request's `Host`. |
|
|
258
258
|
| `clientId` | The OIDC client for this origin. Each URL an app runs at is its own client, because the issuer holds one login URL per application. |
|
|
259
259
|
| `organizationId` | Scopes every sign-in, and is checked on every token. |
|
|
260
|
-
| `cookieSecret` | 32 bytes, `openssl rand -base64 32`.
|
|
260
|
+
| `cookieSecret` | 32 bytes, `openssl rand -base64 32`. **Each app needs its own** -- see below. |
|
|
261
261
|
| `appKey` | This app's key for the sign-in service. Needed for anything that checks or creates a credential. |
|
|
262
262
|
|
|
263
263
|
Values are read on first use rather than at import, so `next build` needs none
|
|
@@ -269,6 +269,13 @@ unset: the error waits until something actually reaches for the key. A secret
|
|
|
269
269
|
that is *supplied and wrong* still throws at startup, because that is a typo
|
|
270
270
|
rather than a deployment that does not need one.
|
|
271
271
|
|
|
272
|
+
**Each app's `cookieSecret` must be its own, not shared with another app.**
|
|
273
|
+
The AES key `cookies.ts` actually encrypts and decrypts with is HKDF-SHA256
|
|
274
|
+
derived from `cookieSecret` using a fixed info string common to every
|
|
275
|
+
consumer of this package -- two apps given the same `cookieSecret` would
|
|
276
|
+
derive the identical key and could open each other's session cookies.
|
|
277
|
+
Foundry already mints a distinct secret per app.
|
|
278
|
+
|
|
272
279
|
## What the app key can do
|
|
273
280
|
|
|
274
281
|
It reaches the sign-in service, which holds ZITADEL's login client. Scoped to
|
package/dist/auth.d.ts
CHANGED
|
@@ -251,6 +251,18 @@ export interface Auth {
|
|
|
251
251
|
}, requestHeaders?: Headers): Promise<SignedIn<SignInError> | (PasswordChangeRequired & {
|
|
252
252
|
message: string;
|
|
253
253
|
})>;
|
|
254
|
+
/**
|
|
255
|
+
* `signUp` as data, run over the same server-started transaction as
|
|
256
|
+
* `signInWithPassword` -- for a caller with no browser to carry through
|
|
257
|
+
* the OIDC redirect. Unlike `signUp`, which needs an `authRequestId` a
|
|
258
|
+
* browser flow already started with `start()`, this starts and finishes
|
|
259
|
+
* its own transaction, exactly as `signInWithPassword` does. Same two
|
|
260
|
+
* checks apply: an `ok: true` with an empty `cookies` means hosted
|
|
261
|
+
* login, and the cookie name is fixed.
|
|
262
|
+
*/
|
|
263
|
+
signUpWithPassword(input: {
|
|
264
|
+
next?: string;
|
|
265
|
+
} & NewUser, requestHeaders?: Headers): Promise<SignedIn<SignUpError>>;
|
|
254
266
|
/** Emails a fresh verification link. Says nothing about whether the address exists. */
|
|
255
267
|
resendVerification(email: string, next?: string): Promise<void>;
|
|
256
268
|
/** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
|
|
@@ -280,6 +292,20 @@ export interface Auth {
|
|
|
280
292
|
invited: boolean;
|
|
281
293
|
personalEmailStored: boolean;
|
|
282
294
|
}>;
|
|
295
|
+
/**
|
|
296
|
+
* A company's own test user: a new person, marked in the sign-in service
|
|
297
|
+
* so nothing it does is read as a real customer. Refused for an address
|
|
298
|
+
* that already has a user, and for an entry that acts as another (stage,
|
|
299
|
+
* a laptop) -- a test user is made from production's entry.
|
|
300
|
+
*/
|
|
301
|
+
createTestUser(input: {
|
|
302
|
+
email: string;
|
|
303
|
+
givenName: string;
|
|
304
|
+
familyName: string;
|
|
305
|
+
username?: string;
|
|
306
|
+
}): Promise<{
|
|
307
|
+
userId: string;
|
|
308
|
+
}>;
|
|
283
309
|
handle(request: Request): Promise<Response>;
|
|
284
310
|
/**
|
|
285
311
|
* `refresh: false` for callers that cannot set cookies; they get a user only while the token is valid.
|
package/dist/auth.js
CHANGED
|
@@ -539,6 +539,11 @@ export function createAuth(input) {
|
|
|
539
539
|
emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
|
|
540
540
|
return result;
|
|
541
541
|
};
|
|
542
|
+
const createTestUser = async (input) => {
|
|
543
|
+
const result = await broker().createTestUser(input);
|
|
544
|
+
emit({ type: 'createTestUser', email: input.email, userId: result.userId });
|
|
545
|
+
return result;
|
|
546
|
+
};
|
|
542
547
|
/**
|
|
543
548
|
* An admin's reset for somebody in this app's own organisation whom
|
|
544
549
|
* `requestPasswordReset` cannot otherwise get back in -- nobody at the
|
|
@@ -670,6 +675,36 @@ export function createAuth(input) {
|
|
|
670
675
|
return { ok: false, error: code, message };
|
|
671
676
|
}
|
|
672
677
|
};
|
|
678
|
+
const signUpWithPassword = async ({ next, ...user }, requestHeaders) => {
|
|
679
|
+
try {
|
|
680
|
+
const flow = await serverFlow();
|
|
681
|
+
const completed = await finishOrHandoff(() => broker().signUp({ authRequestId: flow.id, ...user }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
682
|
+
emit({ type: 'signUp', email: user.email });
|
|
683
|
+
return { ok: true, ...completed };
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
if (error instanceof BrokerError && error.error === 'email_taken') {
|
|
687
|
+
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
688
|
+
}
|
|
689
|
+
if (error instanceof BrokerError && error.error === 'registration_closed') {
|
|
690
|
+
return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
|
|
691
|
+
}
|
|
692
|
+
// Both carry a sentence about the name, for the form to show as it is.
|
|
693
|
+
if (error instanceof BrokerError && error.error === 'username_taken') {
|
|
694
|
+
return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
|
|
695
|
+
}
|
|
696
|
+
if (error instanceof BrokerError && error.error === 'username_invalid') {
|
|
697
|
+
return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
|
|
698
|
+
}
|
|
699
|
+
if (error instanceof BrokerError && error.error === 'request') {
|
|
700
|
+
return { ok: false, error: 'request', message: error.detail ?? error.error };
|
|
701
|
+
}
|
|
702
|
+
if (error instanceof BrokerError && error.status < 500) {
|
|
703
|
+
return { ok: false, error: 'invalid', message: error.detail ?? error.error };
|
|
704
|
+
}
|
|
705
|
+
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
706
|
+
}
|
|
707
|
+
};
|
|
673
708
|
const signUp = async ({ authRequestId, ...user }) => {
|
|
674
709
|
try {
|
|
675
710
|
const redirectTo = await broker().signUp({ authRequestId, ...user });
|
|
@@ -936,6 +971,7 @@ export function createAuth(input) {
|
|
|
936
971
|
resetPassword,
|
|
937
972
|
sendLink,
|
|
938
973
|
invite,
|
|
974
|
+
createTestUser,
|
|
939
975
|
handle,
|
|
940
976
|
read,
|
|
941
977
|
readCookie,
|
|
@@ -954,6 +990,7 @@ export function createAuth(input) {
|
|
|
954
990
|
setNewPassword,
|
|
955
991
|
setNewPasswordHeadless,
|
|
956
992
|
signInWithPassword,
|
|
993
|
+
signUpWithPassword,
|
|
957
994
|
signUp,
|
|
958
995
|
startPasskeyRegistration,
|
|
959
996
|
finishPasskeyRegistration,
|
package/dist/broker.d.ts
CHANGED
|
@@ -137,6 +137,15 @@ export declare class Broker {
|
|
|
137
137
|
personalEmailStored: boolean;
|
|
138
138
|
}>;
|
|
139
139
|
sendLink(email: string, next: string): Promise<unknown>;
|
|
140
|
+
/** A company's own test user, marked in the sign-in service. See `Auth['createTestUser']`. */
|
|
141
|
+
createTestUser(input: {
|
|
142
|
+
email: string;
|
|
143
|
+
givenName: string;
|
|
144
|
+
familyName: string;
|
|
145
|
+
username?: string;
|
|
146
|
+
}): Promise<{
|
|
147
|
+
userId: string;
|
|
148
|
+
}>;
|
|
140
149
|
sendReset(email: string, next: string): Promise<unknown>;
|
|
141
150
|
sendVerification(email: string, next: string): Promise<unknown>;
|
|
142
151
|
/**
|
package/dist/broker.js
CHANGED
|
@@ -118,6 +118,10 @@ export class Broker {
|
|
|
118
118
|
sendLink(email, next) {
|
|
119
119
|
return this.call('POST', '/link/send', { email, next });
|
|
120
120
|
}
|
|
121
|
+
/** A company's own test user, marked in the sign-in service. See `Auth['createTestUser']`. */
|
|
122
|
+
createTestUser(input) {
|
|
123
|
+
return this.call('POST', '/test-users', input);
|
|
124
|
+
}
|
|
121
125
|
sendReset(email, next) {
|
|
122
126
|
return this.call('POST', '/reset/send', { email, next });
|
|
123
127
|
}
|
package/dist/browser.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createNamespaceBrowserAuth } from './namespace-browser.js';
|
|
2
2
|
const TOKEN = 'wtfalch.auth.token';
|
|
3
3
|
const VERIFIER = 'wtfalch.auth.verifier';
|
|
4
|
+
/** CSRF protection on the callback: checked before the code exchange, never persisted past it. */
|
|
5
|
+
const STATE = 'wtfalch.auth.state';
|
|
6
|
+
/** Replay protection on the ID token: checked against its own claim after the exchange, never persisted past it. */
|
|
7
|
+
const NONCE = 'wtfalch.auth.nonce';
|
|
4
8
|
const RETURN = 'wtfalch.auth.return';
|
|
5
9
|
/** Set before a silent attempt leaves, so a refusal cannot become a loop. */
|
|
6
10
|
const TRIED = 'wtfalch.auth.silent-tried';
|
|
@@ -44,9 +48,26 @@ async function challengeFor(verifier) {
|
|
|
44
48
|
}
|
|
45
49
|
function verifier() {
|
|
46
50
|
// 32 bytes is RFC 7636's recommendation; base64url of it is 43 characters,
|
|
47
|
-
// inside the 43..128 the spec allows.
|
|
51
|
+
// inside the 43..128 the spec allows. Reused below for `state` and `nonce`
|
|
52
|
+
// too -- a random, unguessable token is exactly what each of the three is.
|
|
48
53
|
return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
49
54
|
}
|
|
55
|
+
/** The payload of a JWT, read without verifying its signature -- the ID token here comes straight
|
|
56
|
+
* from the token endpoint over TLS, which OIDC Core 1.0 3.1.3.7 allows in place of a signature
|
|
57
|
+
* check for the authorization code flow. Never used to trust a claim from anywhere else. */
|
|
58
|
+
function decodeJwtPayload(jwt) {
|
|
59
|
+
try {
|
|
60
|
+
const part = jwt.split('.')[1];
|
|
61
|
+
if (!part)
|
|
62
|
+
return null;
|
|
63
|
+
const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
64
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
65
|
+
return JSON.parse(atob(padded));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
50
71
|
function publicSession(session) {
|
|
51
72
|
const { refreshToken, ...rest } = session;
|
|
52
73
|
return rest;
|
|
@@ -86,6 +107,8 @@ export function createBrowserAuth(options) {
|
|
|
86
107
|
const key = (name) => (context ? `wtfalch.auth.browser.v1:${context}:${name}` : name);
|
|
87
108
|
const tokenKey = key(TOKEN);
|
|
88
109
|
const verifierKey = key(VERIFIER);
|
|
110
|
+
const stateKey = key(STATE);
|
|
111
|
+
const nonceKey = key(NONCE);
|
|
89
112
|
const returnKey = key(RETURN);
|
|
90
113
|
const triedKey = key(TRIED);
|
|
91
114
|
let contexts = coordinators.get(store);
|
|
@@ -117,6 +140,8 @@ export function createBrowserAuth(options) {
|
|
|
117
140
|
const authorize = async (prompt, returnTo) => {
|
|
118
141
|
const generation = ++coordinator.generation;
|
|
119
142
|
const proof = verifier();
|
|
143
|
+
const state = verifier();
|
|
144
|
+
const nonce = verifier();
|
|
120
145
|
await mutate(async () => {
|
|
121
146
|
if (!current(generation))
|
|
122
147
|
return;
|
|
@@ -127,6 +152,12 @@ export function createBrowserAuth(options) {
|
|
|
127
152
|
if (!current(generation))
|
|
128
153
|
return;
|
|
129
154
|
await store.set(verifierKey, proof);
|
|
155
|
+
if (!current(generation))
|
|
156
|
+
return;
|
|
157
|
+
await store.set(stateKey, state);
|
|
158
|
+
if (!current(generation))
|
|
159
|
+
return;
|
|
160
|
+
await store.set(nonceKey, nonce);
|
|
130
161
|
if (!current(generation))
|
|
131
162
|
return;
|
|
132
163
|
await store.set(returnKey, returnTo);
|
|
@@ -140,6 +171,8 @@ export function createBrowserAuth(options) {
|
|
|
140
171
|
url.searchParams.set('scope', scope);
|
|
141
172
|
url.searchParams.set('code_challenge', await challengeFor(proof));
|
|
142
173
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
174
|
+
url.searchParams.set('state', state);
|
|
175
|
+
url.searchParams.set('nonce', nonce);
|
|
143
176
|
if (prompt)
|
|
144
177
|
url.searchParams.set('prompt', prompt);
|
|
145
178
|
if (!current(generation))
|
|
@@ -240,16 +273,27 @@ export function createBrowserAuth(options) {
|
|
|
240
273
|
if (!current(generation))
|
|
241
274
|
return null;
|
|
242
275
|
const proof = store.get(verifierKey);
|
|
276
|
+
const expectedState = store.get(stateKey);
|
|
277
|
+
const expectedNonce = store.get(nonceKey);
|
|
243
278
|
const returnTo = store.get(returnKey) ?? '/';
|
|
279
|
+
// Cleared together, unconditionally, before any check below runs --
|
|
280
|
+
// a mismatch is refused on its own terms, not merely by virtue of a
|
|
281
|
+
// transaction that cannot be replayed a second time either way.
|
|
244
282
|
await store.drop(verifierKey);
|
|
283
|
+
if (!current(generation))
|
|
284
|
+
return null;
|
|
285
|
+
await store.drop(stateKey);
|
|
286
|
+
if (!current(generation))
|
|
287
|
+
return null;
|
|
288
|
+
await store.drop(nonceKey);
|
|
245
289
|
if (!current(generation))
|
|
246
290
|
return null;
|
|
247
291
|
await store.drop(returnKey);
|
|
248
|
-
return { proof, returnTo };
|
|
292
|
+
return { proof, expectedState, expectedNonce, returnTo };
|
|
249
293
|
});
|
|
250
294
|
if (!transaction || !current(generation))
|
|
251
295
|
return cancelled();
|
|
252
|
-
const { proof, returnTo } = transaction;
|
|
296
|
+
const { proof, expectedState, expectedNonce, returnTo } = transaction;
|
|
253
297
|
const issuerError = params.get('error');
|
|
254
298
|
if (issuerError) {
|
|
255
299
|
if (['login_required', 'interaction_required', 'consent_required'].includes(issuerError)) {
|
|
@@ -257,6 +301,13 @@ export function createBrowserAuth(options) {
|
|
|
257
301
|
}
|
|
258
302
|
return { kind: 'error', error: params.get('error_description') ?? issuerError };
|
|
259
303
|
}
|
|
304
|
+
// Checked before the code exchange: a callback whose `state` does not
|
|
305
|
+
// match this tab's own sign-in is refused before it ever reaches the
|
|
306
|
+
// issuer, exactly as a cross-site request forgery on this callback
|
|
307
|
+
// would need it not to be.
|
|
308
|
+
if (params.get('state') !== expectedState) {
|
|
309
|
+
return { kind: 'error', error: 'the callback state does not match this sign-in' };
|
|
310
|
+
}
|
|
260
311
|
const code = params.get('code');
|
|
261
312
|
if (!code)
|
|
262
313
|
return { kind: 'error', error: 'the issuer sent no code' };
|
|
@@ -293,6 +344,14 @@ export function createBrowserAuth(options) {
|
|
|
293
344
|
return cancelled();
|
|
294
345
|
if (!token.access_token)
|
|
295
346
|
return { kind: 'error', error: 'the issuer returned no access token' };
|
|
347
|
+
// Checked against the ID token's own claim, after the exchange: a
|
|
348
|
+
// code this flow never requested cannot be replayed into a session
|
|
349
|
+
// here even if it were otherwise accepted, because the token minted
|
|
350
|
+
// for it carries a different nonce.
|
|
351
|
+
const idTokenClaims = token.id_token ? decodeJwtPayload(token.id_token) : null;
|
|
352
|
+
if (idTokenClaims?.nonce !== expectedNonce) {
|
|
353
|
+
return { kind: 'error', error: 'the ID token nonce does not match this sign-in' };
|
|
354
|
+
}
|
|
296
355
|
const session = {
|
|
297
356
|
accessToken: token.access_token,
|
|
298
357
|
expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
|
|
@@ -326,6 +385,8 @@ export function createBrowserAuth(options) {
|
|
|
326
385
|
await mutate(async () => {
|
|
327
386
|
await store.drop(tokenKey);
|
|
328
387
|
await store.drop(verifierKey);
|
|
388
|
+
await store.drop(stateKey);
|
|
389
|
+
await store.drop(nonceKey);
|
|
329
390
|
await store.drop(returnKey);
|
|
330
391
|
await store.set(triedKey, '1');
|
|
331
392
|
});
|
package/dist/config.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export type AuthEvent = {
|
|
|
24
24
|
| {
|
|
25
25
|
type: 'adminResetPassword';
|
|
26
26
|
userId: string;
|
|
27
|
+
} | {
|
|
28
|
+
type: 'createTestUser';
|
|
29
|
+
email: string;
|
|
30
|
+
userId: string;
|
|
27
31
|
};
|
|
28
32
|
/**
|
|
29
33
|
* Why `accessToken` refused: no session (no cookie in cookie mode, no live
|
|
@@ -158,3 +162,14 @@ export interface ResolvedOptions {
|
|
|
158
162
|
onAuthEvent: ((event: AuthEvent) => void) | null;
|
|
159
163
|
}
|
|
160
164
|
export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
|
|
165
|
+
/**
|
|
166
|
+
* The actual AES-256 key `cookies.ts` encrypts and decrypts with -- HKDF-SHA256
|
|
167
|
+
* over the decoded `cookieSecret`, never the secret's bytes directly. A secret an
|
|
168
|
+
* operator can read (an env var, a value pasted into a terminal) is weaker key
|
|
169
|
+
* material than a key meant to be used as one; HKDF's job is exactly this gap.
|
|
170
|
+
* No fallback to the raw key: decided as a hard cut (auth#255), so the only
|
|
171
|
+
* way a cookie sealed before this shipped keeps validating is if it already
|
|
172
|
+
* carries no more than its own session's worth of trust -- which is what every
|
|
173
|
+
* cookie here already assumes of `sessionMaxAge`/`TRANSACTION_TTL`/etc.
|
|
174
|
+
*/
|
|
175
|
+
export declare function deriveCookieKey(secret: Uint8Array): Promise<Uint8Array>;
|
package/dist/config.js
CHANGED
|
@@ -105,3 +105,29 @@ function decodeKey(secret) {
|
|
|
105
105
|
}
|
|
106
106
|
return bytes;
|
|
107
107
|
}
|
|
108
|
+
// Fixed, not random: every server sealing or opening a cookie must derive
|
|
109
|
+
// the same AES key from the same `cookieSecret`, so there is nowhere to
|
|
110
|
+
// store a per-process salt even if one were generated. `COOKIE_KEY_INFO`
|
|
111
|
+
// scopes the derivation to this one use of the secret, the same way a
|
|
112
|
+
// derived key's `info` is meant to -- a future second use of `cookieSecret`
|
|
113
|
+
// (there is none today) would derive under a different info string and
|
|
114
|
+
// never collide with this key.
|
|
115
|
+
const COOKIE_KEY_SALT = new TextEncoder().encode('@wtfalch/auth cookie salt v1');
|
|
116
|
+
const COOKIE_KEY_INFO = new TextEncoder().encode('@wtfalch/auth cookie v1');
|
|
117
|
+
/**
|
|
118
|
+
* The actual AES-256 key `cookies.ts` encrypts and decrypts with -- HKDF-SHA256
|
|
119
|
+
* over the decoded `cookieSecret`, never the secret's bytes directly. A secret an
|
|
120
|
+
* operator can read (an env var, a value pasted into a terminal) is weaker key
|
|
121
|
+
* material than a key meant to be used as one; HKDF's job is exactly this gap.
|
|
122
|
+
* No fallback to the raw key: decided as a hard cut (auth#255), so the only
|
|
123
|
+
* way a cookie sealed before this shipped keeps validating is if it already
|
|
124
|
+
* carries no more than its own session's worth of trust -- which is what every
|
|
125
|
+
* cookie here already assumes of `sessionMaxAge`/`TRANSACTION_TTL`/etc.
|
|
126
|
+
*/
|
|
127
|
+
export async function deriveCookieKey(secret) {
|
|
128
|
+
const keyMaterial = await crypto.subtle.importKey('raw', secret, 'HKDF', false, [
|
|
129
|
+
'deriveBits',
|
|
130
|
+
]);
|
|
131
|
+
const bits = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: COOKIE_KEY_SALT, info: COOKIE_KEY_INFO }, keyMaterial, 256);
|
|
132
|
+
return new Uint8Array(bits);
|
|
133
|
+
}
|
package/dist/cookies.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type JWTPayload } from 'jose';
|
|
2
|
-
import type
|
|
2
|
+
import { type ResolvedOptions } from './config.js';
|
|
3
|
+
/** Exported for `namespace-server.ts`, which decrypts a transaction cookie itself to read its `ns` claim before any binding is chosen. */
|
|
4
|
+
export declare function cookieKeyFor(options: ResolvedOptions): Promise<Uint8Array>;
|
|
3
5
|
export interface SessionPayload extends JWTPayload {
|
|
4
6
|
idt: string;
|
|
5
7
|
rt?: string;
|
package/dist/cookies.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { EncryptJWT, jwtDecrypt } from 'jose';
|
|
2
|
+
import { deriveCookieKey } from './config.js';
|
|
2
3
|
import { AuthError } from './oidc.js';
|
|
4
|
+
// `options.cookieKey` decodes once per `ResolvedOptions` (config.ts) and is
|
|
5
|
+
// always the same `Uint8Array` reference after that, so keying the derived
|
|
6
|
+
// key's own cache off it means every seal/open after the first reuses it
|
|
7
|
+
// rather than re-running HKDF per call.
|
|
8
|
+
const derivedKeyCache = new WeakMap();
|
|
9
|
+
/** Exported for `namespace-server.ts`, which decrypts a transaction cookie itself to read its `ns` claim before any binding is chosen. */
|
|
10
|
+
export function cookieKeyFor(options) {
|
|
11
|
+
const raw = options.cookieKey;
|
|
12
|
+
let derived = derivedKeyCache.get(raw);
|
|
13
|
+
if (!derived) {
|
|
14
|
+
derived = deriveCookieKey(raw);
|
|
15
|
+
derivedKeyCache.set(raw, derived);
|
|
16
|
+
}
|
|
17
|
+
return derived;
|
|
18
|
+
}
|
|
3
19
|
const SESSION = 'wtfalch_auth';
|
|
4
20
|
const TRANSACTION = 'wtfalch_auth_tx';
|
|
5
21
|
const LINK = 'wtfalch_auth_link';
|
|
@@ -152,17 +168,21 @@ async function seal(options, kind, payload, ttl) {
|
|
|
152
168
|
.setAudience(await audience(options, kind))
|
|
153
169
|
.setIssuedAt()
|
|
154
170
|
.setExpirationTime(Math.floor(Date.now() / 1000) + ttl)
|
|
155
|
-
.encrypt(options
|
|
171
|
+
.encrypt(await cookieKeyFor(options));
|
|
156
172
|
}
|
|
157
173
|
async function open(options, kind, value) {
|
|
158
174
|
if (!value)
|
|
159
175
|
return null;
|
|
160
176
|
try {
|
|
161
|
-
const { payload } = await jwtDecrypt(value, options
|
|
177
|
+
const { payload } = await jwtDecrypt(value, await cookieKeyFor(options), {
|
|
162
178
|
issuer: options.appUrl.origin,
|
|
163
179
|
audience: await audience(options, kind),
|
|
164
180
|
contentEncryptionAlgorithms: ['A256GCM'],
|
|
165
181
|
keyManagementAlgorithms: ['dir'],
|
|
182
|
+
// Every cookie `seal` makes already sets `exp` (the call's own `ttl`),
|
|
183
|
+
// so requiring it here signs nobody out -- it only refuses the one
|
|
184
|
+
// thing a cookie with none could otherwise be: unbounded.
|
|
185
|
+
requiredClaims: ['exp'],
|
|
166
186
|
});
|
|
167
187
|
return payload;
|
|
168
188
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, ty
|
|
|
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
|
+
export type { PersonTokenConfig, PersonTokenVerifier } from './person-token.js';
|
|
5
6
|
export { BrokerError } from './broker.js';
|
|
6
7
|
export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
|
|
7
8
|
export { safeNextPath } from './redirect.js';
|
package/dist/names.d.ts
CHANGED
|
@@ -38,6 +38,14 @@ export interface NameRules {
|
|
|
38
38
|
pattern: RegExp;
|
|
39
39
|
}
|
|
40
40
|
export declare const DEFAULT_RULES: NameRules;
|
|
41
|
+
/**
|
|
42
|
+
* Lowercase letters with the digits read as letters and nothing else, runs
|
|
43
|
+
* collapsed. Every reading, since `1` may be an `i` or an `l`; the first is
|
|
44
|
+
* the one a list entry is stored under.
|
|
45
|
+
*/
|
|
46
|
+
export declare function flattenings(name: string): string[];
|
|
47
|
+
/** Every entry checkName holds back: the system names, the people's and the reserved-usernames list, once each. */
|
|
48
|
+
export declare function reservedNames(): readonly string[];
|
|
41
49
|
/** The refusal as a sentence the person can act on. */
|
|
42
50
|
export declare function explain(reason: Refusal, rules?: NameRules): string;
|
|
43
51
|
export declare function checkName(name: string, rules?: NameRules): Verdict;
|
package/dist/names.js
CHANGED
|
@@ -35,7 +35,10 @@ strictWords.addPhrase((phrase) => {
|
|
|
35
35
|
phrase.addWhitelistedTerm(term);
|
|
36
36
|
return phrase;
|
|
37
37
|
});
|
|
38
|
-
const strict = new RegExpMatcher({
|
|
38
|
+
const strict = new RegExpMatcher({
|
|
39
|
+
...strictWords.build(),
|
|
40
|
+
...englishRecommendedTransformers,
|
|
41
|
+
});
|
|
39
42
|
/** Only the words that are slurs wherever they appear, read with everything between the letters skipped. */
|
|
40
43
|
const looseWords = new DataSet();
|
|
41
44
|
for (const entry of [
|
|
@@ -84,7 +87,7 @@ const LETTERS = {
|
|
|
84
87
|
* collapsed. Every reading, since `1` may be an `i` or an `l`; the first is
|
|
85
88
|
* the one a list entry is stored under.
|
|
86
89
|
*/
|
|
87
|
-
function flattenings(name) {
|
|
90
|
+
export function flattenings(name) {
|
|
88
91
|
let forms = [''];
|
|
89
92
|
for (const char of name.toLowerCase()) {
|
|
90
93
|
const readings = /[a-z]/.test(char) ? [char] : (LETTERS[char] ?? []);
|
|
@@ -96,7 +99,12 @@ function flattenings(name) {
|
|
|
96
99
|
}
|
|
97
100
|
return [...new Set(forms.map((form) => form.replace(/(.)\1+/g, '$1')))];
|
|
98
101
|
}
|
|
99
|
-
const
|
|
102
|
+
const RESERVED = [...new Set([...reservedUsernames, ...SYSTEM, ...PEOPLE])];
|
|
103
|
+
const reserved = new Set(RESERVED.map((entry) => flattenings(entry)[0]));
|
|
104
|
+
/** Every entry checkName holds back: the system names, the people's and the reserved-usernames list, once each. */
|
|
105
|
+
export function reservedNames() {
|
|
106
|
+
return RESERVED;
|
|
107
|
+
}
|
|
100
108
|
/** The name in the shapes a word might hide in. */
|
|
101
109
|
function shapes(name) {
|
|
102
110
|
const parts = name.split(/[^A-Za-z0-9æøåÆØÅ]+/).filter(Boolean);
|
package/dist/namespace-server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jwtDecrypt } from 'jose';
|
|
2
2
|
import { resolveOptions } from './config.js';
|
|
3
|
-
import { cookieFrom, openTransaction, transactionCookieName } from './cookies.js';
|
|
3
|
+
import { cookieFrom, cookieKeyFor, openTransaction, transactionCookieName } from './cookies.js';
|
|
4
4
|
import { isWebService, parseNamespaceRegistry, } from './namespaces.js';
|
|
5
5
|
/** Resolve a shared callback from its authenticated transaction, never a URL
|
|
6
6
|
* namespace hint. The second open checks the entire current registry binding. */
|
|
@@ -46,7 +46,7 @@ export async function resolveNamespaceCallback(input) {
|
|
|
46
46
|
return null;
|
|
47
47
|
// No identity is returned based on this hint alone. It only chooses which
|
|
48
48
|
// current binding's audience to validate next, so decryption is bounded.
|
|
49
|
-
const { payload } = await jwtDecrypt(value, first
|
|
49
|
+
const { payload } = await jwtDecrypt(value, await cookieKeyFor(first), {
|
|
50
50
|
issuer: input.appOrigin,
|
|
51
51
|
requiredClaims: ['exp', 'iat', 'aud'],
|
|
52
52
|
contentEncryptionAlgorithms: ['A256GCM'],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sleep } from '@wtfalch/utils/datetime';
|
|
1
2
|
import * as client from 'openid-client';
|
|
2
3
|
import { BrokerError } from './broker.js';
|
|
3
4
|
import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
|
|
@@ -15,7 +16,6 @@ const LEASE_SECONDS = 60;
|
|
|
15
16
|
const WAIT_STEP_MS = 100;
|
|
16
17
|
const WAIT_STEPS = 50;
|
|
17
18
|
const now = () => Math.floor(Date.now() / 1000);
|
|
18
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
19
|
/**
|
|
20
20
|
* Server-side sessions for namespace mode. The cookie holds a random handle;
|
|
21
21
|
* the record holds the tokens. Refreshes are serialised across replicas by a
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies a signed-in person's own `@wtfalch/auth`/ZITADEL access token,
|
|
3
|
+
* server-side, for a service that wants to accept one directly rather than
|
|
4
|
+
* only a machine credential. Two token shapes, both checked:
|
|
5
|
+
*
|
|
6
|
+
* - A JWT (three base64url segments): `jwtVerify` against a remote JWKS
|
|
7
|
+
* built from the issuer's discovery document -- signature (RS256),
|
|
8
|
+
* issuer, audience, expiry. `jose`'s `createRemoteJWKSet` refetches on an
|
|
9
|
+
* unrecognised `kid` on its own, so key rotation needs nothing here
|
|
10
|
+
* beyond the cooldown below.
|
|
11
|
+
* - Anything else: RFC 7662 introspection against the issuer's
|
|
12
|
+
* `introspection_endpoint`, authenticating as this resource server with
|
|
13
|
+
* `client_secret_basic` (`introspectionClientId`/`Secret`), checking the
|
|
14
|
+
* response's `active`, `exp` and `aud`. Not attempted -- refused outright
|
|
15
|
+
* -- when either credential is unset, the same as an opaque token would
|
|
16
|
+
* have refused before introspection existed. This matters because some
|
|
17
|
+
* apps mint an *opaque* access token on purpose, and without this path no
|
|
18
|
+
* such token could ever reach a server that only checked JWTs.
|
|
19
|
+
*
|
|
20
|
+
* Neither path checks a fixed ZITADEL organisation: a caller can come from
|
|
21
|
+
* any organisation's app, so there is nothing fixed to compare against.
|
|
22
|
+
* This module only answers "is this a live token, and whose" -- membership
|
|
23
|
+
* and authorization are the caller's own concern, same as for a machine
|
|
24
|
+
* credential.
|
|
25
|
+
*
|
|
26
|
+
* A network failure or a non-2xx from either the discovery document or the
|
|
27
|
+
* introspection endpoint fails closed: `verify` returns `null`, exactly
|
|
28
|
+
* like a bad token, never treated as good. There is no second outcome for
|
|
29
|
+
* "could not check" versus "checked and refused" -- a caller has only one
|
|
30
|
+
* response to either, refuse.
|
|
31
|
+
*/
|
|
32
|
+
export interface PersonTokenVerifier {
|
|
33
|
+
/** The token's subject (`sub`), or null for anything that does not stand
|
|
34
|
+
* up: bad signature, wrong issuer/audience, expired, inactive, or
|
|
35
|
+
* unreachable. Refusals never say which. */
|
|
36
|
+
verify(accessToken: string): Promise<{
|
|
37
|
+
sub: string;
|
|
38
|
+
} | null>;
|
|
39
|
+
}
|
|
40
|
+
export interface PersonTokenConfig {
|
|
41
|
+
/** The issuer every token must have been minted by, e.g.
|
|
42
|
+
* `https://auth.wtfalch.dev`. */
|
|
43
|
+
issuer: string;
|
|
44
|
+
/** The ZITADEL project id a person's token must carry in `aud` --
|
|
45
|
+
* requested by whatever app mints the token (an `extraScopes` entry of
|
|
46
|
+
* `urn:zitadel:iam:org:project:id:<audience>:aud`). */
|
|
47
|
+
audience: string;
|
|
48
|
+
/** This resource server's own credentials at the issuer, for RFC 7662
|
|
49
|
+
* introspection of an opaque (non-JWT) access token --
|
|
50
|
+
* `client_secret_basic`. Both required together; either absent disables
|
|
51
|
+
* introspection and an opaque bearer is refused outright. */
|
|
52
|
+
introspectionClientId?: string;
|
|
53
|
+
introspectionClientSecret?: string;
|
|
54
|
+
/** Test seam; a live deployment never sets this. */
|
|
55
|
+
fetch?: typeof fetch;
|
|
56
|
+
/** jose's default 30s would keep rejecting a token signed with a freshly
|
|
57
|
+
* rotated key for that long; a test passes 0 so it need not sleep past a
|
|
58
|
+
* real cooldown. */
|
|
59
|
+
jwksCooldownMs?: number;
|
|
60
|
+
}
|
|
61
|
+
export declare function createPersonTokenVerifier(config: PersonTokenConfig): PersonTokenVerifier;
|