@arcote.tech/arc-auth 0.7.31 → 0.8.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-auth",
3
3
  "type": "module",
4
- "version": "0.7.31",
4
+ "version": "0.8.0",
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.31",
14
- "@arcote.tech/platform": "^0.7.31",
13
+ "@arcote.tech/arc": "^0.8.0",
14
+ "@arcote.tech/platform": "^0.8.0",
15
15
  "react": "^18.0.0 || ^19.0.0",
16
16
  "typescript": "^5.0.0"
17
17
  },
@@ -36,6 +36,19 @@ async function hashPassword(password: string): Promise<string> {
36
36
  return await Bun.password.hash(password);
37
37
  }
38
38
 
39
+ /**
40
+ * Bramka rejestracji — opcjonalny predykat server-side wołany PRZED utworzeniem
41
+ * nowego konta (email/hasło i OAuth). Zwraca `true` = wolno założyć konto.
42
+ * `undefined` ⇒ rejestracja otwarta (kompatybilność wstecz). `permit` to dowolny
43
+ * dowód uprawnienia (np. podpisany JWT-zaproszenie) przekazany z klienta/route;
44
+ * framework go nie interpretuje — całą politykę (np. zgodność email) rozstrzyga
45
+ * aplikacja w implementacji bramki. Logowanie istniejących kont bramki NIE dotyczy.
46
+ */
47
+ export type RegistrationGate = (input: {
48
+ email: string;
49
+ permit?: string;
50
+ }) => Promise<boolean>;
51
+
39
52
  export type AccountAggregateData<
40
53
  Name extends string,
41
54
  CustomFields extends ArcRawShape,
@@ -46,6 +59,8 @@ export type AccountAggregateData<
46
59
  token: Token<TokenFields>;
47
60
  customFields: CustomFields;
48
61
  tokenFields: TokenFields;
62
+ /** Bramka rejestracji (invite-only). Brak = rejestracja otwarta. */
63
+ registrationGate?: RegistrationGate;
49
64
  };
50
65
 
51
66
  export const createAccountAggregate = <
@@ -55,7 +70,8 @@ export const createAccountAggregate = <
55
70
  >(
56
71
  data: AccountAggregateData<Name, CustomFields, TokenFields>,
57
72
  ) => {
58
- const { accountId, token, customFields, tokenFields } = data;
73
+ const { accountId, token, customFields, tokenFields, registrationGate } =
74
+ data;
59
75
 
60
76
  const name = data.name;
61
77
 
@@ -197,6 +213,9 @@ export const createAccountAggregate = <
197
213
  // egzekwowana przez `validatePassword` w handlerze (jedno źródło
198
214
  // prawdy z checklistą klienta).
199
215
  password: string().minLength(1).maxLength(72),
216
+ // Dowód uprawnienia do rejestracji (invite-only). Interpretuje go
217
+ // wyłącznie `registrationGate`; przy otwartej rejestracji ignorowany.
218
+ permit: string().optional(),
200
219
  },
201
220
  accountFields,
202
221
  ),
@@ -204,7 +223,11 @@ export const createAccountAggregate = <
204
223
  .withResult(
205
224
  { accountId, token: string() },
206
225
  {
207
- error: stringEnum("EMAIL_ALREADY_TAKEN", "WEAK_PASSWORD"),
226
+ error: stringEnum(
227
+ "EMAIL_ALREADY_TAKEN",
228
+ "WEAK_PASSWORD",
229
+ "REGISTRATION_NOT_ALLOWED",
230
+ ),
208
231
  accountId: accountId.optional(),
209
232
  token: string().optional(),
210
233
  },
@@ -228,6 +251,18 @@ export const createAccountAggregate = <
228
251
  };
229
252
  }
230
253
 
254
+ // Bramka invite-only — dotyczy tylko tworzenia NOWEGO konta
255
+ // (istniejący email zwrócił już token wyżej = logowanie).
256
+ if (
257
+ registrationGate &&
258
+ !(await registrationGate({
259
+ email: params.email,
260
+ permit: params.permit,
261
+ }))
262
+ ) {
263
+ return { error: "REGISTRATION_NOT_ALLOWED" as const };
264
+ }
265
+
231
266
  const id = accountId.generate();
