@uln/auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +4 -0
- package/dist/client.js +129 -0
- package/dist/cookie.d.ts +4 -0
- package/dist/cookie.js +29 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +250 -0
- package/dist/middleware.d.ts +5 -0
- package/dist/middleware.js +124 -0
- package/dist/password.d.ts +2 -0
- package/dist/password.js +51 -0
- package/dist/rate-limit.d.ts +8 -0
- package/dist/rate-limit.js +32 -0
- package/dist/service.d.ts +17 -0
- package/dist/service.js +65 -0
- package/dist/token.d.ts +10 -0
- package/dist/token.js +76 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +1 -0
- package/dist/users.d.ts +9 -0
- package/dist/users.js +91 -0
- package/package.json +53 -0
- package/src/client.ts +4 -0
- package/src/cookie.ts +24 -0
- package/src/index.ts +19 -0
- package/src/middleware.ts +35 -0
- package/src/password.ts +57 -0
- package/src/rate-limit.ts +38 -0
- package/src/service.ts +54 -0
- package/src/token.ts +87 -0
- package/src/types.ts +55 -0
- package/src/users.ts +46 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/cookie.ts
|
|
3
|
+
function makeSessionCookie(config, token) {
|
|
4
|
+
const parts = [`${config.name}=${token}`, `Max-Age=${config.maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
5
|
+
if (config.secure)
|
|
6
|
+
parts.push("Secure");
|
|
7
|
+
return parts.join("; ");
|
|
8
|
+
}
|
|
9
|
+
function makeClearCookie(config) {
|
|
10
|
+
const parts = [`${config.name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
11
|
+
if (config.secure)
|
|
12
|
+
parts.push("Secure");
|
|
13
|
+
return parts.join("; ");
|
|
14
|
+
}
|
|
15
|
+
function extractCookie(cookieHeader, name) {
|
|
16
|
+
const prefix = `${name}=`;
|
|
17
|
+
for (const cookie of cookieHeader.split(";")) {
|
|
18
|
+
const trimmed = cookie.trim();
|
|
19
|
+
if (trimmed.startsWith(prefix)) {
|
|
20
|
+
return trimmed.substring(prefix.length);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/service.ts
|
|
27
|
+
async function verifyToken(authService, token) {
|
|
28
|
+
const res = await authService.fetch("https://auth/api/verify-token", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({ token })
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
return null;
|
|
35
|
+
const data = await res.json();
|
|
36
|
+
return {
|
|
37
|
+
sub: data.user.id,
|
|
38
|
+
email: data.user.email,
|
|
39
|
+
name: data.user.name,
|
|
40
|
+
admin: data.user.admin,
|
|
41
|
+
scopes: data.user.scopes,
|
|
42
|
+
iat: 0,
|
|
43
|
+
exp: 0
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function getAuthUser(request, authService, cookieConfig) {
|
|
47
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
48
|
+
if (!cookieHeader)
|
|
49
|
+
return null;
|
|
50
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
51
|
+
if (!token)
|
|
52
|
+
return null;
|
|
53
|
+
return verifyToken(authService, token);
|
|
54
|
+
}
|
|
55
|
+
function redirectToLogin(currentUrl) {
|
|
56
|
+
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
|
57
|
+
return Response.redirect(loginUrl, 302);
|
|
58
|
+
}
|
|
59
|
+
export {
|
|
60
|
+
verifyToken,
|
|
61
|
+
redirectToLogin,
|
|
62
|
+
makeSessionCookie,
|
|
63
|
+
makeClearCookie,
|
|
64
|
+
getAuthUser
|
|
65
|
+
};
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TokenConfig, TokenPayload } from "./types.ts";
|
|
2
|
+
export interface CreateTokenInput {
|
|
3
|
+
id: string;
|
|
4
|
+
email: string;
|
|
5
|
+
name: string;
|
|
6
|
+
isAdmin: boolean;
|
|
7
|
+
scopes: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function createToken(config: TokenConfig, user: CreateTokenInput): Promise<string>;
|
|
10
|
+
export declare function validateToken(config: TokenConfig, token: string): Promise<TokenPayload | null>;
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/token.ts
|
|
3
|
+
var encoder = new TextEncoder;
|
|
4
|
+
function base64urlEncode(data) {
|
|
5
|
+
let binary = "";
|
|
6
|
+
for (const byte of data) {
|
|
7
|
+
binary += String.fromCharCode(byte);
|
|
8
|
+
}
|
|
9
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
10
|
+
}
|
|
11
|
+
function base64urlDecode(str) {
|
|
12
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
13
|
+
const binary = atob(padded);
|
|
14
|
+
const bytes = new Uint8Array(binary.length);
|
|
15
|
+
for (let i = 0;i < binary.length; i++) {
|
|
16
|
+
bytes[i] = binary.charCodeAt(i);
|
|
17
|
+
}
|
|
18
|
+
return bytes;
|
|
19
|
+
}
|
|
20
|
+
async function hmacSign(secret, data) {
|
|
21
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
22
|
+
"sign"
|
|
23
|
+
]);
|
|
24
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
25
|
+
return base64urlEncode(new Uint8Array(signature));
|
|
26
|
+
}
|
|
27
|
+
function timingSafeEqual(a, b) {
|
|
28
|
+
if (a.length !== b.length)
|
|
29
|
+
return false;
|
|
30
|
+
let diff = 0;
|
|
31
|
+
for (let i = 0;i < a.length; i++) {
|
|
32
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
33
|
+
}
|
|
34
|
+
return diff === 0;
|
|
35
|
+
}
|
|
36
|
+
async function createToken(config, user) {
|
|
37
|
+
const now = Math.floor(Date.now() / 1000);
|
|
38
|
+
const payload = {
|
|
39
|
+
sub: user.id,
|
|
40
|
+
email: user.email,
|
|
41
|
+
name: user.name,
|
|
42
|
+
admin: user.isAdmin,
|
|
43
|
+
scopes: user.scopes,
|
|
44
|
+
iat: now,
|
|
45
|
+
exp: now + config.maxAgeSeconds
|
|
46
|
+
};
|
|
47
|
+
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
48
|
+
const sig = await hmacSign(config.secret, payloadB64);
|
|
49
|
+
return `${payloadB64}.${sig}`;
|
|
50
|
+
}
|
|
51
|
+
async function validateToken(config, token) {
|
|
52
|
+
const dotIndex = token.indexOf(".");
|
|
53
|
+
if (dotIndex === -1)
|
|
54
|
+
return null;
|
|
55
|
+
const payloadB64 = token.substring(0, dotIndex);
|
|
56
|
+
const sig = token.substring(dotIndex + 1);
|
|
57
|
+
const expectedSig = await hmacSign(config.secret, payloadB64);
|
|
58
|
+
if (!timingSafeEqual(sig, expectedSig))
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
62
|
+
const payload = JSON.parse(decoded);
|
|
63
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number")
|
|
64
|
+
return null;
|
|
65
|
+
const now = Math.floor(Date.now() / 1000);
|
|
66
|
+
if (now > payload.exp)
|
|
67
|
+
return null;
|
|
68
|
+
return payload;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
validateToken,
|
|
75
|
+
createToken
|
|
76
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface User {
|
|
2
|
+
id: string;
|
|
3
|
+
email: string;
|
|
4
|
+
name: string;
|
|
5
|
+
passwordHash: string;
|
|
6
|
+
isAdmin: boolean;
|
|
7
|
+
emailVerified: boolean;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export interface UserStore {
|
|
12
|
+
getUser(id: string): Promise<User | null>;
|
|
13
|
+
getUserByEmail(email: string): Promise<User | null>;
|
|
14
|
+
listUsers(): Promise<User[]>;
|
|
15
|
+
saveUser(user: User): Promise<void>;
|
|
16
|
+
deleteUser(id: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export interface RateLimitEntry {
|
|
19
|
+
attempts: number;
|
|
20
|
+
firstAttempt: number;
|
|
21
|
+
}
|
|
22
|
+
export interface RateLimitStore {
|
|
23
|
+
get(key: string): Promise<RateLimitEntry | null>;
|
|
24
|
+
set(key: string, entry: RateLimitEntry, ttlSeconds: number): Promise<void>;
|
|
25
|
+
delete(key: string): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export interface RateLimitConfig {
|
|
28
|
+
maxAttempts: number;
|
|
29
|
+
windowSeconds: number;
|
|
30
|
+
}
|
|
31
|
+
export interface TokenPayload {
|
|
32
|
+
sub: string;
|
|
33
|
+
email: string;
|
|
34
|
+
name: string;
|
|
35
|
+
admin: boolean;
|
|
36
|
+
scopes: string[];
|
|
37
|
+
iat: number;
|
|
38
|
+
exp: number;
|
|
39
|
+
}
|
|
40
|
+
export interface TokenConfig {
|
|
41
|
+
secret: string;
|
|
42
|
+
maxAgeSeconds: number;
|
|
43
|
+
}
|
|
44
|
+
export interface CookieConfig {
|
|
45
|
+
name: string;
|
|
46
|
+
maxAgeSeconds: number;
|
|
47
|
+
secure: boolean;
|
|
48
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// @bun
|
package/dist/users.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { User, UserStore } from "./types.ts";
|
|
2
|
+
export declare function createUser(store: UserStore, input: {
|
|
3
|
+
email: string;
|
|
4
|
+
name: string;
|
|
5
|
+
password?: string;
|
|
6
|
+
isAdmin?: boolean;
|
|
7
|
+
}): Promise<User>;
|
|
8
|
+
export declare function verifyUserCredentials(store: UserStore, email: string, password: string): Promise<User | null>;
|
|
9
|
+
export declare function changePassword(store: UserStore, userId: string, newPassword: string): Promise<void>;
|
package/dist/users.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/password.ts
|
|
3
|
+
var SALT_LENGTH = 32;
|
|
4
|
+
var KEY_LENGTH = 64;
|
|
5
|
+
var ITERATIONS = 1e5;
|
|
6
|
+
function toHex(buffer) {
|
|
7
|
+
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
8
|
+
}
|
|
9
|
+
function fromHex(hex) {
|
|
10
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
11
|
+
for (let i = 0;i < hex.length; i += 2) {
|
|
12
|
+
bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16);
|
|
13
|
+
}
|
|
14
|
+
return bytes;
|
|
15
|
+
}
|
|
16
|
+
async function deriveKey(password, salt) {
|
|
17
|
+
const encoder = new TextEncoder;
|
|
18
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
|
|
19
|
+
return crypto.subtle.deriveBits({
|
|
20
|
+
name: "PBKDF2",
|
|
21
|
+
salt,
|
|
22
|
+
iterations: ITERATIONS,
|
|
23
|
+
hash: "SHA-256"
|
|
24
|
+
}, keyMaterial, KEY_LENGTH * 8);
|
|
25
|
+
}
|
|
26
|
+
async function hashPassword(password) {
|
|
27
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
28
|
+
const derived = await deriveKey(password, salt);
|
|
29
|
+
return `${toHex(salt.buffer)}:${toHex(derived)}`;
|
|
30
|
+
}
|
|
31
|
+
async function verifyPassword(password, storedHash) {
|
|
32
|
+
const separatorIndex = storedHash.indexOf(":");
|
|
33
|
+
if (separatorIndex === -1)
|
|
34
|
+
return false;
|
|
35
|
+
const saltHex = storedHash.substring(0, separatorIndex);
|
|
36
|
+
const keyHex = storedHash.substring(separatorIndex + 1);
|
|
37
|
+
const salt = fromHex(saltHex);
|
|
38
|
+
const expectedKey = fromHex(keyHex);
|
|
39
|
+
const derived = new Uint8Array(await deriveKey(password, salt));
|
|
40
|
+
if (derived.length !== expectedKey.length)
|
|
41
|
+
return false;
|
|
42
|
+
let diff = 0;
|
|
43
|
+
for (let i = 0;i < derived.length; i++) {
|
|
44
|
+
diff |= (derived[i] ?? 0) ^ (expectedKey[i] ?? 0);
|
|
45
|
+
}
|
|
46
|
+
return diff === 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/users.ts
|
|
50
|
+
async function createUser(store, input) {
|
|
51
|
+
const existing = await store.getUserByEmail(input.email);
|
|
52
|
+
if (existing)
|
|
53
|
+
throw new Error(`User with email ${input.email} already exists`);
|
|
54
|
+
const now = new Date().toISOString();
|
|
55
|
+
const passwordHash = input.password ? await hashPassword(input.password) : await hashPassword(crypto.randomUUID());
|
|
56
|
+
const user = {
|
|
57
|
+
id: crypto.randomUUID(),
|
|
58
|
+
email: input.email.toLowerCase(),
|
|
59
|
+
name: input.name,
|
|
60
|
+
passwordHash,
|
|
61
|
+
isAdmin: input.isAdmin ?? false,
|
|
62
|
+
emailVerified: input.isAdmin ?? false,
|
|
63
|
+
createdAt: now,
|
|
64
|
+
updatedAt: now
|
|
65
|
+
};
|
|
66
|
+
await store.saveUser(user);
|
|
67
|
+
return user;
|
|
68
|
+
}
|
|
69
|
+
async function verifyUserCredentials(store, email, password) {
|
|
70
|
+
const user = await store.getUserByEmail(email.toLowerCase());
|
|
71
|
+
if (!user)
|
|
72
|
+
return null;
|
|
73
|
+
const valid = await verifyPassword(password, user.passwordHash);
|
|
74
|
+
return valid ? user : null;
|
|
75
|
+
}
|
|
76
|
+
async function changePassword(store, userId, newPassword) {
|
|
77
|
+
const user = await store.getUser(userId);
|
|
78
|
+
if (!user)
|
|
79
|
+
throw new Error(`User ${userId} not found`);
|
|
80
|
+
const updated = {
|
|
81
|
+
...user,
|
|
82
|
+
passwordHash: await hashPassword(newPassword),
|
|
83
|
+
updatedAt: new Date().toISOString()
|
|
84
|
+
};
|
|
85
|
+
await store.saveUser(updated);
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
verifyUserCredentials,
|
|
89
|
+
createUser,
|
|
90
|
+
changePassword
|
|
91
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uln/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"bun": "./src/index.ts",
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"bun": "./src/client.ts",
|
|
13
|
+
"import": "./dist/client.js",
|
|
14
|
+
"types": "./dist/client.d.ts"
|
|
15
|
+
},
|
|
16
|
+
"./service": {
|
|
17
|
+
"bun": "./src/service.ts",
|
|
18
|
+
"import": "./dist/service.js",
|
|
19
|
+
"types": "./dist/service.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "bunx vite",
|
|
29
|
+
"dev:worker": "cd worker && bunx wrangler dev",
|
|
30
|
+
"build": "bun run scripts/build.ts",
|
|
31
|
+
"check": "biome check --write .",
|
|
32
|
+
"test": "bun test",
|
|
33
|
+
"deploy": "bunx vite build && cd worker && bunx wrangler deploy",
|
|
34
|
+
"publish:check": "bun run scripts/publish.ts check",
|
|
35
|
+
"publish:release": "bun run scripts/publish.ts release"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@biomejs/biome": "^1.9.4",
|
|
39
|
+
"@cloudflare/workers-types": "^4.20260317.1",
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"@types/react": "^19.2.14",
|
|
42
|
+
"@types/react-dom": "^19.2.3",
|
|
43
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
44
|
+
"hono": "^4.12.8",
|
|
45
|
+
"react": "^19.2.4",
|
|
46
|
+
"react-dom": "^19.2.4",
|
|
47
|
+
"react-router": "^7.13.1",
|
|
48
|
+
"vite": "^8.0.1"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"typescript": "^5"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { TokenPayload, TokenConfig, CookieConfig } from "./types.ts";
|
|
2
|
+
export { createToken, validateToken } from "./token.ts";
|
|
3
|
+
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
4
|
+
export { getAuthUser, redirectToLogin, getClientIP, checkBearerToken } from "./middleware.ts";
|
package/src/cookie.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CookieConfig } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function makeSessionCookie(config: CookieConfig, token: string): string {
|
|
4
|
+
const parts = [`${config.name}=${token}`, `Max-Age=${config.maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
5
|
+
if (config.secure) parts.push("Secure");
|
|
6
|
+
return parts.join("; ");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function makeClearCookie(config: CookieConfig): string {
|
|
10
|
+
const parts = [`${config.name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
11
|
+
if (config.secure) parts.push("Secure");
|
|
12
|
+
return parts.join("; ");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractCookie(cookieHeader: string, name: string): string | null {
|
|
16
|
+
const prefix = `${name}=`;
|
|
17
|
+
for (const cookie of cookieHeader.split(";")) {
|
|
18
|
+
const trimmed = cookie.trim();
|
|
19
|
+
if (trimmed.startsWith(prefix)) {
|
|
20
|
+
return trimmed.substring(prefix.length);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
User,
|
|
3
|
+
UserStore,
|
|
4
|
+
RateLimitEntry,
|
|
5
|
+
RateLimitStore,
|
|
6
|
+
RateLimitConfig,
|
|
7
|
+
TokenPayload,
|
|
8
|
+
TokenConfig,
|
|
9
|
+
CookieConfig,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export { hashPassword, verifyPassword } from "./password.ts";
|
|
13
|
+
export { createToken, validateToken } from "./token.ts";
|
|
14
|
+
export type { CreateTokenInput } from "./token.ts";
|
|
15
|
+
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
16
|
+
export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
|
|
17
|
+
export type { RateLimitResult } from "./rate-limit.ts";
|
|
18
|
+
export { getClientIP, checkBearerToken, getAuthUser, redirectToLogin } from "./middleware.ts";
|
|
19
|
+
export { createUser, verifyUserCredentials, changePassword } from "./users.ts";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { extractCookie } from "./cookie.ts";
|
|
2
|
+
import { validateToken } from "./token.ts";
|
|
3
|
+
import type { CookieConfig, TokenConfig, TokenPayload } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export function getClientIP(request: Request): string {
|
|
6
|
+
return (
|
|
7
|
+
request.headers.get("cf-connecting-ip") ??
|
|
8
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
9
|
+
"unknown"
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function checkBearerToken(request: Request, expectedToken: string): boolean {
|
|
14
|
+
const header = request.headers.get("Authorization");
|
|
15
|
+
return header === `Bearer ${expectedToken}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function getAuthUser(
|
|
19
|
+
request: Request,
|
|
20
|
+
tokenConfig: TokenConfig,
|
|
21
|
+
cookieConfig: CookieConfig,
|
|
22
|
+
): Promise<TokenPayload | null> {
|
|
23
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
24
|
+
if (!cookieHeader) return null;
|
|
25
|
+
|
|
26
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
27
|
+
if (!token) return null;
|
|
28
|
+
|
|
29
|
+
return validateToken(tokenConfig, token);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function redirectToLogin(currentUrl: string): Response {
|
|
33
|
+
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
|
34
|
+
return Response.redirect(loginUrl, 302);
|
|
35
|
+
}
|
package/src/password.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const SALT_LENGTH = 32;
|
|
2
|
+
const KEY_LENGTH = 64;
|
|
3
|
+
const ITERATIONS = 100_000;
|
|
4
|
+
|
|
5
|
+
function toHex(buffer: ArrayBuffer): string {
|
|
6
|
+
return Array.from(new Uint8Array(buffer))
|
|
7
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
8
|
+
.join("");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fromHex(hex: string): Uint8Array<ArrayBuffer> {
|
|
12
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
13
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
14
|
+
bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16);
|
|
15
|
+
}
|
|
16
|
+
return bytes;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer> {
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
|
|
22
|
+
return crypto.subtle.deriveBits(
|
|
23
|
+
{
|
|
24
|
+
name: "PBKDF2",
|
|
25
|
+
salt,
|
|
26
|
+
iterations: ITERATIONS,
|
|
27
|
+
hash: "SHA-256",
|
|
28
|
+
},
|
|
29
|
+
keyMaterial,
|
|
30
|
+
KEY_LENGTH * 8,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
35
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
36
|
+
const derived = await deriveKey(password, salt);
|
|
37
|
+
return `${toHex(salt.buffer)}:${toHex(derived)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
|
41
|
+
const separatorIndex = storedHash.indexOf(":");
|
|
42
|
+
if (separatorIndex === -1) return false;
|
|
43
|
+
|
|
44
|
+
const saltHex = storedHash.substring(0, separatorIndex);
|
|
45
|
+
const keyHex = storedHash.substring(separatorIndex + 1);
|
|
46
|
+
const salt = fromHex(saltHex);
|
|
47
|
+
const expectedKey = fromHex(keyHex);
|
|
48
|
+
const derived = new Uint8Array(await deriveKey(password, salt));
|
|
49
|
+
|
|
50
|
+
if (derived.length !== expectedKey.length) return false;
|
|
51
|
+
|
|
52
|
+
let diff = 0;
|
|
53
|
+
for (let i = 0; i < derived.length; i++) {
|
|
54
|
+
diff |= (derived[i] ?? 0) ^ (expectedKey[i] ?? 0);
|
|
55
|
+
}
|
|
56
|
+
return diff === 0;
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { RateLimitConfig, RateLimitStore } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export interface RateLimitResult {
|
|
4
|
+
blocked: boolean;
|
|
5
|
+
remainingSeconds?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function checkRateLimit(
|
|
9
|
+
store: RateLimitStore,
|
|
10
|
+
key: string,
|
|
11
|
+
config: RateLimitConfig,
|
|
12
|
+
): Promise<RateLimitResult> {
|
|
13
|
+
const entry = await store.get(key);
|
|
14
|
+
if (!entry) return { blocked: false };
|
|
15
|
+
|
|
16
|
+
if (entry.attempts >= config.maxAttempts) {
|
|
17
|
+
const elapsed = Math.floor((Date.now() - entry.firstAttempt) / 1000);
|
|
18
|
+
const remaining = config.windowSeconds - elapsed;
|
|
19
|
+
if (remaining > 0) return { blocked: true, remainingSeconds: remaining };
|
|
20
|
+
await store.delete(key);
|
|
21
|
+
return { blocked: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { blocked: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function recordFailedAttempt(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<void> {
|
|
28
|
+
const entry = await store.get(key);
|
|
29
|
+
if (entry) {
|
|
30
|
+
await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
|
|
31
|
+
} else {
|
|
32
|
+
await store.set(key, { attempts: 1, firstAttempt: Date.now() }, config.windowSeconds);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function clearRateLimit(store: RateLimitStore, key: string): Promise<void> {
|
|
37
|
+
await store.delete(key);
|
|
38
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { extractCookie } from "./cookie.ts";
|
|
2
|
+
import type { CookieConfig, TokenPayload } from "./types.ts";
|
|
3
|
+
export { makeSessionCookie, makeClearCookie } from "./cookie.ts";
|
|
4
|
+
export type { TokenPayload, CookieConfig } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate a raw token string via the auth service binding.
|
|
8
|
+
* Returns the token payload or null if invalid.
|
|
9
|
+
*/
|
|
10
|
+
export async function verifyToken(authService: Fetcher, token: string): Promise<TokenPayload | null> {
|
|
11
|
+
const res = await authService.fetch("https://auth/api/verify-token", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify({ token }),
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) return null;
|
|
17
|
+
const data: { user: { id: string; email: string; name: string; admin: boolean; scopes: string[] } } =
|
|
18
|
+
await res.json();
|
|
19
|
+
return {
|
|
20
|
+
sub: data.user.id,
|
|
21
|
+
email: data.user.email,
|
|
22
|
+
name: data.user.name,
|
|
23
|
+
admin: data.user.admin,
|
|
24
|
+
scopes: data.user.scopes,
|
|
25
|
+
iat: 0,
|
|
26
|
+
exp: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get the authenticated user from the session cookie via the auth service binding.
|
|
32
|
+
* Returns the token payload or null if not authenticated.
|
|
33
|
+
*/
|
|
34
|
+
export async function getAuthUser(
|
|
35
|
+
request: Request,
|
|
36
|
+
authService: Fetcher,
|
|
37
|
+
cookieConfig: CookieConfig,
|
|
38
|
+
): Promise<TokenPayload | null> {
|
|
39
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
40
|
+
if (!cookieHeader) return null;
|
|
41
|
+
|
|
42
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
43
|
+
if (!token) return null;
|
|
44
|
+
|
|
45
|
+
return verifyToken(authService, token);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Redirect to the centralized login page at auth.uln.industries.
|
|
50
|
+
*/
|
|
51
|
+
export function redirectToLogin(currentUrl: string): Response {
|
|
52
|
+
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
|
53
|
+
return Response.redirect(loginUrl, 302);
|
|
54
|
+
}
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { TokenConfig, TokenPayload } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
|
|
5
|
+
function base64urlEncode(data: Uint8Array): string {
|
|
6
|
+
let binary = "";
|
|
7
|
+
for (const byte of data) {
|
|
8
|
+
binary += String.fromCharCode(byte);
|
|
9
|
+
}
|
|
10
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function base64urlDecode(str: string): Uint8Array {
|
|
14
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
15
|
+
const binary = atob(padded);
|
|
16
|
+
const bytes = new Uint8Array(binary.length);
|
|
17
|
+
for (let i = 0; i < binary.length; i++) {
|
|
18
|
+
bytes[i] = binary.charCodeAt(i);
|
|
19
|
+
}
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function hmacSign(secret: string, data: string): Promise<string> {
|
|
24
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
25
|
+
"sign",
|
|
26
|
+
]);
|
|
27
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
28
|
+
return base64urlEncode(new Uint8Array(signature));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
32
|
+
if (a.length !== b.length) return false;
|
|
33
|
+
let diff = 0;
|
|
34
|
+
for (let i = 0; i < a.length; i++) {
|
|
35
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
36
|
+
}
|
|
37
|
+
return diff === 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CreateTokenInput {
|
|
41
|
+
id: string;
|
|
42
|
+
email: string;
|
|
43
|
+
name: string;
|
|
44
|
+
isAdmin: boolean;
|
|
45
|
+
scopes: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function createToken(config: TokenConfig, user: CreateTokenInput): Promise<string> {
|
|
49
|
+
const now = Math.floor(Date.now() / 1000);
|
|
50
|
+
const payload: TokenPayload = {
|
|
51
|
+
sub: user.id,
|
|
52
|
+
email: user.email,
|
|
53
|
+
name: user.name,
|
|
54
|
+
admin: user.isAdmin,
|
|
55
|
+
scopes: user.scopes,
|
|
56
|
+
iat: now,
|
|
57
|
+
exp: now + config.maxAgeSeconds,
|
|
58
|
+
};
|
|
59
|
+
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
60
|
+
const sig = await hmacSign(config.secret, payloadB64);
|
|
61
|
+
return `${payloadB64}.${sig}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function validateToken(config: TokenConfig, token: string): Promise<TokenPayload | null> {
|
|
65
|
+
const dotIndex = token.indexOf(".");
|
|
66
|
+
if (dotIndex === -1) return null;
|
|
67
|
+
|
|
68
|
+
const payloadB64 = token.substring(0, dotIndex);
|
|
69
|
+
const sig = token.substring(dotIndex + 1);
|
|
70
|
+
|
|
71
|
+
const expectedSig = await hmacSign(config.secret, payloadB64);
|
|
72
|
+
if (!timingSafeEqual(sig, expectedSig)) return null;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
76
|
+
const payload = JSON.parse(decoded) as TokenPayload;
|
|
77
|
+
|
|
78
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number") return null;
|
|
79
|
+
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
if (now > payload.exp) return null;
|
|
82
|
+
|
|
83
|
+
return payload;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|