@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/src/middleware.ts
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
import { extractCookie } from "./cookie.ts";
|
|
2
2
|
import { validateToken } from "./token.ts";
|
|
3
|
-
import type { CookieConfig,
|
|
3
|
+
import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
export function getClientIP(request: Request): string {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
return (
|
|
7
|
+
request.headers.get("cf-connecting-ip") ??
|
|
8
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
9
|
+
"unknown"
|
|
10
|
+
);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function checkBearerToken(request: Request, expectedToken: string): boolean {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const header = request.headers.get("Authorization");
|
|
15
|
+
return header === `Bearer ${expectedToken}`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export async function getAuthUser(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
request: Request,
|
|
20
|
+
verifyConfig: VerifyConfig,
|
|
21
|
+
cookieConfig: CookieConfig,
|
|
22
22
|
): Promise<TokenPayload | null> {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
24
|
+
if (!cookieHeader) return null;
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
27
|
+
if (!token) return null;
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
return validateToken(verifyConfig, token);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function redirectToLogin(currentUrl: string): Response {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
|
34
|
+
return Response.redirect(loginUrl, 302);
|
|
35
35
|
}
|
package/src/password.ts
CHANGED
|
@@ -3,55 +3,55 @@ const KEY_LENGTH = 64;
|
|
|
3
3
|
const ITERATIONS = 100_000;
|
|
4
4
|
|
|
5
5
|
function toHex(buffer: ArrayBuffer): string {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
return Array.from(new Uint8Array(buffer))
|
|
7
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
8
|
+
.join("");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
function fromHex(hex: string): Uint8Array<ArrayBuffer> {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
13
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
14
|
+
bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16);
|
|
15
|
+
}
|
|
16
|
+
return bytes;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer> {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
|
|
22
|
+
return crypto.subtle.deriveBits(
|
|
23
|
+
{
|
|
24
|
+
name: "PBKDF2",
|
|
25
|
+
salt,
|
|
26
|
+
iterations: ITERATIONS,
|
|
27
|
+
hash: "SHA-256",
|
|
28
|
+
},
|
|
29
|
+
keyMaterial,
|
|
30
|
+
KEY_LENGTH * 8,
|
|
31
|
+
);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export async function hashPassword(password: string): Promise<string> {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
36
|
+
const derived = await deriveKey(password, salt);
|
|
37
|
+
return `${toHex(salt.buffer)}:${toHex(derived)}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
41
|
+
const separatorIndex = storedHash.indexOf(":");
|
|
42
|
+
if (separatorIndex === -1) return false;
|
|
43
|
+
|
|
44
|
+
const saltHex = storedHash.substring(0, separatorIndex);
|
|
45
|
+
const keyHex = storedHash.substring(separatorIndex + 1);
|
|
46
|
+
const salt = fromHex(saltHex);
|
|
47
|
+
const expectedKey = fromHex(keyHex);
|
|
48
|
+
const derived = new Uint8Array(await deriveKey(password, salt));
|
|
49
|
+
|
|
50
|
+
if (derived.length !== expectedKey.length) return false;
|
|
51
|
+
|
|
52
|
+
let diff = 0;
|
|
53
|
+
for (let i = 0; i < derived.length; i++) {
|
|
54
|
+
diff |= (derived[i] ?? 0) ^ (expectedKey[i] ?? 0);
|
|
55
|
+
}
|
|
56
|
+
return diff === 0;
|
|
57
57
|
}
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
1
|
import type { RateLimitConfig, RateLimitStore } from "./types.ts";
|
|
2
2
|
|
|
3
3
|
export interface RateLimitResult {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
blocked: boolean;
|
|
5
|
+
remainingSeconds?: number;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export async function checkRateLimit(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
store: RateLimitStore,
|
|
10
|
+
key: string,
|
|
11
|
+
config: RateLimitConfig,
|
|
12
12
|
): Promise<RateLimitResult> {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const entry = await store.get(key);
|
|
14
|
+
if (!entry) return { blocked: false };
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
if (entry.attempts >= config.maxAttempts) {
|
|
17
|
+
const elapsed = Math.floor((Date.now() - entry.firstAttempt) / 1000);
|
|
18
|
+
const remaining = config.windowSeconds - elapsed;
|
|
19
|
+
if (remaining > 0) return { blocked: true, remainingSeconds: remaining };
|
|
20
|
+
await store.delete(key);
|
|
21
|
+
return { blocked: false };
|
|
22
|
+
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
return { blocked: false };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export async function recordFailedAttempt(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<void> {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const entry = await store.get(key);
|
|
29
|
+
if (entry) {
|
|
30
|
+
await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
|
|
31
|
+
} else {
|
|
32
|
+
await store.set(key, { attempts: 1, firstAttempt: Date.now() }, config.windowSeconds);
|
|
33
|
+
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export async function clearRateLimit(store: RateLimitStore, key: string): Promise<void> {
|
|
37
|
-
|
|
37
|
+
await store.delete(key);
|
|
38
38
|
}
|
package/src/service.ts
CHANGED
|
@@ -8,23 +8,34 @@ export type { TokenPayload, CookieConfig } from "./types.ts";
|
|
|
8
8
|
* Returns the token payload or null if invalid.
|
|
9
9
|
*/
|
|
10
10
|
export async function verifyToken(authService: Fetcher, token: string): Promise<TokenPayload | null> {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
11
|
+
const res = await authService.fetch("https://auth/api/verify-token", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify({ token }),
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) return null;
|
|
17
|
+
const data = (await res.json()) as {
|
|
18
|
+
user: {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
name: string;
|
|
22
|
+
admin: boolean;
|
|
23
|
+
scopes: string[];
|
|
24
|
+
flags: Record<string, string[]>;
|
|
25
|
+
kid: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
sub: data.user.id,
|
|
30
|
+
email: data.user.email,
|
|
31
|
+
name: data.user.name,
|
|
32
|
+
admin: data.user.admin,
|
|
33
|
+
scopes: data.user.scopes,
|
|
34
|
+
flags: data.user.flags,
|
|
35
|
+
kid: data.user.kid,
|
|
36
|
+
iat: 0,
|
|
37
|
+
exp: 0,
|
|
38
|
+
};
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
/**
|
|
@@ -32,23 +43,23 @@ export async function verifyToken(authService: Fetcher, token: string): Promise<
|
|
|
32
43
|
* Returns the token payload or null if not authenticated.
|
|
33
44
|
*/
|
|
34
45
|
export async function getAuthUser(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
request: Request,
|
|
47
|
+
authService: Fetcher,
|
|
48
|
+
cookieConfig: CookieConfig,
|
|
38
49
|
): Promise<TokenPayload | null> {
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
51
|
+
if (!cookieHeader) return null;
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
54
|
+
if (!token) return null;
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
return verifyToken(authService, token);
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* Redirect to the centralized login page at auth.uln.industries.
|
|
50
61
|
*/
|
|
51
62
|
export function redirectToLogin(currentUrl: string): Response {
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
|
|
64
|
+
return Response.redirect(loginUrl, 302);
|
|
54
65
|
}
|
package/src/token.ts
CHANGED
|
@@ -1,87 +1,109 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SigningConfig, TokenPayload, VerifyConfig } from "./types.ts";
|
|
2
2
|
|
|
3
3
|
const encoder = new TextEncoder();
|
|
4
4
|
|
|
5
5
|
function base64urlEncode(data: Uint8Array): string {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
let binary = "";
|
|
7
|
+
for (const byte of data) {
|
|
8
|
+
binary += String.fromCharCode(byte);
|
|
9
|
+
}
|
|
10
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
function base64urlDecode(str: string): Uint8Array {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
15
|
+
const binary = atob(padded);
|
|
16
|
+
const bytes = new Uint8Array(binary.length);
|
|
17
|
+
for (let i = 0; i < binary.length; i++) {
|
|
18
|
+
bytes[i] = binary.charCodeAt(i);
|
|
19
|
+
}
|
|
20
|
+
return bytes;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
const ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-256" } as const;
|
|
24
|
+
const SIGN_PARAMS = { name: "ECDSA", hash: "SHA-256" } as const;
|
|
25
|
+
|
|
26
|
+
export async function generateKeyPair(): Promise<{ kid: string; privateKeyJwk: JsonWebKey; publicKeyJwk: JsonWebKey }> {
|
|
27
|
+
const kid = crypto.randomUUID().slice(0, 8);
|
|
28
|
+
const keyPair = await crypto.subtle.generateKey(ECDSA_PARAMS, true, ["sign", "verify"]);
|
|
29
|
+
if (!("privateKey" in keyPair)) throw new Error("Expected CryptoKeyPair from ECDSA generateKey");
|
|
30
|
+
const privateExport = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
|
|
31
|
+
const publicExport = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
|
32
|
+
if (privateExport instanceof ArrayBuffer || publicExport instanceof ArrayBuffer)
|
|
33
|
+
throw new Error("Expected JWK from exportKey");
|
|
34
|
+
return { kid, privateKeyJwk: privateExport, publicKeyJwk: publicExport };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function importPrivateKey(jwk: JsonWebKey): Promise<CryptoKey> {
|
|
38
|
+
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["sign"]);
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
let diff = 0;
|
|
34
|
-
for (let i = 0; i < a.length; i++) {
|
|
35
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
36
|
-
}
|
|
37
|
-
return diff === 0;
|
|
41
|
+
export async function importPublicKey(jwk: JsonWebKey): Promise<CryptoKey> {
|
|
42
|
+
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
export interface CreateTokenInput {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
id: string;
|
|
47
|
+
email: string;
|
|
48
|
+
name: string;
|
|
49
|
+
isAdmin: boolean;
|
|
50
|
+
scopes: string[];
|
|
51
|
+
flags: Record<string, string[]>;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
export async function createToken(config:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
export async function createToken(config: SigningConfig, user: CreateTokenInput): Promise<string> {
|
|
55
|
+
const now = Math.floor(Date.now() / 1000);
|
|
56
|
+
const payload: TokenPayload = {
|
|
57
|
+
sub: user.id,
|
|
58
|
+
email: user.email,
|
|
59
|
+
name: user.name,
|
|
60
|
+
admin: user.isAdmin,
|
|
61
|
+
scopes: user.scopes,
|
|
62
|
+
flags: user.flags,
|
|
63
|
+
kid: config.kid,
|
|
64
|
+
iat: now,
|
|
65
|
+
exp: now + config.maxAgeSeconds,
|
|
66
|
+
};
|
|
67
|
+
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
68
|
+
const payloadBytes = encoder.encode(payloadB64);
|
|
69
|
+
const signature = await crypto.subtle.sign(SIGN_PARAMS, config.privateKey, payloadBytes);
|
|
70
|
+
const sigB64 = base64urlEncode(new Uint8Array(signature));
|
|
71
|
+
return `${payloadB64}.${sigB64}`;
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
export async function validateToken(config:
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
export async function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null> {
|
|
75
|
+
const dotIndex = token.indexOf(".");
|
|
76
|
+
if (dotIndex === -1) return null;
|
|
77
|
+
|
|
78
|
+
const payloadB64 = token.substring(0, dotIndex);
|
|
79
|
+
const sigB64 = token.substring(dotIndex + 1);
|
|
67
80
|
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
let payload: TokenPayload;
|
|
82
|
+
try {
|
|
83
|
+
const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
|
|
84
|
+
payload = JSON.parse(decoded) as TokenPayload;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
73
92
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const payload = JSON.parse(decoded) as TokenPayload;
|
|
93
|
+
const now = Math.floor(Date.now() / 1000);
|
|
94
|
+
if (now > payload.exp) return null;
|
|
77
95
|
|
|
78
|
-
|
|
96
|
+
const entry = config.publicKeys.find((k) => k.kid === payload.kid);
|
|
97
|
+
if (!entry) return null;
|
|
79
98
|
|
|
80
|
-
|
|
81
|
-
|
|
99
|
+
try {
|
|
100
|
+
const signatureBytes = base64urlDecode(sigB64);
|
|
101
|
+
const payloadBytes = encoder.encode(payloadB64);
|
|
102
|
+
const valid = await crypto.subtle.verify(SIGN_PARAMS, entry.key, signatureBytes, payloadBytes);
|
|
103
|
+
if (!valid) return null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
82
107
|
|
|
83
|
-
|
|
84
|
-
} catch {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
108
|
+
return payload;
|
|
87
109
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,55 +1,68 @@
|
|
|
1
1
|
export interface User {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
id: string;
|
|
3
|
+
email: string;
|
|
4
|
+
name: string;
|
|
5
|
+
passwordHash: string;
|
|
6
|
+
isAdmin: boolean;
|
|
7
|
+
emailVerified: boolean;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
updatedAt: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export interface UserStore {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
getUser(id: string): Promise<User | null>;
|
|
14
|
+
getUserByEmail(email: string): Promise<User | null>;
|
|
15
|
+
listUsers(): Promise<User[]>;
|
|
16
|
+
saveUser(user: User): Promise<void>;
|
|
17
|
+
deleteUser(id: string): Promise<void>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface RateLimitEntry {
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
attempts: number;
|
|
22
|
+
firstAttempt: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export interface RateLimitStore {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
get(key: string): Promise<RateLimitEntry | null>;
|
|
27
|
+
set(key: string, entry: RateLimitEntry, ttlSeconds: number): Promise<void>;
|
|
28
|
+
delete(key: string): Promise<void>;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface RateLimitConfig {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
maxAttempts: number;
|
|
33
|
+
windowSeconds: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export interface TokenPayload {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
sub: string;
|
|
38
|
+
email: string;
|
|
39
|
+
name: string;
|
|
40
|
+
admin: boolean;
|
|
41
|
+
scopes: string[];
|
|
42
|
+
flags: Record<string, string[]>;
|
|
43
|
+
kid: string;
|
|
44
|
+
iat: number;
|
|
45
|
+
exp: number;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
export interface
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
export interface PublicKeyEntry {
|
|
49
|
+
kid: string;
|
|
50
|
+
key: CryptoKey;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SigningConfig {
|
|
54
|
+
privateKey: CryptoKey;
|
|
55
|
+
kid: string;
|
|
56
|
+
maxAgeSeconds: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VerifyConfig {
|
|
60
|
+
publicKeys: PublicKeyEntry[];
|
|
61
|
+
maxAgeSeconds: number;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export interface CookieConfig {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
name: string;
|
|
66
|
+
maxAgeSeconds: number;
|
|
67
|
+
secure: boolean;
|
|
55
68
|
}
|
package/src/users.ts
CHANGED
|
@@ -2,45 +2,45 @@ import { hashPassword, verifyPassword } from "./password.ts";
|
|
|
2
2
|
import type { User, UserStore } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
export async function createUser(
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
store: UserStore,
|
|
6
|
+
input: { email: string; name: string; password?: string; isAdmin?: boolean },
|
|
7
7
|
): Promise<User> {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const existing = await store.getUserByEmail(input.email);
|
|
9
|
+
if (existing) throw new Error(`User with email ${input.email} already exists`);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
11
|
+
const now = new Date().toISOString();
|
|
12
|
+
const passwordHash = input.password ? await hashPassword(input.password) : await hashPassword(crypto.randomUUID());
|
|
13
|
+
const user: User = {
|
|
14
|
+
id: crypto.randomUUID(),
|
|
15
|
+
email: input.email.toLowerCase(),
|
|
16
|
+
name: input.name,
|
|
17
|
+
passwordHash,
|
|
18
|
+
isAdmin: input.isAdmin ?? false,
|
|
19
|
+
emailVerified: input.isAdmin ?? false,
|
|
20
|
+
createdAt: now,
|
|
21
|
+
updatedAt: now,
|
|
22
|
+
};
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
await store.saveUser(user);
|
|
25
|
+
return user;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export async function verifyUserCredentials(store: UserStore, email: string, password: string): Promise<User | null> {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const user = await store.getUserByEmail(email.toLowerCase());
|
|
30
|
+
if (!user) return null;
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const valid = await verifyPassword(password, user.passwordHash);
|
|
33
|
+
return valid ? user : null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export async function changePassword(store: UserStore, userId: string, newPassword: string): Promise<void> {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const user = await store.getUser(userId);
|
|
38
|
+
if (!user) throw new Error(`User ${userId} not found`);
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
const updated: User = {
|
|
41
|
+
...user,
|
|
42
|
+
passwordHash: await hashPassword(newPassword),
|
|
43
|
+
updatedAt: new Date().toISOString(),
|
|
44
|
+
};
|
|
45
|
+
await store.saveUser(updated);
|
|
46
46
|
}
|