@uln/auth 0.1.0 → 0.2.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 -2
- package/dist/client.js +59 -29
- package/dist/hono.d.ts +50 -0
- package/dist/hono.js +242 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +50 -29
- package/dist/middleware.d.ts +2 -2
- package/dist/middleware.js +46 -28
- package/dist/service.js +2 -0
- package/dist/token.d.ts +11 -3
- package/dist/token.js +47 -26
- package/dist/types.d.ts +13 -2
- package/package.json +14 -3
- package/src/client.ts +17 -2
- package/src/cookie.ts +14 -14
- package/src/hono.ts +215 -0
- package/src/index.ts +11 -9
- package/src/middleware.ts +18 -18
- package/src/password.ts +39 -39
- package/src/rate-limit.ts +22 -22
- package/src/service.ts +38 -27
- package/src/token.ts +84 -62
- package/src/types.ts +46 -33
- package/src/users.ts +30 -30
package/dist/middleware.js
CHANGED
|
@@ -41,21 +41,24 @@ function base64urlDecode(str) {
|
|
|
41
41
|
}
|
|
42
42
|
return bytes;
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
44
|
+
var ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-256" };
|
|
45
|
+
var SIGN_PARAMS = { name: "ECDSA", hash: "SHA-256" };
|
|
46
|
+
async function generateKeyPair() {
|
|
47
|
+
const kid = crypto.randomUUID().slice(0, 8);
|
|
48
|
+
const keyPair = await crypto.subtle.generateKey(ECDSA_PARAMS, true, ["sign", "verify"]);
|
|
49
|
+
if (!("privateKey" in keyPair))
|
|
50
|
+
throw new Error("Expected CryptoKeyPair from ECDSA generateKey");
|
|
51
|
+
const privateExport = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
|
|
52
|
+
const publicExport = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
|
53
|
+
if (privateExport instanceof ArrayBuffer || publicExport instanceof ArrayBuffer)
|
|
54
|
+
throw new Error("Expected JWK from exportKey");
|
|
55
|
+
return { kid, privateKeyJwk: privateExport, publicKeyJwk: publicExport };
|
|
50
56
|
}
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
57
|
-
}
|
|
58
|
-
return diff === 0;
|
|
57
|
+
async function importPrivateKey(jwk) {
|
|
58
|
+
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["sign"]);
|
|
59
|
+
}
|
|
60
|
+
async function importPublicKey(jwk) {
|
|
61
|
+
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
|
|
59
62
|
}
|
|
60
63
|
async function createToken(config, user) {
|
|
61
64
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -65,34 +68,49 @@ async function createToken(config, user) {
|
|
|
65
68
|
name: user.name,
|
|
66
69
|
admin: user.isAdmin,
|
|
67
70
|
scopes: user.scopes,
|
|
71
|
+
flags: user.flags,
|
|
72
|
+
kid: config.kid,
|
|
68
73
|
iat: now,
|
|
69
74
|
exp: now + config.maxAgeSeconds
|
|
70
75
|
};
|
|
71
76
|
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
72
|
-
const
|
|
73
|
-
|
|
77
|
+
const payloadBytes = encoder.encode(payloadB64);
|
|
78
|
+
const signature = await crypto.subtle.sign(SIGN_PARAMS, config.privateKey, payloadBytes);
|
|
79
|
+
const sigB64 = base64urlEncode(new Uint8Array(signature));
|
|
80
|
+
return `${payloadB64}.${sigB64}`;
|
|
74
81
|
}
|
|
75
82
|
async function validateToken(config, token) {
|
|
76
83
|
const dotIndex = token.indexOf(".");
|
|
77
84
|
if (dotIndex === -1)
|
|
78
85
|
return null;
|
|
79
86
|
const payloadB64 = token.substring(0, dotIndex);
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
if (!timingSafeEqual(sig, expectedSig))
|
|
83
|
-
return null;
|
|
87
|
+
const sigB64 = token.substring(dotIndex + 1);
|
|
88
|
+
let payload;
|
|
84
89
|
try {
|
|
85
90
|
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
payload = JSON.parse(decoded);
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const now = Math.floor(Date.now() / 1000);
|
|
99
|
+
if (now > payload.exp)
|
|
100
|
+
return null;
|
|
101
|
+
const entry = config.publicKeys.find((k) => k.kid === payload.kid);
|
|
102
|
+
if (!entry)
|
|
103
|
+
return null;
|
|
104
|
+
try {
|
|
105
|
+
const signatureBytes = base64urlDecode(sigB64);
|
|
106
|
+
const payloadBytes = encoder.encode(payloadB64);
|
|
107
|
+
const valid = await crypto.subtle.verify(SIGN_PARAMS, entry.key, signatureBytes, payloadBytes);
|
|
108
|
+
if (!valid)
|
|
91
109
|
return null;
|
|
92
|
-
return payload;
|
|
93
110
|
} catch {
|
|
94
111
|
return null;
|
|
95
112
|
}
|
|
113
|
+
return payload;
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
// src/middleware.ts
|
|
@@ -103,14 +121,14 @@ function checkBearerToken(request, expectedToken) {
|
|
|
103
121
|
const header = request.headers.get("Authorization");
|
|
104
122
|
return header === `Bearer ${expectedToken}`;
|
|
105
123
|
}
|
|
106
|
-
async function getAuthUser(request,
|
|
124
|
+
async function getAuthUser(request, verifyConfig, cookieConfig) {
|
|
107
125
|
const cookieHeader = request.headers.get("Cookie");
|
|
108
126
|
if (!cookieHeader)
|
|
109
127
|
return null;
|
|
110
128
|
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
111
129
|
if (!token)
|
|
112
130
|
return null;
|
|
113
|
-
return validateToken(
|
|
131
|
+
return validateToken(verifyConfig, token);
|
|
114
132
|
}
|
|
115
133
|
function redirectToLogin(currentUrl) {
|
|
116
134
|
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
package/dist/service.js
CHANGED
package/dist/token.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SigningConfig, TokenPayload, VerifyConfig } from "./types.ts";
|
|
2
|
+
export declare function generateKeyPair(): Promise<{
|
|
3
|
+
kid: string;
|
|
4
|
+
privateKeyJwk: JsonWebKey;
|
|
5
|
+
publicKeyJwk: JsonWebKey;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function importPrivateKey(jwk: JsonWebKey): Promise<CryptoKey>;
|
|
8
|
+
export declare function importPublicKey(jwk: JsonWebKey): Promise<CryptoKey>;
|
|
2
9
|
export interface CreateTokenInput {
|
|
3
10
|
id: string;
|
|
4
11
|
email: string;
|
|
5
12
|
name: string;
|
|
6
13
|
isAdmin: boolean;
|
|
7
14
|
scopes: string[];
|
|
15
|
+
flags: Record<string, string[]>;
|
|
8
16
|
}
|
|
9
|
-
export declare function createToken(config:
|
|
10
|
-
export declare function validateToken(config:
|
|
17
|
+
export declare function createToken(config: SigningConfig, user: CreateTokenInput): Promise<string>;
|
|
18
|
+
export declare function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null>;
|
package/dist/token.js
CHANGED
|
@@ -17,21 +17,24 @@ function base64urlDecode(str) {
|
|
|
17
17
|
}
|
|
18
18
|
return bytes;
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
20
|
+
var ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-256" };
|
|
21
|
+
var SIGN_PARAMS = { name: "ECDSA", hash: "SHA-256" };
|
|
22
|
+
async function generateKeyPair() {
|
|
23
|
+
const kid = crypto.randomUUID().slice(0, 8);
|
|
24
|
+
const keyPair = await crypto.subtle.generateKey(ECDSA_PARAMS, true, ["sign", "verify"]);
|
|
25
|
+
if (!("privateKey" in keyPair))
|
|
26
|
+
throw new Error("Expected CryptoKeyPair from ECDSA generateKey");
|
|
27
|
+
const privateExport = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
|
|
28
|
+
const publicExport = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
|
29
|
+
if (privateExport instanceof ArrayBuffer || publicExport instanceof ArrayBuffer)
|
|
30
|
+
throw new Error("Expected JWK from exportKey");
|
|
31
|
+
return { kid, privateKeyJwk: privateExport, publicKeyJwk: publicExport };
|
|
26
32
|
}
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
33
|
-
}
|
|
34
|
-
return diff === 0;
|
|
33
|
+
async function importPrivateKey(jwk) {
|
|
34
|
+
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["sign"]);
|
|
35
|
+
}
|
|
36
|
+
async function importPublicKey(jwk) {
|
|
37
|
+
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
|
|
35
38
|
}
|
|
36
39
|
async function createToken(config, user) {
|
|
37
40
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -41,36 +44,54 @@ async function createToken(config, user) {
|
|
|
41
44
|
name: user.name,
|
|
42
45
|
admin: user.isAdmin,
|
|
43
46
|
scopes: user.scopes,
|
|
47
|
+
flags: user.flags,
|
|
48
|
+
kid: config.kid,
|
|
44
49
|
iat: now,
|
|
45
50
|
exp: now + config.maxAgeSeconds
|
|
46
51
|
};
|
|
47
52
|
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
48
|
-
const
|
|
49
|
-
|
|
53
|
+
const payloadBytes = encoder.encode(payloadB64);
|
|
54
|
+
const signature = await crypto.subtle.sign(SIGN_PARAMS, config.privateKey, payloadBytes);
|
|
55
|
+
const sigB64 = base64urlEncode(new Uint8Array(signature));
|
|
56
|
+
return `${payloadB64}.${sigB64}`;
|
|
50
57
|
}
|
|
51
58
|
async function validateToken(config, token) {
|
|
52
59
|
const dotIndex = token.indexOf(".");
|
|
53
60
|
if (dotIndex === -1)
|
|
54
61
|
return null;
|
|
55
62
|
const payloadB64 = token.substring(0, dotIndex);
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
if (!timingSafeEqual(sig, expectedSig))
|
|
59
|
-
return null;
|
|
63
|
+
const sigB64 = token.substring(dotIndex + 1);
|
|
64
|
+
let payload;
|
|
60
65
|
try {
|
|
61
66
|
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
payload = JSON.parse(decoded);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const now = Math.floor(Date.now() / 1000);
|
|
75
|
+
if (now > payload.exp)
|
|
76
|
+
return null;
|
|
77
|
+
const entry = config.publicKeys.find((k) => k.kid === payload.kid);
|
|
78
|
+
if (!entry)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const signatureBytes = base64urlDecode(sigB64);
|
|
82
|
+
const payloadBytes = encoder.encode(payloadB64);
|
|
83
|
+
const valid = await crypto.subtle.verify(SIGN_PARAMS, entry.key, signatureBytes, payloadBytes);
|
|
84
|
+
if (!valid)
|
|
67
85
|
return null;
|
|
68
|
-
return payload;
|
|
69
86
|
} catch {
|
|
70
87
|
return null;
|
|
71
88
|
}
|
|
89
|
+
return payload;
|
|
72
90
|
}
|
|
73
91
|
export {
|
|
74
92
|
validateToken,
|
|
93
|
+
importPublicKey,
|
|
94
|
+
importPrivateKey,
|
|
95
|
+
generateKeyPair,
|
|
75
96
|
createToken
|
|
76
97
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -34,11 +34,22 @@ export interface TokenPayload {
|
|
|
34
34
|
name: string;
|
|
35
35
|
admin: boolean;
|
|
36
36
|
scopes: string[];
|
|
37
|
+
flags: Record<string, string[]>;
|
|
38
|
+
kid: string;
|
|
37
39
|
iat: number;
|
|
38
40
|
exp: number;
|
|
39
41
|
}
|
|
40
|
-
export interface
|
|
41
|
-
|
|
42
|
+
export interface PublicKeyEntry {
|
|
43
|
+
kid: string;
|
|
44
|
+
key: CryptoKey;
|
|
45
|
+
}
|
|
46
|
+
export interface SigningConfig {
|
|
47
|
+
privateKey: CryptoKey;
|
|
48
|
+
kid: string;
|
|
49
|
+
maxAgeSeconds: number;
|
|
50
|
+
}
|
|
51
|
+
export interface VerifyConfig {
|
|
52
|
+
publicKeys: PublicKeyEntry[];
|
|
42
53
|
maxAgeSeconds: number;
|
|
43
54
|
}
|
|
44
55
|
export interface CookieConfig {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uln/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
"bun": "./src/service.ts",
|
|
18
18
|
"import": "./dist/service.js",
|
|
19
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"
|
|
20
25
|
}
|
|
21
26
|
},
|
|
22
27
|
"files": [
|
|
@@ -26,11 +31,11 @@
|
|
|
26
31
|
],
|
|
27
32
|
"scripts": {
|
|
28
33
|
"dev": "bunx vite",
|
|
29
|
-
"dev:worker": "
|
|
34
|
+
"dev:worker": "bunx wrangler dev",
|
|
30
35
|
"build": "bun run scripts/build.ts",
|
|
31
36
|
"check": "biome check --write .",
|
|
32
37
|
"test": "bun test",
|
|
33
|
-
"deploy": "bunx
|
|
38
|
+
"deploy": "bunx wrangler deploy",
|
|
34
39
|
"publish:check": "bun run scripts/publish.ts check",
|
|
35
40
|
"publish:release": "bun run scripts/publish.ts release"
|
|
36
41
|
},
|
|
@@ -48,6 +53,12 @@
|
|
|
48
53
|
"vite": "^8.0.1"
|
|
49
54
|
},
|
|
50
55
|
"peerDependencies": {
|
|
56
|
+
"hono": "^4",
|
|
51
57
|
"typescript": "^5"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"hono": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
52
63
|
}
|
|
53
64
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
export type { TokenPayload,
|
|
2
|
-
export {
|
|
1
|
+
export type { TokenPayload, VerifyConfig, PublicKeyEntry, CookieConfig } from "./types.ts";
|
|
2
|
+
export { validateToken, importPublicKey } from "./token.ts";
|
|
3
3
|
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
4
4
|
export { getAuthUser, redirectToLogin, getClientIP, checkBearerToken } from "./middleware.ts";
|
|
5
|
+
|
|
6
|
+
import { importPublicKey } from "./token.ts";
|
|
7
|
+
import type { PublicKeyEntry } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export async function fetchPublicKeys(baseUrl: string): Promise<PublicKeyEntry[]> {
|
|
10
|
+
const res = await fetch(`${baseUrl}/.well-known/jwk.json`);
|
|
11
|
+
if (!res.ok) throw new Error(`Failed to fetch JWKS from ${baseUrl}: ${res.status}`);
|
|
12
|
+
const jwks = (await res.json()) as { keys: (JsonWebKey & { kid: string })[] };
|
|
13
|
+
return Promise.all(
|
|
14
|
+
jwks.keys.map(async (jwk) => ({
|
|
15
|
+
kid: jwk.kid,
|
|
16
|
+
key: await importPublicKey(jwk),
|
|
17
|
+
})),
|
|
18
|
+
);
|
|
19
|
+
}
|
package/src/cookie.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
import type { CookieConfig } from "./types.ts";
|
|
2
2
|
|
|
3
3
|
export function makeSessionCookie(config: CookieConfig, token: string): string {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const parts = [`${config.name}=${token}`, `Max-Age=${config.maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
5
|
+
if (config.secure) parts.push("Secure");
|
|
6
|
+
return parts.join("; ");
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export function makeClearCookie(config: CookieConfig): string {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const parts = [`${config.name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
11
|
+
if (config.secure) parts.push("Secure");
|
|
12
|
+
return parts.join("; ");
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function extractCookie(cookieHeader: string, name: string): string | null {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
24
|
}
|
package/src/hono.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import { extractCookie } from "./cookie.ts";
|
|
3
|
+
import { makeSessionCookie } from "./cookie.ts";
|
|
4
|
+
import { importPublicKey } from "./token.ts";
|
|
5
|
+
import { validateToken } from "./token.ts";
|
|
6
|
+
import type { CookieConfig, PublicKeyEntry, TokenPayload } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
interface AuthOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Service binding name (e.g. "AUTH"). If provided, validates tokens
|
|
11
|
+
* via the auth worker service binding instead of fetching public keys.
|
|
12
|
+
*/
|
|
13
|
+
binding?: string;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Paths that don't require authentication. Supports exact matches
|
|
17
|
+
* and trailing wildcard (e.g. "/api/public/*").
|
|
18
|
+
*/
|
|
19
|
+
public?: string[];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Auth service base URL. Defaults to "https://auth.uln.industries".
|
|
23
|
+
*/
|
|
24
|
+
authUrl?: string;
|
|
25
|
+
|
|
26
|
+
/** Cookie name. Defaults to "uln_auth". */
|
|
27
|
+
cookie?: string;
|
|
28
|
+
|
|
29
|
+
/** Cookie/token max age in seconds. Defaults to 86400 (24h). */
|
|
30
|
+
maxAge?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_AUTH_URL = "https://auth.uln.industries";
|
|
34
|
+
const DEFAULT_COOKIE = "uln_auth";
|
|
35
|
+
const DEFAULT_MAX_AGE = 86400;
|
|
36
|
+
|
|
37
|
+
function isPublicPath(pathname: string, patterns: string[]): boolean {
|
|
38
|
+
for (const pattern of patterns) {
|
|
39
|
+
if (pattern.endsWith("/*")) {
|
|
40
|
+
const prefix = pattern.slice(0, -1);
|
|
41
|
+
if (pathname === prefix.slice(0, -1) || pathname.startsWith(prefix)) return true;
|
|
42
|
+
} else if (pathname === pattern) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Public key caching (client mode) ---
|
|
50
|
+
|
|
51
|
+
let cachedKeys: PublicKeyEntry[] | null = null;
|
|
52
|
+
let cachedAt = 0;
|
|
53
|
+
const CACHE_TTL = 3600_000; // 1 hour
|
|
54
|
+
|
|
55
|
+
async function getPublicKeys(authUrl: string): Promise<PublicKeyEntry[]> {
|
|
56
|
+
if (cachedKeys && Date.now() - cachedAt < CACHE_TTL) {
|
|
57
|
+
return cachedKeys;
|
|
58
|
+
}
|
|
59
|
+
const res = await fetch(`${authUrl}/.well-known/jwk.json`);
|
|
60
|
+
if (!res.ok) throw new Error(`Failed to fetch JWKS from ${authUrl}: ${res.status}`);
|
|
61
|
+
const jwks = (await res.json()) as { keys: (JsonWebKey & { kid: string })[] };
|
|
62
|
+
cachedKeys = await Promise.all(
|
|
63
|
+
jwks.keys.map(async (jwk) => ({
|
|
64
|
+
kid: jwk.kid,
|
|
65
|
+
key: await importPublicKey(jwk),
|
|
66
|
+
})),
|
|
67
|
+
);
|
|
68
|
+
cachedAt = Date.now();
|
|
69
|
+
return cachedKeys;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Token validation ---
|
|
73
|
+
|
|
74
|
+
async function validateWithPublicKeys(token: string, authUrl: string, maxAge: number): Promise<TokenPayload | null> {
|
|
75
|
+
const publicKeys = await getPublicKeys(authUrl);
|
|
76
|
+
return validateToken({ publicKeys, maxAgeSeconds: maxAge }, token);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function validateWithBinding(token: string, binding: Fetcher): Promise<TokenPayload | null> {
|
|
80
|
+
const res = await binding.fetch("https://auth/api/verify-token", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ token }),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) return null;
|
|
86
|
+
const data = (await res.json()) as {
|
|
87
|
+
user: {
|
|
88
|
+
id: string;
|
|
89
|
+
email: string;
|
|
90
|
+
name: string;
|
|
91
|
+
admin: boolean;
|
|
92
|
+
scopes: string[];
|
|
93
|
+
flags: Record<string, string[]>;
|
|
94
|
+
kid: string;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
sub: data.user.id,
|
|
99
|
+
email: data.user.email,
|
|
100
|
+
name: data.user.name,
|
|
101
|
+
admin: data.user.admin,
|
|
102
|
+
scopes: data.user.scopes,
|
|
103
|
+
flags: data.user.flags,
|
|
104
|
+
kid: data.user.kid,
|
|
105
|
+
iat: 0,
|
|
106
|
+
exp: 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Hono middleware that handles the full auth flow:
|
|
112
|
+
*
|
|
113
|
+
* 1. If `?token=` in URL → validate, set cookie, redirect to clean URL
|
|
114
|
+
* 2. If session cookie present → validate, set `c.get("user")`
|
|
115
|
+
* 3. If unauthenticated → redirect to auth.uln.industries/login
|
|
116
|
+
*
|
|
117
|
+
* Usage:
|
|
118
|
+
* ```ts
|
|
119
|
+
* import { auth } from "@uln/auth/hono";
|
|
120
|
+
*
|
|
121
|
+
* // Public key validation (default — no config needed):
|
|
122
|
+
* app.use("/*", auth());
|
|
123
|
+
*
|
|
124
|
+
* // Service binding (if on same CF account):
|
|
125
|
+
* app.use("/*", auth({ binding: "AUTH" }));
|
|
126
|
+
*
|
|
127
|
+
* // With public paths:
|
|
128
|
+
* app.use("/*", auth({ public: ["/health", "/api/public/*"] }));
|
|
129
|
+
*
|
|
130
|
+
* // Access user in handlers:
|
|
131
|
+
* app.get("/me", (c) => c.json(c.get("user")));
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
135
|
+
const authUrl = options?.authUrl ?? DEFAULT_AUTH_URL;
|
|
136
|
+
const cookieName = options?.cookie ?? DEFAULT_COOKIE;
|
|
137
|
+
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
|
|
138
|
+
const publicPaths = options?.public ?? [];
|
|
139
|
+
const bindingName = options?.binding;
|
|
140
|
+
|
|
141
|
+
const cookieConf: CookieConfig = { name: cookieName, maxAgeSeconds: maxAge, secure: true };
|
|
142
|
+
|
|
143
|
+
async function validate(token: string, env: Record<string, unknown>): Promise<TokenPayload | null> {
|
|
144
|
+
if (bindingName) {
|
|
145
|
+
const binding = env[bindingName] as Fetcher | undefined;
|
|
146
|
+
if (!binding) throw new Error(`Service binding "${bindingName}" not found in env`);
|
|
147
|
+
return validateWithBinding(token, binding);
|
|
148
|
+
}
|
|
149
|
+
return validateWithPublicKeys(token, authUrl, maxAge);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return async (c, next) => {
|
|
153
|
+
const url = new URL(c.req.url);
|
|
154
|
+
|
|
155
|
+
// Public paths bypass auth
|
|
156
|
+
if (isPublicPath(url.pathname, publicPaths)) {
|
|
157
|
+
return next();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Step 1: Handle ?token= callback (login redirect landing)
|
|
161
|
+
const incomingToken = url.searchParams.get("token");
|
|
162
|
+
if (incomingToken) {
|
|
163
|
+
const payload = await validate(incomingToken, c.env as Record<string, unknown>);
|
|
164
|
+
if (!payload) {
|
|
165
|
+
return c.text("Invalid token", 401);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Set cookie and redirect to clean URL (strip token param)
|
|
169
|
+
url.searchParams.delete("token");
|
|
170
|
+
return new Response(null, {
|
|
171
|
+
status: 302,
|
|
172
|
+
headers: {
|
|
173
|
+
Location: url.toString(),
|
|
174
|
+
"Set-Cookie": makeSessionCookie(cookieConf, incomingToken),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Step 2: Check session cookie
|
|
180
|
+
const cookieHeader = c.req.header("Cookie");
|
|
181
|
+
if (cookieHeader) {
|
|
182
|
+
const token = extractCookie(cookieHeader, cookieName);
|
|
183
|
+
if (token) {
|
|
184
|
+
const payload = await validate(token, c.env as Record<string, unknown>);
|
|
185
|
+
if (payload) {
|
|
186
|
+
c.set("user", payload);
|
|
187
|
+
return next();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Step 3: Check Authorization header
|
|
193
|
+
const authHeader = c.req.header("Authorization");
|
|
194
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
195
|
+
const token = authHeader.substring(7);
|
|
196
|
+
const payload = await validate(token, c.env as Record<string, unknown>);
|
|
197
|
+
if (payload) {
|
|
198
|
+
c.set("user", payload);
|
|
199
|
+
return next();
|
|
200
|
+
}
|
|
201
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Step 4: Redirect to login
|
|
205
|
+
const loginUrl = `${authUrl}/login?redirect=${encodeURIComponent(c.req.url)}`;
|
|
206
|
+
return c.redirect(loginUrl, 302);
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Type-safe helper to get the authenticated user from context. */
|
|
211
|
+
export function getUser(c: Context): TokenPayload {
|
|
212
|
+
return c.get("user") as TokenPayload;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type { TokenPayload } from "./types.ts";
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
export type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
User,
|
|
3
|
+
UserStore,
|
|
4
|
+
RateLimitEntry,
|
|
5
|
+
RateLimitStore,
|
|
6
|
+
RateLimitConfig,
|
|
7
|
+
TokenPayload,
|
|
8
|
+
SigningConfig,
|
|
9
|
+
VerifyConfig,
|
|
10
|
+
PublicKeyEntry,
|
|
11
|
+
CookieConfig,
|
|
10
12
|
} from "./types.ts";
|
|
11
13
|
|
|
12
14
|
export { hashPassword, verifyPassword } from "./password.ts";
|
|
13
|
-
export { createToken, validateToken } from "./token.ts";
|
|
15
|
+
export { createToken, validateToken, generateKeyPair, importPrivateKey, importPublicKey } from "./token.ts";
|
|
14
16
|
export type { CreateTokenInput } from "./token.ts";
|
|
15
17
|
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
16
18
|
export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
|