@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/client.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
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
|
+
import type { PublicKeyEntry } from "./types.ts";
|
|
6
|
+
export declare function fetchPublicKeys(baseUrl: string): Promise<PublicKeyEntry[]>;
|
package/dist/client.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,27 +121,39 @@ 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)}`;
|
|
117
135
|
return Response.redirect(loginUrl, 302);
|
|
118
136
|
}
|
|
137
|
+
// src/client.ts
|
|
138
|
+
async function fetchPublicKeys(baseUrl) {
|
|
139
|
+
const res = await fetch(`${baseUrl}/.well-known/jwk.json`);
|
|
140
|
+
if (!res.ok)
|
|
141
|
+
throw new Error(`Failed to fetch JWKS from ${baseUrl}: ${res.status}`);
|
|
142
|
+
const jwks = await res.json();
|
|
143
|
+
return Promise.all(jwks.keys.map(async (jwk) => ({
|
|
144
|
+
kid: jwk.kid,
|
|
145
|
+
key: await importPublicKey(jwk)
|
|
146
|
+
})));
|
|
147
|
+
}
|
|
119
148
|
export {
|
|
120
149
|
validateToken,
|
|
121
150
|
redirectToLogin,
|
|
122
151
|
makeSessionCookie,
|
|
123
152
|
makeClearCookie,
|
|
153
|
+
importPublicKey,
|
|
124
154
|
getClientIP,
|
|
125
155
|
getAuthUser,
|
|
156
|
+
fetchPublicKeys,
|
|
126
157
|
extractCookie,
|
|
127
|
-
createToken,
|
|
128
158
|
checkBearerToken
|
|
129
159
|
};
|
package/dist/hono.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import type { TokenPayload } from "./types.ts";
|
|
3
|
+
interface AuthOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Service binding name (e.g. "AUTH"). If provided, validates tokens
|
|
6
|
+
* via the auth worker service binding instead of fetching public keys.
|
|
7
|
+
*/
|
|
8
|
+
binding?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Paths that don't require authentication. Supports exact matches
|
|
11
|
+
* and trailing wildcard (e.g. "/api/public/*").
|
|
12
|
+
*/
|
|
13
|
+
public?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Auth service base URL. Defaults to "https://auth.uln.industries".
|
|
16
|
+
*/
|
|
17
|
+
authUrl?: string;
|
|
18
|
+
/** Cookie name. Defaults to "uln_auth". */
|
|
19
|
+
cookie?: string;
|
|
20
|
+
/** Cookie/token max age in seconds. Defaults to 86400 (24h). */
|
|
21
|
+
maxAge?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Hono middleware that handles the full auth flow:
|
|
25
|
+
*
|
|
26
|
+
* 1. If `?token=` in URL → validate, set cookie, redirect to clean URL
|
|
27
|
+
* 2. If session cookie present → validate, set `c.get("user")`
|
|
28
|
+
* 3. If unauthenticated → redirect to auth.uln.industries/login
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { auth } from "@uln/auth/hono";
|
|
33
|
+
*
|
|
34
|
+
* // Public key validation (default — no config needed):
|
|
35
|
+
* app.use("/*", auth());
|
|
36
|
+
*
|
|
37
|
+
* // Service binding (if on same CF account):
|
|
38
|
+
* app.use("/*", auth({ binding: "AUTH" }));
|
|
39
|
+
*
|
|
40
|
+
* // With public paths:
|
|
41
|
+
* app.use("/*", auth({ public: ["/health", "/api/public/*"] }));
|
|
42
|
+
*
|
|
43
|
+
* // Access user in handlers:
|
|
44
|
+
* app.get("/me", (c) => c.json(c.get("user")));
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare function auth(options?: AuthOptions): MiddlewareHandler;
|
|
48
|
+
/** Type-safe helper to get the authenticated user from context. */
|
|
49
|
+
export declare function getUser(c: Context): TokenPayload;
|
|
50
|
+
export type { TokenPayload } from "./types.ts";
|
package/dist/hono.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/cookie.ts
|
|
3
|
+
function makeSessionCookie(config, token) {
|
|
4
|
+
const parts = [`${config.name}=${token}`, `Max-Age=${config.maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
5
|
+
if (config.secure)
|
|
6
|
+
parts.push("Secure");
|
|
7
|
+
return parts.join("; ");
|
|
8
|
+
}
|
|
9
|
+
function makeClearCookie(config) {
|
|
10
|
+
const parts = [`${config.name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
11
|
+
if (config.secure)
|
|
12
|
+
parts.push("Secure");
|
|
13
|
+
return parts.join("; ");
|
|
14
|
+
}
|
|
15
|
+
function extractCookie(cookieHeader, name) {
|
|
16
|
+
const prefix = `${name}=`;
|
|
17
|
+
for (const cookie of cookieHeader.split(";")) {
|
|
18
|
+
const trimmed = cookie.trim();
|
|
19
|
+
if (trimmed.startsWith(prefix)) {
|
|
20
|
+
return trimmed.substring(prefix.length);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/token.ts
|
|
27
|
+
var encoder = new TextEncoder;
|
|
28
|
+
function base64urlEncode(data) {
|
|
29
|
+
let binary = "";
|
|
30
|
+
for (const byte of data) {
|
|
31
|
+
binary += String.fromCharCode(byte);
|
|
32
|
+
}
|
|
33
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
}
|
|
35
|
+
function base64urlDecode(str) {
|
|
36
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
|
+
const binary = atob(padded);
|
|
38
|
+
const bytes = new Uint8Array(binary.length);
|
|
39
|
+
for (let i = 0;i < binary.length; i++) {
|
|
40
|
+
bytes[i] = binary.charCodeAt(i);
|
|
41
|
+
}
|
|
42
|
+
return bytes;
|
|
43
|
+
}
|
|
44
|
+
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 };
|
|
56
|
+
}
|
|
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"]);
|
|
62
|
+
}
|
|
63
|
+
async function createToken(config, user) {
|
|
64
|
+
const now = Math.floor(Date.now() / 1000);
|
|
65
|
+
const payload = {
|
|
66
|
+
sub: user.id,
|
|
67
|
+
email: user.email,
|
|
68
|
+
name: user.name,
|
|
69
|
+
admin: user.isAdmin,
|
|
70
|
+
scopes: user.scopes,
|
|
71
|
+
flags: user.flags,
|
|
72
|
+
kid: config.kid,
|
|
73
|
+
iat: now,
|
|
74
|
+
exp: now + config.maxAgeSeconds
|
|
75
|
+
};
|
|
76
|
+
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
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}`;
|
|
81
|
+
}
|
|
82
|
+
async function validateToken(config, token) {
|
|
83
|
+
const dotIndex = token.indexOf(".");
|
|
84
|
+
if (dotIndex === -1)
|
|
85
|
+
return null;
|
|
86
|
+
const payloadB64 = token.substring(0, dotIndex);
|
|
87
|
+
const sigB64 = token.substring(dotIndex + 1);
|
|
88
|
+
let payload;
|
|
89
|
+
try {
|
|
90
|
+
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
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)
|
|
109
|
+
return null;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return payload;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/hono.ts
|
|
117
|
+
var DEFAULT_AUTH_URL = "https://auth.uln.industries";
|
|
118
|
+
var DEFAULT_COOKIE = "uln_auth";
|
|
119
|
+
var DEFAULT_MAX_AGE = 86400;
|
|
120
|
+
function isPublicPath(pathname, patterns) {
|
|
121
|
+
for (const pattern of patterns) {
|
|
122
|
+
if (pattern.endsWith("/*")) {
|
|
123
|
+
const prefix = pattern.slice(0, -1);
|
|
124
|
+
if (pathname === prefix.slice(0, -1) || pathname.startsWith(prefix))
|
|
125
|
+
return true;
|
|
126
|
+
} else if (pathname === pattern) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
var cachedKeys = null;
|
|
133
|
+
var cachedAt = 0;
|
|
134
|
+
var CACHE_TTL = 3600000;
|
|
135
|
+
async function getPublicKeys(authUrl) {
|
|
136
|
+
if (cachedKeys && Date.now() - cachedAt < CACHE_TTL) {
|
|
137
|
+
return cachedKeys;
|
|
138
|
+
}
|
|
139
|
+
const res = await fetch(`${authUrl}/.well-known/jwk.json`);
|
|
140
|
+
if (!res.ok)
|
|
141
|
+
throw new Error(`Failed to fetch JWKS from ${authUrl}: ${res.status}`);
|
|
142
|
+
const jwks = await res.json();
|
|
143
|
+
cachedKeys = await Promise.all(jwks.keys.map(async (jwk) => ({
|
|
144
|
+
kid: jwk.kid,
|
|
145
|
+
key: await importPublicKey(jwk)
|
|
146
|
+
})));
|
|
147
|
+
cachedAt = Date.now();
|
|
148
|
+
return cachedKeys;
|
|
149
|
+
}
|
|
150
|
+
async function validateWithPublicKeys(token, authUrl, maxAge) {
|
|
151
|
+
const publicKeys = await getPublicKeys(authUrl);
|
|
152
|
+
return validateToken({ publicKeys, maxAgeSeconds: maxAge }, token);
|
|
153
|
+
}
|
|
154
|
+
async function validateWithBinding(token, binding) {
|
|
155
|
+
const res = await binding.fetch("https://auth/api/verify-token", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify({ token })
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok)
|
|
161
|
+
return null;
|
|
162
|
+
const data = await res.json();
|
|
163
|
+
return {
|
|
164
|
+
sub: data.user.id,
|
|
165
|
+
email: data.user.email,
|
|
166
|
+
name: data.user.name,
|
|
167
|
+
admin: data.user.admin,
|
|
168
|
+
scopes: data.user.scopes,
|
|
169
|
+
flags: data.user.flags,
|
|
170
|
+
kid: data.user.kid,
|
|
171
|
+
iat: 0,
|
|
172
|
+
exp: 0
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function auth(options) {
|
|
176
|
+
const authUrl = options?.authUrl ?? DEFAULT_AUTH_URL;
|
|
177
|
+
const cookieName = options?.cookie ?? DEFAULT_COOKIE;
|
|
178
|
+
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
|
|
179
|
+
const publicPaths = options?.public ?? [];
|
|
180
|
+
const bindingName = options?.binding;
|
|
181
|
+
const cookieConf = { name: cookieName, maxAgeSeconds: maxAge, secure: true };
|
|
182
|
+
async function validate(token, env) {
|
|
183
|
+
if (bindingName) {
|
|
184
|
+
const binding = env[bindingName];
|
|
185
|
+
if (!binding)
|
|
186
|
+
throw new Error(`Service binding "${bindingName}" not found in env`);
|
|
187
|
+
return validateWithBinding(token, binding);
|
|
188
|
+
}
|
|
189
|
+
return validateWithPublicKeys(token, authUrl, maxAge);
|
|
190
|
+
}
|
|
191
|
+
return async (c, next) => {
|
|
192
|
+
const url = new URL(c.req.url);
|
|
193
|
+
if (isPublicPath(url.pathname, publicPaths)) {
|
|
194
|
+
return next();
|
|
195
|
+
}
|
|
196
|
+
const incomingToken = url.searchParams.get("token");
|
|
197
|
+
if (incomingToken) {
|
|
198
|
+
const payload = await validate(incomingToken, c.env);
|
|
199
|
+
if (!payload) {
|
|
200
|
+
return c.text("Invalid token", 401);
|
|
201
|
+
}
|
|
202
|
+
url.searchParams.delete("token");
|
|
203
|
+
return new Response(null, {
|
|
204
|
+
status: 302,
|
|
205
|
+
headers: {
|
|
206
|
+
Location: url.toString(),
|
|
207
|
+
"Set-Cookie": makeSessionCookie(cookieConf, incomingToken)
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const cookieHeader = c.req.header("Cookie");
|
|
212
|
+
if (cookieHeader) {
|
|
213
|
+
const token = extractCookie(cookieHeader, cookieName);
|
|
214
|
+
if (token) {
|
|
215
|
+
const payload = await validate(token, c.env);
|
|
216
|
+
if (payload) {
|
|
217
|
+
c.set("user", payload);
|
|
218
|
+
return next();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const authHeader = c.req.header("Authorization");
|
|
223
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
224
|
+
const token = authHeader.substring(7);
|
|
225
|
+
const payload = await validate(token, c.env);
|
|
226
|
+
if (payload) {
|
|
227
|
+
c.set("user", payload);
|
|
228
|
+
return next();
|
|
229
|
+
}
|
|
230
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
231
|
+
}
|
|
232
|
+
const loginUrl = `${authUrl}/login?redirect=${encodeURIComponent(c.req.url)}`;
|
|
233
|
+
return c.redirect(loginUrl, 302);
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function getUser(c) {
|
|
237
|
+
return c.get("user");
|
|
238
|
+
}
|
|
239
|
+
export {
|
|
240
|
+
getUser,
|
|
241
|
+
auth
|
|
242
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export type { User, UserStore, RateLimitEntry, RateLimitStore, RateLimitConfig, TokenPayload,
|
|
1
|
+
export type { User, UserStore, RateLimitEntry, RateLimitStore, RateLimitConfig, TokenPayload, SigningConfig, VerifyConfig, PublicKeyEntry, CookieConfig, } from "./types.ts";
|
|
2
2
|
export { hashPassword, verifyPassword } from "./password.ts";
|
|
3
|
-
export { createToken, validateToken } from "./token.ts";
|
|
3
|
+
export { createToken, validateToken, generateKeyPair, importPrivateKey, importPublicKey } from "./token.ts";
|
|
4
4
|
export type { CreateTokenInput } from "./token.ts";
|
|
5
5
|
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
6
6
|
export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
|
package/dist/index.js
CHANGED
|
@@ -41,21 +41,24 @@ function base64urlDecode(str) {
|
|
|
41
41
|
}
|
|
42
42
|
return bytes;
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return
|
|
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 };
|
|
56
|
+
}
|
|
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)}`;
|
|
@@ -237,9 +255,12 @@ export {
|
|
|
237
255
|
recordFailedAttempt,
|
|
238
256
|
makeSessionCookie,
|
|
239
257
|
makeClearCookie,
|
|
258
|
+
importPublicKey,
|
|
259
|
+
importPrivateKey,
|
|
240
260
|
hashPassword,
|
|
241
261
|
getClientIP,
|
|
242
262
|
getAuthUser,
|
|
263
|
+
generateKeyPair,
|
|
243
264
|
extractCookie,
|
|
244
265
|
createUser,
|
|
245
266
|
createToken,
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CookieConfig,
|
|
1
|
+
import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
|
|
2
2
|
export declare function getClientIP(request: Request): string;
|
|
3
3
|
export declare function checkBearerToken(request: Request, expectedToken: string): boolean;
|
|
4
|
-
export declare function getAuthUser(request: Request,
|
|
4
|
+
export declare function getAuthUser(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
|
|
5
5
|
export declare function redirectToLogin(currentUrl: string): Response;
|