@wtfalch/auth 0.19.0 → 0.22.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 +17 -1
- package/dist/auth.d.ts +38 -0
- package/dist/auth.js +60 -4
- package/dist/broker.d.ts +22 -0
- package/dist/broker.js +8 -0
- package/dist/browser.js +64 -3
- package/dist/config.d.ts +20 -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/namespace-server.js +2 -2
- package/dist/namespace-session.js +1 -1
- package/dist/next.d.ts +4 -0
- package/dist/next.js +1 -0
- 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
|
@@ -94,6 +94,15 @@ Auth-request lookup failures lead to the configured error page with
|
|
|
94
94
|
`auth_error=request` or `auth_error=unavailable`. That page should show a
|
|
95
95
|
retry link; it must not automatically restart authorization.
|
|
96
96
|
|
|
97
|
+
When a sign-in fails after it started (reasons such as `state`, `unavailable`,
|
|
98
|
+
`exchange`, `denied`, `organization`, `cookie`), the error URL is
|
|
99
|
+
`<onError>?auth_error=<reason>&next=<path>`. `next` is the page the person came
|
|
100
|
+
for, always a path starting with one `/`, URL-encoded as a single parameter, and
|
|
101
|
+
present only when the sealed value is a safe same-origin path. An `onError` that
|
|
102
|
+
already has a query string keeps it. `expired` and `request` carry no `next`:
|
|
103
|
+
there is no transaction to read it from. Use it for the retry link
|
|
104
|
+
(`/auth/start?next=<path>`); a page that ignores it behaves as before.
|
|
105
|
+
|
|
97
106
|
## In a browser app
|
|
98
107
|
|
|
99
108
|
`@wtfalch/auth/browser` is the other half: a public PKCE client for an app that
|
|
@@ -248,7 +257,7 @@ refresh token to begin with.
|
|
|
248
257
|
| `appUrl` | This app's origin. Every URL the SDK builds starts here, never from the request's `Host`. |
|
|
249
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. |
|
|
250
259
|
| `organizationId` | Scopes every sign-in, and is checked on every token. |
|
|
251
|
-
| `cookieSecret` | 32 bytes, `openssl rand -base64 32`.
|
|
260
|
+
| `cookieSecret` | 32 bytes, `openssl rand -base64 32`. **Each app needs its own** -- see below. |
|
|
252
261
|
| `appKey` | This app's key for the sign-in service. Needed for anything that checks or creates a credential. |
|
|
253
262
|
|
|
254
263
|
Values are read on first use rather than at import, so `next build` needs none
|
|
@@ -260,6 +269,13 @@ unset: the error waits until something actually reaches for the key. A secret
|
|
|
260
269
|
that is *supplied and wrong* still throws at startup, because that is a typo
|
|
261
270
|
rather than a deployment that does not need one.
|
|
262
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
|
+
|
|
263
279
|
## What the app key can do
|
|
264
280
|
|
|
265
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. */
|
|
@@ -278,6 +290,21 @@ export interface Auth {
|
|
|
278
290
|
}): Promise<{
|
|
279
291
|
userId: string;
|
|
280
292
|
invited: boolean;
|
|
293
|
+
personalEmailStored: boolean;
|
|
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;
|
|
281
308
|
}>;
|
|
282
309
|
handle(request: Request): Promise<Response>;
|
|
283
310
|
/**
|
|
@@ -331,6 +358,17 @@ export interface Auth {
|
|
|
331
358
|
userId: string;
|
|
332
359
|
state: string;
|
|
333
360
|
}>;
|
|
361
|
+
/**
|
|
362
|
+
* An admin's reset for somebody in this app's own organisation whom
|
|
363
|
+
* `requestPasswordReset` cannot otherwise get back in -- returns a fresh
|
|
364
|
+
* one-time password the caller is trusted to deliver some other way,
|
|
365
|
+
* rather than mailing it. Refused for a user outside this app's
|
|
366
|
+
* organisation.
|
|
367
|
+
*/
|
|
368
|
+
adminResetPassword(userId: string): Promise<{
|
|
369
|
+
userId: string;
|
|
370
|
+
password: string;
|
|
371
|
+
}>;
|
|
334
372
|
/**
|
|
335
373
|
* Namespace mode: end every session this person has in this service, on
|
|
336
374
|
* every replica, at their next request. Deactivating someone does this too.
|
package/dist/auth.js
CHANGED
|
@@ -159,9 +159,9 @@ export function createAuth(input) {
|
|
|
159
159
|
return failure('expired', options().namespace ? [] : [cleared]);
|
|
160
160
|
const params = new URL(callbackUrl).searchParams;
|
|
161
161
|
if (params.get('state') !== transaction.st)
|
|
162
|
-
return failure('state', options().namespace ? [] : [cleared]);
|
|
162
|
+
return failure('state', options().namespace ? [] : [cleared], transaction.nx);
|
|
163
163
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
164
|
-
return failure('unavailable', [cleared]);
|
|
164
|
+
return failure('unavailable', [cleared], transaction.nx);
|
|
165
165
|
// A silent pass the issuer refused (login_required, interaction_required,
|
|
166
166
|
// consent_required, or any other error) is not a failure: sign in the normal
|
|
167
167
|
// way, once. `done` is already set, so `/start` will not go silent again.
|
|
@@ -181,7 +181,7 @@ export function createAuth(input) {
|
|
|
181
181
|
session = await sessionCookie(tokens, requestHeaders);
|
|
182
182
|
}
|
|
183
183
|
catch (error) {
|
|
184
|
-
return failure(reasonOf(error), [cleared]);
|
|
184
|
+
return failure(reasonOf(error), [cleared], transaction.nx);
|
|
185
185
|
}
|
|
186
186
|
await replacePreviousSession(cookieHeader);
|
|
187
187
|
return {
|
|
@@ -539,6 +539,25 @@ 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
|
+
};
|
|
547
|
+
/**
|
|
548
|
+
* An admin's reset for somebody in this app's own organisation whom
|
|
549
|
+
* `requestPasswordReset` cannot otherwise get back in -- nobody at the
|
|
550
|
+
* mailbox a personal address or the login address itself would reach.
|
|
551
|
+
* Refused for a user outside this app's organisation, exactly as
|
|
552
|
+
* `setPersonActive` is. The password is a one-time one: the person must
|
|
553
|
+
* choose their own at next sign-in, same as an invite's. Audited with the
|
|
554
|
+
* target's id and never the password itself.
|
|
555
|
+
*/
|
|
556
|
+
const adminResetPassword = async (userId) => {
|
|
557
|
+
const result = await broker().adminResetPassword(userId);
|
|
558
|
+
emit({ type: 'adminResetPassword', userId: result.userId });
|
|
559
|
+
return result;
|
|
560
|
+
};
|
|
542
561
|
const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
|
|
543
562
|
try {
|
|
544
563
|
const flow = await serverFlow();
|
|
@@ -656,6 +675,36 @@ export function createAuth(input) {
|
|
|
656
675
|
return { ok: false, error: code, message };
|
|
657
676
|
}
|
|
658
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
|
+
};
|
|
659
708
|
const signUp = async ({ authRequestId, ...user }) => {
|
|
660
709
|
try {
|
|
661
710
|
const redirectTo = await broker().signUp({ authRequestId, ...user });
|
|
@@ -891,9 +940,13 @@ export function createAuth(input) {
|
|
|
891
940
|
cookies,
|
|
892
941
|
};
|
|
893
942
|
};
|
|
894
|
-
|
|
943
|
+
/** `next`, when it is a safe same-origin path, rides on the error URL so the error page can offer a retry to it. */
|
|
944
|
+
const failure = (reason, cookies, next) => {
|
|
895
945
|
const url = new URL(options().onError, options().appUrl);
|
|
896
946
|
url.searchParams.set('auth_error', reason);
|
|
947
|
+
const retry = safeNextPath(next, '');
|
|
948
|
+
if (retry)
|
|
949
|
+
url.searchParams.set('next', retry);
|
|
897
950
|
return { location: url.href, cookies };
|
|
898
951
|
};
|
|
899
952
|
/** `failure`'s location on its own, for a caller that redirects rather than answering. */
|
|
@@ -918,6 +971,7 @@ export function createAuth(input) {
|
|
|
918
971
|
resetPassword,
|
|
919
972
|
sendLink,
|
|
920
973
|
invite,
|
|
974
|
+
createTestUser,
|
|
921
975
|
handle,
|
|
922
976
|
read,
|
|
923
977
|
readCookie,
|
|
@@ -927,6 +981,7 @@ export function createAuth(input) {
|
|
|
927
981
|
errorUrl,
|
|
928
982
|
people,
|
|
929
983
|
setPersonActive,
|
|
984
|
+
adminResetPassword,
|
|
930
985
|
revokeSessions,
|
|
931
986
|
sessions: listSessions,
|
|
932
987
|
endSession,
|
|
@@ -935,6 +990,7 @@ export function createAuth(input) {
|
|
|
935
990
|
setNewPassword,
|
|
936
991
|
setNewPasswordHeadless,
|
|
937
992
|
signInWithPassword,
|
|
993
|
+
signUpWithPassword,
|
|
938
994
|
signUp,
|
|
939
995
|
startPasskeyRegistration,
|
|
940
996
|
finishPasskeyRegistration,
|
package/dist/broker.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface NewUser {
|
|
|
21
21
|
* `password_change_required`, then `setNewPassword`).
|
|
22
22
|
*/
|
|
23
23
|
changeRequired?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Where this person's reset links go instead of their own login address --
|
|
26
|
+
* for somebody who cannot yet open the mailbox that address names. See
|
|
27
|
+
* `Auth['adminResetPassword']` for the person this does not cover.
|
|
28
|
+
*/
|
|
29
|
+
personalEmail?: string;
|
|
24
30
|
}
|
|
25
31
|
/** One of the organisation's people, as the sign-in service reports them. */
|
|
26
32
|
export interface Person {
|
|
@@ -64,6 +70,11 @@ export declare class Broker {
|
|
|
64
70
|
userId: string;
|
|
65
71
|
state: string;
|
|
66
72
|
}>;
|
|
73
|
+
/** An admin's reset for somebody requestPasswordReset cannot get back in: see `Auth['adminResetPassword']`. */
|
|
74
|
+
adminResetPassword(userId: string): Promise<{
|
|
75
|
+
userId: string;
|
|
76
|
+
password: string;
|
|
77
|
+
}>;
|
|
67
78
|
authRequest(id: string): Promise<AuthRequest>;
|
|
68
79
|
signIn(input: {
|
|
69
80
|
authRequestId: string;
|
|
@@ -119,11 +130,22 @@ export declare class Broker {
|
|
|
119
130
|
givenName: string;
|
|
120
131
|
familyName: string;
|
|
121
132
|
next: string;
|
|
133
|
+
personalEmail?: string;
|
|
122
134
|
}): Promise<{
|
|
123
135
|
userId: string;
|
|
124
136
|
invited: boolean;
|
|
137
|
+
personalEmailStored: boolean;
|
|
125
138
|
}>;
|
|
126
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
|
+
}>;
|
|
127
149
|
sendReset(email: string, next: string): Promise<unknown>;
|
|
128
150
|
sendVerification(email: string, next: string): Promise<unknown>;
|
|
129
151
|
/**
|
package/dist/broker.js
CHANGED
|
@@ -67,6 +67,10 @@ export class Broker {
|
|
|
67
67
|
setPersonActive(userId, active) {
|
|
68
68
|
return this.call('POST', active ? '/people/reactivate' : '/people/deactivate', { userId });
|
|
69
69
|
}
|
|
70
|
+
/** An admin's reset for somebody requestPasswordReset cannot get back in: see `Auth['adminResetPassword']`. */
|
|
71
|
+
adminResetPassword(userId) {
|
|
72
|
+
return this.call('POST', '/people/reset-password', { userId });
|
|
73
|
+
}
|
|
70
74
|
authRequest(id) {
|
|
71
75
|
return this.call('GET', '/auth-request', { id });
|
|
72
76
|
}
|
|
@@ -114,6 +118,10 @@ export class Broker {
|
|
|
114
118
|
sendLink(email, next) {
|
|
115
119
|
return this.call('POST', '/link/send', { email, next });
|
|
116
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
|
+
}
|
|
117
125
|
sendReset(email, next) {
|
|
118
126
|
return this.call('POST', '/reset/send', { email, next });
|
|
119
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
|
@@ -19,6 +19,15 @@ export type AuthEvent = {
|
|
|
19
19
|
email: string;
|
|
20
20
|
userId: string;
|
|
21
21
|
invited: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Never the password: an audit record of who was reset, not a copy of the credential. */
|
|
24
|
+
| {
|
|
25
|
+
type: 'adminResetPassword';
|
|
26
|
+
userId: string;
|
|
27
|
+
} | {
|
|
28
|
+
type: 'createTestUser';
|
|
29
|
+
email: string;
|
|
30
|
+
userId: string;
|
|
22
31
|
};
|
|
23
32
|
/**
|
|
24
33
|
* Why `accessToken` refused: no session (no cookie in cookie mode, no live
|
|
@@ -153,3 +162,14 @@ export interface ResolvedOptions {
|
|
|
153
162
|
onAuthEvent: ((event: AuthEvent) => void) | null;
|
|
154
163
|
}
|
|
155
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/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
|
package/dist/next.d.ts
CHANGED
|
@@ -95,14 +95,18 @@ export interface NextAuth {
|
|
|
95
95
|
givenName: string;
|
|
96
96
|
familyName: string;
|
|
97
97
|
next?: string;
|
|
98
|
+
personalEmail?: string;
|
|
98
99
|
}) => Promise<{
|
|
99
100
|
userId: string;
|
|
100
101
|
invited: boolean;
|
|
102
|
+
personalEmailStored: boolean;
|
|
101
103
|
}>;
|
|
102
104
|
/** The organisation's people, for an operator surface. Scoped to this app's organisation by the service. */
|
|
103
105
|
people: Auth['people'];
|
|
104
106
|
/** Deactivate or reactivate somebody in this app's organisation. */
|
|
105
107
|
setPersonActive: Auth['setPersonActive'];
|
|
108
|
+
/** An admin's reset for somebody in this app's organisation whom requestPasswordReset cannot otherwise get back in. */
|
|
109
|
+
adminResetPassword: Auth['adminResetPassword'];
|
|
106
110
|
/** The devices page: the signed-in person's own live sessions, newest first. Namespace mode only. */
|
|
107
111
|
sessions: () => Promise<SessionRecord[]>;
|
|
108
112
|
/**
|
package/dist/next.js
CHANGED