@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/totp.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/totp.ts
|
|
3
|
+
var TOTP_PERIOD = 30;
|
|
4
|
+
var TOTP_DIGITS = 6;
|
|
5
|
+
var TOTP_WINDOW = 1;
|
|
6
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
7
|
+
function base32Encode(data) {
|
|
8
|
+
let bits = 0;
|
|
9
|
+
let value = 0;
|
|
10
|
+
let output = "";
|
|
11
|
+
for (const byte of data) {
|
|
12
|
+
value = value << 8 | byte;
|
|
13
|
+
bits += 8;
|
|
14
|
+
while (bits >= 5) {
|
|
15
|
+
bits -= 5;
|
|
16
|
+
output += BASE32_ALPHABET[value >>> bits & 31];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (bits > 0) {
|
|
20
|
+
output += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
21
|
+
}
|
|
22
|
+
return output;
|
|
23
|
+
}
|
|
24
|
+
function base32Decode(encoded) {
|
|
25
|
+
const cleaned = encoded.replace(/[=\s]/g, "").toUpperCase();
|
|
26
|
+
const output = [];
|
|
27
|
+
let bits = 0;
|
|
28
|
+
let value = 0;
|
|
29
|
+
for (const char of cleaned) {
|
|
30
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
31
|
+
if (idx === -1)
|
|
32
|
+
throw new Error(`Invalid base32 character: ${char}`);
|
|
33
|
+
value = value << 5 | idx;
|
|
34
|
+
bits += 5;
|
|
35
|
+
if (bits >= 8) {
|
|
36
|
+
bits -= 8;
|
|
37
|
+
output.push(value >>> bits & 255);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return new Uint8Array(output);
|
|
41
|
+
}
|
|
42
|
+
function generateTotpSecret() {
|
|
43
|
+
const bytes = new Uint8Array(20);
|
|
44
|
+
crypto.getRandomValues(bytes);
|
|
45
|
+
return base32Encode(bytes);
|
|
46
|
+
}
|
|
47
|
+
function getTotpUri(secret, email, issuer) {
|
|
48
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(email)}`;
|
|
49
|
+
const params = new URLSearchParams({
|
|
50
|
+
secret,
|
|
51
|
+
issuer,
|
|
52
|
+
algorithm: "SHA1",
|
|
53
|
+
digits: String(TOTP_DIGITS),
|
|
54
|
+
period: String(TOTP_PERIOD)
|
|
55
|
+
});
|
|
56
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
57
|
+
}
|
|
58
|
+
async function generateCode(secret, counter) {
|
|
59
|
+
const keyBytes = base32Decode(secret);
|
|
60
|
+
const keyBuffer = new ArrayBuffer(keyBytes.byteLength);
|
|
61
|
+
new Uint8Array(keyBuffer).set(keyBytes);
|
|
62
|
+
const key = await crypto.subtle.importKey("raw", keyBuffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
63
|
+
const counterBuf = new ArrayBuffer(8);
|
|
64
|
+
const view = new DataView(counterBuf);
|
|
65
|
+
view.setUint32(4, counter);
|
|
66
|
+
const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, counterBuf));
|
|
67
|
+
const offset = (hmac[hmac.length - 1] ?? 0) & 15;
|
|
68
|
+
const b0 = hmac[offset] ?? 0;
|
|
69
|
+
const b1 = hmac[offset + 1] ?? 0;
|
|
70
|
+
const b2 = hmac[offset + 2] ?? 0;
|
|
71
|
+
const b3 = hmac[offset + 3] ?? 0;
|
|
72
|
+
const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
|
|
73
|
+
return String(code % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
|
|
74
|
+
}
|
|
75
|
+
async function currentTotpCode(secret, nowMs = Date.now()) {
|
|
76
|
+
const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
|
|
77
|
+
return generateCode(secret, step);
|
|
78
|
+
}
|
|
79
|
+
async function verifyTotpCode(secret, code) {
|
|
80
|
+
if (code.length !== TOTP_DIGITS)
|
|
81
|
+
return false;
|
|
82
|
+
const now = Math.floor(Date.now() / 1000);
|
|
83
|
+
const currentStep = Math.floor(now / TOTP_PERIOD);
|
|
84
|
+
for (let offset = -TOTP_WINDOW;offset <= TOTP_WINDOW; offset++) {
|
|
85
|
+
const expected = await generateCode(secret, currentStep + offset);
|
|
86
|
+
if (timingSafeEqual(code, expected))
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
var BACKUP_CODE_COUNT = 10;
|
|
92
|
+
var BACKUP_CODE_LENGTH = 8;
|
|
93
|
+
var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
94
|
+
function generateBackupCodes() {
|
|
95
|
+
const codes = [];
|
|
96
|
+
for (let i = 0;i < BACKUP_CODE_COUNT; i++) {
|
|
97
|
+
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
98
|
+
crypto.getRandomValues(bytes);
|
|
99
|
+
let code = "";
|
|
100
|
+
for (const byte of bytes) {
|
|
101
|
+
code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
|
|
102
|
+
}
|
|
103
|
+
codes.push(code);
|
|
104
|
+
}
|
|
105
|
+
return codes;
|
|
106
|
+
}
|
|
107
|
+
async function hashBackupCode(code) {
|
|
108
|
+
const data = new TextEncoder().encode(code.toLowerCase());
|
|
109
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
110
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
111
|
+
}
|
|
112
|
+
function timingSafeEqual(a, b) {
|
|
113
|
+
if (a.length !== b.length)
|
|
114
|
+
return false;
|
|
115
|
+
let result = 0;
|
|
116
|
+
for (let i = 0;i < a.length; i++) {
|
|
117
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
118
|
+
}
|
|
119
|
+
return result === 0;
|
|
120
|
+
}
|
|
121
|
+
export {
|
|
122
|
+
verifyTotpCode,
|
|
123
|
+
hashBackupCode,
|
|
124
|
+
getTotpUri,
|
|
125
|
+
generateTotpSecret,
|
|
126
|
+
generateBackupCodes,
|
|
127
|
+
currentTotpCode,
|
|
128
|
+
base32Encode
|
|
129
|
+
};
|
package/dist/types.d.ts
CHANGED
package/dist/users.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { User, UserStore } from "./types.ts";
|
|
2
|
+
/** Validate email format. Rejects missing local/domain parts, whitespace, and emails over 254 chars. */
|
|
3
|
+
export declare function isValidEmail(email: string): boolean;
|
|
2
4
|
export declare function createUser(store: UserStore, input: {
|
|
3
5
|
email: string;
|
|
4
6
|
name: string;
|
|
@@ -6,4 +8,5 @@ export declare function createUser(store: UserStore, input: {
|
|
|
6
8
|
isAdmin?: boolean;
|
|
7
9
|
}): Promise<User>;
|
|
8
10
|
export declare function verifyUserCredentials(store: UserStore, email: string, password: string): Promise<User | null>;
|
|
11
|
+
export declare function updateName(store: UserStore, userId: string, name: string): Promise<void>;
|
|
9
12
|
export declare function changePassword(store: UserStore, userId: string, newPassword: string): Promise<void>;
|
package/dist/users.js
CHANGED
|
@@ -47,7 +47,13 @@ async function verifyPassword(password, storedHash) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// src/users.ts
|
|
50
|
+
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
51
|
+
function isValidEmail(email) {
|
|
52
|
+
return email.length <= 254 && EMAIL_REGEX.test(email);
|
|
53
|
+
}
|
|
50
54
|
async function createUser(store, input) {
|
|
55
|
+
if (!isValidEmail(input.email))
|
|
56
|
+
throw new Error("Invalid email address");
|
|
51
57
|
const existing = await store.getUserByEmail(input.email);
|
|
52
58
|
if (existing)
|
|
53
59
|
throw new Error(`User with email ${input.email} already exists`);
|
|
@@ -60,6 +66,8 @@ async function createUser(store, input) {
|
|
|
60
66
|
passwordHash,
|
|
61
67
|
isAdmin: input.isAdmin ?? false,
|
|
62
68
|
emailVerified: input.isAdmin ?? false,
|
|
69
|
+
totpSecret: null,
|
|
70
|
+
totpEnabled: false,
|
|
63
71
|
createdAt: now,
|
|
64
72
|
updatedAt: now
|
|
65
73
|
};
|
|
@@ -73,6 +81,17 @@ async function verifyUserCredentials(store, email, password) {
|
|
|
73
81
|
const valid = await verifyPassword(password, user.passwordHash);
|
|
74
82
|
return valid ? user : null;
|
|
75
83
|
}
|
|
84
|
+
async function updateName(store, userId, name) {
|
|
85
|
+
const user = await store.getUser(userId);
|
|
86
|
+
if (!user)
|
|
87
|
+
throw new Error(`User ${userId} not found`);
|
|
88
|
+
const updated = {
|
|
89
|
+
...user,
|
|
90
|
+
name,
|
|
91
|
+
updatedAt: new Date().toISOString()
|
|
92
|
+
};
|
|
93
|
+
await store.saveUser(updated);
|
|
94
|
+
}
|
|
76
95
|
async function changePassword(store, userId, newPassword) {
|
|
77
96
|
const user = await store.getUser(userId);
|
|
78
97
|
if (!user)
|
|
@@ -86,6 +105,8 @@ async function changePassword(store, userId, newPassword) {
|
|
|
86
105
|
}
|
|
87
106
|
export {
|
|
88
107
|
verifyUserCredentials,
|
|
108
|
+
updateName,
|
|
109
|
+
isValidEmail,
|
|
89
110
|
createUser,
|
|
90
111
|
changePassword
|
|
91
112
|
};
|
package/package.json
CHANGED
|
@@ -1,64 +1,68 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
2
|
+
"name": "@uln/auth",
|
|
3
|
+
"version": "0.4.1",
|
|
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
|
+
"./hono": {
|
|
22
|
+
"bun": "./src/hono.ts",
|
|
23
|
+
"import": "./dist/hono.js",
|
|
24
|
+
"types": "./dist/hono.d.ts"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": ["dist", "src", "!src/**/*.test.ts"],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "bunx vite",
|
|
30
|
+
"dev:worker": "bunx wrangler dev",
|
|
31
|
+
"build": "bun run scripts/build.ts",
|
|
32
|
+
"check": "biome check --write .",
|
|
33
|
+
"test": "bun test",
|
|
34
|
+
"ci": "biome check . && bun test --coverage",
|
|
35
|
+
"migrate:local": "bunx wrangler d1 migrations apply uln-auth --local",
|
|
36
|
+
"migrate:remote": "bunx wrangler d1 migrations apply uln-auth --remote",
|
|
37
|
+
"deploy": "bun run migrate:remote && bunx wrangler deploy",
|
|
38
|
+
"publish:check": "bun run scripts/publish.ts check",
|
|
39
|
+
"publish:release": "bun run scripts/publish.ts release"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@biomejs/biome": "^1.9.4",
|
|
43
|
+
"@uln/ui": "^0.2.0",
|
|
44
|
+
"@cloudflare/workers-types": "^4.20260317.1",
|
|
45
|
+
"@types/bun": "latest",
|
|
46
|
+
"@types/qrcode": "^1.5.6",
|
|
47
|
+
"@types/react": "^19.2.14",
|
|
48
|
+
"@types/react-dom": "^19.2.3",
|
|
49
|
+
"@uln/log": "^0.3.0",
|
|
50
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
51
|
+
"hono": "^4.12.8",
|
|
52
|
+
"qrcode": "^1.5.4",
|
|
53
|
+
"react": "^19.2.4",
|
|
54
|
+
"react-dom": "^19.2.4",
|
|
55
|
+
"react-router": "^7.13.1",
|
|
56
|
+
"vite": "^8.0.1"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"hono": "^4",
|
|
60
|
+
"typescript": "^5"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"hono": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {}
|
|
64
68
|
}
|
package/src/client.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
|
|
|
6
7
|
import { importPublicKey } from "./token.ts";
|
package/src/hono.ts
CHANGED
|
@@ -107,10 +107,36 @@ async function validateWithBinding(token: string, binding: Fetcher): Promise<Tok
|
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
async function exchangeCodeWithPublicEndpoint(
|
|
111
|
+
code: string,
|
|
112
|
+
redirectOrigin: string,
|
|
113
|
+
authUrl: string,
|
|
114
|
+
): Promise<string | null> {
|
|
115
|
+
const res = await fetch(`${authUrl}/api/exchange-code`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ code, redirectOrigin }),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) return null;
|
|
121
|
+
const data = (await res.json()) as { token?: string };
|
|
122
|
+
return typeof data.token === "string" ? data.token : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function exchangeCodeWithBinding(code: string, redirectOrigin: string, binding: Fetcher): Promise<string | null> {
|
|
126
|
+
const res = await binding.fetch("https://auth/api/exchange-code", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
body: JSON.stringify({ code, redirectOrigin }),
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok) return null;
|
|
132
|
+
const data = (await res.json()) as { token?: string };
|
|
133
|
+
return typeof data.token === "string" ? data.token : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
110
136
|
/**
|
|
111
137
|
* Hono middleware that handles the full auth flow:
|
|
112
138
|
*
|
|
113
|
-
* 1. If `?
|
|
139
|
+
* 1. If `?code=` in URL → exchange for token, set cookie, redirect to clean URL
|
|
114
140
|
* 2. If session cookie present → validate, set `c.get("user")`
|
|
115
141
|
* 3. If unauthenticated → redirect to auth.uln.industries/login
|
|
116
142
|
*
|
|
@@ -149,6 +175,19 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
149
175
|
return validateWithPublicKeys(token, authUrl, maxAge);
|
|
150
176
|
}
|
|
151
177
|
|
|
178
|
+
async function exchangeCode(
|
|
179
|
+
code: string,
|
|
180
|
+
redirectOrigin: string,
|
|
181
|
+
env: Record<string, unknown>,
|
|
182
|
+
): Promise<string | null> {
|
|
183
|
+
if (bindingName) {
|
|
184
|
+
const binding = env[bindingName] as Fetcher | undefined;
|
|
185
|
+
if (!binding) throw new Error(`Service binding "${bindingName}" not found in env`);
|
|
186
|
+
return exchangeCodeWithBinding(code, redirectOrigin, binding);
|
|
187
|
+
}
|
|
188
|
+
return exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl);
|
|
189
|
+
}
|
|
190
|
+
|
|
152
191
|
return async (c, next) => {
|
|
153
192
|
const url = new URL(c.req.url);
|
|
154
193
|
|
|
@@ -157,21 +196,24 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
157
196
|
return next();
|
|
158
197
|
}
|
|
159
198
|
|
|
160
|
-
// Step 1: Handle ?
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
const
|
|
199
|
+
// Step 1: Handle ?code= callback (login redirect landing)
|
|
200
|
+
const incomingCode = url.searchParams.get("code");
|
|
201
|
+
if (incomingCode) {
|
|
202
|
+
const token = await exchangeCode(incomingCode, url.origin, c.env as Record<string, unknown>);
|
|
203
|
+
if (!token) {
|
|
204
|
+
return c.text("Invalid code", 401);
|
|
205
|
+
}
|
|
206
|
+
const payload = await validate(token, c.env as Record<string, unknown>);
|
|
164
207
|
if (!payload) {
|
|
165
208
|
return c.text("Invalid token", 401);
|
|
166
209
|
}
|
|
167
210
|
|
|
168
|
-
|
|
169
|
-
url.searchParams.delete("token");
|
|
211
|
+
url.searchParams.delete("code");
|
|
170
212
|
return new Response(null, {
|
|
171
213
|
status: 302,
|
|
172
214
|
headers: {
|
|
173
215
|
Location: url.toString(),
|
|
174
|
-
"Set-Cookie": makeSessionCookie(cookieConf,
|
|
216
|
+
"Set-Cookie": makeSessionCookie(cookieConf, token),
|
|
175
217
|
},
|
|
176
218
|
});
|
|
177
219
|
}
|
|
@@ -189,7 +231,7 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
189
231
|
}
|
|
190
232
|
}
|
|
191
233
|
|
|
192
|
-
// Step
|
|
234
|
+
// Step 4: Check Authorization header
|
|
193
235
|
const authHeader = c.req.header("Authorization");
|
|
194
236
|
if (authHeader?.startsWith("Bearer ")) {
|
|
195
237
|
const token = authHeader.substring(7);
|
|
@@ -201,9 +243,11 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
201
243
|
return c.json({ error: "Invalid token" }, 401);
|
|
202
244
|
}
|
|
203
245
|
|
|
204
|
-
// Step
|
|
205
|
-
const loginUrl =
|
|
206
|
-
|
|
246
|
+
// Step 5: Redirect to login
|
|
247
|
+
const loginUrl = new URL("/login", authUrl);
|
|
248
|
+
loginUrl.searchParams.set("redirect", c.req.url);
|
|
249
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
250
|
+
return c.redirect(loginUrl.toString(), 302);
|
|
207
251
|
};
|
|
208
252
|
}
|
|
209
253
|
|
package/src/index.ts
CHANGED
|
@@ -18,4 +18,12 @@ export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
|
18
18
|
export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
|
|
19
19
|
export type { RateLimitResult } from "./rate-limit.ts";
|
|
20
20
|
export { getClientIP, checkBearerToken, getAuthUser, redirectToLogin } from "./middleware.ts";
|
|
21
|
-
export { createUser, verifyUserCredentials, changePassword } from "./users.ts";
|
|
21
|
+
export { createUser, verifyUserCredentials, changePassword, isValidEmail } from "./users.ts";
|
|
22
|
+
export {
|
|
23
|
+
generateTotpSecret,
|
|
24
|
+
getTotpUri,
|
|
25
|
+
verifyTotpCode,
|
|
26
|
+
base32Encode,
|
|
27
|
+
generateBackupCodes,
|
|
28
|
+
hashBackupCode,
|
|
29
|
+
} from "./totp.ts";
|
package/src/middleware.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { extractCookie, makeSessionCookie } from "./cookie.ts";
|
|
|
2
2
|
import { validateToken } from "./token.ts";
|
|
3
3
|
import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
|
|
4
4
|
|
|
5
|
+
export interface AuthCodeExchangeConfig {
|
|
6
|
+
authUrl: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
export function getClientIP(request: Request): string {
|
|
6
10
|
return (
|
|
7
11
|
request.headers.get("cf-connecting-ip") ??
|
|
@@ -30,36 +34,59 @@ export async function getAuthUser(
|
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function redirectToLogin(currentUrl: string): Response {
|
|
33
|
-
const loginUrl =
|
|
34
|
-
|
|
37
|
+
const loginUrl = new URL("/login", "https://auth.uln.industries");
|
|
38
|
+
loginUrl.searchParams.set("redirect", currentUrl);
|
|
39
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
40
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function exchangeCode(
|
|
44
|
+
code: string,
|
|
45
|
+
redirectOrigin: string,
|
|
46
|
+
config: AuthCodeExchangeConfig,
|
|
47
|
+
): Promise<string | null> {
|
|
48
|
+
const res = await fetch(`${config.authUrl}/api/exchange-code`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({ code, redirectOrigin }),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) return null;
|
|
54
|
+
const data = (await res.json()) as { token?: string };
|
|
55
|
+
return typeof data.token === "string" ? data.token : null;
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
/**
|
|
38
59
|
* Handle the auth callback on consuming sites.
|
|
39
|
-
* If the request URL has a `?
|
|
40
|
-
* and redirects to the same URL without the
|
|
41
|
-
* Returns null if there's no
|
|
60
|
+
* If the request URL has a `?code=` param, exchanges it for a token,
|
|
61
|
+
* sets a session cookie, and redirects to the same URL without the code param.
|
|
62
|
+
* Returns null if there's no callback param.
|
|
42
63
|
*/
|
|
43
64
|
export async function handleCallback(
|
|
44
65
|
request: Request,
|
|
45
66
|
verifyConfig: VerifyConfig,
|
|
46
67
|
cookieConfig: CookieConfig,
|
|
68
|
+
exchangeConfig?: AuthCodeExchangeConfig,
|
|
47
69
|
): Promise<Response | null> {
|
|
48
70
|
const url = new URL(request.url);
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
71
|
+
const code = url.searchParams.get("code");
|
|
72
|
+
if (code) {
|
|
73
|
+
if (!exchangeConfig) return redirectToLogin(url.origin);
|
|
74
|
+
|
|
75
|
+
const token = await exchangeCode(code, url.origin, exchangeConfig);
|
|
76
|
+
if (!token) return redirectToLogin(url.origin);
|
|
51
77
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
78
|
+
const payload = await validateToken(verifyConfig, token);
|
|
79
|
+
if (!payload) return redirectToLogin(url.origin);
|
|
80
|
+
|
|
81
|
+
url.searchParams.delete("code");
|
|
82
|
+
return new Response(null, {
|
|
83
|
+
status: 302,
|
|
84
|
+
headers: {
|
|
85
|
+
Location: url.toString(),
|
|
86
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token),
|
|
87
|
+
},
|
|
88
|
+
});
|
|
55
89
|
}
|
|
56
90
|
|
|
57
|
-
|
|
58
|
-
return new Response(null, {
|
|
59
|
-
status: 302,
|
|
60
|
-
headers: {
|
|
61
|
-
Location: url.toString(),
|
|
62
|
-
"Set-Cookie": makeSessionCookie(cookieConfig, token),
|
|
63
|
-
},
|
|
64
|
-
});
|
|
91
|
+
return null;
|
|
65
92
|
}
|
package/src/password.ts
CHANGED
|
@@ -31,12 +31,14 @@ async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promi
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Hash a password with PBKDF2-SHA256 (100k iterations, 32-byte salt, 64-byte key). Returns `hex(salt):hex(key)`. */
|
|
34
35
|
export async function hashPassword(password: string): Promise<string> {
|
|
35
36
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
36
37
|
const derived = await deriveKey(password, salt);
|
|
37
38
|
return `${toHex(salt.buffer)}:${toHex(derived)}`;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** Verify a password against a stored `hex(salt):hex(key)` hash. Uses constant-time comparison. */
|
|
40
42
|
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
|
41
43
|
const separatorIndex = storedHash.indexOf(":");
|
|
42
44
|
if (separatorIndex === -1) return false;
|
package/src/service.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractCookie } from "./cookie.ts";
|
|
1
|
+
import { extractCookie, makeSessionCookie } from "./cookie.ts";
|
|
2
2
|
import type { CookieConfig, TokenPayload } from "./types.ts";
|
|
3
3
|
export { makeSessionCookie, makeClearCookie } from "./cookie.ts";
|
|
4
4
|
export type { TokenPayload, CookieConfig } from "./types.ts";
|
|
@@ -58,8 +58,55 @@ export async function getAuthUser(
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Redirect to the centralized login page at auth.uln.industries.
|
|
61
|
+
* Requests one-time auth-code callbacks so signed tokens are not sent through URLs.
|
|
61
62
|
*/
|
|
62
63
|
export function redirectToLogin(currentUrl: string): Response {
|
|
63
|
-
const loginUrl =
|
|
64
|
-
|
|
64
|
+
const loginUrl = new URL("/login", "https://auth.uln.industries");
|
|
65
|
+
loginUrl.searchParams.set("redirect", currentUrl);
|
|
66
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
67
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function exchangeCode(authService: Fetcher, code: string, redirectOrigin: string): Promise<string | null> {
|
|
71
|
+
const res = await authService.fetch("https://auth/api/exchange-code", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ code, redirectOrigin }),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) return null;
|
|
77
|
+
const data = (await res.json()) as { token?: string };
|
|
78
|
+
return typeof data.token === "string" ? data.token : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle an auth callback on a consuming Worker using the auth service binding.
|
|
83
|
+
* Preferred `?code=` callbacks are exchanged for signed tokens before setting the local cookie.
|
|
84
|
+
* Returns null if the request has no auth callback parameters.
|
|
85
|
+
*/
|
|
86
|
+
export async function handleCallback(
|
|
87
|
+
request: Request,
|
|
88
|
+
authService: Fetcher,
|
|
89
|
+
cookieConfig: CookieConfig,
|
|
90
|
+
): Promise<Response | null> {
|
|
91
|
+
const url = new URL(request.url);
|
|
92
|
+
|
|
93
|
+
const code = url.searchParams.get("code");
|
|
94
|
+
if (code) {
|
|
95
|
+
const token = await exchangeCode(authService, code, url.origin);
|
|
96
|
+
if (!token) return redirectToLogin(url.origin);
|
|
97
|
+
|
|
98
|
+
const payload = await verifyToken(authService, token);
|
|
99
|
+
if (!payload) return redirectToLogin(url.origin);
|
|
100
|
+
|
|
101
|
+
url.searchParams.delete("code");
|
|
102
|
+
return new Response(null, {
|
|
103
|
+
status: 302,
|
|
104
|
+
headers: {
|
|
105
|
+
Location: url.toString(),
|
|
106
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
65
112
|
}
|