@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/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, TokenConfig, TokenPayload } from "./types.ts";
3
+ import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
4
4
 
5
5
  export function getClientIP(request: Request): string {
6
- return (
7
- request.headers.get("cf-connecting-ip") ??
8
- request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
9
- "unknown"
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
- const header = request.headers.get("Authorization");
15
- return header === `Bearer ${expectedToken}`;
14
+ const header = request.headers.get("Authorization");
15
+ return header === `Bearer ${expectedToken}`;
16
16
  }
17
17
 
18
18
  export async function getAuthUser(
19
- request: Request,
20
- tokenConfig: TokenConfig,
21
- cookieConfig: CookieConfig,
19
+ request: Request,
20
+ verifyConfig: VerifyConfig,
21
+ cookieConfig: CookieConfig,
22
22
  ): Promise<TokenPayload | null> {
23
- const cookieHeader = request.headers.get("Cookie");
24
- if (!cookieHeader) return null;
23
+ const cookieHeader = request.headers.get("Cookie");
24
+ if (!cookieHeader) return null;
25
25
 
26
- const token = extractCookie(cookieHeader, cookieConfig.name);
27
- if (!token) return null;
26
+ const token = extractCookie(cookieHeader, cookieConfig.name);
27
+ if (!token) return null;
28
28
 
29
- return validateToken(tokenConfig, token);
29
+ return validateToken(verifyConfig, token);
30
30
  }
31
31
 
32
32
  export function redirectToLogin(currentUrl: string): Response {
33
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
34
- return Response.redirect(loginUrl, 302);
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
- return Array.from(new Uint8Array(buffer))
7
- .map((b) => b.toString(16).padStart(2, "0"))
8
- .join("");
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
- 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;
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
- 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
- );
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
- const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
36
- const derived = await deriveKey(password, salt);
37
- return `${toHex(salt.buffer)}:${toHex(derived)}`;
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
- 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;
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
- blocked: boolean;
5
- remainingSeconds?: number;
4
+ blocked: boolean;
5
+ remainingSeconds?: number;
6
6
  }
7
7
 
8
8
  export async function checkRateLimit(
9
- store: RateLimitStore,
10
- key: string,
11
- config: RateLimitConfig,
9
+ store: RateLimitStore,
10
+ key: string,
11
+ config: RateLimitConfig,
12
12
  ): Promise<RateLimitResult> {
13
- const entry = await store.get(key);
14
- if (!entry) return { blocked: false };
13
+ const entry = await store.get(key);
14
+ if (!entry) return { blocked: false };
15
15
 
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
- }
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
- return { blocked: false };
24
+ return { blocked: false };
25
25
  }
26
26
 
