@arcote.tech/arc-auth 0.7.23 → 0.7.25

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-auth",
3
3
  "type": "module",
4
- "version": "0.7.23",
4
+ "version": "0.7.25",
5
5
  "private": false,
6
6
  "description": "Reusable authentication module for Arc framework — aggregate-based auth with factory pattern",
7
7
  "main": "./src/index.ts",
@@ -10,8 +10,8 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc": "^0.7.23",
14
- "@arcote.tech/platform": "^0.7.23",
13
+ "@arcote.tech/arc": "^0.7.25",
14
+ "@arcote.tech/platform": "^0.7.25",
15
15
  "react": "^18.0.0 || ^19.0.0",
16
16
  "typescript": "^5.0.0"
17
17
  },
@@ -1,15 +1,26 @@
1
1
  /// <reference path="../arc.d.ts" />
2
2
  import {
3
3
  aggregate,
4
+ array,
4
5
  boolean,
5
6
  date,
6
7
  mergeUnsafe,
8
+ number,
7
9
  string,
8
10
  stringEnum,
9
11
  type ArcRawShape,
10
12
  } from "@arcote.tech/arc";
11
13
  import type { AccountId } from "../ids/account";
12
14
  import type { Token } from "../tokens/token";
15
+ import { validatePassword } from "../utils/password-policy";
16
+
17
+ /**
18
+ * Blokada konta przy brute-force — standard bezpieczeństwa frameworku. Po
19
+ * `MAX_FAILED_ATTEMPTS` nieudanych logowaniach konto jest czasowo zablokowane
20
+ * na `LOCK_MS`; udane logowanie resetuje licznik.
21
+ */
22
+ const MAX_FAILED_ATTEMPTS = 5;
23
+ const LOCK_MS = 15 * 60_000; // 15 minut
13
24
 
