@uln/auth 0.4.2 → 0.6.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 +7 -5
- package/dist/client.js +349 -95
- package/dist/cookie.js +2 -2
- package/dist/flow.d.ts +58 -0
- package/dist/flow.js +402 -0
- package/dist/hono.d.ts +30 -4
- package/dist/hono.js +354 -122
- package/dist/index.d.ts +10 -9
- package/dist/index.js +500 -172
- package/dist/middleware.d.ts +15 -5
- package/dist/middleware.js +317 -57
- package/dist/password.d.ts +9 -2
- package/dist/password.js +53 -23
- package/dist/rate-limit.d.ts +11 -0
- package/dist/rate-limit.js +21 -2
- package/dist/remote.d.ts +52 -0
- package/dist/remote.js +266 -0
- package/dist/service.d.ts +13 -13
- package/dist/service.js +383 -56
- package/dist/token.d.ts +25 -2
- package/dist/token.js +69 -16
- package/dist/totp.d.ts +7 -1
- package/dist/totp.js +26 -18
- package/dist/types.d.ts +27 -0
- package/dist/users.d.ts +5 -0
- package/dist/users.js +69 -27
- package/dist/version.d.ts +6 -0
- package/dist/version.js +6 -0
- package/package.json +35 -20
- package/src/client.ts +10 -16
- package/src/flow.ts +178 -0
- package/src/hono.ts +120 -134
- package/src/index.ts +31 -23
- package/src/middleware.ts +50 -50
- package/src/password.ts +68 -28
- package/src/rate-limit.ts +38 -0
- package/src/remote.ts +158 -0
- package/src/service.ts +40 -80
- package/src/token.ts +109 -16
- package/src/totp.ts +25 -12
- package/src/types.ts +24 -0
- package/src/users.ts +21 -3
- package/src/version.ts +6 -0
package/dist/totp.js
CHANGED
|
@@ -76,17 +76,21 @@ async function currentTotpCode(secret, nowMs = Date.now()) {
|
|
|
76
76
|
const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
|
|
77
77
|
return generateCode(secret, step);
|
|
78
78
|
}
|
|
79
|
-
async function
|
|
80
|
-
if (code
|
|
81
|
-
return
|
|
82
|
-
const
|
|
83
|
-
|
|
79
|
+
async function verifyTotpStep(secret, code, nowMs = Date.now()) {
|
|
80
|
+
if (!/^\d{6}$/.test(code))
|
|
81
|
+
return null;
|
|
82
|
+
const currentStep = Math.floor(nowMs / 1000 / TOTP_PERIOD);
|
|
83
|
+
let matched = null;
|
|
84
84
|
for (let offset = -TOTP_WINDOW;offset <= TOTP_WINDOW; offset++) {
|
|
85
|
-
const
|
|
85
|
+
const step = currentStep + offset;
|
|
86
|
+
const expected = await generateCode(secret, step);
|
|
86
87
|
if (timingSafeEqual(code, expected))
|
|
87
|
-
|
|
88
|
+
matched = step;
|
|
88
89
|
}
|
|
89
|
-
return
|
|
90
|
+
return matched;
|
|
91
|
+
}
|
|
92
|
+
async function verifyTotpCode(secret, code) {
|
|
93
|
+
return await verifyTotpStep(secret, code) !== null;
|
|
90
94
|
}
|
|
91
95
|
var BACKUP_CODE_COUNT = 10;
|
|
92
96
|
var BACKUP_CODE_LENGTH = 8;
|
|
@@ -94,11 +98,14 @@ var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
94
98
|
function generateBackupCodes() {
|
|
95
99
|
const codes = [];
|
|
96
100
|
for (let i = 0;i < BACKUP_CODE_COUNT; i++) {
|
|
97
|
-
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
98
|
-
crypto.getRandomValues(bytes);
|
|
99
101
|
let code = "";
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
while (code.length < BACKUP_CODE_LENGTH) {
|
|
103
|
+
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
104
|
+
crypto.getRandomValues(bytes);
|
|
105
|
+
for (const byte of bytes) {
|
|
106
|
+
if (byte < 252 && code.length < BACKUP_CODE_LENGTH)
|
|
107
|
+
code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
|
|
108
|
+
}
|
|
102
109
|
}
|
|
103
110
|
codes.push(code);
|
|
104
111
|
}
|
|
@@ -119,11 +126,12 @@ function timingSafeEqual(a, b) {
|
|
|
119
126
|
return result === 0;
|
|
120
127
|
}
|
|
121
128
|
export {
|
|
122
|
-
|
|
123
|
-
hashBackupCode,
|
|
124
|
-
getTotpUri,
|
|
125
|
-
generateTotpSecret,
|
|
126
|
-
generateBackupCodes,
|
|
129
|
+
base32Encode,
|
|
127
130
|
currentTotpCode,
|
|
128
|
-
|
|
131
|
+
generateBackupCodes,
|
|
132
|
+
generateTotpSecret,
|
|
133
|
+
getTotpUri,
|
|
134
|
+
hashBackupCode,
|
|
135
|
+
verifyTotpCode,
|
|
136
|
+
verifyTotpStep
|
|
129
137
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface RateLimitStore {
|
|
|
25
25
|
get(key: string): Promise<RateLimitEntry | null>;
|
|
26
26
|
set(key: string, entry: RateLimitEntry, ttlSeconds: number): Promise<void>;
|
|
27
27
|
delete(key: string): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Atomically add one attempt (creating the entry if needed) and return the result. Optional, but stores that
|
|
30
|
+
* implement it are race-free under concurrent requests; otherwise a read-modify-write via get/set is used.
|
|
31
|
+
*/
|
|
32
|
+
increment?(key: string, ttlSeconds: number): Promise<RateLimitEntry>;
|
|
28
33
|
}
|
|
29
34
|
export interface RateLimitConfig {
|
|
30
35
|
maxAttempts: number;
|
|
@@ -40,6 +45,19 @@ export interface TokenPayload {
|
|
|
40
45
|
kid: string;
|
|
41
46
|
iat: number;
|
|
42
47
|
exp: number;
|
|
48
|
+
/**
|
|
49
|
+
* Audience: the origin this token was issued to (a site's origin, or the auth service's own origin for its session
|
|
50
|
+
* cookie). A token is only honoured where `aud` matches. Absent only on tokens minted before audience binding
|
|
51
|
+
* (library < 0.5); current verifiers reject those.
|
|
52
|
+
*/
|
|
53
|
+
aud?: string;
|
|
54
|
+
/** Auth-service sessions only: the server-side session this token belongs to (worker `sessions` table). */
|
|
55
|
+
sid?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Auth-service sessions only: a short-lived token for a Device Bound Session (DBSC), renewed by a proof from a key
|
|
58
|
+
* held by the user's device. Once a session is bound, only these tokens are honoured for it.
|
|
59
|
+
*/
|
|
60
|
+
dbsc?: boolean;
|
|
43
61
|
}
|
|
44
62
|
export interface PublicKeyEntry {
|
|
45
63
|
kid: string;
|
|
@@ -53,6 +71,15 @@ export interface SigningConfig {
|
|
|
53
71
|
export interface VerifyConfig {
|
|
54
72
|
publicKeys: PublicKeyEntry[];
|
|
55
73
|
maxAgeSeconds: number;
|
|
74
|
+
/**
|
|
75
|
+
* Also confirm locally valid tokens with the auth service (at most once per token every `intervalSeconds`, default
|
|
76
|
+
* 300), so password resets, deleted users and removed scopes take effect without waiting for the token to expire.
|
|
77
|
+
* If the auth service can't be reached the local result stands.
|
|
78
|
+
*/
|
|
79
|
+
revalidate?: {
|
|
80
|
+
authUrl: string;
|
|
81
|
+
intervalSeconds?: number;
|
|
82
|
+
};
|
|
56
83
|
}
|
|
57
84
|
export interface CookieConfig {
|
|
58
85
|
name: string;
|
package/dist/users.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export declare function createUser(store: UserStore, input: {
|
|
|
7
7
|
password?: string;
|
|
8
8
|
isAdmin?: boolean;
|
|
9
9
|
}): Promise<User>;
|
|
10
|
+
/**
|
|
11
|
+
* Check an email/password pair. When the user doesn't exist a dummy hash is still verified, so response timing
|
|
12
|
+
* doesn't reveal which emails have accounts. A correct password stored under older hashing parameters is re-hashed
|
|
13
|
+
* with the current ones.
|
|
14
|
+
*/
|
|
10
15
|
export declare function verifyUserCredentials(store: UserStore, email: string, password: string): Promise<User | null>;
|
|
11
16
|
export declare function updateName(store: UserStore, userId: string, name: string): Promise<void>;
|
|
12
17
|
export declare function changePassword(store: UserStore, userId: string, newPassword: string): Promise<void>;
|
package/dist/users.js
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// src/password.ts
|
|
3
3
|
var SALT_LENGTH = 32;
|
|
4
4
|
var KEY_LENGTH = 64;
|
|
5
|
-
var
|
|
5
|
+
var STAGE_ITERATIONS = 1e5;
|
|
6
|
+
var ITERATIONS = 600000;
|
|
7
|
+
var MAX_ITERATIONS = 1e7;
|
|
8
|
+
var MAX_PASSWORD_LENGTH = 1024;
|
|
9
|
+
var HEX_REGEX = /^(?:[0-9a-f]{2})+$/;
|
|
10
|
+
var PREFIX = "pbkdf2-sha256";
|
|
6
11
|
function toHex(buffer) {
|
|
7
12
|
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
8
13
|
}
|
|
@@ -13,38 +18,61 @@ function fromHex(hex) {
|
|
|
13
18
|
}
|
|
14
19
|
return bytes;
|
|
15
20
|
}
|
|
16
|
-
async function deriveKey(password, salt) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
salt,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
async function deriveKey(password, salt, iterations) {
|
|
22
|
+
let material = new TextEncoder().encode(password);
|
|
23
|
+
let derived = new ArrayBuffer(0);
|
|
24
|
+
for (let done = 0;done < iterations; done += STAGE_ITERATIONS) {
|
|
25
|
+
const keyMaterial = await crypto.subtle.importKey("raw", material, "PBKDF2", false, ["deriveBits"]);
|
|
26
|
+
derived = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: STAGE_ITERATIONS, hash: "SHA-256" }, keyMaterial, KEY_LENGTH * 8);
|
|
27
|
+
material = new Uint8Array(derived);
|
|
28
|
+
}
|
|
29
|
+
return derived;
|
|
30
|
+
}
|
|
31
|
+
function parseHash(storedHash) {
|
|
32
|
+
let iterations = STAGE_ITERATIONS;
|
|
33
|
+
let saltHex;
|
|
34
|
+
let keyHex;
|
|
35
|
+
if (storedHash.startsWith(`${PREFIX}$`)) {
|
|
36
|
+
const [, rawIterations = "", salt = "", key = ""] = storedHash.split("$");
|
|
37
|
+
if (!/^[1-9]\d*$/.test(rawIterations))
|
|
38
|
+
return null;
|
|
39
|
+
iterations = Number(rawIterations);
|
|
40
|
+
if (iterations % STAGE_ITERATIONS !== 0 || iterations > MAX_ITERATIONS)
|
|
41
|
+
return null;
|
|
42
|
+
saltHex = salt;
|
|
43
|
+
keyHex = key;
|
|
44
|
+
} else {
|
|
45
|
+
const separatorIndex = storedHash.indexOf(":");
|
|
46
|
+
if (separatorIndex === -1)
|
|
47
|
+
return null;
|
|
48
|
+
saltHex = storedHash.substring(0, separatorIndex);
|
|
49
|
+
keyHex = storedHash.substring(separatorIndex + 1);
|
|
50
|
+
}
|
|
51
|
+
if (!HEX_REGEX.test(saltHex) || !HEX_REGEX.test(keyHex))
|
|
52
|
+
return null;
|
|
53
|
+
return { iterations, salt: fromHex(saltHex), key: fromHex(keyHex) };
|
|
25
54
|
}
|
|
26
55
|
async function hashPassword(password) {
|
|
27
56
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
28
|
-
const derived = await deriveKey(password, salt);
|
|
29
|
-
return `${toHex(salt.buffer)}
|
|
57
|
+
const derived = await deriveKey(password, salt, ITERATIONS);
|
|
58
|
+
return `${PREFIX}$${ITERATIONS}$${toHex(salt.buffer)}$${toHex(derived)}`;
|
|
30
59
|
}
|
|
31
60
|
async function verifyPassword(password, storedHash) {
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
61
|
+
const parsed = parseHash(storedHash);
|
|
62
|
+
if (!parsed)
|
|
34
63
|
return false;
|
|
35
|
-
const
|
|
36
|
-
|
|
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)
|
|
64
|
+
const derived = new Uint8Array(await deriveKey(password, parsed.salt, parsed.iterations));
|
|
65
|
+
if (derived.length !== parsed.key.length)
|
|
41
66
|
return false;
|
|
42
67
|
let diff = 0;
|
|
43
68
|
for (let i = 0;i < derived.length; i++) {
|
|
44
|
-
diff |= (derived[i] ?? 0) ^ (
|
|
69
|
+
diff |= (derived[i] ?? 0) ^ (parsed.key[i] ?? 0);
|
|
45
70
|
}
|
|
46
71
|
return diff === 0;
|
|
47
72
|
}
|
|
73
|
+
function needsRehash(storedHash) {
|
|
74
|
+
return !storedHash.startsWith(`${PREFIX}$`) || (parseHash(storedHash)?.iterations ?? 0) < ITERATIONS;
|
|
75
|
+
}
|
|
48
76
|
|
|
49
77
|
// src/users.ts
|
|
50
78
|
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -54,6 +82,8 @@ function isValidEmail(email) {
|
|
|
54
82
|
async function createUser(store, input) {
|
|
55
83
|
if (!isValidEmail(input.email))
|
|
56
84
|
throw new Error("Invalid email address");
|
|
85
|
+
if (input.password !== undefined && input.password.length > MAX_PASSWORD_LENGTH)
|
|
86
|
+
throw new Error("Password is too long");
|
|
57
87
|
const existing = await store.getUserByEmail(input.email);
|
|
58
88
|
if (existing)
|
|
59
89
|
throw new Error(`User with email ${input.email} already exists`);
|
|
@@ -74,12 +104,22 @@ async function createUser(store, input) {
|
|
|
74
104
|
await store.saveUser(user);
|
|
75
105
|
return user;
|
|
76
106
|
}
|
|
107
|
+
var dummyHash = null;
|
|
77
108
|
async function verifyUserCredentials(store, email, password) {
|
|
78
109
|
const user = await store.getUserByEmail(email.toLowerCase());
|
|
79
|
-
if (!user)
|
|
110
|
+
if (!user) {
|
|
111
|
+
dummyHash ??= hashPassword(crypto.randomUUID());
|
|
112
|
+
await verifyPassword(password, await dummyHash);
|
|
80
113
|
return null;
|
|
114
|
+
}
|
|
81
115
|
const valid = await verifyPassword(password, user.passwordHash);
|
|
82
|
-
|
|
116
|
+
if (!valid)
|
|
117
|
+
return null;
|
|
118
|
+
if (!needsRehash(user.passwordHash))
|
|
119
|
+
return user;
|
|
120
|
+
const upgraded = { ...user, passwordHash: await hashPassword(password), updatedAt: new Date().toISOString() };
|
|
121
|
+
await store.saveUser(upgraded);
|
|
122
|
+
return upgraded;
|
|
83
123
|
}
|
|
84
124
|
async function updateName(store, userId, name) {
|
|
85
125
|
const user = await store.getUser(userId);
|
|
@@ -93,6 +133,8 @@ async function updateName(store, userId, name) {
|
|
|
93
133
|
await store.saveUser(updated);
|
|
94
134
|
}
|
|
95
135
|
async function changePassword(store, userId, newPassword) {
|
|
136
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH)
|
|
137
|
+
throw new Error("Password is too long");
|
|
96
138
|
const user = await store.getUser(userId);
|
|
97
139
|
if (!user)
|
|
98
140
|
throw new Error(`User ${userId} not found`);
|
|
@@ -104,9 +146,9 @@ async function changePassword(store, userId, newPassword) {
|
|
|
104
146
|
await store.saveUser(updated);
|
|
105
147
|
}
|
|
106
148
|
export {
|
|
107
|
-
|
|
108
|
-
updateName,
|
|
109
|
-
isValidEmail,
|
|
149
|
+
changePassword,
|
|
110
150
|
createUser,
|
|
111
|
-
|
|
151
|
+
isValidEmail,
|
|
152
|
+
updateName,
|
|
153
|
+
verifyUserCredentials
|
|
112
154
|
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This library's version. Sent to the auth service on every login (`uln_v` on the return URL) so Admin → Sites can show
|
|
3
|
+
* which version each site runs and open upgrade PRs for the ones behind. `src/version.test.ts` keeps it equal to
|
|
4
|
+
* `package.json`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const LIBRARY_VERSION = "0.6.1";
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uln/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/ulnd/auth.git"
|
|
7
|
+
},
|
|
4
8
|
"type": "module",
|
|
5
9
|
"exports": {
|
|
6
10
|
".": {
|
|
@@ -24,45 +28,56 @@
|
|
|
24
28
|
"types": "./dist/hono.d.ts"
|
|
25
29
|
}
|
|
26
30
|
},
|
|
27
|
-
"files": [
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!src/**/*.test.ts"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
28
39
|
"scripts": {
|
|
29
40
|
"dev": "bunx vite",
|
|
30
|
-
"dev:worker": "
|
|
41
|
+
"dev:worker": "wrangler dev",
|
|
31
42
|
"build": "bun run scripts/build.ts",
|
|
32
43
|
"check": "biome check --write .",
|
|
33
44
|
"test": "bun test",
|
|
34
|
-
"ci": "biome check . && bun test --coverage",
|
|
45
|
+
"ci": "biome check . && bun run typecheck && bun test --coverage",
|
|
35
46
|
"migrate:local": "bunx wrangler d1 migrations apply uln-auth --local",
|
|
36
47
|
"migrate:remote": "bunx wrangler d1 migrations apply uln-auth --remote",
|
|
37
48
|
"deploy": "bun run migrate:remote && bunx wrangler deploy",
|
|
38
|
-
"
|
|
39
|
-
"publish:release": "bun run scripts/publish.ts release"
|
|
49
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p client/tsconfig.json"
|
|
40
50
|
},
|
|
41
51
|
"devDependencies": {
|
|
42
|
-
"@biomejs/biome": "^
|
|
43
|
-
"@
|
|
44
|
-
"@
|
|
45
|
-
"@types/bun": "latest",
|
|
52
|
+
"@biomejs/biome": "^2.5.15",
|
|
53
|
+
"@cloudflare/workers-types": "^5.20261001.1",
|
|
54
|
+
"@types/bun": "^1.4.2",
|
|
46
55
|
"@types/qrcode": "^1.5.6",
|
|
47
|
-
"@types/react": "^19.
|
|
48
|
-
"@types/react-dom": "^19.
|
|
56
|
+
"@types/react": "^19.3.0",
|
|
57
|
+
"@types/react-dom": "^19.3.0",
|
|
49
58
|
"@uln/log": "^0.3.0",
|
|
50
|
-
"@
|
|
51
|
-
"
|
|
59
|
+
"@uln/ui": "^0.2.0",
|
|
60
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
61
|
+
"hono": "^4.13.12",
|
|
52
62
|
"qrcode": "^1.5.4",
|
|
53
|
-
"react": "^19.
|
|
54
|
-
"react-dom": "^19.
|
|
55
|
-
"react-router": "^
|
|
56
|
-
"
|
|
63
|
+
"react": "^19.3.0",
|
|
64
|
+
"react-dom": "^19.3.0",
|
|
65
|
+
"react-router": "^8.4.0",
|
|
66
|
+
"typescript": "^7.0.2",
|
|
67
|
+
"vite": "^8.3.2",
|
|
68
|
+
"wrangler": "^4.145.0"
|
|
57
69
|
},
|
|
58
70
|
"peerDependencies": {
|
|
59
71
|
"hono": "^4",
|
|
60
|
-
"typescript": "^5 || ^6"
|
|
72
|
+
"typescript": "^5 || ^6 || ^7"
|
|
61
73
|
},
|
|
62
74
|
"peerDependenciesMeta": {
|
|
63
75
|
"hono": {
|
|
64
76
|
"optional": true
|
|
65
77
|
}
|
|
66
78
|
},
|
|
67
|
-
"dependencies": {}
|
|
79
|
+
"dependencies": {},
|
|
80
|
+
"overrides": {
|
|
81
|
+
"hono": "^4.13.12"
|
|
82
|
+
}
|
|
68
83
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
export { validateToken, importPublicKey } from "./token.ts";
|
|
3
|
-
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
4
|
-
export type { AuthCodeExchangeConfig } from "./middleware.ts";
|
|
5
|
-
export { getAuthUser, redirectToLogin, handleCallback, getClientIP, checkBearerToken } from "./middleware.ts";
|
|
6
|
-
|
|
7
|
-
import { importPublicKey } from "./token.ts";
|
|
1
|
+
import { fetchJwks, publicFetcher } from "./remote.ts";
|
|
8
2
|
import type { PublicKeyEntry } from "./types.ts";
|
|
9
3
|
|
|
4
|
+
export { extractCookie, makeClearCookie, makeSessionCookie } from "./cookie.ts";
|
|
5
|
+
export type { AuthCodeExchangeConfig } from "./middleware.ts";
|
|
6
|
+
export { checkBearerToken, getAuthUser, getClientIP, handleCallback, redirectToLogin } from "./middleware.ts";
|
|
7
|
+
export { hasSiteAccess } from "./remote.ts";
|
|
8
|
+
export { importPublicKey, validateToken } from "./token.ts";
|
|
9
|
+
export type { CookieConfig, PublicKeyEntry, TokenPayload, VerifyConfig } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/** Fetch and import the auth service's public signing keys. Cache the result; refetch when a token's kid is unknown. */
|
|
10
12
|
export async function fetchPublicKeys(baseUrl: string): Promise<PublicKeyEntry[]> {
|
|
11
|
-
|
|
12
|
-
if (!res.ok) throw new Error(`Failed to fetch JWKS from ${baseUrl}: ${res.status}`);
|
|
13
|
-
const jwks = (await res.json()) as { keys: (JsonWebKey & { kid: string })[] };
|
|
14
|
-
return Promise.all(
|
|
15
|
-
jwks.keys.map(async (jwk) => ({
|
|
16
|
-
kid: jwk.kid,
|
|
17
|
-
key: await importPublicKey(jwk),
|
|
18
|
-
})),
|
|
19
|
-
);
|
|
13
|
+
return fetchJwks(publicFetcher(baseUrl));
|
|
20
14
|
}
|
package/src/flow.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { extractCookie, makeSessionCookie } from "./cookie.ts";
|
|
2
|
+
import { checkTokenRemote, exchangeAuthCode, type FetchLike, hasSiteAccessFor } from "./remote.ts";
|
|
3
|
+
import type { CookieConfig, TokenPayload } from "./types.ts";
|
|
4
|
+
import { LIBRARY_VERSION } from "./version.ts";
|
|
5
|
+
|
|
6
|
+
// The one implementation of the site side of the login flow, shared by `@uln/auth/client`, `/service` and `/hono`.
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_AUTH_URL = "https://auth.uln.industries";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Short-lived cookie tying a `?code=` callback to the browser that started the login. Its value is
|
|
12
|
+
* `<state>.<pkce verifier>`. The `__Host-` prefix makes browsers refuse it unless it was set by this exact host
|
|
13
|
+
* (Secure, `Path=/`, no `Domain`), so a page on a sibling subdomain can't plant a known state for the whole domain.
|
|
14
|
+
*/
|
|
15
|
+
export const STATE_COOKIE = "__Host-uln_auth_state";
|
|
16
|
+
/** The state cookie's name on plain-http origins (local development), where browsers refuse `__Host-` cookies. */
|
|
17
|
+
export const INSECURE_STATE_COOKIE = "uln_auth_state";
|
|
18
|
+
/** Query parameter that carries the state through the auth service and back on the return URL. */
|
|
19
|
+
export const STATE_PARAM = "uln_state";
|
|
20
|
+
/**
|
|
21
|
+
* PKCE (RFC 7636, S256) challenge on the return URL. The auth service stores it with the one-time code and only
|
|
22
|
+
* exchanges the code for a client that presents the verifier, which never leaves this browser's state cookie, so a
|
|
23
|
+
* code that leaks (history, logs, a shared link) is useless on its own.
|
|
24
|
+
*/
|
|
25
|
+
export const CHALLENGE_PARAM = "uln_challenge";
|
|
26
|
+
/**
|
|
27
|
+
* This library's version on the return URL (0.6.1+), so the auth service can track which version each site runs.
|
|
28
|
+
* The auth service strips it from the URL it sends the browser back to.
|
|
29
|
+
*/
|
|
30
|
+
export const VERSION_PARAM = "uln_v";
|
|
31
|
+
const STATE_MAX_AGE_SECONDS = 600;
|
|
32
|
+
|
|
33
|
+
function base64url(bytes: Uint8Array): string {
|
|
34
|
+
let binary = "";
|
|
35
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
36
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function randomToken(): string {
|
|
40
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** S256 code challenge for a PKCE verifier: base64url(SHA-256(verifier)). */
|
|
44
|
+
export async function pkceChallenge(verifier: string): Promise<string> {
|
|
45
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stateCookie(value: string, secure: boolean, maxAgeSeconds: number): string {
|
|
49
|
+
const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
|
|
50
|
+
const parts = [`${name}=${value}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
51
|
+
if (secure) parts.push("Secure");
|
|
52
|
+
return parts.join("; ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readStateCookie(request: Request, secure: boolean): { state: string; verifier: string } | null {
|
|
56
|
+
const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
|
|
57
|
+
const [state = "", verifier = "", ...rest] = (extractCookie(request.headers.get("Cookie") ?? "", name) ?? "").split(
|
|
58
|
+
".",
|
|
59
|
+
);
|
|
60
|
+
return state && verifier && rest.length === 0 ? { state, verifier } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripCallbackParams(url: URL): void {
|
|
64
|
+
url.searchParams.delete("code");
|
|
65
|
+
url.searchParams.delete(STATE_PARAM);
|
|
66
|
+
url.searchParams.delete(CHALLENGE_PARAM);
|
|
67
|
+
url.searchParams.delete(VERSION_PARAM);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Redirect to the auth service's login page. Sets a one-time state cookie and puts the state on the return URL, which
|
|
72
|
+
* the auth service hands back untouched alongside `?code=`, together with a PKCE challenge for the verifier kept in
|
|
73
|
+
* the cookie. Requests a one-time code callback, so signed tokens never travel in URLs.
|
|
74
|
+
*/
|
|
75
|
+
export async function redirectToLogin(currentUrl: string, authUrl: string = DEFAULT_AUTH_URL): Promise<Response> {
|
|
76
|
+
const returnUrl = new URL(currentUrl);
|
|
77
|
+
stripCallbackParams(returnUrl);
|
|
78
|
+
const state = randomToken();
|
|
79
|
+
const verifier = randomToken();
|
|
80
|
+
returnUrl.searchParams.set(STATE_PARAM, state);
|
|
81
|
+
returnUrl.searchParams.set(CHALLENGE_PARAM, await pkceChallenge(verifier));
|
|
82
|
+
returnUrl.searchParams.set(VERSION_PARAM, LIBRARY_VERSION);
|
|
83
|
+
|
|
84
|
+
const loginUrl = new URL("/login", authUrl);
|
|
85
|
+
loginUrl.searchParams.set("redirect", returnUrl.toString());
|
|
86
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
87
|
+
|
|
88
|
+
const headers = new Headers({ Location: loginUrl.toString() });
|
|
89
|
+
headers.append(
|
|
90
|
+
"Set-Cookie",
|
|
91
|
+
stateCookie(`${state}.${verifier}`, returnUrl.protocol === "https:", STATE_MAX_AGE_SECONDS),
|
|
92
|
+
);
|
|
93
|
+
return new Response(null, { status: 302, headers });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface LoginCallbackOptions {
|
|
97
|
+
auth: FetchLike;
|
|
98
|
+
authUrl: string;
|
|
99
|
+
cookieConfig: CookieConfig;
|
|
100
|
+
/** Validate the exchanged token (locally or with the auth service). Null rejects it. */
|
|
101
|
+
validate(token: string): Promise<TokenPayload | null>;
|
|
102
|
+
/** Origin registered for scopes, when it differs from the request URL's origin. */
|
|
103
|
+
scopeOrigin?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handle a `?code=` login callback; null when the request isn't one. The code is only exchanged when the request's
|
|
108
|
+
* `uln_state` matches this browser's state cookie, so a code planted in a link by someone else (login CSRF, which
|
|
109
|
+
* would sign the victim in to the attacker's account) is refused, and the exchange proves this browser holds the PKCE
|
|
110
|
+
* verifier. Any failure sends the user back to log in.
|
|
111
|
+
*/
|
|
112
|
+
export async function handleLoginCallback(request: Request, options: LoginCallbackOptions): Promise<Response | null> {
|
|
113
|
+
const url = new URL(request.url);
|
|
114
|
+
const code = url.searchParams.get("code");
|
|
115
|
+
if (!code) return null;
|
|
116
|
+
|
|
117
|
+
const state = url.searchParams.get(STATE_PARAM);
|
|
118
|
+
const secure = url.protocol === "https:";
|
|
119
|
+
const expected = readStateCookie(request, secure);
|
|
120
|
+
stripCallbackParams(url);
|
|
121
|
+
const startOver = () => redirectToLogin(url.toString(), options.authUrl);
|
|
122
|
+
if (!state || !expected || state !== expected.state) return startOver();
|
|
123
|
+
|
|
124
|
+
const token = await exchangeAuthCode(options.auth, code, url.origin, expected.verifier);
|
|
125
|
+
if (!token) return startOver();
|
|
126
|
+
const payload = await options.validate(token);
|
|
127
|
+
if (!payload) return startOver();
|
|
128
|
+
const scopeOrigin = options.scopeOrigin ?? url.origin;
|
|
129
|
+
if (!hasSiteAccessFor(payload, { audiences: [url.origin, scopeOrigin], scopeOrigin })) {
|
|
130
|
+
return new Response("You don't have access to this site", { status: 403 });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const headers = new Headers({ Location: url.toString() });
|
|
134
|
+
headers.append("Set-Cookie", makeSessionCookie(options.cookieConfig, token));
|
|
135
|
+
headers.append("Set-Cookie", stateCookie("", secure, 0));
|
|
136
|
+
return new Response(null, { status: 302, headers });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Revocation checks for locally validated (JWKS) tokens ---
|
|
140
|
+
|
|
141
|
+
/** Token → when the auth service last confirmed it (ms). Per isolate; insertion order doubles as LRU order. */
|
|
142
|
+
const confirmedAt = new Map<string, number>();
|
|
143
|
+
const MAX_CONFIRMED = 5000;
|
|
144
|
+
export const DEFAULT_REVALIDATE_SECONDS = 300;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Confirm a locally valid token with the auth service, at most once per `intervalSeconds` per token. Returns the
|
|
148
|
+
* payload to use: the auth service's (current admin/scopes/flags) after a fresh check, the local one while a previous
|
|
149
|
+
* confirmation is fresh or when the auth service can't be reached, and null when the auth service rejects the token
|
|
150
|
+
* (deleted user, revoked session, wrong audience).
|
|
151
|
+
*/
|
|
152
|
+
export async function revalidateToken(
|
|
153
|
+
auth: FetchLike,
|
|
154
|
+
token: string,
|
|
155
|
+
local: TokenPayload,
|
|
156
|
+
audience: string,
|
|
157
|
+
intervalSeconds: number = DEFAULT_REVALIDATE_SECONDS,
|
|
158
|
+
): Promise<TokenPayload | null> {
|
|
159
|
+
const last = confirmedAt.get(token);
|
|
160
|
+
if (last !== undefined && Date.now() - last < intervalSeconds * 1000) return local;
|
|
161
|
+
|
|
162
|
+
const result = await checkTokenRemote(auth, token, audience);
|
|
163
|
+
if (result.status === "unavailable") return local;
|
|
164
|
+
confirmedAt.delete(token);
|
|
165
|
+
if (result.status === "invalid") return null;
|
|
166
|
+
|
|
167
|
+
if (confirmedAt.size >= MAX_CONFIRMED) {
|
|
168
|
+
const oldest = confirmedAt.keys().next();
|
|
169
|
+
if (!oldest.done) confirmedAt.delete(oldest.value);
|
|
170
|
+
}
|
|
171
|
+
confirmedAt.set(token, Date.now());
|
|
172
|
+
return result.payload;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Test hook: forget which tokens were recently confirmed. */
|
|
176
|
+
export function clearRevalidationCache(): void {
|
|
177
|
+
confirmedAt.clear();
|
|
178
|
+
}
|