@uln/auth 0.2.0 → 0.4.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 +1 -1
- package/dist/client.js +55 -3
- package/dist/hono.d.ts +2 -0
- package/dist/hono.js +56 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +201 -4
- package/dist/middleware.d.ts +11 -1
- package/dist/middleware.js +55 -3
- package/dist/password.d.ts +2 -0
- package/dist/service.d.ts +9 -1
- package/dist/service.js +52 -3
- 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 -1
- package/src/hono.ts +73 -6
- package/src/index.ts +9 -1
- package/src/middleware.ts +76 -4
- package/src/password.ts +2 -0
- package/src/service.ts +65 -4
- 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.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
|
+
"./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,7 +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 { getAuthUser, redirectToLogin, getClientIP, checkBearerToken } from "./middleware.ts";
|
|
4
|
+
export { getAuthUser, redirectToLogin, handleCallback, getClientIP, checkBearerToken } from "./middleware.ts";
|
|
5
5
|
|
|
6
6
|
import { importPublicKey } from "./token.ts";
|
|
7
7
|
import type { PublicKeyEntry } from "./types.ts";
|
package/src/hono.ts
CHANGED
|
@@ -28,6 +28,9 @@ interface AuthOptions {
|
|
|
28
28
|
|
|
29
29
|
/** Cookie/token max age in seconds. Defaults to 86400 (24h). */
|
|
30
30
|
maxAge?: number;
|
|
31
|
+
|
|
32
|
+
/** Redirect callback mode. "code" avoids putting signed tokens in URLs. Defaults to "code". */
|
|
33
|
+
responseType?: "code" | "token";
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
const DEFAULT_AUTH_URL = "https://auth.uln.industries";
|
|
@@ -107,6 +110,32 @@ async function validateWithBinding(token: string, binding: Fetcher): Promise<Tok
|
|
|
107
110
|
};
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
async function exchangeCodeWithPublicEndpoint(
|
|
114
|
+
code: string,
|
|
115
|
+
redirectOrigin: string,
|
|
116
|
+
authUrl: string,
|
|
117
|
+
): Promise<string | null> {
|
|
118
|
+
const res = await fetch(`${authUrl}/api/exchange-code`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ code, redirectOrigin }),
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok) return null;
|
|
124
|
+
const data = (await res.json()) as { token?: string };
|
|
125
|
+
return typeof data.token === "string" ? data.token : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function exchangeCodeWithBinding(code: string, redirectOrigin: string, binding: Fetcher): Promise<string | null> {
|
|
129
|
+
const res = await binding.fetch("https://auth/api/exchange-code", {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "Content-Type": "application/json" },
|
|
132
|
+
body: JSON.stringify({ code, redirectOrigin }),
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) return null;
|
|
135
|
+
const data = (await res.json()) as { token?: string };
|
|
136
|
+
return typeof data.token === "string" ? data.token : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
110
139
|
/**
|
|
111
140
|
* Hono middleware that handles the full auth flow:
|
|
112
141
|
*
|
|
@@ -135,6 +164,7 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
135
164
|
const authUrl = options?.authUrl ?? DEFAULT_AUTH_URL;
|
|
136
165
|
const cookieName = options?.cookie ?? DEFAULT_COOKIE;
|
|
137
166
|
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
|
|
167
|
+
const responseType = options?.responseType ?? "code";
|
|
138
168
|
const publicPaths = options?.public ?? [];
|
|
139
169
|
const bindingName = options?.binding;
|
|
140
170
|
|
|
@@ -149,6 +179,19 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
149
179
|
return validateWithPublicKeys(token, authUrl, maxAge);
|
|
150
180
|
}
|
|
151
181
|
|
|
182
|
+
async function exchangeCode(
|
|
183
|
+
code: string,
|
|
184
|
+
redirectOrigin: string,
|
|
185
|
+
env: Record<string, unknown>,
|
|
186
|
+
): Promise<string | null> {
|
|
187
|
+
if (bindingName) {
|
|
188
|
+
const binding = env[bindingName] as Fetcher | undefined;
|
|
189
|
+
if (!binding) throw new Error(`Service binding "${bindingName}" not found in env`);
|
|
190
|
+
return exchangeCodeWithBinding(code, redirectOrigin, binding);
|
|
191
|
+
}
|
|
192
|
+
return exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl);
|
|
193
|
+
}
|
|
194
|
+
|
|
152
195
|
return async (c, next) => {
|
|
153
196
|
const url = new URL(c.req.url);
|
|
154
197
|
|
|
@@ -157,7 +200,29 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
157
200
|
return next();
|
|
158
201
|
}
|
|
159
202
|
|
|
160
|
-
// Step 1: Handle ?
|
|
203
|
+
// Step 1: Handle ?code= callback (preferred login redirect landing)
|
|
204
|
+
const incomingCode = url.searchParams.get("code");
|
|
205
|
+
if (incomingCode) {
|
|
206
|
+
const token = await exchangeCode(incomingCode, url.origin, c.env as Record<string, unknown>);
|
|
207
|
+
if (!token) {
|
|
208
|
+
return c.text("Invalid code", 401);
|
|
209
|
+
}
|
|
210
|
+
const payload = await validate(token, c.env as Record<string, unknown>);
|
|
211
|
+
if (!payload) {
|
|
212
|
+
return c.text("Invalid token", 401);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
url.searchParams.delete("code");
|
|
216
|
+
return new Response(null, {
|
|
217
|
+
status: 302,
|
|
218
|
+
headers: {
|
|
219
|
+
Location: url.toString(),
|
|
220
|
+
"Set-Cookie": makeSessionCookie(cookieConf, token),
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Step 2: Handle legacy ?token= callback
|
|
161
226
|
const incomingToken = url.searchParams.get("token");
|
|
162
227
|
if (incomingToken) {
|
|
163
228
|
const payload = await validate(incomingToken, c.env as Record<string, unknown>);
|
|
@@ -176,7 +241,7 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
176
241
|
});
|
|
177
242
|
}
|
|
178
243
|
|
|
179
|
-
// Step
|
|
244
|
+
// Step 3: Check session cookie
|
|
180
245
|
const cookieHeader = c.req.header("Cookie");
|
|
181
246
|
if (cookieHeader) {
|
|
182
247
|
const token = extractCookie(cookieHeader, cookieName);
|
|
@@ -189,7 +254,7 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
189
254
|
}
|
|
190
255
|
}
|
|
191
256
|
|
|
192
|
-
// Step
|
|
257
|
+
// Step 4: Check Authorization header
|
|
193
258
|
const authHeader = c.req.header("Authorization");
|
|
194
259
|
if (authHeader?.startsWith("Bearer ")) {
|
|
195
260
|
const token = authHeader.substring(7);
|
|
@@ -201,9 +266,11 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
201
266
|
return c.json({ error: "Invalid token" }, 401);
|
|
202
267
|
}
|
|
203
268
|
|
|
204
|
-
// Step
|
|
205
|
-
const loginUrl =
|
|
206
|
-
|
|
269
|
+
// Step 5: Redirect to login
|
|
270
|
+
const loginUrl = new URL("/login", authUrl);
|
|
271
|
+
loginUrl.searchParams.set("redirect", c.req.url);
|
|
272
|
+
if (responseType === "code") loginUrl.searchParams.set("response_type", "code");
|
|
273
|
+
return c.redirect(loginUrl.toString(), 302);
|
|
207
274
|
};
|
|
208
275
|
}
|
|
209
276
|
|
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
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { extractCookie } from "./cookie.ts";
|
|
1
|
+
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") ??
|
|
@@ -29,7 +33,75 @@ export async function getAuthUser(
|
|
|
29
33
|
return validateToken(verifyConfig, token);
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
export function redirectToLogin(currentUrl: string): Response {
|
|
33
|
-
const loginUrl =
|
|
34
|
-
|
|
36
|
+
export function redirectToLogin(currentUrl: string, responseType: "code" | "token" = "code"): Response {
|
|
37
|
+
const loginUrl = new URL("/login", "https://auth.uln.industries");
|
|
38
|
+
loginUrl.searchParams.set("redirect", currentUrl);
|
|
39
|
+
if (responseType === "code") 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;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Handle the auth callback on consuming sites.
|
|
60
|
+
* If the request URL has a `?code=` or legacy `?token=` param, sets a session cookie,
|
|
61
|
+
* and redirects to the same URL without the token param.
|
|
62
|
+
* Returns null if there's no callback param.
|
|
63
|
+
*/
|
|
64
|
+
export async function handleCallback(
|
|
65
|
+
request: Request,
|
|
66
|
+
verifyConfig: VerifyConfig,
|
|
67
|
+
cookieConfig: CookieConfig,
|
|
68
|
+
exchangeConfig?: AuthCodeExchangeConfig,
|
|
69
|
+
): Promise<Response | null> {
|
|
70
|
+
const url = new URL(request.url);
|
|
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, "code");
|
|
77
|
+
|
|
78
|
+
const payload = await validateToken(verifyConfig, token);
|
|
79
|
+
if (!payload) return redirectToLogin(url.origin, "code");
|
|
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
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const token = url.searchParams.get("token");
|
|
92
|
+
if (!token) return null;
|
|
93
|
+
|
|
94
|
+
const payload = await validateToken(verifyConfig, token);
|
|
95
|
+
if (!payload) {
|
|
96
|
+
return redirectToLogin(url.origin);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
url.searchParams.delete("token");
|
|
100
|
+
return new Response(null, {
|
|
101
|
+
status: 302,
|
|
102
|
+
headers: {
|
|
103
|
+
Location: url.toString(),
|
|
104
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
35
107
|
}
|
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;
|