@wtfalch/auth 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/onboarding.d.ts +30 -0
- package/dist/onboarding.js +93 -0
- package/package.json +5 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The onboarding hold: what the broker hands an app's onboarding page during an
|
|
3
|
+
* MCP sign-in (ADR 0002), and what the app checks before it trusts the page's
|
|
4
|
+
* caller. Only WebCrypto, so this entry runs on workerd and in Node alike.
|
|
5
|
+
*
|
|
6
|
+
* `HOLD = base64url(JSON {h, s, o, e}) . base64url(HMAC-SHA256(appKey, firstPart))`
|
|
7
|
+
* where `h` is the hold id, `s` the person's ZITADEL user id, `o` their
|
|
8
|
+
* organisation id and `e` the expiry in epoch seconds. The key is the app's
|
|
9
|
+
* broker key (`AUTH_APP_KEY`), which only the broker and that app hold. A hold
|
|
10
|
+
* is a bearer proof that the broker just signed this person in; it carries no
|
|
11
|
+
* token and it is single use at the broker.
|
|
12
|
+
*/
|
|
13
|
+
export interface OnboardingHold {
|
|
14
|
+
holdId: string;
|
|
15
|
+
sub: string;
|
|
16
|
+
organizationId: string;
|
|
17
|
+
}
|
|
18
|
+
/** How long the broker accepts a hold it issued. */
|
|
19
|
+
export declare const ONBOARDING_HOLD_TTL_SECONDS = 600;
|
|
20
|
+
/** Signs a hold for `appKey`. `expiresAt` is epoch seconds. */
|
|
21
|
+
export declare function signOnboardingHold(input: OnboardingHold & {
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
}, appKey: string): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* The hold's contents when it is well formed, signed with `appKey` and not
|
|
26
|
+
* expired; otherwise null, with no hint which check failed. `now` is unix
|
|
27
|
+
* seconds, the unit of the hold's `e` (a test seam). The signature is compared by `crypto.subtle.verify`,
|
|
28
|
+
* which is constant time.
|
|
29
|
+
*/
|
|
30
|
+
export declare function verifyOnboardingHold(hold: string, appKey: string, now?: number): Promise<OnboardingHold | null>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The onboarding hold: what the broker hands an app's onboarding page during an
|
|
3
|
+
* MCP sign-in (ADR 0002), and what the app checks before it trusts the page's
|
|
4
|
+
* caller. Only WebCrypto, so this entry runs on workerd and in Node alike.
|
|
5
|
+
*
|
|
6
|
+
* `HOLD = base64url(JSON {h, s, o, e}) . base64url(HMAC-SHA256(appKey, firstPart))`
|
|
7
|
+
* where `h` is the hold id, `s` the person's ZITADEL user id, `o` their
|
|
8
|
+
* organisation id and `e` the expiry in epoch seconds. The key is the app's
|
|
9
|
+
* broker key (`AUTH_APP_KEY`), which only the broker and that app hold. A hold
|
|
10
|
+
* is a bearer proof that the broker just signed this person in; it carries no
|
|
11
|
+
* token and it is single use at the broker.
|
|
12
|
+
*/
|
|
13
|
+
/** How long the broker accepts a hold it issued. */
|
|
14
|
+
export const ONBOARDING_HOLD_TTL_SECONDS = 600;
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const HOLD_RE = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/;
|
|
17
|
+
const MAX_HOLD_LENGTH = 2048;
|
|
18
|
+
const toBase64Url = (bytes) => {
|
|
19
|
+
let binary = '';
|
|
20
|
+
for (const byte of bytes)
|
|
21
|
+
binary += String.fromCharCode(byte);
|
|
22
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
23
|
+
};
|
|
24
|
+
const fromBase64Url = (value) => {
|
|
25
|
+
try {
|
|
26
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
27
|
+
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4));
|
|
28
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const hmacKey = (appKey, usage) => crypto.subtle.importKey('raw', encoder.encode(appKey), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
|
35
|
+
usage,
|
|
36
|
+
]);
|
|
37
|
+
/** Signs a hold for `appKey`. `expiresAt` is epoch seconds. */
|
|
38
|
+
export async function signOnboardingHold(input, appKey) {
|
|
39
|
+
const payload = toBase64Url(encoder.encode(JSON.stringify({
|
|
40
|
+
h: input.holdId,
|
|
41
|
+
s: input.sub,
|
|
42
|
+
o: input.organizationId,
|
|
43
|
+
e: input.expiresAt,
|
|
44
|
+
})));
|
|
45
|
+
const mac = await crypto.subtle.sign('HMAC', await hmacKey(appKey, 'sign'), encoder.encode(payload));
|
|
46
|
+
return `${payload}.${toBase64Url(new Uint8Array(mac))}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The hold's contents when it is well formed, signed with `appKey` and not
|
|
50
|
+
* expired; otherwise null, with no hint which check failed. `now` is unix
|
|
51
|
+
* seconds, the unit of the hold's `e` (a test seam). The signature is compared by `crypto.subtle.verify`,
|
|
52
|
+
* which is constant time.
|
|
53
|
+
*/
|
|
54
|
+
export async function verifyOnboardingHold(hold, appKey, now = Math.floor(Date.now() / 1000)) {
|
|
55
|
+
if (typeof hold !== 'string' || hold.length > MAX_HOLD_LENGTH || !appKey)
|
|
56
|
+
return null;
|
|
57
|
+
const match = HOLD_RE.exec(hold);
|
|
58
|
+
if (!match)
|
|
59
|
+
return null;
|
|
60
|
+
const [, payload, signature] = match;
|
|
61
|
+
const mac = fromBase64Url(signature);
|
|
62
|
+
if (!mac)
|
|
63
|
+
return null;
|
|
64
|
+
let valid;
|
|
65
|
+
try {
|
|
66
|
+
valid = await crypto.subtle.verify('HMAC', await hmacKey(appKey, 'verify'), mac, encoder.encode(payload));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!valid)
|
|
72
|
+
return null;
|
|
73
|
+
const bytes = fromBase64Url(payload);
|
|
74
|
+
if (!bytes)
|
|
75
|
+
return null;
|
|
76
|
+
let claims;
|
|
77
|
+
try {
|
|
78
|
+
claims = JSON.parse(new TextDecoder().decode(bytes));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (claims === null || typeof claims !== 'object')
|
|
84
|
+
return null;
|
|
85
|
+
const { h, s, o, e } = claims;
|
|
86
|
+
if (typeof h !== 'string' || !h || typeof s !== 'string' || !s)
|
|
87
|
+
return null;
|
|
88
|
+
if (typeof o !== 'string' || !o || typeof e !== 'number' || !Number.isFinite(e))
|
|
89
|
+
return null;
|
|
90
|
+
if (e <= now)
|
|
91
|
+
return null;
|
|
92
|
+
return { holdId: h, sub: s, organizationId: o };
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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",
|
|
@@ -50,6 +50,10 @@
|
|
|
50
50
|
"types": "./dist/mcp.d.ts",
|
|
51
51
|
"default": "./dist/mcp.js"
|
|
52
52
|
},
|
|
53
|
+
"./onboarding": {
|
|
54
|
+
"types": "./dist/onboarding.d.ts",
|
|
55
|
+
"default": "./dist/onboarding.js"
|
|
56
|
+
},
|
|
53
57
|
"./worker-session": {
|
|
54
58
|
"types": "./dist/worker-session.d.ts",
|
|
55
59
|
"default": "./dist/worker-session.js"
|