@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/client.d.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/dist/client.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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/token.ts
|
|
27
|
+
var encoder = new TextEncoder;
|
|
28
|
+
function base64urlEncode(data) {
|
|
29
|
+
let binary = "";
|
|
30
|
+
for (const byte of data) {
|
|
31
|
+
binary += String.fromCharCode(byte);
|
|
32
|
+
}
|
|
33
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
}
|
|
35
|
+
function base64urlDecode(str) {
|
|
36
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
|
+
const binary = atob(padded);
|
|
38
|
+
const bytes = new Uint8Array(binary.length);
|
|
39
|
+
for (let i = 0;i < binary.length; i++) {
|
|
40
|
+
bytes[i] = binary.charCodeAt(i);
|
|
41
|
+
}
|
|
42
|
+
return bytes;
|
|
43
|
+
}
|
|
44
|
+
async function hmacSign(secret, data) {
|
|
45
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
46
|
+
"sign"
|
|
47
|
+
]);
|
|
48
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
49
|
+
return base64urlEncode(new Uint8Array(signature));
|
|
50
|
+
}
|
|
51
|
+
function timingSafeEqual(a, b) {
|
|
52
|
+
if (a.length !== b.length)
|
|
53
|
+
return false;
|
|
54
|
+
let diff = 0;
|
|
55
|
+
for (let i = 0;i < a.length; i++) {
|
|
56
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
57
|
+
}
|
|
58
|
+
return diff === 0;
|
|
59
|
+
}
|
|
60
|
+
async function createToken(config, user) {
|
|
61
|
+
const now = Math.floor(Date.now() / 1000);
|
|
62
|
+
const payload = {
|
|
63
|
+
sub: user.id,
|
|
64
|
+
email: user.email,
|
|
65
|
+
name: user.name,
|
|
66
|
+
admin: user.isAdmin,
|
|
67
|
+
scopes: user.scopes,
|
|
68
|
+
iat: now,
|
|
69
|
+
exp: now + config.maxAgeSeconds
|
|
70
|
+
};
|
|
71
|
+
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
72
|
+
const sig = await hmacSign(config.secret, payloadB64);
|
|
73
|
+
return `${payloadB64}.${sig}`;
|
|
74
|
+
}
|
|
75
|
+
async function validateToken(config, token) {
|
|
76
|
+
const dotIndex = token.indexOf(".");
|
|
77
|
+
if (dotIndex === -1)
|
|
78
|
+
return null;
|
|
79
|
+
const payloadB64 = token.substring(0, dotIndex);
|
|
80
|
+
const sig = token.substring(dotIndex + 1);
|
|
81
|
+
const expectedSig = await hmacSign(config.secret, payloadB64);
|
|
82
|
+
if (!timingSafeEqual(sig, expectedSig))
|
|
83
|
+
return null;
|
|
84
|
+
try {
|
|
85
|
+
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
86
|
+
const payload = JSON.parse(decoded);
|
|
87
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number")
|
|
88
|
+
return null;
|
|
89
|
+
const now = Math.floor(Date.now() / 1000);
|
|
90
|
+
if (now > payload.exp)
|
|
91
|
+
return null;
|
|
92
|
+
return payload;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/middleware.ts
|
|
99
|
+
function getClientIP(request) {
|
|
100
|
+
return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
101
|
+
}
|
|
102
|
+
function checkBearerToken(request, expectedToken) {
|
|
103
|
+
const header = request.headers.get("Authorization");
|
|
104
|
+
return header === `Bearer ${expectedToken}`;
|
|
105
|
+
}
|
|
106
|
+
async function getAuthUser(request, tokenConfig, cookieConfig) {
|
|
107
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
108
|
+
if (!cookieHeader)
|
|
109
|
+
return null;
|
|
110
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
111
|
+
if (!token)
|
|
112
|
+
return null;
|
|
113
|
+
return validateToken(tokenConfig, token);
|
|
114
|
+
}
|
|
115
|
+
function redirectToLogin(currentUrl) {
|
|
116
|
+
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
|
117
|
+
return Response.redirect(loginUrl, 302);
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
validateToken,
|
|
121
|
+
redirectToLogin,
|
|
122
|
+
makeSessionCookie,
|
|
123
|
+
makeClearCookie,
|
|
124
|
+
getClientIP,
|
|
125
|
+
getAuthUser,
|
|
126
|
+
extractCookie,
|
|
127
|
+
createToken,
|
|
128
|
+
checkBearerToken
|
|
129
|
+
};
|
package/dist/cookie.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CookieConfig } from "./types.ts";
|
|
2
|
+
export declare function makeSessionCookie(config: CookieConfig, token: string): string;
|
|
3
|
+
export declare function makeClearCookie(config: CookieConfig): string;
|
|
4
|
+
export declare function extractCookie(cookieHeader: string, name: string): string | null;
|
package/dist/cookie.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
export {
|
|
26
|
+
makeSessionCookie,
|
|
27
|
+
makeClearCookie,
|
|
28
|
+
extractCookie
|
|
29
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { User, UserStore, RateLimitEntry, RateLimitStore, RateLimitConfig, TokenPayload, TokenConfig, CookieConfig, } from "./types.ts";
|
|
2
|
+
export { hashPassword, verifyPassword } from "./password.ts";
|
|
3
|
+
export { createToken, validateToken } from "./token.ts";
|
|
4
|
+
export type { CreateTokenInput } from "./token.ts";
|
|
5
|
+
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
6
|
+
export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
|
|
7
|
+
export type { RateLimitResult } from "./rate-limit.ts";
|
|
8
|
+
export { getClientIP, checkBearerToken, getAuthUser, redirectToLogin } from "./middleware.ts";
|
|
9
|
+
export { createUser, verifyUserCredentials, changePassword } from "./users.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
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/token.ts
|
|
27
|
+
var encoder = new TextEncoder;
|
|
28
|
+
function base64urlEncode(data) {
|
|
29
|
+
let binary = "";
|
|
30
|
+
for (const byte of data) {
|
|
31
|
+
binary += String.fromCharCode(byte);
|
|
32
|
+
}
|
|
33
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
}
|
|
35
|
+
function base64urlDecode(str) {
|
|
36
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
|
+
const binary = atob(padded);
|
|
38
|
+
const bytes = new Uint8Array(binary.length);
|
|
39
|
+
for (let i = 0;i < binary.length; i++) {
|
|
40
|
+
bytes[i] = binary.charCodeAt(i);
|
|
41
|
+
}
|
|
42
|
+
return bytes;
|
|
43
|
+
}
|
|
44
|
+
async function hmacSign(secret, data) {
|
|
45
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
46
|
+
"sign"
|
|
47
|
+
]);
|
|
48
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
49
|
+
return base64urlEncode(new Uint8Array(signature));
|
|
50
|
+
}
|
|
51
|
+
function timingSafeEqual(a, b) {
|
|
52
|
+
if (a.length !== b.length)
|
|
53
|
+
return false;
|
|
54
|
+
let diff = 0;
|
|
55
|
+
for (let i = 0;i < a.length; i++) {
|
|
56
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
57
|
+
}
|
|
58
|
+
return diff === 0;
|
|
59
|
+
}
|
|
60
|
+
async function createToken(config, user) {
|
|
61
|
+
const now = Math.floor(Date.now() / 1000);
|
|
62
|
+
const payload = {
|
|
63
|
+
sub: user.id,
|
|
64
|
+
email: user.email,
|
|
65
|
+
name: user.name,
|
|
66
|
+
admin: user.isAdmin,
|
|
67
|
+
scopes: user.scopes,
|
|
68
|
+
iat: now,
|
|
69
|
+
exp: now + config.maxAgeSeconds
|
|
70
|
+
};
|
|
71
|
+
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
72
|
+
const sig = await hmacSign(config.secret, payloadB64);
|
|
73
|
+
return `${payloadB64}.${sig}`;
|
|
74
|
+
}
|
|
75
|
+
async function validateToken(config, token) {
|
|
76
|
+
const dotIndex = token.indexOf(".");
|
|
77
|
+
if (dotIndex === -1)
|
|
78
|
+
return null;
|
|
79
|
+
const payloadB64 = token.substring(0, dotIndex);
|
|
80
|
+
const sig = token.substring(dotIndex + 1);
|
|
81
|
+
const expectedSig = await hmacSign(config.secret, payloadB64);
|
|
82
|
+
if (!timingSafeEqual(sig, expectedSig))
|
|
83
|
+
return null;
|
|
84
|
+
try {
|
|
85
|
+
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
86
|
+
const payload = JSON.parse(decoded);
|
|
87
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number")
|
|
88
|
+
return null;
|
|
89
|
+
const now = Math.floor(Date.now() / 1000);
|
|
90
|
+
if (now > payload.exp)
|
|
91
|
+
return null;
|
|
92
|
+
return payload;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/middleware.ts
|
|
99
|
+
function getClientIP(request) {
|
|
100
|
+
return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
101
|
+
}
|
|
102
|
+
function checkBearerToken(request, expectedToken) {
|
|
103
|
+
const header = request.headers.get("Authorization");
|
|
104
|
+
return header === `Bearer ${expectedToken}`;
|
|
105
|
+
}
|
|
106
|
+
async function getAuthUser(request, tokenConfig, cookieConfig) {
|
|
107
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
108
|
+
if (!cookieHeader)
|
|
109
|
+
return null;
|
|
110
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
111
|
+
if (!token)
|
|
112
|
+
return null;
|
|
113
|
+
return validateToken(tokenConfig, token);
|
|
114
|
+
}
|
|
115
|
+
function redirectToLogin(currentUrl) {
|
|
116
|
+
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
|
117
|
+
return Response.redirect(loginUrl, 302);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/password.ts
|
|
121
|
+
var SALT_LENGTH = 32;
|
|
122
|
+
var KEY_LENGTH = 64;
|
|
123
|
+
var ITERATIONS = 1e5;
|
|
124
|
+
function toHex(buffer) {
|
|
125
|
+
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
126
|
+
}
|
|
127
|
+
function fromHex(hex) {
|
|
128
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
129
|
+
for (let i = 0;i < hex.length; i += 2) {
|
|
130
|
+
bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16);
|
|
131
|
+
}
|
|
132
|
+
return bytes;
|
|
133
|
+
}
|
|
134
|
+
async function deriveKey(password, salt) {
|
|
135
|
+
const encoder2 = new TextEncoder;
|
|
136
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encoder2.encode(password), "PBKDF2", false, ["deriveBits"]);
|
|
137
|
+
return crypto.subtle.deriveBits({
|
|
138
|
+
name: "PBKDF2",
|
|
139
|
+
salt,
|
|
140
|
+
iterations: ITERATIONS,
|
|
141
|
+
hash: "SHA-256"
|
|
142
|
+
}, keyMaterial, KEY_LENGTH * 8);
|
|
143
|
+
}
|
|
144
|
+
async function hashPassword(password) {
|
|
145
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
146
|
+
const derived = await deriveKey(password, salt);
|
|
147
|
+
return `${toHex(salt.buffer)}:${toHex(derived)}`;
|
|
148
|
+
}
|
|
149
|
+
async function verifyPassword(password, storedHash) {
|
|
150
|
+
const separatorIndex = storedHash.indexOf(":");
|
|
151
|
+
if (separatorIndex === -1)
|
|
152
|
+
return false;
|
|
153
|
+
const saltHex = storedHash.substring(0, separatorIndex);
|
|
154
|
+
const keyHex = storedHash.substring(separatorIndex + 1);
|
|
155
|
+
const salt = fromHex(saltHex);
|
|
156
|
+
const expectedKey = fromHex(keyHex);
|
|
157
|
+
const derived = new Uint8Array(await deriveKey(password, salt));
|
|
158
|
+
if (derived.length !== expectedKey.length)
|
|
159
|
+
return false;
|
|
160
|
+
let diff = 0;
|
|
161
|
+
for (let i = 0;i < derived.length; i++) {
|
|
162
|
+
diff |= (derived[i] ?? 0) ^ (expectedKey[i] ?? 0);
|
|
163
|
+
}
|
|
164
|
+
return diff === 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/users.ts
|
|
168
|
+
async function createUser(store, input) {
|
|
169
|
+
const existing = await store.getUserByEmail(input.email);
|
|
170
|
+
if (existing)
|
|
171
|
+
throw new Error(`User with email ${input.email} already exists`);
|
|
172
|
+
const now = new Date().toISOString();
|
|
173
|
+
const passwordHash = input.password ? await hashPassword(input.password) : await hashPassword(crypto.randomUUID());
|
|
174
|
+
const user = {
|
|
175
|
+
id: crypto.randomUUID(),
|
|
176
|
+
email: input.email.toLowerCase(),
|
|
177
|
+
name: input.name,
|
|
178
|
+
passwordHash,
|
|
179
|
+
isAdmin: input.isAdmin ?? false,
|
|
180
|
+
emailVerified: input.isAdmin ?? false,
|
|
181
|
+
createdAt: now,
|
|
182
|
+
updatedAt: now
|
|
183
|
+
};
|
|
184
|
+
await store.saveUser(user);
|
|
185
|
+
return user;
|
|
186
|
+
}
|
|
187
|
+
async function verifyUserCredentials(store, email, password) {
|
|
188
|
+
const user = await store.getUserByEmail(email.toLowerCase());
|
|
189
|
+
if (!user)
|
|
190
|
+
return null;
|
|
191
|
+
const valid = await verifyPassword(password, user.passwordHash);
|
|
192
|
+
return valid ? user : null;
|
|
193
|
+
}
|
|
194
|
+
async function changePassword(store, userId, newPassword) {
|
|
195
|
+
const user = await store.getUser(userId);
|
|
196
|
+
if (!user)
|
|
197
|
+
throw new Error(`User ${userId} not found`);
|
|
198
|
+
const updated = {
|
|
199
|
+
...user,
|
|
200
|
+
passwordHash: await hashPassword(newPassword),
|
|
201
|
+
updatedAt: new Date().toISOString()
|
|
202
|
+
};
|
|
203
|
+
await store.saveUser(updated);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/rate-limit.ts
|
|
207
|
+
async function checkRateLimit(store, key, config) {
|
|
208
|
+
const entry = await store.get(key);
|
|
209
|
+
if (!entry)
|
|
210
|
+
return { blocked: false };
|
|
211
|
+
if (entry.attempts >= config.maxAttempts) {
|
|
212
|
+
const elapsed = Math.floor((Date.now() - entry.firstAttempt) / 1000);
|
|
213
|
+
const remaining = config.windowSeconds - elapsed;
|
|
214
|
+
if (remaining > 0)
|
|
215
|
+
return { blocked: true, remainingSeconds: remaining };
|
|
216
|
+
await store.delete(key);
|
|
217
|
+
return { blocked: false };
|
|
218
|
+
}
|
|
219
|
+
return { blocked: false };
|
|
220
|
+
}
|
|
221
|
+
async function recordFailedAttempt(store, key, config) {
|
|
222
|
+
const entry = await store.get(key);
|
|
223
|
+
if (entry) {
|
|
224
|
+
await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
|
|
225
|
+
} else {
|
|
226
|
+
await store.set(key, { attempts: 1, firstAttempt: Date.now() }, config.windowSeconds);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function clearRateLimit(store, key) {
|
|
230
|
+
await store.delete(key);
|
|
231
|
+
}
|
|
232
|
+
export {
|
|
233
|
+
verifyUserCredentials,
|
|
234
|
+
verifyPassword,
|
|
235
|
+
validateToken,
|
|
236
|
+
redirectToLogin,
|
|
237
|
+
recordFailedAttempt,
|
|
238
|
+
makeSessionCookie,
|
|
239
|
+
makeClearCookie,
|
|
240
|
+
hashPassword,
|
|
241
|
+
getClientIP,
|
|
242
|
+
getAuthUser,
|
|
243
|
+
extractCookie,
|
|
244
|
+
createUser,
|
|
245
|
+
createToken,
|
|
246
|
+
clearRateLimit,
|
|
247
|
+
checkRateLimit,
|
|
248
|
+
checkBearerToken,
|
|
249
|
+
changePassword
|
|
250
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CookieConfig, TokenConfig, TokenPayload } from "./types.ts";
|
|
2
|
+
export declare function getClientIP(request: Request): string;
|
|
3
|
+
export declare function checkBearerToken(request: Request, expectedToken: string): boolean;
|
|
4
|
+
export declare function getAuthUser(request: Request, tokenConfig: TokenConfig, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
|
|
5
|
+
export declare function redirectToLogin(currentUrl: string): Response;
|
|
@@ -0,0 +1,124 @@
|
|
|
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/token.ts
|
|
27
|
+
var encoder = new TextEncoder;
|
|
28
|
+
function base64urlEncode(data) {
|
|
29
|
+
let binary = "";
|
|
30
|
+
for (const byte of data) {
|
|
31
|
+
binary += String.fromCharCode(byte);
|
|
32
|
+
}
|
|
33
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
}
|
|
35
|
+
function base64urlDecode(str) {
|
|
36
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
|
+
const binary = atob(padded);
|
|
38
|
+
const bytes = new Uint8Array(binary.length);
|
|
39
|
+
for (let i = 0;i < binary.length; i++) {
|
|
40
|
+
bytes[i] = binary.charCodeAt(i);
|
|
41
|
+
}
|
|
42
|
+
return bytes;
|
|
43
|
+
}
|
|
44
|
+
async function hmacSign(secret, data) {
|
|
45
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
46
|
+
"sign"
|
|
47
|
+
]);
|
|
48
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
49
|
+
return base64urlEncode(new Uint8Array(signature));
|
|
50
|
+
}
|
|
51
|
+
function timingSafeEqual(a, b) {
|
|
52
|
+
if (a.length !== b.length)
|
|
53
|
+
return false;
|
|
54
|
+
let diff = 0;
|
|
55
|
+
for (let i = 0;i < a.length; i++) {
|
|
56
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
57
|
+
}
|
|
58
|
+
return diff === 0;
|
|
59
|
+
}
|
|
60
|
+
async function createToken(config, user) {
|
|
61
|
+
const now = Math.floor(Date.now() / 1000);
|
|
62
|
+
const payload = {
|
|
63
|
+
sub: user.id,
|
|
64
|
+
email: user.email,
|
|
65
|
+
name: user.name,
|
|
66
|
+
admin: user.isAdmin,
|
|
67
|
+
scopes: user.scopes,
|
|
68
|
+
iat: now,
|
|
69
|
+
exp: now + config.maxAgeSeconds
|
|
70
|
+
};
|
|
71
|
+
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
72
|
+
const sig = await hmacSign(config.secret, payloadB64);
|
|
73
|
+
return `${payloadB64}.${sig}`;
|
|
74
|
+
}
|
|
75
|
+
async function validateToken(config, token) {
|
|
76
|
+
const dotIndex = token.indexOf(".");
|
|
77
|
+
if (dotIndex === -1)
|
|
78
|
+
return null;
|
|
79
|
+
const payloadB64 = token.substring(0, dotIndex);
|
|
80
|
+
const sig = token.substring(dotIndex + 1);
|
|
81
|
+
const expectedSig = await hmacSign(config.secret, payloadB64);
|
|
82
|
+
if (!timingSafeEqual(sig, expectedSig))
|
|
83
|
+
return null;
|
|
84
|
+
try {
|
|
85
|
+
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
86
|
+
const payload = JSON.parse(decoded);
|
|
87
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number")
|
|
88
|
+
return null;
|
|
89
|
+
const now = Math.floor(Date.now() / 1000);
|
|
90
|
+
if (now > payload.exp)
|
|
91
|
+
return null;
|
|
92
|
+
return payload;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/middleware.ts
|
|
99
|
+
function getClientIP(request) {
|
|
100
|
+
return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
101
|
+
}
|
|
102
|
+
function checkBearerToken(request, expectedToken) {
|
|
103
|
+
const header = request.headers.get("Authorization");
|
|
104
|
+
return header === `Bearer ${expectedToken}`;
|
|
105
|
+
}
|
|
106
|
+
async function getAuthUser(request, tokenConfig, cookieConfig) {
|
|
107
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
108
|
+
if (!cookieHeader)
|
|
109
|
+
return null;
|
|
110
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
111
|
+
if (!token)
|
|
112
|
+
return null;
|
|
113
|
+
return validateToken(tokenConfig, token);
|
|
114
|
+
}
|
|
115
|
+
function redirectToLogin(currentUrl) {
|
|
116
|
+
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
|
117
|
+
return Response.redirect(loginUrl, 302);
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
redirectToLogin,
|
|
121
|
+
getClientIP,
|
|
122
|
+
getAuthUser,
|
|
123
|
+
checkBearerToken
|
|
124
|
+
};
|
package/dist/password.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
export {
|
|
49
|
+
verifyPassword,
|
|
50
|
+
hashPassword
|
|
51
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RateLimitConfig, RateLimitStore } from "./types.ts";
|
|
2
|
+
export interface RateLimitResult {
|
|
3
|
+
blocked: boolean;
|
|
4
|
+
remainingSeconds?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function checkRateLimit(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<RateLimitResult>;
|
|
7
|
+
export declare function recordFailedAttempt(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<void>;
|
|
8
|
+
export declare function clearRateLimit(store: RateLimitStore, key: string): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/rate-limit.ts
|
|
3
|
+
async function checkRateLimit(store, key, config) {
|
|
4
|
+
const entry = await store.get(key);
|
|
5
|
+
if (!entry)
|
|
6
|
+
return { blocked: false };
|
|
7
|
+
if (entry.attempts >= config.maxAttempts) {
|
|
8
|
+
const elapsed = Math.floor((Date.now() - entry.firstAttempt) / 1000);
|
|
9
|
+
const remaining = config.windowSeconds - elapsed;
|
|
10
|
+
if (remaining > 0)
|
|
11
|
+
return { blocked: true, remainingSeconds: remaining };
|
|
12
|
+
await store.delete(key);
|
|
13
|
+
return { blocked: false };
|
|
14
|
+
}
|
|
15
|
+
return { blocked: false };
|
|
16
|
+
}
|
|
17
|
+
async function recordFailedAttempt(store, key, config) {
|
|
18
|
+
const entry = await store.get(key);
|
|
19
|
+
if (entry) {
|
|
20
|
+
await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
|
|
21
|
+
} else {
|
|
22
|
+
await store.set(key, { attempts: 1, firstAttempt: Date.now() }, config.windowSeconds);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function clearRateLimit(store, key) {
|
|
26
|
+
await store.delete(key);
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
recordFailedAttempt,
|
|
30
|
+
clearRateLimit,
|
|
31
|
+
checkRateLimit
|
|
32
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CookieConfig, TokenPayload } from "./types.ts";
|
|
2
|
+
export { makeSessionCookie, makeClearCookie } from "./cookie.ts";
|
|
3
|
+
export type { TokenPayload, CookieConfig } from "./types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Validate a raw token string via the auth service binding.
|
|
6
|
+
* Returns the token payload or null if invalid.
|
|
7
|
+
*/
|
|
8
|
+
export declare function verifyToken(authService: Fetcher, token: string): Promise<TokenPayload | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Get the authenticated user from the session cookie via the auth service binding.
|
|
11
|
+
* Returns the token payload or null if not authenticated.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getAuthUser(request: Request, authService: Fetcher, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
|
|
14
|
+
/**
|
|
15
|
+
* Redirect to the centralized login page at auth.uln.industries.
|
|
16
|
+
*/
|
|
17
|
+
export declare function redirectToLogin(currentUrl: string): Response;
|