@zappie/sdk 0.1.16 → 0.1.18
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/CHANGELOG.md +25 -0
- package/README.md +41 -15
- package/dist/checkout/embed.d.ts +148 -0
- package/dist/checkout/embed.js +240 -0
- package/dist/checkout/embed.js.map +7 -0
- package/dist/checkout/react.d.ts +150 -0
- package/dist/checkout/react.js +39 -0
- package/dist/checkout/react.js.map +7 -0
- package/dist/debug.js +1 -1
- package/dist/index.js +1 -1
- package/dist/server/auth/crypto.js +106 -0
- package/dist/server/auth/crypto.js.map +7 -0
- package/dist/server/auth/jwt.js +204 -0
- package/dist/server/auth/jwt.js.map +7 -0
- package/dist/server/auth/session-store.js +46 -0
- package/dist/server/auth/session-store.js.map +7 -0
- package/dist/server/edge-snapshot.js +200 -0
- package/dist/server/edge-snapshot.js.map +7 -0
- package/dist/server/index.d.ts +324 -2
- package/dist/server/index.js +40 -0
- package/dist/server/index.js.map +2 -2
- package/package.json +9 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { WireLine, WireMoney } from '@zappie/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Address fields you can send with a delivery checkout request.
|
|
5
|
+
*/
|
|
6
|
+
type CheckoutAddress = {
|
|
7
|
+
line1?: string;
|
|
8
|
+
city?: string;
|
|
9
|
+
place_id?: string;
|
|
10
|
+
lat?: number;
|
|
11
|
+
lng?: number;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Pickup or delivery details for a checkout session, including an optional schedule.
|
|
15
|
+
*/
|
|
16
|
+
type CheckoutFulfilment = {
|
|
17
|
+
/** Dine-in orders are not checked out through a session. */
|
|
18
|
+
type: "pickup" | "delivery";
|
|
19
|
+
address?: CheckoutAddress;
|
|
20
|
+
scheduled_for?: string | null;
|
|
21
|
+
delivery_region_id?: string;
|
|
22
|
+
instructions?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Cart lines, branch, fulfilment and return URLs you send to open checkout.
|
|
26
|
+
*/
|
|
27
|
+
type CheckoutRequest = {
|
|
28
|
+
branch_id: string;
|
|
29
|
+
menu_version: string;
|
|
30
|
+
lines: WireLine[];
|
|
31
|
+
fulfilment: CheckoutFulfilment;
|
|
32
|
+
contact_phone?: string;
|
|
33
|
+
promo_code?: string;
|
|
34
|
+
return_url: string;
|
|
35
|
+
cancel_url?: string;
|
|
36
|
+
source?: string;
|
|
37
|
+
campaign?: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* A checkout session returned by the service, with its amount, expiry and order status.
|
|
41
|
+
*/
|
|
42
|
+
type CheckoutSession = {
|
|
43
|
+
id: string;
|
|
44
|
+
status: "open" | "completed" | "expired" | "cancelled";
|
|
45
|
+
url?: string;
|
|
46
|
+
amount: WireMoney;
|
|
47
|
+
order_id: string | null;
|
|
48
|
+
return_url: string;
|
|
49
|
+
cancel_url?: string;
|
|
50
|
+
expires_at: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* A checkout session with a payment URL you can use to send the shopper to checkout.
|
|
54
|
+
*/
|
|
55
|
+
type OpenCheckoutSession = CheckoutSession & {
|
|
56
|
+
url: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* A terminal checkout result: a completed order, an expired session or a cancelled session.
|
|
60
|
+
*/
|
|
61
|
+
type CheckoutCompletion = (CheckoutSession & {
|
|
62
|
+
status: "completed";
|
|
63
|
+
order_id: string;
|
|
64
|
+
}) | (CheckoutSession & {
|
|
65
|
+
status: "expired" | "cancelled";
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* Optional customer API base URL and fetch implementation for the checkout client.
|
|
69
|
+
*/
|
|
70
|
+
type CheckoutClientOptions = {
|
|
71
|
+
customerBase?: string;
|
|
72
|
+
fetch?: typeof fetch;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Cancellation signal and polling interval in milliseconds for awaiting checkout completion.
|
|
76
|
+
*/
|
|
77
|
+
type AwaitCompletionOptions = {
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
intervalMs?: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a client that opens checkout sessions, reads them and polls for completion.
|
|
84
|
+
*
|
|
85
|
+
* @param options - Override the default `/_zappie/api` base or fetch implementation.
|
|
86
|
+
* @returns A checkout client. Retain each idempotency key when retrying the same cart.
|
|
87
|
+
*/
|
|
88
|
+
declare function createCheckout(options?: CheckoutClientOptions): {
|
|
89
|
+
/** The caller retains this key and reuses it when retrying the same cart. */
|
|
90
|
+
open(body: CheckoutRequest, idempotencyKey: string): Promise<OpenCheckoutSession>;
|
|
91
|
+
session(id: string): Promise<CheckoutSession>;
|
|
92
|
+
awaitCompletion(id: string, options?: AwaitCompletionOptions): Promise<CheckoutCompletion>;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* The session creation, lookup and completion methods returned by createCheckout.
|
|
96
|
+
*/
|
|
97
|
+
type CheckoutClient = ReturnType<typeof createCheckout>;
|
|
98
|
+
|
|
99
|
+
/** How checkout opens. Auto redirects in in-app browsers and framed pages, else a modal on desktop and a sheet on mobile. */
|
|
100
|
+
type CheckoutPresentation = "auto" | "modal" | "sheet" | "fullscreen" | "redirect";
|
|
101
|
+
/** The checkout look from toCheckoutTheme in @zappie/tokens/checkout. The checkout validates it before use. */
|
|
102
|
+
type CheckoutTheme = {
|
|
103
|
+
version: 1;
|
|
104
|
+
tokens: Partial<Record<`--z-${string}`, string>>;
|
|
105
|
+
logo?: {
|
|
106
|
+
url: string;
|
|
107
|
+
alt: string;
|
|
108
|
+
};
|
|
109
|
+
fonts?: {
|
|
110
|
+
family: string;
|
|
111
|
+
role: "body" | "display";
|
|
112
|
+
weights?: number[];
|
|
113
|
+
}[];
|
|
114
|
+
copy?: {
|
|
115
|
+
payButton?: string;
|
|
116
|
+
successTitle?: string;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
/** Presentation, theme and step reporting for an embedded checkout. */
|
|
120
|
+
type EmbedOptions = {
|
|
121
|
+
presentation?: CheckoutPresentation;
|
|
122
|
+
theme?: CheckoutTheme;
|
|
123
|
+
/** Called with each checkout step, and whether a payment has started. */
|
|
124
|
+
onStep?: (step: string, paying: boolean) => void;
|
|
125
|
+
};
|
|
126
|
+
/** How an embedded checkout ended. A completed result is confirmed by the server first. */
|
|
127
|
+
type CheckoutResult = {
|
|
128
|
+
status: "completed";
|
|
129
|
+
orderId: string;
|
|
130
|
+
} | {
|
|
131
|
+
status: "cancelled";
|
|
132
|
+
reason: string;
|
|
133
|
+
} | {
|
|
134
|
+
status: "redirected";
|
|
135
|
+
};
|
|
136
|
+
/** Ask the open checkout to close. Mid-payment it asks the shopper first. */
|
|
137
|
+
declare function closeCheckout(): void;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Open checkout for a cart and follow it. `open` creates the session, then embeds or redirects it as
|
|
141
|
+
* `presentation` decides, and resolves with how it ended. Retain the idempotency key when retrying a cart.
|
|
142
|
+
*/
|
|
143
|
+
declare function useCheckout(checkout: CheckoutClient): {
|
|
144
|
+
open: (body: CheckoutRequest, key: string, options?: EmbedOptions) => Promise<CheckoutResult>;
|
|
145
|
+
close: typeof closeCheckout;
|
|
146
|
+
status: "open" | "completed" | "cancelled" | "redirected" | "idle" | "opening";
|
|
147
|
+
step: string | undefined;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export { useCheckout };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/checkout/react.ts
|
|
4
|
+
import { useCallback, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
closeCheckout,
|
|
7
|
+
embedCheckout
|
|
8
|
+
} from "./embed.js";
|
|
9
|
+
function useCheckout(checkout) {
|
|
10
|
+
const [status, setStatus] = useState("idle");
|
|
11
|
+
const [step, setStep] = useState();
|
|
12
|
+
const open = useCallback(
|
|
13
|
+
async (body, key, options = {}) => {
|
|
14
|
+
setStatus("opening");
|
|
15
|
+
try {
|
|
16
|
+
const session = await checkout.open(body, key);
|
|
17
|
+
setStatus("open");
|
|
18
|
+
const result = await embedCheckout(checkout, session, {
|
|
19
|
+
...options,
|
|
20
|
+
onStep: (next, paying) => {
|
|
21
|
+
setStep(next);
|
|
22
|
+
options.onStep?.(next, paying);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
setStatus(result.status);
|
|
26
|
+
return result;
|
|
27
|
+
} catch (failure) {
|
|
28
|
+
setStatus("idle");
|
|
29
|
+
throw failure;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
[checkout]
|
|
33
|
+
);
|
|
34
|
+
return { open, close: closeCheckout, status, step };
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
useCheckout
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=react.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/checkout/react.ts"],
|
|
4
|
+
"sourcesContent": ["\"use client\";\nimport { useCallback, useState } from \"react\";\nimport type { CheckoutClient } from \"./client\";\nimport {\n closeCheckout,\n embedCheckout,\n type CheckoutResult,\n type EmbedOptions,\n} from \"./embed\";\nimport type { CheckoutRequest } from \"./types\";\n\n/**\n * Open checkout for a cart and follow it. `open` creates the session, then embeds or redirects it as\n * `presentation` decides, and resolves with how it ended. Retain the idempotency key when retrying a cart.\n */\nexport function useCheckout(checkout: CheckoutClient) {\n const [status, setStatus] = useState<\n \"idle\" | \"opening\" | \"open\" | CheckoutResult[\"status\"]\n >(\"idle\");\n const [step, setStep] = useState<string>();\n const open = useCallback(\n async (body: CheckoutRequest, key: string, options: EmbedOptions = {}) => {\n setStatus(\"opening\");\n try {\n const session = await checkout.open(body, key);\n setStatus(\"open\");\n const result = await embedCheckout(checkout, session, {\n ...options,\n onStep: (next, paying) => {\n setStep(next);\n options.onStep?.(next, paying);\n },\n });\n setStatus(result.status);\n return result;\n } catch (failure) {\n setStatus(\"idle\");\n throw failure;\n }\n },\n [checkout],\n );\n return { open, close: closeCheckout, status, step };\n}\n"],
|
|
5
|
+
"mappings": ";;;AACA,SAAS,aAAa,gBAAgB;AAEtC;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AAOA,SAAS,YAAY,UAA0B;AACpD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAE1B,MAAM;AACR,QAAM,CAAC,MAAM,OAAO,IAAI,SAAiB;AACzC,QAAM,OAAO;AAAA,IACX,OAAO,MAAuB,KAAa,UAAwB,CAAC,MAAM;AACxE,gBAAU,SAAS;AACnB,UAAI;AACF,cAAM,UAAU,MAAM,SAAS,KAAK,MAAM,GAAG;AAC7C,kBAAU,MAAM;AAChB,cAAM,SAAS,MAAM,cAAc,UAAU,SAAS;AAAA,UACpD,GAAG;AAAA,UACH,QAAQ,CAAC,MAAM,WAAW;AACxB,oBAAQ,IAAI;AACZ,oBAAQ,SAAS,MAAM,MAAM;AAAA,UAC/B;AAAA,QACF,CAAC;AACD,kBAAU,OAAO,MAAM;AACvB,eAAO;AAAA,MACT,SAAS,SAAS;AAChB,kBAAU,MAAM;AAChB,cAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,CAAC,QAAQ;AAAA,EACX;AACA,SAAO,EAAE,MAAM,OAAO,eAAe,QAAQ,KAAK;AACpD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/debug.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { ZappieError, isZappieError } from "./errors.js";
|
|
|
10
10
|
export * from "./models.js";
|
|
11
11
|
import { format, money } from "./money.js";
|
|
12
12
|
import { RestTransport } from "./transport/rest.js";
|
|
13
|
-
var VERSION = true ? "0.1.
|
|
13
|
+
var VERSION = true ? "0.1.18" : "0.0.0-development";
|
|
14
14
|
export {
|
|
15
15
|
API_VERSION,
|
|
16
16
|
RestTransport,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/server/auth/crypto.ts
|
|
2
|
+
var encoder = new TextEncoder();
|
|
3
|
+
var decoder = new TextDecoder();
|
|
4
|
+
function base64UrlEncode(bytes) {
|
|
5
|
+
let binary = "";
|
|
6
|
+
for (const byte of bytes) {
|
|
7
|
+
binary += String.fromCharCode(byte);
|
|
8
|
+
}
|
|
9
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
10
|
+
}
|
|
11
|
+
function base64UrlDecode(text) {
|
|
12
|
+
return /^[\w-]*$/.test(text) && text.length % 4 !== 1 ? Uint8Array.from(
|
|
13
|
+
atob(text.replaceAll("-", "+").replaceAll("_", "/")),
|
|
14
|
+
(c) => c.charCodeAt(0)
|
|
15
|
+
) : null;
|
|
16
|
+
}
|
|
17
|
+
function randomToken(bytes = 32) {
|
|
18
|
+
if (!Number.isInteger(bytes) || bytes < 16 || bytes > 1024) {
|
|
19
|
+
throw new RangeError("randomToken takes between 16 and 1024 bytes.");
|
|
20
|
+
}
|
|
21
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
22
|
+
}
|
|
23
|
+
async function pkceChallenge(verifier) {
|
|
24
|
+
const digest = await crypto.subtle.digest(
|
|
25
|
+
"SHA-256",
|
|
26
|
+
encoder.encode(verifier)
|
|
27
|
+
);
|
|
28
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
29
|
+
}
|
|
30
|
+
async function createPkce() {
|
|
31
|
+
const verifier = randomToken();
|
|
32
|
+
return { verifier, challenge: await pkceChallenge(verifier), method: "S256" };
|
|
33
|
+
}
|
|
34
|
+
function secretsOf(secret) {
|
|
35
|
+
const list = typeof secret === "string" ? [secret] : secret;
|
|
36
|
+
if (!list.length || list.some((value) => encoder.encode(value).length < 32)) {
|
|
37
|
+
throw new TypeError("Each sealing secret needs at least 32 bytes.");
|
|
38
|
+
}
|
|
39
|
+
return list;
|
|
40
|
+
}
|
|
41
|
+
var keys = /* @__PURE__ */ new Map();
|
|
42
|
+
function keyFor(secret) {
|
|
43
|
+
if (!keys.has(secret)) {
|
|
44
|
+
if (keys.size >= 16) {
|
|
45
|
+
keys.clear();
|
|
46
|
+
}
|
|
47
|
+
keys.set(
|
|
48
|
+
secret,
|
|
49
|
+
crypto.subtle.digest("SHA-256", encoder.encode(secret)).then(
|
|
50
|
+
(digest) => crypto.subtle.importKey("raw", digest, "AES-GCM", false, [
|
|
51
|
+
"encrypt",
|
|
52
|
+
"decrypt"
|
|
53
|
+
])
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return keys.get(secret);
|
|
58
|
+
}
|
|
59
|
+
var params = (iv, purpose) => ({
|
|
60
|
+
name: "AES-GCM",
|
|
61
|
+
iv,
|
|
62
|
+
...purpose === void 0 ? {} : { additionalData: encoder.encode(purpose) }
|
|
63
|
+
});
|
|
64
|
+
async function sealValue(value, { secret, purpose }) {
|
|
65
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
66
|
+
const cipher = await crypto.subtle.encrypt(
|
|
67
|
+
params(iv, purpose),
|
|
68
|
+
await keyFor(secretsOf(secret)[0]),
|
|
69
|
+
encoder.encode(JSON.stringify(value))
|
|
70
|
+
);
|
|
71
|
+
return `${base64UrlEncode(iv)}.${base64UrlEncode(new Uint8Array(cipher))}`;
|
|
72
|
+
}
|
|
73
|
+
async function openSealedValue(sealed, { secret, purpose }) {
|
|
74
|
+
const list = secretsOf(secret);
|
|
75
|
+
const [ivText = "", cipherText = "", extra] = (sealed ?? "").split(".");
|
|
76
|
+
const iv = base64UrlDecode(ivText);
|
|
77
|
+
const cipher = base64UrlDecode(cipherText);
|
|
78
|
+
if (iv?.length !== 12 || !cipher?.length || extra !== void 0) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
for (const candidate of list) {
|
|
82
|
+
try {
|
|
83
|
+
const plain = await crypto.subtle.decrypt(
|
|
84
|
+
params(iv, purpose),
|
|
85
|
+
await keyFor(candidate),
|
|
86
|
+
cipher
|
|
87
|
+
);
|
|
88
|
+
return JSON.parse(decoder.decode(plain));
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
export {
|
|
95
|
+
base64UrlDecode,
|
|
96
|
+
base64UrlEncode,
|
|
97
|
+
createPkce,
|
|
98
|
+
decoder,
|
|
99
|
+
encoder,
|
|
100
|
+
openSealedValue,
|
|
101
|
+
pkceChallenge,
|
|
102
|
+
randomToken,
|
|
103
|
+
sealValue,
|
|
104
|
+
secretsOf
|
|
105
|
+
};
|
|
106
|
+
//# sourceMappingURL=crypto.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/server/auth/crypto.ts"],
|
|
4
|
+
"sourcesContent": ["export const encoder = new TextEncoder();\nexport const decoder = new TextDecoder();\n\nexport function base64UrlEncode(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) {\n binary += String.fromCharCode(byte);\n }\n return btoa(binary)\n .replaceAll(\"+\", \"-\")\n .replaceAll(\"/\", \"_\")\n .replace(/=+$/, \"\");\n}\n\n// Null for anything but unpadded base64url, so a forged segment is refused\n// as malformed rather than thrown as a DOMException.\nexport function base64UrlDecode(text: string): Uint8Array<ArrayBuffer> | null {\n return /^[\\w-]*$/.test(text) && text.length % 4 !== 1\n ? Uint8Array.from(\n atob(text.replaceAll(\"-\", \"+\").replaceAll(\"_\", \"/\")),\n (c) => c.charCodeAt(0),\n )\n : null;\n}\n\n/** A PKCE code verifier and its S256 challenge for one sign-in attempt. */\nexport type PkcePair = {\n /** Keep this on the server and send it with the code exchange. */\n verifier: string;\n /** Send this to the authorize endpoint as `code_challenge`. */\n challenge: string;\n /** Send this as `code_challenge_method`. */\n method: \"S256\";\n};\n\n/** One sealing secret, or several while you rotate them. The first one seals. */\nexport type SealSecret = string | readonly string[];\n\n/** Options for sealing a value into a cookie-safe string. */\nexport type SealOptions = {\n /**\n * At least 32 bytes each, from a random generator such as `randomToken()`,\n * never a password or phrase. The key is the secret's SHA-256 with no\n * stretching, so a guessable secret can be found offline from one sealed\n * value. List the new secret first while you rotate.\n */\n secret: SealSecret;\n /**\n * Binds the sealed value to one use, such as `\"tx\"`, so it won't open for\n * another. Leave it out to match the hosted sign-in Worker's format.\n */\n purpose?: string;\n};\n\n/** Create a random URL-safe token from `bytes` random bytes, for state, nonces and ids. */\nexport function randomToken(bytes = 32): string {\n if (!Number.isInteger(bytes) || bytes < 16 || bytes > 1024) {\n throw new RangeError(\"randomToken takes between 16 and 1024 bytes.\");\n }\n return base64UrlEncode(crypto.getRandomValues(new Uint8Array(bytes)));\n}\n\nexport async function pkceChallenge(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest(\n \"SHA-256\",\n encoder.encode(verifier),\n );\n return base64UrlEncode(new Uint8Array(digest));\n}\n\n/** Create a PKCE verifier and its S256 challenge. */\nexport async function createPkce(): Promise<PkcePair> {\n const verifier = randomToken();\n return { verifier, challenge: await pkceChallenge(verifier), method: \"S256\" };\n}\n\nexport function secretsOf(secret: SealSecret): readonly string[] {\n const list = typeof secret === \"string\" ? [secret] : secret;\n if (!list.length || list.some((value) => encoder.encode(value).length < 32)) {\n throw new TypeError(\"Each sealing secret needs at least 32 bytes.\");\n }\n return list;\n}\n\nconst keys = new Map<string, Promise<CryptoKey>>();\n\n// The key is the SHA-256 of the secret, as in the hosted sign-in Worker, so a\n// value it sealed opens here with the same secret.\nfunction keyFor(secret: string) {\n if (!keys.has(secret)) {\n if (keys.size >= 16) {\n keys.clear();\n }\n keys.set(\n secret,\n crypto.subtle\n .digest(\"SHA-256\", encoder.encode(secret))\n .then((digest) =>\n crypto.subtle.importKey(\"raw\", digest, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]),\n ),\n );\n }\n return keys.get(secret) as Promise<CryptoKey>;\n}\n\nconst params = (iv: Uint8Array<ArrayBuffer>, purpose?: string) => ({\n name: \"AES-GCM\",\n iv,\n ...(purpose === undefined ? {} : { additionalData: encoder.encode(purpose) }),\n});\n\n/** Encrypt a JSON value with AES-GCM into a cookie-safe string. */\nexport async function sealValue(\n value: unknown,\n { secret, purpose }: SealOptions,\n): Promise<string> {\n const iv = crypto.getRandomValues(new Uint8Array(12));\n const cipher = await crypto.subtle.encrypt(\n params(iv, purpose),\n await keyFor(secretsOf(secret)[0] as string),\n encoder.encode(JSON.stringify(value)),\n );\n return `${base64UrlEncode(iv)}.${base64UrlEncode(new Uint8Array(cipher))}`;\n}\n\n/** Decrypt a sealed value with any listed secret. Returns null when none opens it. */\nexport async function openSealedValue<T = unknown>(\n sealed: string | null | undefined,\n { secret, purpose }: SealOptions,\n): Promise<T | null> {\n const list = secretsOf(secret);\n const [ivText = \"\", cipherText = \"\", extra] = (sealed ?? \"\").split(\".\");\n const iv = base64UrlDecode(ivText);\n const cipher = base64UrlDecode(cipherText);\n if (iv?.length !== 12 || !cipher?.length || extra !== undefined) {\n return null;\n }\n for (const candidate of list) {\n try {\n const plain = await crypto.subtle.decrypt(\n params(iv, purpose),\n await keyFor(candidate),\n cipher,\n );\n return JSON.parse(decoder.decode(plain)) as T;\n } catch {\n // Try the next secret.\n }\n }\n return null;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/server/auth/jwt.ts
|
|
2
|
+
import { ZappieError } from "../../errors.js";
|
|
3
|
+
import { base64UrlDecode, decoder, encoder } from "./crypto.js";
|
|
4
|
+
var messages = {
|
|
5
|
+
malformed: "The token isn't a signed JWT.",
|
|
6
|
+
unsupported_algorithm: "The token uses an algorithm this verifier refuses.",
|
|
7
|
+
unknown_key: "The token was signed with a key the issuer doesn't publish.",
|
|
8
|
+
invalid_signature: "The token's signature doesn't match.",
|
|
9
|
+
wrong_issuer: "The token comes from a different issuer.",
|
|
10
|
+
wrong_audience: "The token was issued for a different audience.",
|
|
11
|
+
wrong_nonce: "The token wasn't issued for this sign-in.",
|
|
12
|
+
wrong_subject: "The token's subject isn't a Zappie customer.",
|
|
13
|
+
wrong_type: "The token isn't an ID token.",
|
|
14
|
+
expired: "The token has expired.",
|
|
15
|
+
not_yet_valid: "The token isn't valid yet.",
|
|
16
|
+
keys_unavailable: "The identity issuer's signing keys couldn't be read."
|
|
17
|
+
};
|
|
18
|
+
var ZappieTokenError = class extends ZappieError {
|
|
19
|
+
constructor(reason, cause) {
|
|
20
|
+
super(
|
|
21
|
+
reason === "keys_unavailable" ? "network_error" : "unauthorized",
|
|
22
|
+
messages[reason],
|
|
23
|
+
cause === void 0 ? {} : { cause }
|
|
24
|
+
);
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
this.name = "ZappieTokenError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var specs = {
|
|
30
|
+
RS256: {
|
|
31
|
+
kty: "RSA",
|
|
32
|
+
jwk: (key) => ({ kty: "RSA", n: key.n, e: key.e }),
|
|
33
|
+
import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
34
|
+
verify: { name: "RSASSA-PKCS1-v1_5" }
|
|
35
|
+
},
|
|
36
|
+
ES256: {
|
|
37
|
+
kty: "EC",
|
|
38
|
+
jwk: (key) => ({ kty: "EC", crv: "P-256", x: key.x, y: key.y }),
|
|
39
|
+
import: { name: "ECDSA", namedCurve: "P-256" },
|
|
40
|
+
verify: { name: "ECDSA", hash: "SHA-256" }
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var fail = (reason, cause) => {
|
|
44
|
+
throw new ZappieTokenError(reason, cause);
|
|
45
|
+
};
|
|
46
|
+
var numeric = (value) => typeof value === "number" && Number.isFinite(value);
|
|
47
|
+
var trimSlash = (value) => value.replace(/\/+$/, "");
|
|
48
|
+
var KEY_RETRY_MS = 3e4;
|
|
49
|
+
function decodeJson(text) {
|
|
50
|
+
const bytes = base64UrlDecode(text) ?? fail("malformed");
|
|
51
|
+
let value;
|
|
52
|
+
try {
|
|
53
|
+
value = JSON.parse(decoder.decode(bytes));
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : fail("malformed");
|
|
57
|
+
}
|
|
58
|
+
function createTokenVerifier(options) {
|
|
59
|
+
const issuer = trimSlash(options.issuer);
|
|
60
|
+
const allowed = options.algorithms ?? ["RS256", "ES256"];
|
|
61
|
+
const tolerance = options.clockToleranceSec ?? 60;
|
|
62
|
+
const ttl = (options.cacheTtlSec ?? 3600) * 1e3;
|
|
63
|
+
const prefix = options.subjectPrefix === void 0 ? "cus_" : options.subjectPrefix;
|
|
64
|
+
const now = options.now ?? Date.now;
|
|
65
|
+
const read = options.fetch ?? ((url, init) => fetch(url, init));
|
|
66
|
+
if (!issuer || !options.audience || !allowed.length || allowed.some((alg) => !(alg in specs)) || !numeric(tolerance) || tolerance < 0 || !numeric(ttl) || ttl < 0) {
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
"createTokenVerifier needs an issuer, an audience, RS256 or ES256, and a clock tolerance and cache time of 0 or more."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
let cached;
|
|
72
|
+
let pending;
|
|
73
|
+
let triedAt = Number.NEGATIVE_INFINITY;
|
|
74
|
+
let lastError;
|
|
75
|
+
const load = async () => {
|
|
76
|
+
try {
|
|
77
|
+
const response = await read(
|
|
78
|
+
options.jwksUrl ?? `${issuer}/.well-known/jwks.json`,
|
|
79
|
+
{
|
|
80
|
+
headers: { accept: "application/json" },
|
|
81
|
+
signal: AbortSignal.timeout(1e4)
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
const text = response.ok ? await response.text() : "";
|
|
85
|
+
if (!text || text.length > 1 << 20) {
|
|
86
|
+
throw new Error(`The key set answered ${response.status}.`);
|
|
87
|
+
}
|
|
88
|
+
const keys = /* @__PURE__ */ new Map();
|
|
89
|
+
const list = JSON.parse(text).keys;
|
|
90
|
+
for (const jwk of Array.isArray(list) ? list : []) {
|
|
91
|
+
for (const alg of allowed) {
|
|
92
|
+
const spec = specs[alg];
|
|
93
|
+
if (jwk.kty === spec.kty && (jwk.use ?? "sig") === "sig" && (jwk.alg ?? alg) === alg && (alg === "RS256" || jwk.crv === "P-256")) {
|
|
94
|
+
await crypto.subtle.importKey(
|
|
95
|
+
"jwk",
|
|
96
|
+
spec.jwk(jwk),
|
|
97
|
+
spec.import,
|
|
98
|
+
false,
|
|
99
|
+
["verify"]
|
|
100
|
+
).then((key) => keys.set(`${alg}:${String(jwk.kid ?? "")}`, key)).catch(() => void 0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!keys.size) {
|
|
105
|
+
throw new Error("The key set has no keys this verifier can use.");
|
|
106
|
+
}
|
|
107
|
+
return cached = { keys, at: now() };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return fail("keys_unavailable", error);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const find = (set, kid, alg) => {
|
|
113
|
+
if (typeof kid === "string") {
|
|
114
|
+
return set.keys.get(`${alg}:${kid}`);
|
|
115
|
+
}
|
|
116
|
+
const fits = [...set.keys].filter(([id]) => id.startsWith(`${alg}:`));
|
|
117
|
+
return fits.length === 1 ? fits[0]?.[1] : void 0;
|
|
118
|
+
};
|
|
119
|
+
const keyFor = async (kid, alg) => {
|
|
120
|
+
if (!pending) {
|
|
121
|
+
const key = cached && find(cached, kid, alg);
|
|
122
|
+
if (key && cached && now() - cached.at < ttl) {
|
|
123
|
+
return key;
|
|
124
|
+
}
|
|
125
|
+
if (now() - triedAt < KEY_RETRY_MS) {
|
|
126
|
+
return cached ? key : fail("keys_unavailable", lastError);
|
|
127
|
+
}
|
|
128
|
+
triedAt = now();
|
|
129
|
+
pending = load().finally(() => pending = void 0);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const set = await pending;
|
|
133
|
+
lastError = void 0;
|
|
134
|
+
return find(set, kid, alg);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
lastError = error;
|
|
137
|
+
return (cached && find(cached, kid, alg)) ?? fail("keys_unavailable", error);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const check = async (token, checks, idToken) => {
|
|
141
|
+
const parts = typeof token === "string" && token.length < 16384 ? token.split(".") : [];
|
|
142
|
+
const [head = "", body = "", sig = ""] = parts.length === 3 ? parts : fail("malformed");
|
|
143
|
+
const header = decodeJson(head);
|
|
144
|
+
const claims = decodeJson(body);
|
|
145
|
+
const signature = base64UrlDecode(sig) ?? fail("malformed");
|
|
146
|
+
const alg = header.alg;
|
|
147
|
+
if (!allowed.includes(alg) || header.crit !== void 0) {
|
|
148
|
+
fail("unsupported_algorithm");
|
|
149
|
+
}
|
|
150
|
+
if (idToken && header.typ !== void 0 && (typeof header.typ !== "string" || header.typ.toLowerCase().replace(/^application\//, "") !== "jwt")) {
|
|
151
|
+
fail("wrong_type");
|
|
152
|
+
}
|
|
153
|
+
const key = await keyFor(header.kid, alg) ?? fail("unknown_key");
|
|
154
|
+
if (alg === "ES256" && signature.length !== 64 || !await crypto.subtle.verify(
|
|
155
|
+
specs[alg].verify,
|
|
156
|
+
key,
|
|
157
|
+
signature,
|
|
158
|
+
encoder.encode(`${head}.${body}`)
|
|
159
|
+
)) {
|
|
160
|
+
fail("invalid_signature");
|
|
161
|
+
}
|
|
162
|
+
const seconds = now() / 1e3;
|
|
163
|
+
const times = [claims.nbf, claims.iat];
|
|
164
|
+
const sub = claims.sub;
|
|
165
|
+
if (typeof claims.iss !== "string" || trimSlash(claims.iss) !== issuer) {
|
|
166
|
+
fail("wrong_issuer");
|
|
167
|
+
}
|
|
168
|
+
if (![claims.aud].flat().includes(checks.audience ?? options.audience)) {
|
|
169
|
+
fail("wrong_audience");
|
|
170
|
+
}
|
|
171
|
+
if (!numeric(claims.exp) || times.some((time) => time !== void 0 && !numeric(time))) {
|
|
172
|
+
fail("malformed");
|
|
173
|
+
}
|
|
174
|
+
if (seconds > claims.exp + tolerance) {
|
|
175
|
+
fail("expired");
|
|
176
|
+
}
|
|
177
|
+
if (times.some((time) => time > seconds + tolerance)) {
|
|
178
|
+
fail("not_yet_valid");
|
|
179
|
+
}
|
|
180
|
+
if (checks.nonce !== void 0 && claims.nonce !== checks.nonce) {
|
|
181
|
+
fail("wrong_nonce");
|
|
182
|
+
}
|
|
183
|
+
if ((prefix !== null || idToken) && (typeof sub !== "string" || !sub || prefix !== null && (!sub.startsWith(prefix) || sub.length === prefix.length))) {
|
|
184
|
+
fail("wrong_subject");
|
|
185
|
+
}
|
|
186
|
+
return claims;
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
verify: (token, checks = {}) => check(token, checks, false),
|
|
190
|
+
async verifyIdToken(token, checks) {
|
|
191
|
+
if (typeof checks?.nonce !== "string" || !checks.nonce) {
|
|
192
|
+
throw new TypeError(
|
|
193
|
+
"verifyIdToken needs the nonce you sent with the sign-in."
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return await check(token, checks, true);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export {
|
|
201
|
+
ZappieTokenError,
|
|
202
|
+
createTokenVerifier
|
|
203
|
+
};
|
|
204
|
+
//# sourceMappingURL=jwt.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/server/auth/jwt.ts"],
|
|
4
|
+
"sourcesContent": ["import { ZappieError } from \"../../errors\";\nimport { base64UrlDecode, decoder, encoder } from \"./crypto\";\n\n/** Signing algorithms Sign in with Zappie tokens can use. */\nexport type TokenAlgorithm = \"RS256\" | \"ES256\";\n\nconst messages = {\n malformed: \"The token isn't a signed JWT.\",\n unsupported_algorithm: \"The token uses an algorithm this verifier refuses.\",\n unknown_key: \"The token was signed with a key the issuer doesn't publish.\",\n invalid_signature: \"The token's signature doesn't match.\",\n wrong_issuer: \"The token comes from a different issuer.\",\n wrong_audience: \"The token was issued for a different audience.\",\n wrong_nonce: \"The token wasn't issued for this sign-in.\",\n wrong_subject: \"The token's subject isn't a Zappie customer.\",\n wrong_type: \"The token isn't an ID token.\",\n expired: \"The token has expired.\",\n not_yet_valid: \"The token isn't valid yet.\",\n keys_unavailable: \"The identity issuer's signing keys couldn't be read.\",\n};\n\n/** Why a token was refused. */\nexport type TokenErrorReason = keyof typeof messages;\n\n/** A token Zappie's identity issuer didn't sign for you, or one that is no longer valid. */\nexport class ZappieTokenError extends ZappieError {\n constructor(\n readonly reason: TokenErrorReason,\n cause?: unknown,\n ) {\n super(\n reason === \"keys_unavailable\" ? \"network_error\" : \"unauthorized\",\n messages[reason],\n cause === undefined ? {} : { cause },\n );\n this.name = \"ZappieTokenError\";\n }\n}\n\n/** The verified claims of a token. Extra claims stay on the object. */\nexport type TokenClaims = {\n iss: string;\n aud: string | string[];\n exp: number;\n sub?: string;\n iat?: number;\n nbf?: number;\n nonce?: string;\n sid?: string;\n [claim: string]: unknown;\n};\n\n/** Settings for verifying tokens from Zappie's identity issuer. */\nexport type TokenVerifierOptions = {\n /** The issuer URL, such as `https://accounts.zappie.ai`. The token's `iss` must equal it. A trailing slash on either is ignored. */\n issuer: string;\n /** The audience the token must name: your site's client id for ID tokens, or the API audience for access tokens. */\n audience: string;\n /** Where to read the signing keys. Defaults to `<issuer>/.well-known/jwks.json`. */\n jwksUrl?: string;\n /** Allowed algorithms. Defaults to RS256 and ES256. */\n algorithms?: readonly TokenAlgorithm[];\n /** Seconds of clock skew allowed on `exp`, `nbf` and `iat`. Defaults to 60. */\n clockToleranceSec?: number;\n /** Seconds to keep the key set before reading it again, 0 or more. Defaults to one hour. */\n cacheTtlSec?: number;\n /** Prefix the `sub` claim must start with. Zappie customers are `cus_`. A token without `sub` is refused. Pass `null` to skip both checks. */\n subjectPrefix?: string | null;\n /** The fetch to read keys with. Defaults to the global fetch. */\n fetch?: typeof fetch;\n /** The current time in milliseconds. For tests. */\n now?: () => number;\n};\n\n/** Per-call checks on top of the verifier's settings. */\nexport type VerifyTokenChecks = {\n /** The nonce you sent with the sign-in. When you pass it, the token's `nonce` must equal it. */\n nonce?: string;\n /** Overrides the verifier's audience for this token. */\n audience?: string;\n};\n\n/** Checks for an ID token from a sign-in. */\nexport type IdTokenChecks = {\n /** The nonce you sent with this sign-in. The token's `nonce` must equal it. */\n nonce: string;\n /** Overrides the verifier's audience for this token. */\n audience?: string;\n};\n\n/** The verified claims of an ID token. */\nexport type IdTokenClaims = TokenClaims & { sub: string; nonce: string };\n\n/** Verifies signed tokens against the issuer's published keys. */\nexport type TokenVerifier = {\n /** Verify a token's signature and claims, and return its claims. */\n verify(token: string, checks?: VerifyTokenChecks): Promise<TokenClaims>;\n /**\n * Verify the ID token from a sign-in. It needs the nonce you sent, a `sub`,\n * and refuses a token whose `typ` header names another kind of token, such\n * as an access token (`at+jwt`).\n */\n verifyIdToken(token: string, checks: IdTokenChecks): Promise<IdTokenClaims>;\n};\n\ntype Jwk = Record<string, unknown>;\ntype KeySet = { keys: Map<string, CryptoKey>; at: number };\n\nconst specs = {\n RS256: {\n kty: \"RSA\",\n jwk: (key: Jwk) => ({ kty: \"RSA\", n: key.n, e: key.e }),\n import: { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-256\" },\n verify: { name: \"RSASSA-PKCS1-v1_5\" },\n },\n ES256: {\n kty: \"EC\",\n jwk: (key: Jwk) => ({ kty: \"EC\", crv: \"P-256\", x: key.x, y: key.y }),\n import: { name: \"ECDSA\", namedCurve: \"P-256\" },\n verify: { name: \"ECDSA\", hash: \"SHA-256\" },\n },\n} as const;\n\nconst fail = (reason: TokenErrorReason, cause?: unknown): never => {\n throw new ZappieTokenError(reason, cause);\n};\nconst numeric = (value: unknown): value is number =>\n typeof value === \"number\" && Number.isFinite(value);\nconst trimSlash = (value: string) => value.replace(/\\/+$/, \"\");\nconst KEY_RETRY_MS = 30_000;\n\nfunction decodeJson(text: string): Record<string, unknown> {\n const bytes = base64UrlDecode(text) ?? fail(\"malformed\");\n let value: unknown;\n try {\n value = JSON.parse(decoder.decode(bytes));\n } catch {\n // Refused below.\n }\n return value && typeof value === \"object\" && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : fail(\"malformed\");\n}\n\n/**\n * Create a verifier for tokens from Zappie's identity issuer. It caches the\n * issuer's key set, reads it again when a token names an unknown key, and\n * keeps the last good set while the issuer is down. It uses Web Crypto only,\n * so it runs in Workers and Node 20 or later.\n */\nexport function createTokenVerifier(\n options: TokenVerifierOptions,\n): TokenVerifier {\n const issuer = trimSlash(options.issuer);\n const allowed: readonly string[] = options.algorithms ?? [\"RS256\", \"ES256\"];\n const tolerance = options.clockToleranceSec ?? 60;\n const ttl = (options.cacheTtlSec ?? 3600) * 1000;\n const prefix =\n options.subjectPrefix === undefined ? \"cus_\" : options.subjectPrefix;\n const now = options.now ?? Date.now;\n const read = options.fetch ?? ((url, init) => fetch(url, init));\n if (\n !issuer ||\n !options.audience ||\n !allowed.length ||\n allowed.some((alg) => !(alg in specs)) ||\n !numeric(tolerance) ||\n tolerance < 0 ||\n !numeric(ttl) ||\n ttl < 0\n ) {\n throw new TypeError(\n \"createTokenVerifier needs an issuer, an audience, RS256 or ES256, and a clock tolerance and cache time of 0 or more.\",\n );\n }\n\n let cached: KeySet | undefined;\n let pending: Promise<KeySet> | undefined;\n let triedAt = Number.NEGATIVE_INFINITY;\n let lastError: unknown;\n\n const load = async (): Promise<KeySet> => {\n try {\n const response = await read(\n options.jwksUrl ?? `${issuer}/.well-known/jwks.json`,\n {\n headers: { accept: \"application/json\" },\n signal: AbortSignal.timeout(10_000),\n },\n );\n const text = response.ok ? await response.text() : \"\";\n if (!text || text.length > 1 << 20) {\n throw new Error(`The key set answered ${response.status}.`);\n }\n const keys = new Map<string, CryptoKey>();\n const list = (JSON.parse(text) as { keys?: unknown }).keys;\n for (const jwk of Array.isArray(list) ? (list as Jwk[]) : []) {\n for (const alg of allowed as TokenAlgorithm[]) {\n const spec = specs[alg];\n if (\n jwk.kty === spec.kty &&\n (jwk.use ?? \"sig\") === \"sig\" &&\n (jwk.alg ?? alg) === alg &&\n (alg === \"RS256\" || jwk.crv === \"P-256\")\n ) {\n // A key this runtime can't import can't have signed a token we accept.\n await crypto.subtle\n .importKey(\n \"jwk\",\n spec.jwk(jwk) as JsonWebKey,\n spec.import,\n false,\n [\"verify\"],\n )\n .then((key) => keys.set(`${alg}:${String(jwk.kid ?? \"\")}`, key))\n .catch(() => undefined);\n }\n }\n }\n // An empty or unusable set is an issuer fault, not a rotation, so it\n // never replaces the keys that verified tokens until now.\n if (!keys.size) {\n throw new Error(\"The key set has no keys this verifier can use.\");\n }\n return (cached = { keys, at: now() });\n } catch (error) {\n return fail(\"keys_unavailable\", error);\n }\n };\n\n const find = (set: KeySet, kid: unknown, alg: string) => {\n if (typeof kid === \"string\") {\n return set.keys.get(`${alg}:${kid}`);\n }\n // Without a kid, only a key set with one key for the algorithm can answer.\n const fits = [...set.keys].filter(([id]) => id.startsWith(`${alg}:`));\n return fits.length === 1 ? fits[0]?.[1] : undefined;\n };\n\n // The key set is read at most every 30 seconds, whether a forged kid\n // missed the cache or the issuer is down, so neither can make every\n // request call the issuer.\n const keyFor = async (kid: unknown, alg: string) => {\n if (!pending) {\n const key = cached && find(cached, kid, alg);\n if (key && cached && now() - cached.at < ttl) {\n return key;\n }\n if (now() - triedAt < KEY_RETRY_MS) {\n return cached ? key : fail(\"keys_unavailable\", lastError);\n }\n triedAt = now();\n pending = load().finally(() => (pending = undefined));\n }\n try {\n const set = await pending;\n lastError = undefined;\n return find(set, kid, alg);\n } catch (error) {\n lastError = error;\n return (\n (cached && find(cached, kid, alg)) ?? fail(\"keys_unavailable\", error)\n );\n }\n };\n\n const check = async (\n token: string,\n checks: VerifyTokenChecks,\n idToken: boolean,\n ): Promise<TokenClaims> => {\n const parts =\n typeof token === \"string\" && token.length < 16_384\n ? token.split(\".\")\n : [];\n const [head = \"\", body = \"\", sig = \"\"] =\n parts.length === 3 ? parts : fail(\"malformed\");\n const header = decodeJson(head);\n const claims = decodeJson(body);\n const signature = base64UrlDecode(sig) ?? fail(\"malformed\");\n const alg = header.alg as TokenAlgorithm;\n if (!allowed.includes(alg) || header.crit !== undefined) {\n fail(\"unsupported_algorithm\");\n }\n if (\n idToken &&\n header.typ !== undefined &&\n (typeof header.typ !== \"string\" ||\n header.typ.toLowerCase().replace(/^application\\//, \"\") !== \"jwt\")\n ) {\n fail(\"wrong_type\");\n }\n const key = (await keyFor(header.kid, alg)) ?? fail(\"unknown_key\");\n if (\n (alg === \"ES256\" && signature.length !== 64) ||\n !(await crypto.subtle.verify(\n specs[alg].verify,\n key,\n signature,\n encoder.encode(`${head}.${body}`),\n ))\n ) {\n fail(\"invalid_signature\");\n }\n\n const seconds = now() / 1000;\n const times = [claims.nbf, claims.iat];\n const sub = claims.sub;\n if (typeof claims.iss !== \"string\" || trimSlash(claims.iss) !== issuer) {\n fail(\"wrong_issuer\");\n }\n if (![claims.aud].flat().includes(checks.audience ?? options.audience)) {\n fail(\"wrong_audience\");\n }\n if (\n !numeric(claims.exp) ||\n times.some((time) => time !== undefined && !numeric(time))\n ) {\n fail(\"malformed\");\n }\n if (seconds > (claims.exp as number) + tolerance) {\n fail(\"expired\");\n }\n if (times.some((time) => (time as number) > seconds + tolerance)) {\n fail(\"not_yet_valid\");\n }\n if (checks.nonce !== undefined && claims.nonce !== checks.nonce) {\n fail(\"wrong_nonce\");\n }\n if (\n (prefix !== null || idToken) &&\n (typeof sub !== \"string\" ||\n !sub ||\n (prefix !== null &&\n (!sub.startsWith(prefix) || sub.length === prefix.length)))\n ) {\n fail(\"wrong_subject\");\n }\n return claims as TokenClaims;\n };\n\n return {\n verify: (token, checks = {}) => check(token, checks, false),\n async verifyIdToken(token, checks) {\n if (typeof checks?.nonce !== \"string\" || !checks.nonce) {\n throw new TypeError(\n \"verifyIdToken needs the nonce you sent with the sign-in.\",\n );\n }\n return (await check(token, checks, true)) as IdTokenClaims;\n },\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/server/auth/session-store.ts
|
|
2
|
+
import { ZappieError } from "../../errors.js";
|
|
3
|
+
import {
|
|
4
|
+
openSealedValue,
|
|
5
|
+
sealValue,
|
|
6
|
+
secretsOf
|
|
7
|
+
} from "./crypto.js";
|
|
8
|
+
function cookieSessionStore(options) {
|
|
9
|
+
const maxAge = (options.maxAgeSec ?? 30 * 24 * 60 * 60) * 1e3;
|
|
10
|
+
const now = options.now ?? Date.now;
|
|
11
|
+
const sealing = { secret: options.secret, purpose: "zappie_session" };
|
|
12
|
+
secretsOf(options.secret);
|
|
13
|
+
if (!(maxAge > 0 && Number.isFinite(maxAge))) {
|
|
14
|
+
throw new TypeError("maxAgeSec must be a positive number of seconds.");
|
|
15
|
+
}
|
|
16
|
+
const refuse = (message) => {
|
|
17
|
+
throw new ZappieError("invalid_request", message);
|
|
18
|
+
};
|
|
19
|
+
const seal = async (envelope) => {
|
|
20
|
+
const sealed = await sealValue(envelope, sealing);
|
|
21
|
+
return sealed.length > 3800 ? refuse(
|
|
22
|
+
"This session is too large for a cookie. Use a session store that keeps sessions on the server."
|
|
23
|
+
) : sealed;
|
|
24
|
+
};
|
|
25
|
+
const open = async (sid) => {
|
|
26
|
+
const envelope = await openSealedValue(sid, sealing);
|
|
27
|
+
return envelope?.v === 1 && envelope.exp > now() ? envelope : null;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
create: (data) => seal({ v: 1, exp: now() + maxAge, data }),
|
|
31
|
+
get: async (sid) => (await open(sid))?.data ?? null,
|
|
32
|
+
async update(sid, patch) {
|
|
33
|
+
const envelope = await open(sid);
|
|
34
|
+
return envelope ? seal({ ...envelope, data: { ...envelope.data, ...patch } }) : null;
|
|
35
|
+
},
|
|
36
|
+
// The session is the cookie, so clearing the cookie ends it.
|
|
37
|
+
delete: async () => void 0,
|
|
38
|
+
deleteBySub: async () => refuse(
|
|
39
|
+
"A cookie session store can't end sessions from the server. Use a session store that keeps sessions on the server for back-channel logout."
|
|
40
|
+
)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
cookieSessionStore
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=session-store.js.map
|