232
267
  const pwHash = await hashPassword(params.password);
233
268
 
@@ -330,6 +365,10 @@ export const createAccountAggregate = <
330
365
  email: string().email(),
331
366
  provider: string(),
332
367
  providerUserId: string(),
368
+ // Dowód uprawnienia do rejestracji (invite-only), przekazywany z
369
+ // route OAuth (cookie `oauth_permit`). Ignorowany przy otwartej
370
+ // rejestracji; nie trafia do eventu (schemat strippuje).
371
+ permit: string().optional(),
333
372
  },
334
373
  customFields,
335
374
  ),
@@ -348,6 +387,18 @@ export const createAccountAggregate = <
348
387
  };
349
388
  }
350
389
 
390
+ // Bramka invite-only — jak w `register`, dotyczy tylko realnego
391
+ // założenia konta (istniejący email = logowanie, obsłużone wyżej).
392
+ if (
393
+ registrationGate &&
394
+ !(await registrationGate({
395
+ email: params.email,
396
+ permit: params.permit,
397
+ }))
398
+ ) {
399
+ return { error: "REGISTRATION_NOT_ALLOWED" as const };
400
+ }
401
+
351
402
  const id = accountId.generate();
352
403
 
353
404
  await ctx.accountRegisteredViaOAuth.emit({
@@ -5,7 +5,10 @@ import {
5
5
  type ArcRawShape,
6
6
  type ArcTokenAny,
7
7
  } from "@arcote.tech/arc";
8
- import type { AccountAggregate } from "./aggregates/account";
8
+ import type {
9
+ AccountAggregate,
10
+ RegistrationGate,
11
+ } from "./aggregates/account";
9
12
  import { createAccountAggregate } from "./aggregates/account";
10
13
  import type { OAuthIdentityAggregate } from "./aggregates/oauth-identity";
11
14
  import { createOAuthIdentityAggregate } from "./aggregates/oauth-identity";
@@ -104,6 +107,10 @@ export function auth<
104
107
  /** Schemat pól wnoszonych do payloadu JWT (np. `{ role: stringEnum(...) }`).
105
108
  * Mergowany do schematu agregatu (są to też pola konta) i wprost do tokena. */
106
109
  tokenFields?: TokenFields;
110
+ /** Bramka rejestracji (invite-only). Predykat server-side wołany przed
111
+ * utworzeniem NOWEGO konta w `register`/`registerViaOAuth`. Brak = rejestracja
112
+ * otwarta. Politykę (np. weryfikacja permitu, zgodność email) rozstrzyga app. */
113
+ registrationGate?: RegistrationGate;
107
114
  }) {
108
115
  const accountId = createAccountId({ name: config.name });
109
116
  const tokenFields = (config.tokenFields ?? {}) as TokenFields;
@@ -120,6 +127,7 @@ export function auth<
120
127
  token: t,
121
128
  customFields: config.customFields,
122
129
  tokenFields,
130
+ registrationGate: config.registrationGate,
123
131
  });
124
132
 
