@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.
- package/dist/adapters/kysely.d.mts +62 -0
- package/dist/adapters/kysely.d.mts.map +1 -0
- package/dist/adapters/kysely.mjs +379 -0
- package/dist/adapters/kysely.mjs.map +1 -0
- package/dist/authenticate-D5UgaoTH.d.mts +124 -0
- package/dist/authenticate-D5UgaoTH.d.mts.map +1 -0
- package/dist/authenticate-j5GayLXB.mjs +373 -0
- package/dist/authenticate-j5GayLXB.mjs.map +1 -0
- package/dist/index.d.mts +444 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +728 -0
- package/dist/index.mjs.map +1 -0
- package/dist/oauth/providers/github.d.mts +12 -0
- package/dist/oauth/providers/github.d.mts.map +1 -0
- package/dist/oauth/providers/github.mjs +55 -0
- package/dist/oauth/providers/github.mjs.map +1 -0
- package/dist/oauth/providers/google.d.mts +7 -0
- package/dist/oauth/providers/google.d.mts.map +1 -0
- package/dist/oauth/providers/google.mjs +38 -0
- package/dist/oauth/providers/google.mjs.map +1 -0
- package/dist/passkey/index.d.mts +2 -0
- package/dist/passkey/index.mjs +3 -0
- package/dist/types-Bu4irX9A.d.mts +35 -0
- package/dist/types-Bu4irX9A.d.mts.map +1 -0
- package/dist/types-CiSNpRI9.mjs +60 -0
- package/dist/types-CiSNpRI9.mjs.map +1 -0
- package/dist/types-HtRc90Wi.d.mts +208 -0
- package/dist/types-HtRc90Wi.d.mts.map +1 -0
- package/package.json +72 -0
- package/src/adapters/kysely.ts +715 -0
- package/src/config.ts +214 -0
- package/src/index.ts +135 -0
- package/src/invite.ts +205 -0
- package/src/magic-link/index.ts +150 -0
- package/src/oauth/consumer.ts +324 -0
- package/src/oauth/providers/github.ts +68 -0
- package/src/oauth/providers/google.ts +34 -0
- package/src/oauth/types.ts +36 -0
- package/src/passkey/authenticate.ts +183 -0
- package/src/passkey/index.ts +27 -0
- package/src/passkey/register.ts +232 -0
- package/src/passkey/types.ts +120 -0
- package/src/rbac.test.ts +141 -0
- package/src/rbac.ts +205 -0
- package/src/signup.ts +210 -0
- package/src/tokens.test.ts +141 -0
- package/src/tokens.ts +238 -0
- 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
|
+
}
|