27
27
  export async function recordFailedAttempt(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<void> {
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
- }
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
- await store.delete(key);
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
- 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: { user: { id: string; email: string; name: string; admin: boolean; scopes: string[] } } =
18
- await res.json();
19
- return {
20
- sub: data.user.id,
21
- email: data.user.email,
22
- name: data.user.name,
23
- admin: data.user.admin,
24
- scopes: data.user.scopes,
25
- iat: 0,
26
- exp: 0,
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
- request: Request,
36
- authService: Fetcher,
37
- cookieConfig: CookieConfig,
46
+ request: Request,
47
+ authService: Fetcher,
48
+ cookieConfig: CookieConfig,
38
49
  ): Promise<TokenPayload | null> {
39
- const cookieHeader = request.headers.get("Cookie");
40
- if (!cookieHeader) return null;
50
+ const cookieHeader = request.headers.get("Cookie");
51
+ if (!cookieHeader) return null;
41
52
 
42
- const token = extractCookie(cookieHeader, cookieConfig.name);
43
- if (!token) return null;
53
+ const token = extractCookie(cookieHeader, cookieConfig.name);
54
+ if (!token) return null;
44
55
 
45
- return verifyToken(authService, token);
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
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
53
- return Response.redirect(loginUrl, 302);
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 { TokenConfig, TokenPayload } from "./types.ts";
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
- let binary = "";
7
- for (const byte of data) {
8
- binary += String.fromCharCode(byte);
9
- }
10
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
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
- 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;
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
- async function hmacSign(secret: string, data: string): Promise<string> {
24
- const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
25
- "sign",
26
- ]);
27
- const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
28
- return base64urlEncode(new Uint8Array(signature));
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 timingSafeEqual(a: string, b: string): boolean {
32
- if (a.length !== b.length) return false;
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
- id: string;
42
- email: string;
43
- name: string;
44
- isAdmin: boolean;
45
- scopes: string[];
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: TokenConfig, user: CreateTokenInput): Promise<string> {
49
- const now = Math.floor(Date.now() / 1000);
50
- const payload: TokenPayload = {
51
- sub: user.id,
52
- email: user.email,
53
- name: user.name,
54
- admin: user.isAdmin,
55
- scopes: user.scopes,
56
- iat: now,
57
- exp: now + config.maxAgeSeconds,
58
- };
59
- const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
60
- const sig = await hmacSign(config.secret, payloadB64);
61
- return `${payloadB64}.${sig}`;
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: TokenConfig, token: string): Promise<TokenPayload | null> {
65
- const dotIndex = token.indexOf(".");
66
- if (dotIndex === -1) return null;
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
- const payloadB64 = token.substring(0, dotIndex);
69
- const sig = token.substring(dotIndex + 1);
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
- const expectedSig = await hmacSign(config.secret, payloadB64);
72
- if (!timingSafeEqual(sig, expectedSig)) return null;
89
+ if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
90
+ return null;
91
+ }
73
92
 
74
- try {
75
- const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
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
- if (typeof payload.sub !== "string" || typeof payload.exp !== "number") return null;
96
+ const entry = config.publicKeys.find((k) => k.kid === payload.kid);
97
+ if (!entry) return null;
79
98
 
80
- const now = Math.floor(Date.now() / 1000);
81
- if (now > payload.exp) return null;
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
- return payload;
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
- id: string;
3
- email: string;
4
- name: string;
5
- passwordHash: string;
6
- isAdmin: boolean;
7
- emailVerified: boolean;
8
- createdAt: string;
9
- updatedAt: string;
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
- 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>;
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
- attempts: number;
22
- firstAttempt: number;
21
+ attempts: number;
22
+ firstAttempt: number;
23
23
  }
24
24
 
25
25
  export interface RateLimitStore {
26
- get(key: string): Promise<RateLimitEntry | null>;
27
- set(key: string, entry: RateLimitEntry, ttlSeconds: number): Promise<void>;
28
- delete(key: string): Promise<void>;
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
- maxAttempts: number;
33
- windowSeconds: number;
32
+ maxAttempts: number;
33
+ windowSeconds: number;
34
34
  }
35
35
 
36
36
  export interface TokenPayload {
37
- sub: string;
38
- email: string;
39
- name: string;
40
- admin: boolean;
41
- scopes: string[];
42
- iat: number;
43
- exp: number;
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 TokenConfig {
47
- secret: string;
48
- maxAgeSeconds: number;
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
- name: string;
53
- maxAgeSeconds: number;
54
- secure: boolean;
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
- store: UserStore,
6
- input: { email: string; name: string; password?: string; isAdmin?: boolean },
5
+ store: UserStore,
6
+ input: { email: string; name: string; password?: string; isAdmin?: boolean },
7
7
  ): Promise<User> {
8
- const existing = await store.getUserByEmail(input.email);
9
- if (existing) throw new Error(`User with email ${input.email} already exists`);
8
+ const existing = await store.getUserByEmail(input.email);
9
+ if (existing) throw new Error(`User with email ${input.email} already exists`);
10
10
 
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
- };
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
- await store.saveUser(user);
25
- return user;
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
- const user = await store.getUserByEmail(email.toLowerCase());
30
- if (!user) return null;
29
+ const user = await store.getUserByEmail(email.toLowerCase());
30
+ if (!user) return null;
31
31
 
32
- const valid = await verifyPassword(password, user.passwordHash);
33
- return valid ? user : null;
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
- const user = await store.getUser(userId);
38
- if (!user) throw new Error(`User ${userId} not found`);
37
+ const user = await store.getUser(userId);
38
+ if (!user) throw new Error(`User ${userId} not found`);
39
39
 
40
- const updated: User = {
41
- ...user,
42
- passwordHash: await hashPassword(newPassword),
43
- updatedAt: new Date().toISOString(),
44
- };
45
- await store.saveUser(updated);
40
+ const updated: User = {
41
+ ...user,
42
+ passwordHash: await hashPassword(newPassword),
43
+ updatedAt: new Date().toISOString(),
44
+ };
45
+ await store.saveUser(updated);
46
46
  }