@emdash-cms/auth 0.0.1

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.
Files changed (48) hide show
  1. package/dist/adapters/kysely.d.mts +62 -0
  2. package/dist/adapters/kysely.d.mts.map +1 -0
  3. package/dist/adapters/kysely.mjs +379 -0
  4. package/dist/adapters/kysely.mjs.map +1 -0
  5. package/dist/authenticate-D5UgaoTH.d.mts +124 -0
  6. package/dist/authenticate-D5UgaoTH.d.mts.map +1 -0
  7. package/dist/authenticate-j5GayLXB.mjs +373 -0
  8. package/dist/authenticate-j5GayLXB.mjs.map +1 -0
  9. package/dist/index.d.mts +444 -0
  10. package/dist/index.d.mts.map +1 -0
  11. package/dist/index.mjs +728 -0
  12. package/dist/index.mjs.map +1 -0
  13. package/dist/oauth/providers/github.d.mts +12 -0
  14. package/dist/oauth/providers/github.d.mts.map +1 -0
  15. package/dist/oauth/providers/github.mjs +55 -0
  16. package/dist/oauth/providers/github.mjs.map +1 -0
  17. package/dist/oauth/providers/google.d.mts +7 -0
  18. package/dist/oauth/providers/google.d.mts.map +1 -0
  19. package/dist/oauth/providers/google.mjs +38 -0
  20. package/dist/oauth/providers/google.mjs.map +1 -0
  21. package/dist/passkey/index.d.mts +2 -0
  22. package/dist/passkey/index.mjs +3 -0
  23. package/dist/types-Bu4irX9A.d.mts +35 -0
  24. package/dist/types-Bu4irX9A.d.mts.map +1 -0
  25. package/dist/types-CiSNpRI9.mjs +60 -0
  26. package/dist/types-CiSNpRI9.mjs.map +1 -0
  27. package/dist/types-HtRc90Wi.d.mts +208 -0
  28. package/dist/types-HtRc90Wi.d.mts.map +1 -0
  29. package/package.json +72 -0
  30. package/src/adapters/kysely.ts +715 -0
  31. package/src/config.ts +214 -0
  32. package/src/index.ts +135 -0
  33. package/src/invite.ts +205 -0
  34. package/src/magic-link/index.ts +150 -0
  35. package/src/oauth/consumer.ts +324 -0
  36. package/src/oauth/providers/github.ts +68 -0
  37. package/src/oauth/providers/google.ts +34 -0
  38. package/src/oauth/types.ts +36 -0
  39. package/src/passkey/authenticate.ts +183 -0
  40. package/src/passkey/index.ts +27 -0
  41. package/src/passkey/register.ts +232 -0
  42. package/src/passkey/types.ts +120 -0
  43. package/src/rbac.test.ts +141 -0
  44. package/src/rbac.ts +205 -0
  45. package/src/signup.ts +210 -0
  46. package/src/tokens.test.ts +141 -0
  47. package/src/tokens.ts +238 -0
  48. package/src/types.ts +352 -0
