@wtfalch/auth 0.20.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 +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/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/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;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
|
|
2
|
+
/** A JWT is three base64url segments; a ZITADEL opaque token is not shaped
|
|
3
|
+
* like that at all, so this is enough to route between `jwtVerify` and
|
|
4
|
+
* introspection without ever trying to parse either kind as the other. */
|
|
5
|
+
const JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
6
|
+
/** How long a positive introspection result is trusted before this host
|
|
7
|
+
* checks again -- bounded well under the token's own `exp` so a revoked
|
|
8
|
+
* token can't outlive a ZITADEL-side revocation for long. */
|
|
9
|
+
const INTROSPECTION_CACHE_TTL_MS = 60_000;
|
|
10
|
+
/** An optional endpoint from the discovery document, required to be on the
|
|
11
|
+
* issuer's own origin -- a compromised or misconfigured discovery
|
|
12
|
+
* response must never point signature checking, or a token's liveness,
|
|
13
|
+
* at somebody else's server. */
|
|
14
|
+
function sameOriginEndpoint(value, name, issuerOrigin) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return null;
|
|
17
|
+
if (typeof value !== 'string')
|
|
18
|
+
throw new Error(`person-token: ${name} is not a string`);
|
|
19
|
+
if (new URL(value).origin !== issuerOrigin)
|
|
20
|
+
throw new Error(`person-token: ${name} origin does not match the issuer`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
async function discoverDocument(issuer, fetchImpl) {
|
|
24
|
+
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
25
|
+
const issuerOrigin = new URL(issuer).origin;
|
|
26
|
+
const response = await fetchImpl(discoveryUrl);
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(`person-token: discovery document request failed with ${response.status}`);
|
|
29
|
+
}
|
|
30
|
+
const doc = (await response.json());
|
|
31
|
+
if (typeof doc.jwks_uri !== 'string') {
|
|
32
|
+
throw new Error('person-token: the issuer publishes no jwks_uri');
|
|
33
|
+
}
|
|
34
|
+
if (new URL(doc.jwks_uri).origin !== issuerOrigin) {
|
|
35
|
+
throw new Error('person-token: jwks_uri origin does not match the issuer');
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
jwksUri: doc.jwks_uri,
|
|
39
|
+
introspectionEndpoint: sameOriginEndpoint(doc.introspection_endpoint, 'introspection_endpoint', issuerOrigin),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A size-bounded, least-recently-used, TTL cache for a positive
|
|
44
|
+
* introspection result only -- a refusal is never cached, so a transient
|
|
45
|
+
* issuer problem never sticks as "no" for whatever is left of the entry's
|
|
46
|
+
* life. Bounded so a flood of distinct garbage tokens cannot grow it
|
|
47
|
+
* without limit.
|
|
48
|
+
*/
|
|
49
|
+
class PositiveIntrospectionCache {
|
|
50
|
+
max;
|
|
51
|
+
entries = new Map();
|
|
52
|
+
constructor(max = 1000) {
|
|
53
|
+
this.max = max;
|
|
54
|
+
}
|
|
55
|
+
get(token, now) {
|
|
56
|
+
const entry = this.entries.get(token);
|
|
57
|
+
if (!entry)
|
|
58
|
+
return null;
|
|
59
|
+
if (entry.expiresAt <= now) {
|
|
60
|
+
this.entries.delete(token);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// Refresh recency: delete-then-set moves it to the end of the Map's
|
|
64
|
+
// iteration order, which eviction below reads from the front.
|
|
65
|
+
this.entries.delete(token);
|
|
66
|
+
this.entries.set(token, entry);
|
|
67
|
+
return entry.payload;
|
|
68
|
+
}
|
|
69
|
+
set(token, payload, expiresAt) {
|
|
70
|
+
this.entries.delete(token);
|
|
71
|
+
if (this.entries.size >= this.max) {
|
|
72
|
+
const oldest = this.entries.keys().next().value;
|
|
73
|
+
if (oldest !== undefined)
|
|
74
|
+
this.entries.delete(oldest);
|
|
75
|
+
}
|
|
76
|
+
this.entries.set(token, { payload, expiresAt });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function createPersonTokenVerifier(config) {
|
|
80
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
81
|
+
let discovery = null;
|
|
82
|
+
let jwks = null;
|
|
83
|
+
const introspectionCache = new PositiveIntrospectionCache();
|
|
84
|
+
const document = () => {
|
|
85
|
+
discovery ??= discoverDocument(config.issuer, fetchImpl).catch((error) => {
|
|
86
|
+
discovery = null;
|
|
87
|
+
throw error;
|
|
88
|
+
});
|
|
89
|
+
return discovery;
|
|
90
|
+
};
|
|
91
|
+
const keys = () => {
|
|
92
|
+
jwks ??= document()
|
|
93
|
+
.then((doc) => createRemoteJWKSet(new URL(doc.jwksUri), {
|
|
94
|
+
cooldownDuration: config.jwksCooldownMs ?? 5_000,
|
|
95
|
+
[customFetch]: (url, init) => fetchImpl(url, init),
|
|
96
|
+
}))
|
|
97
|
+
.catch((error) => {
|
|
98
|
+
jwks = null;
|
|
99
|
+
throw error;
|
|
100
|
+
});
|
|
101
|
+
return jwks;
|
|
102
|
+
};
|
|
103
|
+
async function verifyJwt(accessToken) {
|
|
104
|
+
try {
|
|
105
|
+
const keyset = await keys();
|
|
106
|
+
const { payload } = await jwtVerify(accessToken, keyset, {
|
|
107
|
+
issuer: config.issuer,
|
|
108
|
+
audience: config.audience,
|
|
109
|
+
algorithms: ['RS256'],
|
|
110
|
+
clockTolerance: 60,
|
|
111
|
+
});
|
|
112
|
+
return payload;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* RFC 7662 token introspection for an opaque access token: POSTs
|
|
120
|
+
* `token=<accessToken>` to the issuer's `introspection_endpoint`,
|
|
121
|
+
* authenticating as this resource server with `client_secret_basic`.
|
|
122
|
+
* Fails closed on anything that keeps this from reaching a real
|
|
123
|
+
* `active`/`exp`/`aud` verdict: no credentials configured, no endpoint
|
|
124
|
+
* published, a network failure, a non-2xx response, or an unparseable
|
|
125
|
+
* body -- every one of those returns `null`, refused, never accepted.
|
|
126
|
+
*/
|
|
127
|
+
async function introspect(accessToken) {
|
|
128
|
+
if (!config.introspectionClientId || !config.introspectionClientSecret)
|
|
129
|
+
return null;
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const cached = introspectionCache.get(accessToken, now);
|
|
132
|
+
if (cached)
|
|
133
|
+
return cached;
|
|
134
|
+
let endpoint;
|
|
135
|
+
try {
|
|
136
|
+
({ introspectionEndpoint: endpoint } = await document());
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
if (!endpoint)
|
|
142
|
+
return null;
|
|
143
|
+
const credentials = Buffer.from(`${config.introspectionClientId}:${config.introspectionClientSecret}`).toString('base64');
|
|
144
|
+
let response;
|
|
145
|
+
try {
|
|
146
|
+
response = await fetchImpl(endpoint, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: {
|
|
149
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
150
|
+
authorization: `Basic ${credentials}`,
|
|
151
|
+
},
|
|
152
|
+
body: new URLSearchParams({ token: accessToken }).toString(),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
return null;
|
|
160
|
+
let body;
|
|
161
|
+
try {
|
|
162
|
+
body = (await response.json());
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (body.active !== true)
|
|
168
|
+
return null;
|
|
169
|
+
if (typeof body.exp !== 'number' || body.exp <= now / 1000)
|
|
170
|
+
return null;
|
|
171
|
+
const aud = body.aud;
|
|
172
|
+
const audiences = Array.isArray(aud) ? aud : aud === undefined ? [] : [aud];
|
|
173
|
+
if (!audiences.includes(config.audience))
|
|
174
|
+
return null;
|
|
175
|
+
if (typeof body.iss === 'string' && body.iss !== config.issuer)
|
|
176
|
+
return null;
|
|
177
|
+
const payload = body;
|
|
178
|
+
introspectionCache.set(accessToken, payload, Math.min(body.exp * 1000, now + INTROSPECTION_CACHE_TTL_MS));
|
|
179
|
+
return payload;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
async verify(accessToken) {
|
|
183
|
+
const payload = JWT_SHAPE.test(accessToken)
|
|
184
|
+
? await verifyJwt(accessToken)
|
|
185
|
+
: await introspect(accessToken);
|
|
186
|
+
if (!payload || typeof payload.sub !== 'string' || !payload.sub)
|
|
187
|
+
return null;
|
|
188
|
+
return { sub: payload.sub };
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type QrDoorMark = 'agora' | 'boule' | 'archon';
|
|
2
|
+
export interface QrDoorTheme {
|
|
3
|
+
ink: string;
|
|
4
|
+
accent: string;
|
|
5
|
+
paper: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const QR_DOOR_THEME: QrDoorTheme;
|
|
8
|
+
/** Side of the logical drawing space. */
|
|
9
|
+
export declare const QR_DOOR_SIZE = 360;
|
|
10
|
+
/** Half-width of the clearing the doorway sits in. */
|
|
11
|
+
export declare const QR_DOOR_CLEARING = 25;
|
|
12
|
+
/** Largest QR version allowed: version 7 and up put an alignment pattern dead centre, under the door. */
|
|
13
|
+
export declare const QR_DOOR_MAX_VERSION = 6;
|
|
14
|
+
/** Length of the approval animation. */
|
|
15
|
+
export declare const QR_DOOR_DURATION: number;
|
|
16
|
+
export interface QrDoorPoint {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
}
|
|
20
|
+
export interface QrDoorPiece {
|
|
21
|
+
/** Index into `dots` of the module that lifts out. */
|
|
22
|
+
src: number;
|
|
23
|
+
sx: number;
|
|
24
|
+
sy: number;
|
|
25
|
+
tx: number;
|
|
26
|
+
ty: number;
|
|
27
|
+
/** Delay before it lifts, seconds. */
|
|
28
|
+
dl: number;
|
|
29
|
+
}
|
|
30
|
+
export interface QrDoorScene {
|
|
31
|
+
mark: QrDoorMark;
|
|
32
|
+
/** The error-correction level used, highest that keeps the version at or under `QR_DOOR_MAX_VERSION`. */
|
|
33
|
+
errorCorrection: 'Q' | 'M' | 'L';
|
|
34
|
+
version: number;
|
|
35
|
+
/** Modules per side, and module size in logical px. */
|
|
36
|
+
modules: number;
|
|
37
|
+
moduleSize: number;
|
|
38
|
+
/** Dark data modules, finders and the clearing left out. */
|
|
39
|
+
dots: QrDoorPoint[];
|
|
40
|
+
/** Centres of the three finder eyes. */
|
|
41
|
+
finders: QrDoorPoint[];
|
|
42
|
+
pieces: QrDoorPiece[];
|
|
43
|
+
camera: {
|
|
44
|
+
Zg: number;
|
|
45
|
+
zg: number;
|
|
46
|
+
Dp: number;
|
|
47
|
+
kk: number;
|
|
48
|
+
zf: number;
|
|
49
|
+
t1: number;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Builds the scene for `url` (the device grant's verification URL). Tries
|
|
54
|
+
* error correction Q, then M, then L, keeping the first that fits in version 6.
|
|
55
|
+
* The clearing knocks out about 3% of modules, well inside even L's 7%.
|
|
56
|
+
* Throws a `RangeError` for a URL too long for version 6 at any level.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildQrDoorScene(url: string, mark: QrDoorMark): QrDoorScene;
|
|
59
|
+
/** Icon size (logical px) at time `t` after approval. Continuous by construction. */
|
|
60
|
+
export declare function qrDoorIconSize(scene: QrDoorScene, t: number): number;
|
|
61
|
+
type Ctx = CanvasRenderingContext2D;
|
|
62
|
+
/** The code at rest, waiting for the phone. */
|
|
63
|
+
export declare function drawQrDoorRest(c: Ctx, s: QrDoorScene, th?: QrDoorTheme): void;
|
|
64
|
+
/** The frame `t` seconds after approval; `t` is clamped to `[0, QR_DOOR_DURATION]`. */
|
|
65
|
+
export declare function drawQrDoorScene(c: Ctx, s: QrDoorScene, time: number, th?: QrDoorTheme): void;
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "The code builds the door": the QR sign-in code, and the short animation it
|
|
3
|
+
* plays once the phone approves. Framework-free canvas drawing; `./qr-door`
|
|
4
|
+
* wraps it in a React component.
|
|
5
|
+
*
|
|
6
|
+
* At rest it is an ordinary, scannable QR code for the device grant's `url`
|
|
7
|
+
* with a small clearing in the middle. On approval, modules lift out of the
|
|
8
|
+
* code and land on the outline of the app's mark, the solid line traces in
|
|
9
|
+
* behind them, a person (the mark's red dot) steps into the doorway, and one
|
|
10
|
+
* perspective camera move carries you through it to the app's icon.
|
|
11
|
+
*
|
|
12
|
+
* Everything is drawn in a fixed 360×360 logical space; the caller sets the
|
|
13
|
+
* canvas transform for its size and devicePixelRatio. `buildQrDoorScene` does
|
|
14
|
+
* the one-off work (QR matrix, which module flies where, the camera solve) and
|
|
15
|
+
* is plain data, safe on a server. `drawQrDoorRest` and `drawQrDoorScene` need
|
|
16
|
+
* a canvas and are a pure function of (scene, t): scrub, test or freeze them.
|
|
17
|
+
*
|
|
18
|
+
* Browser file for drawing: nothing here touches OIDC, cookies or `node:`.
|
|
19
|
+
*/
|
|
20
|
+
import QRCode from 'qrcode';
|
|
21
|
+
const MARKS = {
|
|
22
|
+
agora: {
|
|
23
|
+
d: 'M5.6 18.2V12.6a6.4 6.4 0 0 1 12.8 0V18.2',
|
|
24
|
+
dot: [12, 15.1],
|
|
25
|
+
len: 31.3,
|
|
26
|
+
hc: [12, 13.4],
|
|
27
|
+
hr: 5.2,
|
|
28
|
+
segs: [
|
|
29
|
+
['L', 5.6, 18.2, 5.6, 12.6],
|
|
30
|
+
['A', 12, 12.6, 6.4, 180, 360],
|
|
31
|
+
['L', 18.4, 12.6, 18.4, 18.2],
|
|
32
|
+
],
|
|
33
|
+
hole: 'M7.3 18.2V12.6a4.7 4.7 0 0 1 9.4 0V18.2Z',
|
|
34
|
+
},
|
|
35
|
+
boule: {
|
|
36
|
+
d: 'M8.6 17.89A6.8 6.8 0 1 1 15.4 17.89',
|
|
37
|
+
dot: [12, 12],
|
|
38
|
+
len: 35.6,
|
|
39
|
+
hc: [12, 12],
|
|
40
|
+
hr: 5.1,
|
|
41
|
+
segs: [['A', 12, 12, 6.8, 120, 420]],
|
|
42
|
+
hole: 'M6.9 12a5.1 5.1 0 1 0 10.2 0a5.1 5.1 0 1 0 -10.2 0Z',
|
|
43
|
+
},
|
|
44
|
+
archon: {
|
|
45
|
+
d: 'M9.3 19.28A5.4 5.4 0 1 1 14.7 19.28',
|
|
46
|
+
dot: [12, 4.3],
|
|
47
|
+
len: 28.3,
|
|
48
|
+
hc: [12, 14.6],
|
|
49
|
+
hr: 3.7,
|
|
50
|
+
segs: [['A', 12, 14.6, 5.4, 120, 420]],
|
|
51
|
+
hole: 'M8.3 14.6a3.7 3.7 0 1 0 7.4 0a3.7 3.7 0 1 0 -7.4 0Z',
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
export const QR_DOOR_THEME = { ink: '#1D1B16', accent: '#B23A0C', paper: '#F6F3EC' };
|
|
55
|
+
/* ---------- geometry (logical px) ---------- */
|
|
56
|
+
/** Side of the logical drawing space. */
|
|
57
|
+
export const QR_DOOR_SIZE = 360;
|
|
58
|
+
const L = QR_DOOR_SIZE;
|
|
59
|
+
const C0 = L / 2;
|
|
60
|
+
const CODE = 290;
|
|
61
|
+
const O = (L - CODE) / 2;
|
|
62
|
+
const ICON = 228;
|
|
63
|
+
const TAU = Math.PI * 2;
|
|
64
|
+
/** Icon units to px for the doorway drawn in the code. */
|
|
65
|
+
const S1 = 2.9;
|
|
66
|
+
/** Half-width of the clearing the doorway sits in. */
|
|
67
|
+
export const QR_DOOR_CLEARING = 25;
|
|
68
|
+
/** Icon size when first seen through the doorway, and the instant the doorway clears the stage. */
|
|
69
|
+
const START = 9;
|
|
70
|
+
const S0 = 88;
|
|
71
|
+
/** Finder-eye corner rounding. Above about 0.5 strict decoders stop finding the eyes. */
|
|
72
|
+
const EYE_ROUND = 0.4;
|
|
73
|
+
/** Largest QR version allowed: version 7 and up put an alignment pattern dead centre, under the door. */
|
|
74
|
+
export const QR_DOOR_MAX_VERSION = 6;
|
|
75
|
+
/* ---------- timeline (seconds after approval) ---------- */
|
|
76
|
+
const WALK = 0.95;
|
|
77
|
+
const DOLLY = 1.55;
|
|
78
|
+
/** Length of the approval animation. */
|
|
79
|
+
export const QR_DOOR_DURATION = WALK + DOLLY + 0.3;
|
|
80
|
+
/* ---------- easing ---------- */
|
|
81
|
+
const clamp = (v, a = 0, b = 1) => (v < a ? a : v > b ? b : v);
|
|
82
|
+
const seg = (u, a, b) => clamp((u - a) / (b - a));
|
|
83
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
84
|
+
const io = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
|
85
|
+
const sine = (t) => -(Math.cos(Math.PI * t) - 1) / 2;
|
|
86
|
+
const back = (t) => 1 + 2.5 * (t - 1) ** 3 + 1.5 * (t - 1) ** 2;
|
|
87
|
+
/**
|
|
88
|
+
* Builds the scene for `url` (the device grant's verification URL). Tries
|
|
89
|
+
* error correction Q, then M, then L, keeping the first that fits in version 6.
|
|
90
|
+
* The clearing knocks out about 3% of modules, well inside even L's 7%.
|
|
91
|
+
* Throws a `RangeError` for a URL too long for version 6 at any level.
|
|
92
|
+
*/
|
|
93
|
+
export function buildQrDoorScene(url, mark) {
|
|
94
|
+
const geo = MARKS[mark];
|
|
95
|
+
let qr = null;
|
|
96
|
+
let errorCorrection = 'Q';
|
|
97
|
+
for (const level of ['Q', 'M', 'L']) {
|
|
98
|
+
const candidate = QRCode.create(url, { errorCorrectionLevel: level });
|
|
99
|
+
if (candidate.version <= QR_DOOR_MAX_VERSION) {
|
|
100
|
+
qr = candidate;
|
|
101
|
+
errorCorrection = level;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!qr) {
|
|
106
|
+
throw new RangeError(`qr-door: ${url.length} characters needs a QR version above ${QR_DOOR_MAX_VERSION}`);
|
|
107
|
+
}
|
|
108
|
+
const n = qr.modules.size;
|
|
109
|
+
const data = qr.modules.data;
|
|
110
|
+
const m = CODE / n;
|
|
111
|
+
const isFinder = (x, y) => (x < 7 && y < 7) || (x > n - 8 && y < 7) || (x < 7 && y > n - 8);
|
|
112
|
+
const dots = [];
|
|
113
|
+
for (let y = 0; y < n; y++) {
|
|
114
|
+
for (let x = 0; x < n; x++) {
|
|
115
|
+
if (!data[y * n + x] || isFinder(x, y))
|
|
116
|
+
continue;
|
|
117
|
+
const px = O + (x + 0.5) * m;
|
|
118
|
+
const py = O + (y + 0.5) * m;
|
|
119
|
+
if (Math.abs(px - C0) < QR_DOOR_CLEARING && Math.abs(py - C0) < QR_DOOR_CLEARING)
|
|
120
|
+
continue;
|
|
121
|
+
dots.push({ x: px, y: py });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const finders = [
|
|
125
|
+
[0, 0],
|
|
126
|
+
[n - 7, 0],
|
|
127
|
+
[0, n - 7],
|
|
128
|
+
].map(([fx, fy]) => ({ x: O + (fx + 3.5) * m, y: O + (fy + 3.5) * m }));
|
|
129
|
+
// Sample the outline evenly and give each sample the nearest module not yet taken.
|
|
130
|
+
const poly = [];
|
|
131
|
+
for (const s of geo.segs) {
|
|
132
|
+
if (s[0] === 'L') {
|
|
133
|
+
for (let i = 0; i <= 30; i++)
|
|
134
|
+
poly.push([lerp(s[1], s[3], i / 30), lerp(s[2], s[4], i / 30)]);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
for (let i = 0; i <= 120; i++) {
|
|
138
|
+
const a = ((s[4] + ((s[5] - s[4]) * i) / 120) * Math.PI) / 180;
|
|
139
|
+
poly.push([s[1] + s[3] * Math.cos(a), s[2] + s[3] * Math.sin(a)]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const cum = [0];
|
|
144
|
+
for (let i = 1; i < poly.length; i++)
|
|
145
|
+
cum.push(cum[i - 1] + Math.hypot(poly[i][0] - poly[i - 1][0], poly[i][1] - poly[i - 1][1]));
|
|
146
|
+
const len = cum[cum.length - 1];
|
|
147
|
+
const count = Math.ceil((len * S1) / 3.6);
|
|
148
|
+
const used = new Set();
|
|
149
|
+
const pieces = [];
|
|
150
|
+
for (let j = 0; j < count; j++) {
|
|
151
|
+
const l = (j / (count - 1)) * len;
|
|
152
|
+
let q = 1;
|
|
153
|
+
while (q < cum.length - 1 && cum[q] < l)
|
|
154
|
+
q++;
|
|
155
|
+
const a = poly[q - 1];
|
|
156
|
+
const b = poly[q];
|
|
157
|
+
const f = clamp((l - cum[q - 1]) / (cum[q] - cum[q - 1] || 1));
|
|
158
|
+
const tx = C0 + (lerp(a[0], b[0], f) - geo.hc[0]) * S1;
|
|
159
|
+
const ty = C0 + (lerp(a[1], b[1], f) - geo.hc[1]) * S1;
|
|
160
|
+
let best = -1;
|
|
161
|
+
let bd = Number.POSITIVE_INFINITY;
|
|
162
|
+
for (let d = 0; d < dots.length; d++) {
|
|
163
|
+
if (used.has(d))
|
|
164
|
+
continue;
|
|
165
|
+
const dd = Math.hypot(dots[d].x - tx, dots[d].y - ty);
|
|
166
|
+
if (dd < bd) {
|
|
167
|
+
bd = dd;
|
|
168
|
+
best = d;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
used.add(best);
|
|
172
|
+
pieces.push({ src: best, sx: dots[best].x, sy: dots[best].y, tx, ty, dl: (j / count) * 0.32 });
|
|
173
|
+
}
|
|
174
|
+
// One perspective camera: the code is a plane at depth 1, the icon at depth Dp.
|
|
175
|
+
// Solved so the icon is START px when first seen, S0 px the instant the doorway
|
|
176
|
+
// fills the stage, and ICON px at rest -- no hand-off between two zooms.
|
|
177
|
+
const Zg = (1.06 * 255) / (geo.hr * S1);
|
|
178
|
+
const zg = 1 - 1 / Zg;
|
|
179
|
+
const Dp = (zg * S0) / (S0 - START);
|
|
180
|
+
const kk = START * Dp;
|
|
181
|
+
const zf = Dp - kk / ICON;
|
|
182
|
+
let lo = 0;
|
|
183
|
+
let hi = 1;
|
|
184
|
+
for (let i = 0; i < 30; i++) {
|
|
185
|
+
const mid = (lo + hi) / 2;
|
|
186
|
+
if (io(mid) * zf < zg)
|
|
187
|
+
lo = mid;
|
|
188
|
+
else
|
|
189
|
+
hi = mid;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
mark,
|
|
193
|
+
errorCorrection,
|
|
194
|
+
version: qr.version,
|
|
195
|
+
modules: n,
|
|
196
|
+
moduleSize: m,
|
|
197
|
+
dots,
|
|
198
|
+
finders,
|
|
199
|
+
pieces,
|
|
200
|
+
camera: { Zg, zg, Dp, kk, zf, t1: WALK + DOLLY * hi },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** Icon size (logical px) at time `t` after approval. Continuous by construction. */
|
|
204
|
+
export function qrDoorIconSize(scene, t) {
|
|
205
|
+
const c = scene.camera;
|
|
206
|
+
const z = c.zf * io(seg(t, WALK, WALK + DOLLY));
|
|
207
|
+
return c.kk / (c.Dp - z);
|
|
208
|
+
}
|
|
209
|
+
const paths = new Map();
|
|
210
|
+
function pathsFor(mark) {
|
|
211
|
+
let p = paths.get(mark);
|
|
212
|
+
if (!p) {
|
|
213
|
+
p = { stroke: new Path2D(MARKS[mark].d), hole: new Path2D(MARKS[mark].hole) };
|
|
214
|
+
paths.set(mark, p);
|
|
215
|
+
}
|
|
216
|
+
return p;
|
|
217
|
+
}
|
|
218
|
+
function rr(c, x, y, w, h, r) {
|
|
219
|
+
c.moveTo(x + r, y);
|
|
220
|
+
c.arcTo(x + w, y, x + w, y + h, r);
|
|
221
|
+
c.arcTo(x + w, y + h, x, y + h, r);
|
|
222
|
+
c.arcTo(x, y + h, x, y, r);
|
|
223
|
+
c.arcTo(x, y, x + w, y, r);
|
|
224
|
+
c.closePath();
|
|
225
|
+
}
|
|
226
|
+
function finder(c, s, p, th) {
|
|
227
|
+
const M = s.moduleSize;
|
|
228
|
+
const h = 3.5 * M;
|
|
229
|
+
c.save();
|
|
230
|
+
c.translate(p.x - h, p.y - h);
|
|
231
|
+
c.fillStyle = th.ink;
|
|
232
|
+
c.beginPath();
|
|
233
|
+
rr(c, 0, 0, 7 * M, 7 * M, 1.6 * M * EYE_ROUND);
|
|
234
|
+
rr(c, M, M, 5 * M, 5 * M, 0.8 * M * EYE_ROUND);
|
|
235
|
+
c.fill('evenodd');
|
|
236
|
+
c.beginPath();
|
|
237
|
+
rr(c, 2 * M, 2 * M, 3 * M, 3 * M, 0.75 * M * EYE_ROUND);
|
|
238
|
+
c.fill();
|
|
239
|
+
c.restore();
|
|
240
|
+
}
|
|
241
|
+
function circles(c, xyr, color) {
|
|
242
|
+
c.fillStyle = color;
|
|
243
|
+
c.beginPath();
|
|
244
|
+
for (let i = 0; i < xyr.length; i += 3) {
|
|
245
|
+
const r = xyr[i + 2];
|
|
246
|
+
if (r <= 0.1)
|
|
247
|
+
continue;
|
|
248
|
+
c.moveTo(xyr[i] + r, xyr[i + 1]);
|
|
249
|
+
c.arc(xyr[i], xyr[i + 1], r, 0, TAU);
|
|
250
|
+
}
|
|
251
|
+
c.fill();
|
|
252
|
+
}
|
|
253
|
+
function stroke(c, s, th, dash) {
|
|
254
|
+
c.lineWidth = 3.4;
|
|
255
|
+
c.lineCap = 'round';
|
|
256
|
+
c.strokeStyle = th.ink;
|
|
257
|
+
if (dash)
|
|
258
|
+
c.setLineDash(dash);
|
|
259
|
+
c.stroke(pathsFor(s.mark).stroke);
|
|
260
|
+
c.setLineDash([]);
|
|
261
|
+
}
|
|
262
|
+
function icon(c, s, th, size) {
|
|
263
|
+
const k = size / 24;
|
|
264
|
+
c.save();
|
|
265
|
+
c.translate(C0 - size / 2, C0 - size / 2);
|
|
266
|
+
c.scale(k, k);
|
|
267
|
+
c.save();
|
|
268
|
+
c.shadowColor = 'rgba(29,27,22,.16)';
|
|
269
|
+
c.shadowBlur = 18 / k;
|
|
270
|
+
c.shadowOffsetY = 6 / k;
|
|
271
|
+
c.beginPath();
|
|
272
|
+
rr(c, 0.25, 0.25, 23.5, 23.5, 5.4);
|
|
273
|
+
c.fillStyle = th.paper;
|
|
274
|
+
c.fill();
|
|
275
|
+
c.shadowColor = 'transparent';
|
|
276
|
+
c.lineWidth = 0.2;
|
|
277
|
+
c.strokeStyle = 'rgba(29,27,22,.14)';
|
|
278
|
+
c.stroke();
|
|
279
|
+
c.restore();
|
|
280
|
+
stroke(c, s, th);
|
|
281
|
+
c.restore();
|
|
282
|
+
}
|
|
283
|
+
/** The code at rest, waiting for the phone. */
|
|
284
|
+
export function drawQrDoorRest(c, s, th = QR_DOOR_THEME) {
|
|
285
|
+
c.fillStyle = th.paper;
|
|
286
|
+
c.fillRect(0, 0, L, L);
|
|
287
|
+
const r = 0.42 * s.moduleSize;
|
|
288
|
+
const a = [];
|
|
289
|
+
for (const p of s.dots)
|
|
290
|
+
a.push(p.x, p.y, r);
|
|
291
|
+
circles(c, a, th.ink);
|
|
292
|
+
for (const f of s.finders)
|
|
293
|
+
finder(c, s, f, th);
|
|
294
|
+
}
|
|
295
|
+
/** The frame `t` seconds after approval; `t` is clamped to `[0, QR_DOOR_DURATION]`. */
|
|
296
|
+
export function drawQrDoorScene(c, s, time, th = QR_DOOR_THEME) {
|
|
297
|
+
const t = clamp(time, 0, QR_DOOR_DURATION);
|
|
298
|
+
const geo = MARKS[s.mark];
|
|
299
|
+
const cam = s.camera;
|
|
300
|
+
const hc = geo.hc;
|
|
301
|
+
const R0 = 0.42 * s.moduleSize;
|
|
302
|
+
const halfLine = 1.7 * S1;
|
|
303
|
+
const { hole: holePath } = pathsFor(s.mark);
|
|
304
|
+
// The opening: modules fly onto the outline; the line traces in behind the landings.
|
|
305
|
+
// Each lands at exactly half the line's width, so there is no cross-fade to see.
|
|
306
|
+
const trace = clamp((t - 0.345) / 0.32);
|
|
307
|
+
const hole = io(seg(t, 0.55, 0.8));
|
|
308
|
+
const arrive = back(seg(t, 0.72, 0.95));
|
|
309
|
+
const lifted = new Set();
|
|
310
|
+
for (const p of s.pieces)
|
|
311
|
+
if (t > p.dl)
|
|
312
|
+
lifted.add(p.src);
|
|
313
|
+
// The walk: one camera for both planes.
|
|
314
|
+
const z = cam.zf * io(seg(t, WALK, WALK + DOLLY));
|
|
315
|
+
const gone = z >= cam.zg;
|
|
316
|
+
const zoom = gone ? cam.Zg : 1 / (1 - z);
|
|
317
|
+
const isz = cam.kk / (cam.Dp - z);
|
|
318
|
+
const k = isz / ICON;
|
|
319
|
+
// Far side: dotted ground and the icon, visible through the doorway from the start of the walk.
|
|
320
|
+
c.fillStyle = th.paper;
|
|
321
|
+
c.fillRect(0, 0, L, L);
|
|
322
|
+
const sp = Math.max(22 * k, 4.5);
|
|
323
|
+
const dr = sp * 0.05;
|
|
324
|
+
const g0 = C0 - Math.ceil(C0 / sp) * sp;
|
|
325
|
+
c.fillStyle = 'rgba(29,27,22,.13)';
|
|
326
|
+
c.beginPath();
|
|
327
|
+
for (let gx = g0; gx < L + sp; gx += sp) {
|
|
328
|
+
for (let gy = g0; gy < L + sp; gy += sp) {
|
|
329
|
+
c.moveTo(gx + dr, gy);
|
|
330
|
+
c.arc(gx, gy, dr, 0, TAU);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
c.fill();
|
|
334
|
+
const iconAlpha = sine(seg(t, WALK - 0.1, WALK + 0.35));
|
|
335
|
+
if (iconAlpha > 0) {
|
|
336
|
+
c.save();
|
|
337
|
+
c.globalAlpha = iconAlpha;
|
|
338
|
+
icon(c, s, th, isz);
|
|
339
|
+
c.restore();
|
|
340
|
+
}
|
|
341
|
+
// The person: from the doorway to the icon's own dot, then rides with it.
|
|
342
|
+
const w = io(seg(t, WALK, cam.t1));
|
|
343
|
+
const px = lerp(C0 + (geo.dot[0] - hc[0]) * S1, C0 + ((geo.dot[0] - 12) * isz) / 24, w);
|
|
344
|
+
const py = lerp(C0 + (geo.dot[1] - hc[1]) * S1, C0 + ((geo.dot[1] - 12) * isz) / 24, w);
|
|
345
|
+
const pr = lerp(2.6 * S1 * arrive, (2.6 * isz) / 24, w);
|
|
346
|
+
// Near side: the code, the doorway cut out of it, rushing past the camera.
|
|
347
|
+
if (!gone) {
|
|
348
|
+
c.save();
|
|
349
|
+
c.translate(C0, C0);
|
|
350
|
+
c.scale(zoom, zoom);
|
|
351
|
+
c.translate(-C0, -C0);
|
|
352
|
+
c.save();
|
|
353
|
+
const clip = new Path2D();
|
|
354
|
+
clip.rect(-4000, -4000, 8000 + L, 8000 + L);
|
|
355
|
+
if (hole > 0) {
|
|
356
|
+
clip.addPath(holePath, new DOMMatrix()
|
|
357
|
+
.translate(C0 - hc[0] * S1, C0 - hc[1] * S1)
|
|
358
|
+
.scale(S1)
|
|
359
|
+
.translate(hc[0], hc[1])
|
|
360
|
+
.scale(hole)
|
|
361
|
+
.translate(-hc[0], -hc[1]));
|
|
362
|
+
}
|
|
363
|
+
c.clip(clip, 'evenodd');
|
|
364
|
+
c.fillStyle = th.paper;
|
|
365
|
+
c.fillRect(-4000, -4000, 8000 + L, 8000 + L);
|
|
366
|
+
const rest = [];
|
|
367
|
+
s.dots.forEach((p, i) => {
|
|
368
|
+
if (!lifted.has(i))
|
|
369
|
+
rest.push(p.x, p.y, R0);
|
|
370
|
+
});
|
|
371
|
+
circles(c, rest, th.ink);
|
|
372
|
+
for (const f of s.finders)
|
|
373
|
+
finder(c, s, f, th);
|
|
374
|
+
c.restore();
|
|
375
|
+
if (trace < 1) {
|
|
376
|
+
const fly = [];
|
|
377
|
+
for (const p of s.pieces) {
|
|
378
|
+
if (t <= p.dl)
|
|
379
|
+
continue;
|
|
380
|
+
const e = io(seg(t, p.dl, p.dl + 0.32));
|
|
381
|
+
const lift = Math.sin(Math.PI * e) * 10;
|
|
382
|
+
fly.push(lerp(p.sx, p.tx, e), lerp(p.sy, p.ty, e) - lift, lerp(R0, halfLine, e));
|
|
383
|
+
}
|
|
384
|
+
circles(c, fly, th.ink);
|
|
385
|
+
}
|
|
386
|
+
c.restore();
|
|
387
|
+
}
|
|
388
|
+
if (pr > 0.1) {
|
|
389
|
+
c.fillStyle = th.accent;
|
|
390
|
+
c.beginPath();
|
|
391
|
+
c.arc(px, py, pr, 0, TAU);
|
|
392
|
+
c.fill();
|
|
393
|
+
}
|
|
394
|
+
if (!gone && trace > 0) {
|
|
395
|
+
c.save();
|
|
396
|
+
c.translate(C0, C0);
|
|
397
|
+
c.scale(zoom, zoom);
|
|
398
|
+
c.translate(-C0, -C0);
|
|
399
|
+
c.translate(C0 - hc[0] * S1, C0 - hc[1] * S1);
|
|
400
|
+
c.scale(S1, S1);
|
|
401
|
+
stroke(c, s, th, trace < 1 ? [geo.len * trace, geo.len * 2] : undefined);
|
|
402
|
+
c.restore();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type QrDoorMark, type QrDoorTheme } from './qr-door-scene.js';
|
|
2
|
+
export { QR_DOOR_DURATION, QR_DOOR_THEME, type QrDoorMark, type QrDoorScene, type QrDoorTheme, buildQrDoorScene, drawQrDoorRest, drawQrDoorScene, } from './qr-door-scene.js';
|
|
3
|
+
export interface QrDoorProps {
|
|
4
|
+
/** What the phone scans: `startQrSignIn()`'s `url`. */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Whose door: the app's mark. */
|
|
7
|
+
mark: QrDoorMark;
|
|
8
|
+
/** True once `pollQrSignIn()` answered `signed_in`. */
|
|
9
|
+
approved: boolean;
|
|
10
|
+
/** Called when the walk-through ends, or at once under reduced motion. Navigate here. */
|
|
11
|
+
onDone?: () => void;
|
|
12
|
+
theme?: Partial<QrDoorTheme>;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function QrDoor({ url, mark, approved, onDone, theme, className }: QrDoorProps): import("react/jsx-runtime").JSX.Element;
|
package/dist/qr-door.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* `<QrDoor>`: the QR sign-in code as a React component. Draws `url` (the
|
|
5
|
+
* `url` from `startQrSignIn()`) and plays the door animation once `approved`
|
|
6
|
+
* turns true -- that is, once `pollQrSignIn()` answered `signed_in`. The
|
|
7
|
+
* drawing itself lives in `qr-door-scene.ts`; this file only owns the canvas,
|
|
8
|
+
* its size, the animation clock and reduced motion.
|
|
9
|
+
*
|
|
10
|
+
* Browser file: nothing here touches OIDC, cookies or `node:`.
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
13
|
+
import { QR_DOOR_DURATION, QR_DOOR_SIZE, QR_DOOR_THEME, buildQrDoorScene, drawQrDoorRest, drawQrDoorScene, } from './qr-door-scene.js';
|
|
14
|
+
export { QR_DOOR_DURATION, QR_DOOR_THEME, buildQrDoorScene, drawQrDoorRest, drawQrDoorScene, } from './qr-door-scene.js';
|
|
15
|
+
export function QrDoor({ url, mark, approved, onDone, theme, className }) {
|
|
16
|
+
const canvasRef = useRef(null);
|
|
17
|
+
const scene = useMemo(() => buildQrDoorScene(url, mark), [url, mark]);
|
|
18
|
+
const ink = theme?.ink ?? QR_DOOR_THEME.ink;
|
|
19
|
+
const accent = theme?.accent ?? QR_DOOR_THEME.accent;
|
|
20
|
+
const paper = theme?.paper ?? QR_DOOR_THEME.paper;
|
|
21
|
+
const onDoneRef = useRef(onDone);
|
|
22
|
+
onDoneRef.current = onDone;
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const canvas = canvasRef.current;
|
|
25
|
+
const ctx = canvas?.getContext('2d');
|
|
26
|
+
if (!canvas || !ctx)
|
|
27
|
+
return;
|
|
28
|
+
const th = { ink, accent, paper };
|
|
29
|
+
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
30
|
+
const start = performance.now();
|
|
31
|
+
let raf = 0;
|
|
32
|
+
let finished = false;
|
|
33
|
+
const fit = () => {
|
|
34
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2.5);
|
|
35
|
+
const px = Math.round((canvas.clientWidth || QR_DOOR_SIZE) * dpr);
|
|
36
|
+
// A canvas starts at 300×150, so check both sides, not just the width.
|
|
37
|
+
if (canvas.width !== px || canvas.height !== px)
|
|
38
|
+
canvas.width = canvas.height = px;
|
|
39
|
+
ctx.setTransform(px / QR_DOOR_SIZE, 0, 0, px / QR_DOOR_SIZE, 0, 0);
|
|
40
|
+
};
|
|
41
|
+
const paint = () => {
|
|
42
|
+
fit();
|
|
43
|
+
if (!approved) {
|
|
44
|
+
drawQrDoorRest(ctx, scene, th);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const t = reduce ? QR_DOOR_DURATION : (performance.now() - start) / 1000;
|
|
48
|
+
drawQrDoorScene(ctx, scene, t, th);
|
|
49
|
+
if (t >= QR_DOOR_DURATION) {
|
|
50
|
+
if (!finished) {
|
|
51
|
+
finished = true;
|
|
52
|
+
onDoneRef.current?.();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
raf = requestAnimationFrame(paint);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
paint();
|
|
60
|
+
const resize = new ResizeObserver(() => {
|
|
61
|
+
if (!approved || finished)
|
|
62
|
+
paint();
|
|
63
|
+
});
|
|
64
|
+
resize.observe(canvas);
|
|
65
|
+
return () => {
|
|
66
|
+
cancelAnimationFrame(raf);
|
|
67
|
+
resize.disconnect();
|
|
68
|
+
};
|
|
69
|
+
}, [scene, approved, ink, accent, paper]);
|
|
70
|
+
return (_jsx("canvas", { ref: canvasRef, className: className, style: { display: 'block', width: '100%', aspectRatio: '1' }, role: "img", "aria-label": approved ? 'Sign-in approved' : 'Scan this code with your phone to sign in' }));
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|
|
@@ -42,6 +42,14 @@
|
|
|
42
42
|
"types": "./dist/sessions.d.ts",
|
|
43
43
|
"default": "./dist/sessions.js"
|
|
44
44
|
},
|
|
45
|
+
"./person-token": {
|
|
46
|
+
"types": "./dist/person-token.d.ts",
|
|
47
|
+
"default": "./dist/person-token.js"
|
|
48
|
+
},
|
|
49
|
+
"./qr-door": {
|
|
50
|
+
"types": "./dist/qr-door.d.ts",
|
|
51
|
+
"default": "./dist/qr-door.js"
|
|
52
|
+
},
|
|
45
53
|
"./testing": {
|
|
46
54
|
"types": "./dist/testing.d.ts",
|
|
47
55
|
"default": "./dist/testing.js"
|
|
@@ -59,25 +67,32 @@
|
|
|
59
67
|
"test:cookie-browser": "vitest run --config vitest.cookie-browser.config.ts"
|
|
60
68
|
},
|
|
61
69
|
"dependencies": {
|
|
70
|
+
"@wtfalch/utils": "0.1.0",
|
|
62
71
|
"jose": "^6.2.10",
|
|
63
72
|
"obscenity": "^0.4.6",
|
|
64
73
|
"openid-client": "6.8.7",
|
|
74
|
+
"qrcode": "^1.5.4",
|
|
65
75
|
"reserved-usernames": "^1.1.6"
|
|
66
76
|
},
|
|
67
77
|
"peerDependencies": {
|
|
68
|
-
"next": ">=15.0.0"
|
|
78
|
+
"next": ">=15.0.0",
|
|
79
|
+
"react": ">=19.0.0"
|
|
69
80
|
},
|
|
70
81
|
"peerDependenciesMeta": {
|
|
71
82
|
"next": {
|
|
72
83
|
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"react": {
|
|
86
|
+
"optional": true
|
|
73
87
|
}
|
|
74
88
|
},
|
|
75
89
|
"devDependencies": {
|
|
76
90
|
"@electric-sql/pglite": "^0.5.8",
|
|
77
91
|
"@types/node": "^22",
|
|
92
|
+
"@types/qrcode": "^1.5.6",
|
|
78
93
|
"@types/react": "^19",
|
|
79
94
|
"@wtfalch/auth-broker": "workspace:*",
|
|
80
|
-
"next": "^16",
|
|
95
|
+
"next": "^16.4.0",
|
|
81
96
|
"playwright-core": "^1.62.1",
|
|
82
97
|
"react": "^19",
|
|
83
98
|
"typescript": "^5.9.0",
|