14
25
  /**
15
26
  * Password helpers — server-only, tree-shaken on client.
@@ -71,6 +82,9 @@ export const createAccountAggregate = <
71
82
  authMethod: string(),
72
83
  registeredAt: date(),
73
84
  lastSignedInAt: date().optional(),
85
+ // Brute-force lockout — licznik nieudanych logowań + czasowa blokada.
86
+ failedLoginCount: number(),
87
+ lockedUntil: date().optional(),
74
88
  },
75
89
  customFields,
76
90
  ),
@@ -97,6 +111,7 @@ export const createAccountAggregate = <
97
111
  authMethod: "email",
98
112
  registeredAt: event.createdAt,
99
113
  lastSignedInAt: undefined,
114
+ failedLoginCount: 0,
100
115
  ...event.payload,
101
116
  });
102
117
  },
@@ -120,6 +135,7 @@ export const createAccountAggregate = <
120
135
  authMethod: "oauth",
121
136
  registeredAt: event.createdAt,
122
137
  lastSignedInAt: event.createdAt,
138
+ failedLoginCount: 0,
123
139
  ...event.payload,
124
140
  });
125
141
  },
@@ -129,8 +145,26 @@ export const createAccountAggregate = <
129
145
  "signedIn",
130
146
  { accountId, email: string().email() },
131
147
  async (ctx, event) => {
148
+ // Udane logowanie resetuje licznik blokady.
132
149
  await ctx.modify(event.payload.accountId, {
133
150
  lastSignedInAt: event.createdAt,
151
+ failedLoginCount: 0,
152
+ lockedUntil: undefined,
153
+ });
154
+ },
155
+ )
156
+
157
+ .publicEvent(
158
+ "loginAttemptFailed",
159
+ {
160
+ accountId,
161
+ failedLoginCount: number(),
162
+ lockedUntil: date().optional(),
163
+ },
164
+ async (ctx, event) => {
165
+ await ctx.modify(event.payload.accountId, {
166
+ failedLoginCount: event.payload.failedLoginCount,
167
+ lockedUntil: event.payload.lockedUntil,
134
168
  });
135
169
  },
136
170
  )
@@ -149,7 +183,10 @@ export const createAccountAggregate = <
149
183
  mergeUnsafe(
150
184
  {
151
185
  email: string().email(),
152
- password: string().minLength(6).maxLength(32),
186
+ // Twardy limit bcrypt 72 bajty; właściwa polityka (≥8 + złożoność)
187
+ // egzekwowana przez `validatePassword` w handlerze (jedno źródło
188
+ // prawdy z checklistą klienta).
189
+ password: string().minLength(1).maxLength(72),
153
190
  },
154
191
  customFields,
155
192
  ),
@@ -157,14 +194,19 @@ export const createAccountAggregate = <
157
194
  .withResult(
158
195
  { accountId, token: string() },
159
196
  {
160
- error: stringEnum("EMAIL_ALREADY_TAKEN"),
161
- accountId,
162
- token: string(),
197
+ error: stringEnum("EMAIL_ALREADY_TAKEN", "WEAK_PASSWORD"),
198
+ accountId: accountId.optional(),
199
+ token: string().optional(),
163
200
  },
164
201
  )
165
202
  .handle(
166
203
  ONLY_SERVER &&
167
204
  (async (ctx, params) => {
205
+ const policy = validatePassword(params.password);
206
+ if (!policy.ok) {
207
+ return { error: "WEAK_PASSWORD" as const };
208
+ }
209
+
168
210
  const existing = await ctx.$query.findOne({
169
211
  email: params.email,
170
212
  });
@@ -199,8 +241,16 @@ export const createAccountAggregate = <
199
241
  fn
200
242
  .withParams({
201
243
  email: string().email(),
202
- password: string().minLength(6).maxLength(32),
244
+ password: string().minLength(1).maxLength(72),
203
245
  })
246
+ .withResult(
247
+ { token: string() },
248
+ {
249
+ error: stringEnum("INVALID_EMAIL_OR_PASSWORD", "ACCOUNT_LOCKED"),
250
+ /** ISO timestamp do kiedy konto zablokowane (dla ACCOUNT_LOCKED). */
251
+ lockedUntil: string().optional(),
252
+ },
253
+ )
204
254
  .handle(
205
255
  ONLY_SERVER &&
206
256
  (async (ctx, params) => {
@@ -212,11 +262,41 @@ export const createAccountAggregate = <
212
262
  return { error: "INVALID_EMAIL_OR_PASSWORD" as const };
213
263
  }
214
264
 
265
+ // Blokada czasowa — odrzuć przed weryfikacją hasła.
266
+ const now = Date.now();
267
+ if (
268
+ account.lockedUntil &&
269
+ new Date(account.lockedUntil).getTime() > now
270
+ ) {
271
+ return {
272
+ error: "ACCOUNT_LOCKED" as const,
273
+ lockedUntil: new Date(account.lockedUntil).toISOString(),
274
+ };
275
+ }
276
+
215
277
  const isValid = await verifyPassword(
216
278
  params.password,
217
279
  account.passwordHash!,
218
280
  );
219
281
  if (!isValid) {
282
+ const attempts = (account.failedLoginCount ?? 0) + 1;
283
+ const shouldLock = attempts >= MAX_FAILED_ATTEMPTS;
284
+ const lockedUntil = shouldLock
285
+ ? new Date(now + LOCK_MS)
286
+ : undefined;
287
+ await ctx.loginAttemptFailed.emit({
288
+ accountId: account._id,
289
+ // Po zablokowaniu zerujemy licznik — po wygaśnięciu blokady
290
+ // użytkownik dostaje świeżą pulę prób.
291
+ failedLoginCount: shouldLock ? 0 : attempts,
292
+ lockedUntil,
293
+ });
294
+ if (shouldLock) {
295
+ return {
296
+ error: "ACCOUNT_LOCKED" as const,
297
+ lockedUntil: lockedUntil!.toISOString(),
298
+ };
299
+ }
220
300
  return { error: "INVALID_EMAIL_OR_PASSWORD" as const };
221
301
  }
222
302
 
@@ -12,6 +12,7 @@ import { createAccountId } from "./ids/account";
12
12
  import { createOAuthIdentityId } from "./ids/oauth-identity";
13
13
  import { createToken } from "./tokens/token";
14
14
  import { createOAuthRoutes } from "./routes/oauth-routes";
15
+ import { createLogoutRoute } from "./routes/logout-route";
15
16
  import type { AccountId } from "./ids/account";
16
17
  import type { OAuthProvidersConfig } from "./providers/types";
17
18
  import type { Token } from "./tokens/token";
@@ -86,8 +87,10 @@ export class AuthBuilder<
86
87
  }
87
88
 
88
89
  build() {
90
+ // Logout route zawsze obecny (niezależnie od OAuth) — czyści cookie sesji.
91
+ const logout = createLogoutRoute();
89
92
  return {
90
- context: context(this.elements),
93
+ context: context([...this.elements, logout]),
91
94
  accountId: this.accountId,
92
95
  token: this.token,
93
96
  Account: this.Account,
package/src/index.ts CHANGED
@@ -13,6 +13,10 @@ export type { OAuthIdentityId } from "./ids/oauth-identity";
13
13
  export { createToken } from "./tokens/token";
14
14
  export type { Token } from "./tokens/token";
15
15
 
16
+ // Polityka haseł — jedno źródło prawdy dla checklisty klienta i walidacji serwerowej
17
+ export { validatePassword, passwordRequirements } from "./utils/password-policy";
18
+ export type { PasswordRule, PasswordValidation } from "./utils/password-policy";
19
+
16
20
  // Provider types
17
21
  export type {
18
22
  OAuthProviderConfig,
@@ -13,7 +13,7 @@ export interface AuthContextType {
13
13
  /** Resolves when the module loader has synced chunks for the new token —
14
14
  * await before navigating to a token-gated route. */
15
15
  setAccessToken: (token: string) => Promise<void>;
16
- logout: () => void;
16
+ logout: () => Promise<void>;
17
17
  }
18
18
 
19
19
  const AuthContext = createContext<AuthContextType | null>(null);
@@ -60,7 +60,19 @@ export function AuthProvider({ children, onTokenChange, initialToken }: AuthProv
60
60
  await onTokenChange?.(newToken);
61
61
  };
62
62
 
63
- const logout = () => {
63
+ const logout = async (): Promise<void> => {
64
+ // Najpierw serwerowo wygaś cookie `arc_token` (HttpOnly — JS go nie ruszy);
65
+ // bez tego serwer używałby go jako fallback (m.in. upgrade /ws) i sesja
66
+ // trwałaby po wylogowaniu. Best-effort: błąd sieci nie blokuje czyszczenia
67
+ // stanu klienta, żeby UI był wylogowany.
68
+ try {
69
+ await fetch("/route/auth/logout", {
70
+ method: "POST",
71
+ credentials: "include",
72
+ });
73
+ } catch {
74
+ /* offline / serwer niedostępny — i tak czyścimy stan lokalny */
75
+ }
64
76
  setToken(null);
65
77
  onTokenChange?.(null); // scope.setToken(null) → adapter clears this scope
66
78
  };
@@ -0,0 +1,28 @@
1
+ import { route } from "@arcote.tech/arc";
2
+
3
+ /**
4
+ * Publiczny route wylogowania — wygasza serwerowe cookie sesji `arc_token`
5
+ * (ustawiane przez OAuth callback, Max-Age 7 dni). Symetryczny do logowania:
6
+ * skoro OAuth ustawia cookie, logout musi je czyścić serwerowo — JS nie ruszy
7
+ * HttpOnly cookie, a serwer używa go jako fallback dla każdego requestu (w tym
8
+ * upgrade'u `/ws`), więc bez tego po wylogowaniu sesja trwała dalej.
9
+ *
10
+ * Publiczny (logout nie może wymagać auth). Czyszczenie nieistniejącego cookie
11
+ * (login hasłem) jest no-opem. Atrybuty muszą pokrywać się z ustawieniem
12
+ * (Path=/) żeby przeglądarka dopasowała cookie do usunięcia.
13
+ */
14
+ export function createLogoutRoute() {
15
+ return route("authLogout")
16
+ .path("/auth/logout")
17
+ .public()
18
+ .handle({
19
+ POST: async () => {
20
+ const headers = new Headers();
21
+ headers.append(
22
+ "Set-Cookie",
23
+ "arc_token=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
24
+ );
25
+ return new Response(null, { status: 204, headers });
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { validatePassword } from "./password-policy";
3
+
4
+ describe("validatePassword", () => {
5
+ it("akceptuje hasło spełniające wszystkie reguły (cyfra)", () => {
6
+ expect(validatePassword("Haslo123")).toEqual({ ok: true, failed: [] });
7
+ });
8
+
9
+ it("akceptuje wariant ze znakiem specjalnym zamiast cyfry", () => {
10
+ expect(validatePassword("Haslo!ab")).toEqual({ ok: true, failed: [] });
11
+ });
12
+
13
+ it("za krótkie → failed length", () => {
14
+ expect(validatePassword("Ab1")).toEqual({ ok: false, failed: ["length"] });
15
+ });
16
+
17
+ it("brak wielkiej litery", () => {
18
+ const r = validatePassword("haslo123");
19
+ expect(r.ok).toBe(false);
20
+ expect(r.failed).toContain("uppercase");
21
+ });
22
+
23
+ it("brak małej litery", () => {
24
+ const r = validatePassword("HASLO123");
25
+ expect(r.ok).toBe(false);
26
+ expect(r.failed).toContain("lowercase");
27
+ });
28
+
29
+ it("brak cyfry i znaku specjalnego", () => {
30
+ const r = validatePassword("HasloAbcd");
31
+ expect(r.ok).toBe(false);
32
+ expect(r.failed).toContain("digitOrSymbol");
33
+ });
34
+
35
+ it("puste hasło → wszystkie reguły poza znakiem", () => {
36
+ const r = validatePassword("");
37
+ expect(r.ok).toBe(false);
38
+ expect(r.failed).toEqual(["length", "lowercase", "uppercase", "digitOrSymbol"]);
39
+ });
40
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Polityka haseł — jedno źródło prawdy dla walidacji serwerowej (`register`) i
3
+ * checklisty po stronie klienta. Reguły są language-neutralne (`key` + `test`);
4
+ * etykiety (i18n) dostarcza konsument, mapując po `key`.
5
+ *
6
+ * Standard: ≥8 znaków, mała litera, wielka litera, oraz cyfra LUB znak specjalny.
7
+ */
8
+ export interface PasswordRule {
9
+ /** Stabilny klucz reguły — konsument mapuje go na etykietę (i18n). */
10
+ key: "length" | "lowercase" | "uppercase" | "digitOrSymbol";
11
+ test: (password: string) => boolean;
12
+ }
13
+
14
+ export const passwordRequirements: readonly PasswordRule[] = [
15
+ { key: "length", test: (p) => p.length >= 8 },
16
+ { key: "lowercase", test: (p) => /[a-z]/.test(p) },
17
+ { key: "uppercase", test: (p) => /[A-Z]/.test(p) },
18
+ { key: "digitOrSymbol", test: (p) => /[0-9]/.test(p) || /[^A-Za-z0-9]/.test(p) },
19
+ ];
20
+
21
+ export interface PasswordValidation {
22
+ ok: boolean;
23
+ /** Klucze reguł, które NIE są spełnione. */
24
+ failed: PasswordRule["key"][];
25
+ }
26
+
27
+ /** Waliduje hasło względem polityki. `ok` gdy wszystkie reguły spełnione. */
28
+ export function validatePassword(password: string): PasswordValidation {
29
+ const failed = passwordRequirements
30
+ .filter((r) => !r.test(password))
31
+ .map((r) => r.key);
32
+ return { ok: failed.length === 0, failed };
33
+ }