package/src/signup.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Self-signup for allowed email domains
3
+ */
4
+
5
+ import { escapeHtml } from "./invite.js";
6
+ import { generateTokenWithHash, hashToken } from "./tokens.js";
7
+ import type { AuthAdapter, RoleLevel, EmailMessage, User } from "./types.js";
8
+
9
+ const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
10
+
11
+ /** Function that sends an email (matches the EmailPipeline.send signature) */
12
+ export type EmailSendFn = (message: EmailMessage) => Promise<void>;
13
+
14
+ /**
15
+ * Add artificial delay with jitter to prevent timing attacks.
16
+ * Range approximates the time for token creation + email send.
17
+ */
18
+ async function timingDelay(): Promise<void> {
19
+ const delay = 100 + Math.random() * 150; // 100-250ms
20
+ await new Promise((resolve) => setTimeout(resolve, delay));
21
+ }
22
+
23
+ export interface SignupConfig {
24
+ baseUrl: string;
25
+ siteName: string;
26
+ /** Optional email sender. When omitted, signup verification cannot be sent. */
27
+ email?: EmailSendFn;
28
+ }
29
+
30
+ /**
31
+ * Check if an email domain is allowed for self-signup
32
+ */
33
+ export async function canSignup(
34
+ adapter: AuthAdapter,
35
+ email: string,
36
+ ): Promise<{ allowed: boolean; role: RoleLevel } | null> {
37
+ const domain = email.split("@")[1]?.toLowerCase();
38
+ if (!domain) return null;
39
+
40
+ const allowedDomain = await adapter.getAllowedDomain(domain);
41
+ if (!allowedDomain || !allowedDomain.enabled) {
42
+ return null;
43
+ }
44
+
45
+ return {
46
+ allowed: true,
47
+ role: allowedDomain.defaultRole,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Request self-signup (sends verification email).
53
+ *
54
+ * Requires `config.email` to be set. Throws if no email sender is configured.
55
+ */
56
+ export async function requestSignup(
57
+ config: SignupConfig,
58
+ adapter: AuthAdapter,
59
+ email: string,
60
+ ): Promise<void> {
61
+ if (!config.email) {
62
+ throw new SignupError("email_not_configured", "Email is not configured");
63
+ }
64
+
65
+ // Check if user already exists
66
+ const existing = await adapter.getUserByEmail(email);
67
+ if (existing) {
68
+ // Don't reveal that user exists - add delay to match successful path timing
69
+ await timingDelay();
70
+ return;
71
+ }
72
+
73
+ // Check if domain is allowed
74
+ const signup = await canSignup(adapter, email);
75
+ if (!signup) {
76
+ // Don't reveal that domain is not allowed - add delay to match successful path timing
77
+ await timingDelay();
78
+ return;
79
+ }
80
+
81
+ // Generate token
82
+ const { token, hash } = generateTokenWithHash();
83
+
84
+ // Store token with role info
85
+ await adapter.createToken({
86
+ hash,
87
+ email,
88
+ type: "email_verify",
89
+ role: signup.role,
90
+ expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS),
91
+ });
92
+
93
+ // Build verification URL
94
+ const url = new URL("/api/auth/signup/verify", config.baseUrl);
95
+ url.searchParams.set("token", token);
96
+
97
+ // Send email
98
+ const safeName = escapeHtml(config.siteName);
99
+ await config.email({
100
+ to: email,
101
+ subject: `Verify your email for ${config.siteName}`,
102
+ text: `Click this link to verify your email and create your account:\n\n${url.toString()}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`,
103
+ html: `
104
+ <!DOCTYPE html>
105
+ <html>
106
+ <head>
107
+ <meta charset="utf-8">
108
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
109
+ </head>
110
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
111
+ <h1 style="font-size: 24px; margin-bottom: 20px;">Verify your email</h1>
112
+ <p>Click the button below to verify your email and create your ${safeName} account:</p>
113
+ <p style="margin: 30px 0;">
114
+ <a href="${url.toString()}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Verify Email</a>
115
+ </p>
116
+ <p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>
117
+ <p style="color: #666; font-size: 14px;">If you didn't request this, you can safely ignore this email.</p>
118
+ </body>
119
+ </html>`,
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Validate a signup verification token
125
+ */
126
+ export async function validateSignupToken(
127
+ adapter: AuthAdapter,
128
+ token: string,
129
+ ): Promise<{ email: string; role: RoleLevel }> {
130
+ const hash = hashToken(token);
131
+
132
+ const authToken = await adapter.getToken(hash, "email_verify");
133
+ if (!authToken) {
134
+ throw new SignupError("invalid_token", "Invalid or expired verification link");
135
+ }
136
+
137
+ if (authToken.expiresAt < new Date()) {
138
+ await adapter.deleteToken(hash);
139
+ throw new SignupError("token_expired", "This link has expired");
140
+ }
141
+
142
+ if (!authToken.email || authToken.role === null) {
143
+ throw new SignupError("invalid_token", "Invalid token data");
144
+ }
145
+
146
+ return {
147
+ email: authToken.email,
148
+ role: authToken.role,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Complete signup process (after passkey registration)
154
+ */
155
+ export async function completeSignup(
156
+ adapter: AuthAdapter,
157
+ token: string,
158
+ userData: {
159
+ name?: string;
160
+ avatarUrl?: string;
161
+ },
162
+ ): Promise<User> {
163
+ const hash = hashToken(token);
164
+
165
+ // Validate token one more time
166
+ const authToken = await adapter.getToken(hash, "email_verify");
167
+ if (!authToken || authToken.expiresAt < new Date()) {
168
+ throw new SignupError("invalid_token", "Invalid or expired verification");
169
+ }
170
+
171
+ if (!authToken.email || authToken.role === null) {
172
+ throw new SignupError("invalid_token", "Invalid token data");
173
+ }
174
+
175
+ // Check user doesn't already exist
176
+ const existing = await adapter.getUserByEmail(authToken.email);
177
+ if (existing) {
178
+ await adapter.deleteToken(hash);
179
+ throw new SignupError("user_exists", "An account with this email already exists");
180
+ }
181
+
182
+ // Delete token (single-use)
183
+ await adapter.deleteToken(hash);
184
+
185
+ // Create user
186
+ const user = await adapter.createUser({
187
+ email: authToken.email,
188
+ name: userData.name,
189
+ avatarUrl: userData.avatarUrl,
190
+ role: authToken.role,
191
+ emailVerified: true,
192
+ });
193
+
194
+ return user;
195
+ }
196
+
197
+ export class SignupError extends Error {
198
+ constructor(
199
+ public code:
200
+ | "invalid_token"
201
+ | "token_expired"
202
+ | "user_exists"
203
+ | "domain_not_allowed"
204
+ | "email_not_configured",
205
+ message: string,
206
+ ) {
207
+ super(message);
208
+ this.name = "SignupError";
209
+ }
210
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import {
4
+ generateToken,
5
+ hashToken,
6
+ generateTokenWithHash,
7
+ generateSessionId,
8
+ generateAuthSecret,
9
+ secureCompare,
10
+ computeS256Challenge,
11
+ encrypt,
12
+ decrypt,
13
+ } from "./tokens.js";
14
+
15
+ const BASE64URL_REGEX = /^[A-Za-z0-9_-]+$/;
16
+ const NO_PADDING_REGEX = /^[A-Za-z0-9_-]+$/;
17
+
18
+ describe("tokens", () => {
19
+ describe("generateToken", () => {
20
+ it("generates a base64url-encoded token", () => {
21
+ const token = generateToken();
22
+ expect(token).toMatch(BASE64URL_REGEX);
23
+ // 32 bytes = 43 base64url characters (without padding)
24
+ expect(token.length).toBe(43);
25
+ });
26
+
27
+ it("generates unique tokens", () => {
28
+ // eslint-disable-next-line e18e/prefer-array-fill -- We need unique tokens, not the same token repeated
29
+ const tokens = new Set(Array.from({ length: 100 }, () => generateToken()));
30
+ expect(tokens.size).toBe(100);
31
+ });
32
+ });
33
+
34
+ describe("hashToken", () => {
35
+ it("produces consistent hashes", () => {
36
+ const token = generateToken();
37
+ const hash1 = hashToken(token);
38
+ const hash2 = hashToken(token);
39
+ expect(hash1).toBe(hash2);
40
+ });
41
+
42
+ it("produces different hashes for different tokens", () => {
43
+ const token1 = generateToken();
44
+ const token2 = generateToken();
45
+ expect(hashToken(token1)).not.toBe(hashToken(token2));
46
+ });
47
+ });
48
+
49
+ describe("generateTokenWithHash", () => {
50
+ it("returns both token and hash", () => {
51
+ const { token, hash } = generateTokenWithHash();
52
+ expect(token).toBeDefined();
53
+ expect(hash).toBeDefined();
54
+ expect(hashToken(token)).toBe(hash);
55
+ });
56
+ });
57
+
58
+ describe("generateSessionId", () => {
59
+ it("generates a shorter session ID", () => {
60
+ const sessionId = generateSessionId();
61
+ expect(sessionId).toMatch(BASE64URL_REGEX);
62
+ // 20 bytes = 27 base64url characters
63
+ expect(sessionId.length).toBe(27);
64
+ });
65
+ });
66
+
67
+ describe("generateAuthSecret", () => {
68
+ it("generates a 32-byte secret", () => {
69
+ const secret = generateAuthSecret();
70
+ expect(secret).toMatch(BASE64URL_REGEX);
71
+ expect(secret.length).toBe(43);
72
+ });
73
+ });
74
+
75
+ describe("secureCompare", () => {
76
+ it("returns true for equal strings", () => {
77
+ expect(secureCompare("hello", "hello")).toBe(true);
78
+ });
79
+
80
+ it("returns false for different strings", () => {
81
+ expect(secureCompare("hello", "world")).toBe(false);
82
+ });
83
+
84
+ it("returns false for different length strings", () => {
85
+ expect(secureCompare("hello", "hello!")).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("computeS256Challenge", () => {
90
+ it("produces correct S256 challenge for a known verifier", () => {
91
+ // RFC 7636 Appendix B test vector:
92
+ // verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
93
+ // expected challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
94
+ const challenge = computeS256Challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
95
+ expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
96
+ });
97
+
98
+ it("produces base64url output without padding", () => {
99
+ const challenge = computeS256Challenge("test-verifier-string");
100
+ expect(challenge).toMatch(NO_PADDING_REGEX);
101
+ expect(challenge).not.toContain("=");
102
+ });
103
+
104
+ it("is deterministic", () => {
105
+ const a = computeS256Challenge("same-input");
106
+ const b = computeS256Challenge("same-input");
107
+ expect(a).toBe(b);
108
+ });
109
+
110
+ it("produces different output for different input", () => {
111
+ const a = computeS256Challenge("verifier-one");
112
+ const b = computeS256Challenge("verifier-two");
113
+ expect(a).not.toBe(b);
114
+ });
115
+ });
116
+
117
+ describe("encrypt/decrypt", () => {
118
+ const secret = generateAuthSecret();
119
+
120
+ it("encrypts and decrypts a string", async () => {
121
+ const plaintext = "my-secret-value";
122
+ const encrypted = await encrypt(plaintext, secret);
123
+ const decrypted = await decrypt(encrypted, secret);
124
+ expect(decrypted).toBe(plaintext);
125
+ });
126
+
127
+ it("produces different ciphertext each time (due to random IV)", async () => {
128
+ const plaintext = "my-secret-value";
129
+ const encrypted1 = await encrypt(plaintext, secret);
130
+ const encrypted2 = await encrypt(plaintext, secret);
131
+ expect(encrypted1).not.toBe(encrypted2);
132
+ });
133
+
134
+ it("fails to decrypt with wrong secret", async () => {
135
+ const plaintext = "my-secret-value";
136
+ const encrypted = await encrypt(plaintext, secret);
137
+ const wrongSecret = generateAuthSecret();
138
+ await expect(decrypt(encrypted, wrongSecret)).rejects.toThrow();
139
+ });
140
+ });
141
+ });
package/src/tokens.ts ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Secure token utilities
3
+ *
4
+ * Crypto via Oslo.js (@oslojs/crypto). Base64url via @oslojs/encoding.
5
+ *
6
+ * Tokens are opaque random values. We store only the SHA-256 hash in the database.
7
+ */
8
+
9
+ import { sha256 } from "@oslojs/crypto/sha2";
10
+ import { encodeBase64urlNoPadding, decodeBase64urlIgnorePadding } from "@oslojs/encoding";
11
+
12
+ const TOKEN_BYTES = 32; // 256 bits of entropy
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // API Token Prefixes
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Valid API token prefixes */
19
+ export const TOKEN_PREFIXES = {
20
+ PAT: "ec_pat_",
21
+ OAUTH_ACCESS: "ec_oat_",
22
+ OAUTH_REFRESH: "ec_ort_",
23
+ } as const;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Scopes
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** All valid API token scopes */
30
+ export const VALID_SCOPES = [
31
+ "content:read",
32
+ "content:write",
33
+ "media:read",
34
+ "media:write",
35
+ "schema:read",
36
+ "schema:write",
37
+ "admin",
38
+ ] as const;
39
+
40
+ export type ApiTokenScope = (typeof VALID_SCOPES)[number];
41
+
42
+ /**
43
+ * Validate that scopes are all valid.
44
+ * Returns the invalid scopes, or empty array if all valid.
45
+ */
46
+ export function validateScopes(scopes: string[]): string[] {
47
+ const validSet = new Set<string>(VALID_SCOPES);
48
+ return scopes.filter((s) => !validSet.has(s));
49
+ }
50
+
51
+ /**
52
+ * Check if a set of scopes includes a required scope.
53
+ * The `admin` scope grants access to everything.
54
+ */
55
+ export function hasScope(scopes: string[], required: string): boolean {
56
+ if (scopes.includes("admin")) return true;
57
+ return scopes.includes(required);
58
+ }
59
+
60
+ /**
61
+ * Generate a cryptographically secure random token
62
+ * Returns base64url-encoded string (URL-safe)
63
+ */
64
+ export function generateToken(): string {
65
+ const bytes = new Uint8Array(TOKEN_BYTES);
66
+ crypto.getRandomValues(bytes);
67
+ return encodeBase64urlNoPadding(bytes);
68
+ }
69
+
70
+ /**
71
+ * Hash a token for storage
72
+ * We never store raw tokens - only their SHA-256 hash
73
+ */
74
+ export function hashToken(token: string): string {
75
+ const bytes = decodeBase64urlIgnorePadding(token);
76
+ const hash = sha256(bytes);
77
+ return encodeBase64urlNoPadding(hash);
78
+ }
79
+
80
+ /**
81
+ * Generate a token and its hash together
82
+ */
83
+ export function generateTokenWithHash(): { token: string; hash: string } {
84
+ const token = generateToken();
85
+ const hash = hashToken(token);
86
+ return { token, hash };
87
+ }
88
+
89
+ /**
90
+ * Generate a session ID (shorter, for cookie storage)
91
+ */
92
+ export function generateSessionId(): string {
93
+ const bytes = new Uint8Array(20); // 160 bits
94
+ crypto.getRandomValues(bytes);
95
+ return encodeBase64urlNoPadding(bytes);
96
+ }
97
+
98
+ /**
99
+ * Generate an auth secret for configuration
100
+ */
101
+ export function generateAuthSecret(): string {
102
+ const bytes = new Uint8Array(32);
103
+ crypto.getRandomValues(bytes);
104
+ return encodeBase64urlNoPadding(bytes);
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Prefixed API tokens (ec_pat_, ec_oat_, ec_ort_)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Generate a prefixed API token and its hash.
113
+ * Returns the raw token (shown once to the user), the hash (stored server-side),
114
+ * and a display prefix (for identification in UIs/logs).
115
+ *
116
+ * Uses oslo/crypto for SHA-256 hashing.
117
+ */
118
+ export function generatePrefixedToken(prefix: string): {
119
+ raw: string;
120
+ hash: string;
121
+ prefix: string;
122
+ } {
123
+ const bytes = new Uint8Array(TOKEN_BYTES);
124
+ crypto.getRandomValues(bytes);
125
+
126
+ const encoded = encodeBase64urlNoPadding(bytes);
127
+ const raw = `${prefix}${encoded}`;
128
+ const hash = hashPrefixedToken(raw);
129
+
130
+ // First few chars for identification in UIs
131
+ const displayPrefix = raw.slice(0, prefix.length + 4);
132
+
133
+ return { raw, hash, prefix: displayPrefix };
134
+ }
135
+
136
+ /**
137
+ * Hash a prefixed API token for storage/lookup.
138
+ * Hashes the full prefixed token string via SHA-256, returns base64url (no padding).
139
+ */
140
+ export function hashPrefixedToken(token: string): string {
141
+ const bytes = new TextEncoder().encode(token);
142
+ const hash = sha256(bytes);
143
+ return encodeBase64urlNoPadding(hash);
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // PKCE (RFC 7636) — server-side verification
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Compute an S256 PKCE code challenge from a code verifier.
152
+ * Used server-side to verify that code_verifier matches the stored code_challenge.
153
+ *
154
+ * Equivalent to: BASE64URL(SHA256(ASCII(code_verifier)))
155
+ */
156
+ export function computeS256Challenge(codeVerifier: string): string {
157
+ const hash = sha256(new TextEncoder().encode(codeVerifier));
158
+ return encodeBase64urlNoPadding(hash);
159
+ }
160
+
161
+ /**
162
+ * Constant-time comparison to prevent timing attacks
163
+ */
164
+ export function secureCompare(a: string, b: string): boolean {
165
+ if (a.length !== b.length) return false;
166
+
167
+ const aBytes = new TextEncoder().encode(a);
168
+ const bBytes = new TextEncoder().encode(b);
169
+
170
+ let result = 0;
171
+ for (let i = 0; i < aBytes.length; i++) {
172
+ result |= aBytes[i]! ^ bBytes[i]!;
173
+ }
174
+ return result === 0;
175
+ }
176
+
177
+ // ============================================================================
178
+ // Encryption utilities (for storing OAuth secrets)
179
+ // ============================================================================
180
+
181
+ const ALGORITHM = "AES-GCM";
182
+ const IV_BYTES = 12;
183
+
184
+ /**
185
+ * Derive an encryption key from the auth secret
186
+ */
187
+ async function deriveKey(secret: string): Promise<CryptoKey> {
188
+ const decoded = decodeBase64urlIgnorePadding(secret);
189
+ // Create a new ArrayBuffer to ensure compatibility with crypto.subtle
190
+ const buffer = new Uint8Array(decoded).buffer;
191
+ const keyMaterial = await crypto.subtle.importKey("raw", buffer, "PBKDF2", false, ["deriveKey"]);
192
+
193
+ return crypto.subtle.deriveKey(
194
+ {
195
+ name: "PBKDF2",
196
+ salt: new TextEncoder().encode("emdash-auth-v1"),
197
+ iterations: 100000,
198
+ hash: "SHA-256",
199
+ },
200
+ keyMaterial,
201
+ { name: ALGORITHM, length: 256 },
202
+ false,
203
+ ["encrypt", "decrypt"],
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Encrypt a value using AES-GCM
209
+ */
210
+ export async function encrypt(plaintext: string, secret: string): Promise<string> {
211
+ const key = await deriveKey(secret);
212
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
213
+ const encoded = new TextEncoder().encode(plaintext);
214
+
215
+ const ciphertext = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, encoded);
216
+
217
+ // Prepend IV to ciphertext
218
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
219
+ combined.set(iv);
220
+ combined.set(new Uint8Array(ciphertext), iv.length);
221
+
222
+ return encodeBase64urlNoPadding(combined);
223
+ }
224
+
225
+ /**
226
+ * Decrypt a value encrypted with encrypt()
227
+ */
228
+ export async function decrypt(encrypted: string, secret: string): Promise<string> {
229
+ const key = await deriveKey(secret);
230
+ const combined = decodeBase64urlIgnorePadding(encrypted);
231
+
232
+ const iv = combined.slice(0, IV_BYTES);
233
+ const ciphertext = combined.slice(IV_BYTES);
234
+
235
+ const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv }, key, ciphertext);
236
+
237
+ return new TextDecoder().decode(decrypted);
238
+ }