@arcote.tech/arc-auth 0.7.32 → 0.8.1
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 +3 -3
- package/src/aggregates/account.ts +53 -2
- package/src/auth-builder.ts +9 -1
- package/src/index.ts +1 -1
- package/src/routes/oauth-routes.ts +53 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-auth",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.1",
|
|
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.
|
|
14
|
-
"@arcote.tech/platform": "^0.
|
|
13
|
+
"@arcote.tech/arc": "^0.8.1",
|
|
14
|
+
"@arcote.tech/platform": "^0.8.1",
|
|
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 } =
|
|
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(
|
|
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({
|
package/src/auth-builder.ts
CHANGED
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
type ArcRawShape,
|
|
6
6
|
type ArcTokenAny,
|
|
7
7
|
} from "@arcote.tech/arc";
|
|
8
|
-
import type {
|
|
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,
|