125
133
  return new AuthBuilder(
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ export { auth, AuthBuilder } from "./auth-builder";
3
3
 
4
4
  // Re-exports
5
5
  export { createAccountAggregate } from "./aggregates/account";
6
- export type { AccountAggregate } from "./aggregates/account";
6
+ export type { AccountAggregate, RegistrationGate } from "./aggregates/account";
7
7
  export { createOAuthIdentityAggregate } from "./aggregates/oauth-identity";
8
8
  export type { OAuthIdentityAggregate } from "./aggregates/oauth-identity";
9
9
  export { createAccountId } from "./ids/account";
@@ -68,6 +68,10 @@ export function createOAuthRoutes(data: OAuthRoutesData) {
68
68
 
69
69
  const state = generateState();
70
70
  const redirectTo = url.searchParams.get("redirectTo") || "/";
71
+ // Dowód uprawnienia do rejestracji (invite-only) — przenoszony przez
72
+ // roundtrip OAuth w HttpOnly cookie (nie w `state`, bo permit bywa długim
73
+ // JWT). Callback odczyta go i poda do `registerViaOAuth`.
74
+ const permit = url.searchParams.get("inviteToken");
71
75
 
72
76
  const redirectUri = `${baseUrl}/route/auth/oauth/${providerName}/callback`;
73
77
  const scopes = config.scopes || defaultScopes[providerName] || [];
@@ -86,6 +90,12 @@ export function createOAuthRoutes(data: OAuthRoutesData) {
86
90
  "Set-Cookie",
87
91
  `oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`,
88
92
  );
93
+ if (permit) {
94
+ headers.append(
95
+ "Set-Cookie",
96
+ `oauth_permit=${encodeURIComponent(permit)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`,
97
+ );
98
+ }
89
99
  return new Response(response.body, {
90
100
  status: 302,
91
101
  headers,
@@ -121,6 +131,12 @@ export function createOAuthRoutes(data: OAuthRoutesData) {
121
131
 
122
132
  const cookieHeader = req.headers.get("cookie") || "";
123
133
  const stateCookie = parseCookie(cookieHeader, "oauth_state");
134
+ // Permit rejestracji (invite-only) przeniesiony z `oauthStart`. Może być
135
+ // null (zwykłe logowanie / brak zaproszenia) — bramka decyduje.
136
+ const permitCookie = parseCookie(cookieHeader, "oauth_permit");
137
+ const permit = permitCookie
138
+ ? decodeURIComponent(permitCookie)
139
+ : undefined;
124
140
 
125
141
  const [stateValue, redirectTo] = stateParam.split(":");
126
142
  if (!stateCookie || stateCookie !== stateValue) {
@@ -223,11 +239,13 @@ export function createOAuthRoutes(data: OAuthRoutesData) {
223
239
  });
224
240
  } else {
225
241
  // Try to register — registerViaOAuth uses internal $query (bypasses protectBy)
226
- // and returns EMAIL_ALREADY_TAKEN with accountId if account exists
242
+ // and returns EMAIL_ALREADY_TAKEN with accountId if account exists.
243
+ // `permit` niesie dowód zaproszenia dla bramki invite-only (jeśli włączona).
227
244
  const registerResult = await accounts.registerViaOAuth({
228
245
  email: profile.email,
229
246
  provider: providerName,
230
247
  providerUserId: profile.providerUserId,
248
+ ...(permit ? { permit } : {}),
231
249
  ...(profile.displayName
232
250
  ? { displayName: profile.displayName }
233
251
  : {}),
@@ -236,6 +254,36 @@ export function createOAuthRoutes(data: OAuthRoutesData) {
236
254
  : {}),
237
255
  });
238
256
 
257
+ // Bramka invite-only odrzuciła założenie konta (brak/niewłaściwy permit
258
+ // lub niezgodny email) — wróć na stronę logowania z błędem, wyczyść cookies.
259
+ // UWAGA: EMAIL_ALREADY_TAKEN NIE jest błędem — to istniejące konto, do
260
+ // którego dopinamy nowego providera (login), więc niesie accountId+token.
261
+ if (
262
+ "error" in registerResult &&
263
+ registerResult.error === "REGISTRATION_NOT_ALLOWED"
264
+ ) {
265
+ const decodedRedirect = decodeURIComponent(redirectTo || "/");
266
+ const errRedirect = Response.redirect(
267
+ `${baseUrl}${decodedRedirect}?auth_error=${encodeURIComponent(
268
+ registerResult.error,
269
+ )}`,
270
+ 302,
271
+ );
272
+ const errHeaders = new Headers(errRedirect.headers);
273
+ errHeaders.append(
274
+ "Set-Cookie",
275
+ `oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
276
+ );
277
+ errHeaders.append(
278
+ "Set-Cookie",
279
+ `oauth_permit=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
280
+ );
281
+ return new Response(errRedirect.body, {
282
+ status: 302,
283
+ headers: errHeaders,
284
+ });
285
+ }
286
+
239
287
  // Both branches return accountId + token (JWT with tokenFields)
240
288
  const accountId = registerResult.accountId;
241
289
  jwtToken = registerResult.token;
@@ -265,6 +313,10 @@ export function createOAuthRoutes(data: OAuthRoutesData) {
265
313
  "Set-Cookie",
266
314
  `oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
267
315
  );
316
+ headers.append(
317
+ "Set-Cookie",
318
+ `oauth_permit=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
319
+ );
268
320
  return new Response(response.body, {
269
321
  status: 302,
270
322
  headers,