@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
|
@@ -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 {};
|