@uln/auth 0.3.0 → 0.4.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/client.d.ts +1 -0
- package/dist/client.js +35 -17
- package/dist/hono.d.ts +1 -1
- package/dist/hono.js +44 -7
- package/dist/index.d.ts +2 -1
- package/dist/index.js +182 -18
- package/dist/middleware.d.ts +7 -4
- package/dist/middleware.js +35 -17
- package/dist/password.d.ts +2 -0
- package/dist/service.d.ts +7 -0
- package/dist/service.js +37 -2
- package/dist/token.d.ts +5 -0
- package/dist/totp.d.ts +19 -0
- package/dist/totp.js +129 -0
- package/dist/types.d.ts +2 -0
- package/dist/users.d.ts +3 -0
- package/dist/users.js +21 -0
- package/package.json +66 -62
- package/src/client.ts +1 -0
- package/src/hono.ts +56 -12
- package/src/index.ts +9 -1
- package/src/middleware.ts +45 -18
- package/src/password.ts +2 -0
- package/src/service.ts +50 -3
- package/src/token.ts +5 -0
- package/src/totp.ts +165 -0
- package/src/types.ts +2 -0
- package/src/users.ts +22 -0
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { TokenPayload, VerifyConfig, PublicKeyEntry, CookieConfig } from "./types.ts";
|
|
2
2
|
export { validateToken, importPublicKey } from "./token.ts";
|
|
3
3
|
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
4
|
+
export type { AuthCodeExchangeConfig } from "./middleware.ts";
|
|
4
5
|
export { getAuthUser, redirectToLogin, handleCallback, getClientIP, checkBearerToken } from "./middleware.ts";
|
|
5
6
|
import type { PublicKeyEntry } from "./types.ts";
|
|
6
7
|
export declare function fetchPublicKeys(baseUrl: string): Promise<PublicKeyEntry[]>;
|
package/dist/client.js
CHANGED
|
@@ -131,26 +131,44 @@ async function getAuthUser(request, verifyConfig, cookieConfig) {
|
|
|
131
131
|
return validateToken(verifyConfig, token);
|
|
132
132
|
}
|
|
133
133
|
function redirectToLogin(currentUrl) {
|
|
134
|
-
const loginUrl =
|
|
135
|
-
|
|
134
|
+
const loginUrl = new URL("/login", "https://auth.uln.industries");
|
|
135
|
+
loginUrl.searchParams.set("redirect", currentUrl);
|
|
136
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
137
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
138
|
+
}
|
|
139
|
+
async function exchangeCode(code, redirectOrigin, config) {
|
|
140
|
+
const res = await fetch(`${config.authUrl}/api/exchange-code`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({ code, redirectOrigin })
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok)
|
|
146
|
+
return null;
|
|
147
|
+
const data = await res.json();
|
|
148
|
+
return typeof data.token === "string" ? data.token : null;
|
|
136
149
|
}
|
|
137
|
-
async function handleCallback(request, verifyConfig, cookieConfig) {
|
|
150
|
+
async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
|
|
138
151
|
const url = new URL(request.url);
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
152
|
+
const code = url.searchParams.get("code");
|
|
153
|
+
if (code) {
|
|
154
|
+
if (!exchangeConfig)
|
|
155
|
+
return redirectToLogin(url.origin);
|
|
156
|
+
const token = await exchangeCode(code, url.origin, exchangeConfig);
|
|
157
|
+
if (!token)
|
|
158
|
+
return redirectToLogin(url.origin);
|
|
159
|
+
const payload = await validateToken(verifyConfig, token);
|
|
160
|
+
if (!payload)
|
|
161
|
+
return redirectToLogin(url.origin);
|
|
162
|
+
url.searchParams.delete("code");
|
|
163
|
+
return new Response(null, {
|
|
164
|
+
status: 302,
|
|
165
|
+
headers: {
|
|
166
|
+
Location: url.toString(),
|
|
167
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token)
|
|
168
|
+
}
|
|
169
|
+
});
|
|
145
170
|
}
|
|
146
|
-
|
|
147
|
-
return new Response(null, {
|
|
148
|
-
status: 302,
|
|
149
|
-
headers: {
|
|
150
|
-
Location: url.toString(),
|
|
151
|
-
"Set-Cookie": makeSessionCookie(cookieConfig, token)
|
|
152
|
-
}
|
|
153
|
-
});
|
|
171
|
+
return null;
|
|
154
172
|
}
|
|
155
173
|
// src/client.ts
|
|
156
174
|
async function fetchPublicKeys(baseUrl) {
|
package/dist/hono.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ interface AuthOptions {
|
|
|
23
23
|
/**
|
|
24
24
|
* Hono middleware that handles the full auth flow:
|
|
25
25
|
*
|
|
26
|
-
* 1. If `?
|
|
26
|
+
* 1. If `?code=` in URL → exchange for token, set cookie, redirect to clean URL
|
|
27
27
|
* 2. If session cookie present → validate, set `c.get("user")`
|
|
28
28
|
* 3. If unauthenticated → redirect to auth.uln.industries/login
|
|
29
29
|
*
|
package/dist/hono.js
CHANGED
|
@@ -172,6 +172,28 @@ async function validateWithBinding(token, binding) {
|
|
|
172
172
|
exp: 0
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
|
+
async function exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl) {
|
|
176
|
+
const res = await fetch(`${authUrl}/api/exchange-code`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "Content-Type": "application/json" },
|
|
179
|
+
body: JSON.stringify({ code, redirectOrigin })
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok)
|
|
182
|
+
return null;
|
|
183
|
+
const data = await res.json();
|
|
184
|
+
return typeof data.token === "string" ? data.token : null;
|
|
185
|
+
}
|
|
186
|
+
async function exchangeCodeWithBinding(code, redirectOrigin, binding) {
|
|
187
|
+
const res = await binding.fetch("https://auth/api/exchange-code", {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "Content-Type": "application/json" },
|
|
190
|
+
body: JSON.stringify({ code, redirectOrigin })
|
|
191
|
+
});
|
|
192
|
+
if (!res.ok)
|
|
193
|
+
return null;
|
|
194
|
+
const data = await res.json();
|
|
195
|
+
return typeof data.token === "string" ? data.token : null;
|
|
196
|
+
}
|
|
175
197
|
function auth(options) {
|
|
176
198
|
const authUrl = options?.authUrl ?? DEFAULT_AUTH_URL;
|
|
177
199
|
const cookieName = options?.cookie ?? DEFAULT_COOKIE;
|
|
@@ -188,23 +210,36 @@ function auth(options) {
|
|
|
188
210
|
}
|
|
189
211
|
return validateWithPublicKeys(token, authUrl, maxAge);
|
|
190
212
|
}
|
|
213
|
+
async function exchangeCode(code, redirectOrigin, env) {
|
|
214
|
+
if (bindingName) {
|
|
215
|
+
const binding = env[bindingName];
|
|
216
|
+
if (!binding)
|
|
217
|
+
throw new Error(`Service binding "${bindingName}" not found in env`);
|
|
218
|
+
return exchangeCodeWithBinding(code, redirectOrigin, binding);
|
|
219
|
+
}
|
|
220
|
+
return exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl);
|
|
221
|
+
}
|
|
191
222
|
return async (c, next) => {
|
|
192
223
|
const url = new URL(c.req.url);
|
|
193
224
|
if (isPublicPath(url.pathname, publicPaths)) {
|
|
194
225
|
return next();
|
|
195
226
|
}
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
const
|
|
227
|
+
const incomingCode = url.searchParams.get("code");
|
|
228
|
+
if (incomingCode) {
|
|
229
|
+
const token = await exchangeCode(incomingCode, url.origin, c.env);
|
|
230
|
+
if (!token) {
|
|
231
|
+
return c.text("Invalid code", 401);
|
|
232
|
+
}
|
|
233
|
+
const payload = await validate(token, c.env);
|
|
199
234
|
if (!payload) {
|
|
200
235
|
return c.text("Invalid token", 401);
|
|
201
236
|
}
|
|
202
|
-
url.searchParams.delete("
|
|
237
|
+
url.searchParams.delete("code");
|
|
203
238
|
return new Response(null, {
|
|
204
239
|
status: 302,
|
|
205
240
|
headers: {
|
|
206
241
|
Location: url.toString(),
|
|
207
|
-
"Set-Cookie": makeSessionCookie(cookieConf,
|
|
242
|
+
"Set-Cookie": makeSessionCookie(cookieConf, token)
|
|
208
243
|
}
|
|
209
244
|
});
|
|
210
245
|
}
|
|
@@ -229,8 +264,10 @@ function auth(options) {
|
|
|
229
264
|
}
|
|
230
265
|
return c.json({ error: "Invalid token" }, 401);
|
|
231
266
|
}
|
|
232
|
-
const loginUrl =
|
|
233
|
-
|
|
267
|
+
const loginUrl = new URL("/login", authUrl);
|
|
268
|
+
loginUrl.searchParams.set("redirect", c.req.url);
|
|
269
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
270
|
+
return c.redirect(loginUrl.toString(), 302);
|
|
234
271
|
};
|
|
235
272
|
}
|
|
236
273
|
function getUser(c) {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,4 +6,5 @@ export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
|
6
6
|
export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
|
|
7
7
|
export type { RateLimitResult } from "./rate-limit.ts";
|
|
8
8
|
export { getClientIP, checkBearerToken, getAuthUser, redirectToLogin } from "./middleware.ts";
|
|
9
|
-
export { createUser, verifyUserCredentials, changePassword } from "./users.ts";
|
|
9
|
+
export { createUser, verifyUserCredentials, changePassword, isValidEmail } from "./users.ts";
|
|
10
|
+
export { generateTotpSecret, getTotpUri, verifyTotpCode, base32Encode, generateBackupCodes, hashBackupCode, } from "./totp.ts";
|
package/dist/index.js
CHANGED
|
@@ -131,26 +131,44 @@ async function getAuthUser(request, verifyConfig, cookieConfig) {
|
|
|
131
131
|
return validateToken(verifyConfig, token);
|
|
132
132
|
}
|
|
133
133
|
function redirectToLogin(currentUrl) {
|
|
134
|
-
const loginUrl =
|
|
135
|
-
|
|
134
|
+
const loginUrl = new URL("/login", "https://auth.uln.industries");
|
|
135
|
+
loginUrl.searchParams.set("redirect", currentUrl);
|
|
136
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
137
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
136
138
|
}
|
|
137
|
-
async function
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
async function exchangeCode(code, redirectOrigin, config) {
|
|
140
|
+
const res = await fetch(`${config.authUrl}/api/exchange-code`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({ code, redirectOrigin })
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok)
|
|
141
146
|
return null;
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
const data = await res.json();
|
|
148
|
+
return typeof data.token === "string" ? data.token : null;
|
|
149
|
+
}
|
|
150
|
+
async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
|
|
151
|
+
const url = new URL(request.url);
|
|
152
|
+
const code = url.searchParams.get("code");
|
|
153
|
+
if (code) {
|
|
154
|
+
if (!exchangeConfig)
|
|
155
|
+
return redirectToLogin(url.origin);
|
|
156
|
+
const token = await exchangeCode(code, url.origin, exchangeConfig);
|
|
157
|
+
if (!token)
|
|
158
|
+
return redirectToLogin(url.origin);
|
|
159
|
+
const payload = await validateToken(verifyConfig, token);
|
|
160
|
+
if (!payload)
|
|
161
|
+
return redirectToLogin(url.origin);
|
|
162
|
+
url.searchParams.delete("code");
|
|
163
|
+
return new Response(null, {
|
|
164
|
+
status: 302,
|
|
165
|
+
headers: {
|
|
166
|
+
Location: url.toString(),
|
|
167
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token)
|
|
168
|
+
}
|
|
169
|
+
});
|
|
145
170
|
}
|
|
146
|
-
|
|
147
|
-
return new Response(null, {
|
|
148
|
-
status: 302,
|
|
149
|
-
headers: {
|
|
150
|
-
Location: url.toString(),
|
|
151
|
-
"Set-Cookie": makeSessionCookie(cookieConfig, token)
|
|
152
|
-
}
|
|
153
|
-
});
|
|
171
|
+
return null;
|
|
154
172
|
}
|
|
155
173
|
|
|
156
174
|
// src/password.ts
|
|
@@ -201,7 +219,13 @@ async function verifyPassword(password, storedHash) {
|
|
|
201
219
|
}
|
|
202
220
|
|
|
203
221
|
// src/users.ts
|
|
222
|
+
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
223
|
+
function isValidEmail(email) {
|
|
224
|
+
return email.length <= 254 && EMAIL_REGEX.test(email);
|
|
225
|
+
}
|
|
204
226
|
async function createUser(store, input) {
|
|
227
|
+
if (!isValidEmail(input.email))
|
|
228
|
+
throw new Error("Invalid email address");
|
|
205
229
|
const existing = await store.getUserByEmail(input.email);
|
|
206
230
|
if (existing)
|
|
207
231
|
throw new Error(`User with email ${input.email} already exists`);
|
|
@@ -214,6 +238,8 @@ async function createUser(store, input) {
|
|
|
214
238
|
passwordHash,
|
|
215
239
|
isAdmin: input.isAdmin ?? false,
|
|
216
240
|
emailVerified: input.isAdmin ?? false,
|
|
241
|
+
totpSecret: null,
|
|
242
|
+
totpEnabled: false,
|
|
217
243
|
createdAt: now,
|
|
218
244
|
updatedAt: now
|
|
219
245
|
};
|
|
@@ -227,6 +253,17 @@ async function verifyUserCredentials(store, email, password) {
|
|
|
227
253
|
const valid = await verifyPassword(password, user.passwordHash);
|
|
228
254
|
return valid ? user : null;
|
|
229
255
|
}
|
|
256
|
+
async function updateName(store, userId, name) {
|
|
257
|
+
const user = await store.getUser(userId);
|
|
258
|
+
if (!user)
|
|
259
|
+
throw new Error(`User ${userId} not found`);
|
|
260
|
+
const updated = {
|
|
261
|
+
...user,
|
|
262
|
+
name,
|
|
263
|
+
updatedAt: new Date().toISOString()
|
|
264
|
+
};
|
|
265
|
+
await store.saveUser(updated);
|
|
266
|
+
}
|
|
230
267
|
async function changePassword(store, userId, newPassword) {
|
|
231
268
|
const user = await store.getUser(userId);
|
|
232
269
|
if (!user)
|
|
@@ -265,25 +302,152 @@ async function recordFailedAttempt(store, key, config) {
|
|
|
265
302
|
async function clearRateLimit(store, key) {
|
|
266
303
|
await store.delete(key);
|
|
267
304
|
}
|
|
305
|
+
|
|
306
|
+
// src/totp.ts
|
|
307
|
+
var TOTP_PERIOD = 30;
|
|
308
|
+
var TOTP_DIGITS = 6;
|
|
309
|
+
var TOTP_WINDOW = 1;
|
|
310
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
311
|
+
function base32Encode(data) {
|
|
312
|
+
let bits = 0;
|
|
313
|
+
let value = 0;
|
|
314
|
+
let output = "";
|
|
315
|
+
for (const byte of data) {
|
|
316
|
+
value = value << 8 | byte;
|
|
317
|
+
bits += 8;
|
|
318
|
+
while (bits >= 5) {
|
|
319
|
+
bits -= 5;
|
|
320
|
+
output += BASE32_ALPHABET[value >>> bits & 31];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (bits > 0) {
|
|
324
|
+
output += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
325
|
+
}
|
|
326
|
+
return output;
|
|
327
|
+
}
|
|
328
|
+
function base32Decode(encoded) {
|
|
329
|
+
const cleaned = encoded.replace(/[=\s]/g, "").toUpperCase();
|
|
330
|
+
const output = [];
|
|
331
|
+
let bits = 0;
|
|
332
|
+
let value = 0;
|
|
333
|
+
for (const char of cleaned) {
|
|
334
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
335
|
+
if (idx === -1)
|
|
336
|
+
throw new Error(`Invalid base32 character: ${char}`);
|
|
337
|
+
value = value << 5 | idx;
|
|
338
|
+
bits += 5;
|
|
339
|
+
if (bits >= 8) {
|
|
340
|
+
bits -= 8;
|
|
341
|
+
output.push(value >>> bits & 255);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return new Uint8Array(output);
|
|
345
|
+
}
|
|
346
|
+
function generateTotpSecret() {
|
|
347
|
+
const bytes = new Uint8Array(20);
|
|
348
|
+
crypto.getRandomValues(bytes);
|
|
349
|
+
return base32Encode(bytes);
|
|
350
|
+
}
|
|
351
|
+
function getTotpUri(secret, email, issuer) {
|
|
352
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(email)}`;
|
|
353
|
+
const params = new URLSearchParams({
|
|
354
|
+
secret,
|
|
355
|
+
issuer,
|
|
356
|
+
algorithm: "SHA1",
|
|
357
|
+
digits: String(TOTP_DIGITS),
|
|
358
|
+
period: String(TOTP_PERIOD)
|
|
359
|
+
});
|
|
360
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
361
|
+
}
|
|
362
|
+
async function generateCode(secret, counter) {
|
|
363
|
+
const keyBytes = base32Decode(secret);
|
|
364
|
+
const keyBuffer = new ArrayBuffer(keyBytes.byteLength);
|
|
365
|
+
new Uint8Array(keyBuffer).set(keyBytes);
|
|
366
|
+
const key = await crypto.subtle.importKey("raw", keyBuffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
367
|
+
const counterBuf = new ArrayBuffer(8);
|
|
368
|
+
const view = new DataView(counterBuf);
|
|
369
|
+
view.setUint32(4, counter);
|
|
370
|
+
const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, counterBuf));
|
|
371
|
+
const offset = (hmac[hmac.length - 1] ?? 0) & 15;
|
|
372
|
+
const b0 = hmac[offset] ?? 0;
|
|
373
|
+
const b1 = hmac[offset + 1] ?? 0;
|
|
374
|
+
const b2 = hmac[offset + 2] ?? 0;
|
|
375
|
+
const b3 = hmac[offset + 3] ?? 0;
|
|
376
|
+
const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
|
|
377
|
+
return String(code % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
|
|
378
|
+
}
|
|
379
|
+
async function currentTotpCode(secret, nowMs = Date.now()) {
|
|
380
|
+
const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
|
|
381
|
+
return generateCode(secret, step);
|
|
382
|
+
}
|
|
383
|
+
async function verifyTotpCode(secret, code) {
|
|
384
|
+
if (code.length !== TOTP_DIGITS)
|
|
385
|
+
return false;
|
|
386
|
+
const now = Math.floor(Date.now() / 1000);
|
|
387
|
+
const currentStep = Math.floor(now / TOTP_PERIOD);
|
|
388
|
+
for (let offset = -TOTP_WINDOW;offset <= TOTP_WINDOW; offset++) {
|
|
389
|
+
const expected = await generateCode(secret, currentStep + offset);
|
|
390
|
+
if (timingSafeEqual(code, expected))
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
var BACKUP_CODE_COUNT = 10;
|
|
396
|
+
var BACKUP_CODE_LENGTH = 8;
|
|
397
|
+
var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
398
|
+
function generateBackupCodes() {
|
|
399
|
+
const codes = [];
|
|
400
|
+
for (let i = 0;i < BACKUP_CODE_COUNT; i++) {
|
|
401
|
+
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
402
|
+
crypto.getRandomValues(bytes);
|
|
403
|
+
let code = "";
|
|
404
|
+
for (const byte of bytes) {
|
|
405
|
+
code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
|
|
406
|
+
}
|
|
407
|
+
codes.push(code);
|
|
408
|
+
}
|
|
409
|
+
return codes;
|
|
410
|
+
}
|
|
411
|
+
async function hashBackupCode(code) {
|
|
412
|
+
const data = new TextEncoder().encode(code.toLowerCase());
|
|
413
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
414
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
415
|
+
}
|
|
416
|
+
function timingSafeEqual(a, b) {
|
|
417
|
+
if (a.length !== b.length)
|
|
418
|
+
return false;
|
|
419
|
+
let result = 0;
|
|
420
|
+
for (let i = 0;i < a.length; i++) {
|
|
421
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
422
|
+
}
|
|
423
|
+
return result === 0;
|
|
424
|
+
}
|
|
268
425
|
export {
|
|
269
426
|
verifyUserCredentials,
|
|
427
|
+
verifyTotpCode,
|
|
270
428
|
verifyPassword,
|
|
271
429
|
validateToken,
|
|
272
430
|
redirectToLogin,
|
|
273
431
|
recordFailedAttempt,
|
|
274
432
|
makeSessionCookie,
|
|
275
433
|
makeClearCookie,
|
|
434
|
+
isValidEmail,
|
|
276
435
|
importPublicKey,
|
|
277
436
|
importPrivateKey,
|
|
278
437
|
hashPassword,
|
|
438
|
+
hashBackupCode,
|
|
439
|
+
getTotpUri,
|
|
279
440
|
getClientIP,
|
|
280
441
|
getAuthUser,
|
|
442
|
+
generateTotpSecret,
|
|
281
443
|
generateKeyPair,
|
|
444
|
+
generateBackupCodes,
|
|
282
445
|
extractCookie,
|
|
283
446
|
createUser,
|
|
284
447
|
createToken,
|
|
285
448
|
clearRateLimit,
|
|
286
449
|
checkRateLimit,
|
|
287
450
|
checkBearerToken,
|
|
288
|
-
changePassword
|
|
451
|
+
changePassword,
|
|
452
|
+
base32Encode
|
|
289
453
|
};
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
|
|
2
|
+
export interface AuthCodeExchangeConfig {
|
|
3
|
+
authUrl: string;
|
|
4
|
+
}
|
|
2
5
|
export declare function getClientIP(request: Request): string;
|
|
3
6
|
export declare function checkBearerToken(request: Request, expectedToken: string): boolean;
|
|
4
7
|
export declare function getAuthUser(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
|
|
5
8
|
export declare function redirectToLogin(currentUrl: string): Response;
|
|
6
9
|
/**
|
|
7
10
|
* Handle the auth callback on consuming sites.
|
|
8
|
-
* If the request URL has a `?
|
|
9
|
-
* and redirects to the same URL without the
|
|
10
|
-
* Returns null if there's no
|
|
11
|
+
* If the request URL has a `?code=` param, exchanges it for a token,
|
|
12
|
+
* sets a session cookie, and redirects to the same URL without the code param.
|
|
13
|
+
* Returns null if there's no callback param.
|
|
11
14
|
*/
|
|
12
|
-
export declare function handleCallback(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig): Promise<Response | null>;
|
|
15
|
+
export declare function handleCallback(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig, exchangeConfig?: AuthCodeExchangeConfig): Promise<Response | null>;
|
package/dist/middleware.js
CHANGED
|
@@ -131,26 +131,44 @@ async function getAuthUser(request, verifyConfig, cookieConfig) {
|
|
|
131
131
|
return validateToken(verifyConfig, token);
|
|
132
132
|
}
|
|
133
133
|
function redirectToLogin(currentUrl) {
|
|
134
|
-
const loginUrl =
|
|
135
|
-
|
|
134
|
+
const loginUrl = new URL("/login", "https://auth.uln.industries");
|
|
135
|
+
loginUrl.searchParams.set("redirect", currentUrl);
|
|
136
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
137
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
136
138
|
}
|
|
137
|
-
async function
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
async function exchangeCode(code, redirectOrigin, config) {
|
|
140
|
+
const res = await fetch(`${config.authUrl}/api/exchange-code`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({ code, redirectOrigin })
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok)
|
|
141
146
|
return null;
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
const data = await res.json();
|
|
148
|
+
return typeof data.token === "string" ? data.token : null;
|
|
149
|
+
}
|
|
150
|
+
async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
|
|
151
|
+
const url = new URL(request.url);
|
|
152
|
+
const code = url.searchParams.get("code");
|
|
153
|
+
if (code) {
|
|
154
|
+
if (!exchangeConfig)
|
|
155
|
+
return redirectToLogin(url.origin);
|
|
156
|
+
const token = await exchangeCode(code, url.origin, exchangeConfig);
|
|
157
|
+
if (!token)
|
|
158
|
+
return redirectToLogin(url.origin);
|
|
159
|
+
const payload = await validateToken(verifyConfig, token);
|
|
160
|
+
if (!payload)
|
|
161
|
+
return redirectToLogin(url.origin);
|
|
162
|
+
url.searchParams.delete("code");
|
|
163
|
+
return new Response(null, {
|
|
164
|
+
status: 302,
|
|
165
|
+
headers: {
|
|
166
|
+
Location: url.toString(),
|
|
167
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token)
|
|
168
|
+
}
|
|
169
|
+
});
|
|
145
170
|
}
|
|
146
|
-
|
|
147
|
-
return new Response(null, {
|
|
148
|
-
status: 302,
|
|
149
|
-
headers: {
|
|
150
|
-
Location: url.toString(),
|
|
151
|
-
"Set-Cookie": makeSessionCookie(cookieConfig, token)
|
|
152
|
-
}
|
|
153
|
-
});
|
|
171
|
+
return null;
|
|
154
172
|
}
|
|
155
173
|
export {
|
|
156
174
|
redirectToLogin,
|
package/dist/password.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
+
/** Hash a password with PBKDF2-SHA256 (100k iterations, 32-byte salt, 64-byte key). Returns `hex(salt):hex(key)`. */
|
|
1
2
|
export declare function hashPassword(password: string): Promise<string>;
|
|
3
|
+
/** Verify a password against a stored `hex(salt):hex(key)` hash. Uses constant-time comparison. */
|
|
2
4
|
export declare function verifyPassword(password: string, storedHash: string): Promise<boolean>;
|
package/dist/service.d.ts
CHANGED
|
@@ -13,5 +13,12 @@ export declare function verifyToken(authService: Fetcher, token: string): Promis
|
|
|
13
13
|
export declare function getAuthUser(request: Request, authService: Fetcher, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
|
|
14
14
|
/**
|
|
15
15
|
* Redirect to the centralized login page at auth.uln.industries.
|
|
16
|
+
* Requests one-time auth-code callbacks so signed tokens are not sent through URLs.
|
|
16
17
|
*/
|
|
17
18
|
export declare function redirectToLogin(currentUrl: string): Response;
|
|
19
|
+
/**
|
|
20
|
+
* Handle an auth callback on a consuming Worker using the auth service binding.
|
|
21
|
+
* Preferred `?code=` callbacks are exchanged for signed tokens before setting the local cookie.
|
|
22
|
+
* Returns null if the request has no auth callback parameters.
|
|
23
|
+
*/
|
|
24
|
+
export declare function handleCallback(request: Request, authService: Fetcher, cookieConfig: CookieConfig): Promise<Response | null>;
|
package/dist/service.js
CHANGED
|
@@ -55,13 +55,48 @@ async function getAuthUser(request, authService, cookieConfig) {
|
|
|
55
55
|
return verifyToken(authService, token);
|
|
56
56
|
}
|
|
57
57
|
function redirectToLogin(currentUrl) {
|
|
58
|
-
const loginUrl =
|
|
59
|
-
|
|
58
|
+
const loginUrl = new URL("/login", "https://auth.uln.industries");
|
|
59
|
+
loginUrl.searchParams.set("redirect", currentUrl);
|
|
60
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
61
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
62
|
+
}
|
|
63
|
+
async function exchangeCode(authService, code, redirectOrigin) {
|
|
64
|
+
const res = await authService.fetch("https://auth/api/exchange-code", {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ code, redirectOrigin })
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
return null;
|
|
71
|
+
const data = await res.json();
|
|
72
|
+
return typeof data.token === "string" ? data.token : null;
|
|
73
|
+
}
|
|
74
|
+
async function handleCallback(request, authService, cookieConfig) {
|
|
75
|
+
const url = new URL(request.url);
|
|
76
|
+
const code = url.searchParams.get("code");
|
|
77
|
+
if (code) {
|
|
78
|
+
const token = await exchangeCode(authService, code, url.origin);
|
|
79
|
+
if (!token)
|
|
80
|
+
return redirectToLogin(url.origin);
|
|
81
|
+
const payload = await verifyToken(authService, token);
|
|
82
|
+
if (!payload)
|
|
83
|
+
return redirectToLogin(url.origin);
|
|
84
|
+
url.searchParams.delete("code");
|
|
85
|
+
return new Response(null, {
|
|
86
|
+
status: 302,
|
|
87
|
+
headers: {
|
|
88
|
+
Location: url.toString(),
|
|
89
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token)
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
60
94
|
}
|
|
61
95
|
export {
|
|
62
96
|
verifyToken,
|
|
63
97
|
redirectToLogin,
|
|
64
98
|
makeSessionCookie,
|
|
65
99
|
makeClearCookie,
|
|
100
|
+
handleCallback,
|
|
66
101
|
getAuthUser
|
|
67
102
|
};
|
package/dist/token.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { SigningConfig, TokenPayload, VerifyConfig } from "./types.ts";
|
|
2
|
+
/** Generate an ECDSA P-256 key pair for token signing. Returns a short `kid` and both JWK exports. */
|
|
2
3
|
export declare function generateKeyPair(): Promise<{
|
|
3
4
|
kid: string;
|
|
4
5
|
privateKeyJwk: JsonWebKey;
|
|
5
6
|
publicKeyJwk: JsonWebKey;
|
|
6
7
|
}>;
|
|
8
|
+
/** Import an ECDSA P-256 private key from JWK for signing. */
|
|
7
9
|
export declare function importPrivateKey(jwk: JsonWebKey): Promise<CryptoKey>;
|
|
10
|
+
/** Import an ECDSA P-256 public key from JWK for verification. */
|
|
8
11
|
export declare function importPublicKey(jwk: JsonWebKey): Promise<CryptoKey>;
|
|
9
12
|
export interface CreateTokenInput {
|
|
10
13
|
id: string;
|
|
@@ -14,5 +17,7 @@ export interface CreateTokenInput {
|
|
|
14
17
|
scopes: string[];
|
|
15
18
|
flags: Record<string, string[]>;
|
|
16
19
|
}
|
|
20
|
+
/** Create a signed token: `base64url(JSON payload).base64url(ECDSA-SHA256 signature)`. */
|
|
17
21
|
export declare function createToken(config: SigningConfig, user: CreateTokenInput): Promise<string>;
|
|
22
|
+
/** Validate a signed token. Returns the payload if valid and not expired, or null. Matches `kid` to find the correct public key. */
|
|
18
23
|
export declare function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null>;
|
package/dist/totp.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOTP (Time-based One-Time Password) implementation per RFC 6238.
|
|
3
|
+
* Uses Web Crypto API only — no node:crypto imports.
|
|
4
|
+
*
|
|
5
|
+
* Algorithm: HMAC-SHA1, 6 digits, 30-second period, ±1 step window.
|
|
6
|
+
*/
|
|
7
|
+
export declare function base32Encode(data: Uint8Array): string;
|
|
8
|
+
/** Generate a 20-byte random TOTP secret, returned as base32. */
|
|
9
|
+
export declare function generateTotpSecret(): string;
|
|
10
|
+
/** Build an otpauth:// URI for authenticator apps. */
|
|
11
|
+
export declare function getTotpUri(secret: string, email: string, issuer: string): string;
|
|
12
|
+
/** Six-digit code for the current 30-second step (deterministic counterpart to verifyTotpCode in tests). */
|
|
13
|
+
export declare function currentTotpCode(secret: string, nowMs?: number): Promise<string>;
|
|
14
|
+
/** Verify a TOTP code against the secret. Allows ±1 time step for clock drift. */
|
|
15
|
+
export declare function verifyTotpCode(secret: string, code: string): Promise<boolean>;
|
|
16
|
+
/** Generate a set of one-time backup codes for 2FA recovery. Returns plain-text codes. */
|
|
17
|
+
export declare function generateBackupCodes(): string[];
|
|
18
|
+
/** Hash a backup code for storage. Uses SHA-256 (no salt needed — codes are high-entropy). */
|
|
19
|
+
export declare function hashBackupCode(code: string): Promise<string>;
|