@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/src/service.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractCookie } from "./cookie.ts";
|
|
1
|
+
import { extractCookie, makeSessionCookie } from "./cookie.ts";
|
|
2
2
|
import type { CookieConfig, TokenPayload } from "./types.ts";
|
|
3
3
|
export { makeSessionCookie, makeClearCookie } from "./cookie.ts";
|
|
4
4
|
export type { TokenPayload, CookieConfig } from "./types.ts";
|
|
@@ -58,8 +58,69 @@ export async function getAuthUser(
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Redirect to the centralized login page at auth.uln.industries.
|
|
61
|
+
* Requests one-time auth-code callbacks by default so signed tokens are not sent through URLs.
|
|
61
62
|
*/
|
|
62
|
-
export function redirectToLogin(currentUrl: string): Response {
|
|
63
|
-
const loginUrl =
|
|
64
|
-
|
|
63
|
+
export function redirectToLogin(currentUrl: string, responseType: "code" | "token" = "code"): Response {
|
|
64
|
+
const loginUrl = new URL("/login", "https://auth.uln.industries");
|
|
65
|
+
loginUrl.searchParams.set("redirect", currentUrl);
|
|
66
|
+
if (responseType === "code") loginUrl.searchParams.set("response_type", "code");
|
|
67
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function exchangeCode(authService: Fetcher, code: string, redirectOrigin: string): Promise<string | null> {
|
|
71
|
+
const res = await authService.fetch("https://auth/api/exchange-code", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ code, redirectOrigin }),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) return null;
|
|
77
|
+
const data = (await res.json()) as { token?: string };
|
|
78
|
+
return typeof data.token === "string" ? data.token : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle an auth callback on a consuming Worker using the auth service binding.
|
|
83
|
+
* Preferred `?code=` callbacks are exchanged for signed tokens before setting the local cookie.
|
|
84
|
+
* Legacy `?token=` callbacks are still accepted during migration and are stripped immediately.
|
|
85
|
+
* Returns null if the request has no auth callback parameters.
|
|
86
|
+
*/
|
|
87
|
+
export async function handleCallback(
|
|
88
|
+
request: Request,
|
|
89
|
+
authService: Fetcher,
|
|
90
|
+
cookieConfig: CookieConfig,
|
|
91
|
+
): Promise<Response | null> {
|
|
92
|
+
const url = new URL(request.url);
|
|
93
|
+
|
|
94
|
+
const code = url.searchParams.get("code");
|
|
95
|
+
if (code) {
|
|
96
|
+
const token = await exchangeCode(authService, code, url.origin);
|
|
97
|
+
if (!token) return redirectToLogin(url.origin, "code");
|
|
98
|
+
|
|
99
|
+
const payload = await verifyToken(authService, token);
|
|
100
|
+
if (!payload) return redirectToLogin(url.origin, "code");
|
|
101
|
+
|
|
102
|
+
url.searchParams.delete("code");
|
|
103
|
+
return new Response(null, {
|
|
104
|
+
status: 302,
|
|
105
|
+
headers: {
|
|
106
|
+
Location: url.toString(),
|
|
107
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const token = url.searchParams.get("token");
|
|
113
|
+
if (!token) return null;
|
|
114
|
+
|
|
115
|
+
const payload = await verifyToken(authService, token);
|
|
116
|
+
if (!payload) return redirectToLogin(url.origin, "code");
|
|
117
|
+
|
|
118
|
+
url.searchParams.delete("token");
|
|
119
|
+
return new Response(null, {
|
|
120
|
+
status: 302,
|
|
121
|
+
headers: {
|
|
122
|
+
Location: url.toString(),
|
|
123
|
+
"Set-Cookie": makeSessionCookie(cookieConfig, token),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
65
126
|
}
|
package/src/token.ts
CHANGED
|
@@ -23,6 +23,7 @@ function base64urlDecode(str: string): Uint8Array {
|
|
|
23
23
|
const ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-256" } as const;
|
|
24
24
|
const SIGN_PARAMS = { name: "ECDSA", hash: "SHA-256" } as const;
|
|
25
25
|
|
|
26
|
+
/** Generate an ECDSA P-256 key pair for token signing. Returns a short `kid` and both JWK exports. */
|
|
26
27
|
export async function generateKeyPair(): Promise<{ kid: string; privateKeyJwk: JsonWebKey; publicKeyJwk: JsonWebKey }> {
|
|
27
28
|
const kid = crypto.randomUUID().slice(0, 8);
|
|
28
29
|
const keyPair = await crypto.subtle.generateKey(ECDSA_PARAMS, true, ["sign", "verify"]);
|
|
@@ -34,10 +35,12 @@ export async function generateKeyPair(): Promise<{ kid: string; privateKeyJwk: J
|
|
|
34
35
|
return { kid, privateKeyJwk: privateExport, publicKeyJwk: publicExport };
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Import an ECDSA P-256 private key from JWK for signing. */
|
|
37
39
|
export async function importPrivateKey(jwk: JsonWebKey): Promise<CryptoKey> {
|
|
38
40
|
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["sign"]);
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** Import an ECDSA P-256 public key from JWK for verification. */
|
|
41
44
|
export async function importPublicKey(jwk: JsonWebKey): Promise<CryptoKey> {
|
|
42
45
|
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
|
|
43
46
|
}
|
|
@@ -51,6 +54,7 @@ export interface CreateTokenInput {
|
|
|
51
54
|
flags: Record<string, string[]>;
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/** Create a signed token: `base64url(JSON payload).base64url(ECDSA-SHA256 signature)`. */
|
|
54
58
|
export async function createToken(config: SigningConfig, user: CreateTokenInput): Promise<string> {
|
|
55
59
|
const now = Math.floor(Date.now() / 1000);
|
|
56
60
|
const payload: TokenPayload = {
|
|
@@ -71,6 +75,7 @@ export async function createToken(config: SigningConfig, user: CreateTokenInput)
|
|
|
71
75
|
return `${payloadB64}.${sigB64}`;
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/** Validate a signed token. Returns the payload if valid and not expired, or null. Matches `kid` to find the correct public key. */
|
|
74
79
|
export async function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null> {
|
|
75
80
|
const dotIndex = token.indexOf(".");
|
|
76
81
|
if (dotIndex === -1) return null;
|
package/src/totp.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOTP (Time-based One-Time Password) implementation per RFC 6238.
|
|
3
|
+
* Uses Web Crypto API only — no node:crypto imports.
|
|
4
|
+
*
|
|
5
|
+
* Algorithm: HMAC-SHA1, 6 digits, 30-second period, ±1 step window.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TOTP_PERIOD = 30;
|
|
9
|
+
const TOTP_DIGITS = 6;
|
|
10
|
+
const TOTP_WINDOW = 1; // allow ±1 step for clock drift
|
|
11
|
+
|
|
12
|
+
// ── Base32 encoding/decoding (RFC 4648) ──
|
|
13
|
+
|
|
14
|
+
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
15
|
+
|
|
16
|
+
export function base32Encode(data: Uint8Array): string {
|
|
17
|
+
let bits = 0;
|
|
18
|
+
let value = 0;
|
|
19
|
+
let output = "";
|
|
20
|
+
|
|
21
|
+
for (const byte of data) {
|
|
22
|
+
value = (value << 8) | byte;
|
|
23
|
+
bits += 8;
|
|
24
|
+
|
|
25
|
+
while (bits >= 5) {
|
|
26
|
+
bits -= 5;
|
|
27
|
+
output += BASE32_ALPHABET[(value >>> bits) & 0x1f];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (bits > 0) {
|
|
32
|
+
output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function base32Decode(encoded: string): Uint8Array {
|
|
39
|
+
const cleaned = encoded.replace(/[=\s]/g, "").toUpperCase();
|
|
40
|
+
const output: number[] = [];
|
|
41
|
+
let bits = 0;
|
|
42
|
+
let value = 0;
|
|
43
|
+
|
|
44
|
+
for (const char of cleaned) {
|
|
45
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
46
|
+
if (idx === -1) throw new Error(`Invalid base32 character: ${char}`);
|
|
47
|
+
value = (value << 5) | idx;
|
|
48
|
+
bits += 5;
|
|
49
|
+
|
|
50
|
+
if (bits >= 8) {
|
|
51
|
+
bits -= 8;
|
|
52
|
+
output.push((value >>> bits) & 0xff);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return new Uint8Array(output);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Core TOTP functions ──
|
|
60
|
+
|
|
61
|
+
/** Generate a 20-byte random TOTP secret, returned as base32. */
|
|
62
|
+
export function generateTotpSecret(): string {
|
|
63
|
+
const bytes = new Uint8Array(20);
|
|
64
|
+
crypto.getRandomValues(bytes);
|
|
65
|
+
return base32Encode(bytes);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build an otpauth:// URI for authenticator apps. */
|
|
69
|
+
export function getTotpUri(secret: string, email: string, issuer: string): string {
|
|
70
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(email)}`;
|
|
71
|
+
const params = new URLSearchParams({
|
|
72
|
+
secret,
|
|
73
|
+
issuer,
|
|
74
|
+
algorithm: "SHA1",
|
|
75
|
+
digits: String(TOTP_DIGITS),
|
|
76
|
+
period: String(TOTP_PERIOD),
|
|
77
|
+
});
|
|
78
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Generate a TOTP code for the given secret and time step. */
|
|
82
|
+
async function generateCode(secret: string, counter: number): Promise<string> {
|
|
83
|
+
const keyBytes = base32Decode(secret);
|
|
84
|
+
// Copy into a fresh ArrayBuffer to satisfy the BufferSource type
|
|
85
|
+
const keyBuffer = new ArrayBuffer(keyBytes.byteLength);
|
|
86
|
+
new Uint8Array(keyBuffer).set(keyBytes);
|
|
87
|
+
const key = await crypto.subtle.importKey("raw", keyBuffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
88
|
+
|
|
89
|
+
// Counter as big-endian 8-byte buffer
|
|
90
|
+
const counterBuf = new ArrayBuffer(8);
|
|
91
|
+
const view = new DataView(counterBuf);
|
|
92
|
+
view.setUint32(4, counter);
|
|
93
|
+
|
|
94
|
+
const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, counterBuf));
|
|
95
|
+
|
|
96
|
+
// Dynamic truncation (RFC 4226 §5.4)
|
|
97
|
+
// HMAC-SHA1 always produces 20 bytes, so all indices are safe
|
|
98
|
+
const offset = (hmac[hmac.length - 1] ?? 0) & 0x0f;
|
|
99
|
+
const b0 = hmac[offset] ?? 0;
|
|
100
|
+
const b1 = hmac[offset + 1] ?? 0;
|
|
101
|
+
const b2 = hmac[offset + 2] ?? 0;
|
|
102
|
+
const b3 = hmac[offset + 3] ?? 0;
|
|
103
|
+
const code = ((b0 & 0x7f) << 24) | ((b1 & 0xff) << 16) | ((b2 & 0xff) << 8) | (b3 & 0xff);
|
|
104
|
+
|
|
105
|
+
return String(code % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Six-digit code for the current 30-second step (deterministic counterpart to verifyTotpCode in tests). */
|
|
109
|
+
export async function currentTotpCode(secret: string, nowMs = Date.now()): Promise<string> {
|
|
110
|
+
const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
|
|
111
|
+
return generateCode(secret, step);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Verify a TOTP code against the secret. Allows ±1 time step for clock drift. */
|
|
115
|
+
export async function verifyTotpCode(secret: string, code: string): Promise<boolean> {
|
|
116
|
+
if (code.length !== TOTP_DIGITS) return false;
|
|
117
|
+
|
|
118
|
+
const now = Math.floor(Date.now() / 1000);
|
|
119
|
+
const currentStep = Math.floor(now / TOTP_PERIOD);
|
|
120
|
+
|
|
121
|
+
for (let offset = -TOTP_WINDOW; offset <= TOTP_WINDOW; offset++) {
|
|
122
|
+
const expected = await generateCode(secret, currentStep + offset);
|
|
123
|
+
if (timingSafeEqual(code, expected)) return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const BACKUP_CODE_COUNT = 10;
|
|
130
|
+
const BACKUP_CODE_LENGTH = 8;
|
|
131
|
+
const BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
132
|
+
|
|
133
|
+
/** Generate a set of one-time backup codes for 2FA recovery. Returns plain-text codes. */
|
|
134
|
+
export function generateBackupCodes(): string[] {
|
|
135
|
+
const codes: string[] = [];
|
|
136
|
+
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
|
137
|
+
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
138
|
+
crypto.getRandomValues(bytes);
|
|
139
|
+
let code = "";
|
|
140
|
+
for (const byte of bytes) {
|
|
141
|
+
code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
|
|
142
|
+
}
|
|
143
|
+
codes.push(code);
|
|
144
|
+
}
|
|
145
|
+
return codes;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Hash a backup code for storage. Uses SHA-256 (no salt needed — codes are high-entropy). */
|
|
149
|
+
export async function hashBackupCode(code: string): Promise<string> {
|
|
150
|
+
const data = new TextEncoder().encode(code.toLowerCase());
|
|
151
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
152
|
+
return Array.from(new Uint8Array(hash))
|
|
153
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
154
|
+
.join("");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Constant-time string comparison to prevent timing attacks. */
|
|
158
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
159
|
+
if (a.length !== b.length) return false;
|
|
160
|
+
let result = 0;
|
|
161
|
+
for (let i = 0; i < a.length; i++) {
|
|
162
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
163
|
+
}
|
|
164
|
+
return result === 0;
|
|
165
|
+
}
|
package/src/types.ts
CHANGED
package/src/users.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { hashPassword, verifyPassword } from "./password.ts";
|
|
2
2
|
import type { User, UserStore } from "./types.ts";
|
|
3
3
|
|
|
4
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
5
|
+
|
|
6
|
+
/** Validate email format. Rejects missing local/domain parts, whitespace, and emails over 254 chars. */
|
|
7
|
+
export function isValidEmail(email: string): boolean {
|
|
8
|
+
return email.length <= 254 && EMAIL_REGEX.test(email);
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
export async function createUser(
|
|
5
12
|
store: UserStore,
|
|
6
13
|
input: { email: string; name: string; password?: string; isAdmin?: boolean },
|
|
7
14
|
): Promise<User> {
|
|
15
|
+
if (!isValidEmail(input.email)) throw new Error("Invalid email address");
|
|
8
16
|
const existing = await store.getUserByEmail(input.email);
|
|
9
17
|
if (existing) throw new Error(`User with email ${input.email} already exists`);
|
|
10
18
|
|
|
@@ -17,6 +25,8 @@ export async function createUser(
|
|
|
17
25
|
passwordHash,
|
|
18
26
|
isAdmin: input.isAdmin ?? false,
|
|
19
27
|
emailVerified: input.isAdmin ?? false,
|
|
28
|
+
totpSecret: null,
|
|
29
|
+
totpEnabled: false,
|
|
20
30
|
createdAt: now,
|
|
21
31
|
updatedAt: now,
|
|
22
32
|
};
|
|
@@ -33,6 +43,18 @@ export async function verifyUserCredentials(store: UserStore, email: string, pas
|
|
|
33
43
|
return valid ? user : null;
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
export async function updateName(store: UserStore, userId: string, name: string): Promise<void> {
|
|
47
|
+
const user = await store.getUser(userId);
|
|
48
|
+
if (!user) throw new Error(`User ${userId} not found`);
|
|
49
|
+
|
|
50
|
+
const updated: User = {
|
|
51
|
+
...user,
|
|
52
|
+
name,
|
|
53
|
+
updatedAt: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
await store.saveUser(updated);
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
export async function changePassword(store: UserStore, userId: string, newPassword: string): Promise<void> {
|
|
37
59
|
const user = await store.getUser(userId);
|
|
38
60
|
if (!user) throw new Error(`User ${userId} not found`);
|