@arcote.tech/arc-auth 0.8.0 → 0.8.2

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.8.0",
4
+ "version": "0.8.2",
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.8.0",
14
- "@arcote.tech/platform": "^0.8.0",
13
+ "@arcote.tech/arc": "^0.8.2",
14
+ "@arcote.tech/platform": "^0.8.2",
15
15
  "react": "^18.0.0 || ^19.0.0",
16
16
  "typescript": "^5.0.0"
17
17
  },
package/src/index.ts CHANGED
@@ -25,7 +25,14 @@ export type {
25
25
  } from "./providers/types";
26
26
 
27
27
  // React components
28
- export { AuthProvider, ProtectedRoute, SignInPage, useAuth } from "./react";
28
+ export {
29
+ AuthProvider,
30
+ ProtectedRoute,
31
+ SignInPage,
32
+ useAuth,
33
+ decodeTokenName,
34
+ isAppToken,
35
+ } from "./react";
29
36
  export type {
30
37
  AuthContextType,
31
38
  AuthProviderProps,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Testy granicy adopcji tokenu przez AuthProvider.
3
+ *
4
+ * Regresja bezpieczeństwa: token o innym przeznaczeniu (np. publiczny token
5
+ * lead/intake, który dzieli parametr URL `?token=`) NIE może zostać przyjęty
6
+ * jako token sesji aplikacji. Jedynym rozróżnieniem jest `tokenName` w payloadzie
7
+ * JWT — `isAppToken` musi go egzekwować, gdy podano oczekiwaną nazwę.
8
+ *
9
+ * Uruchomienie:
10
+ * bun test packages/fragments/auth/src/react/auth-provider.test.ts
11
+ */
12
+
13
+ import { describe, expect, it } from "bun:test";
14
+ import { string, token } from "@arcote.tech/arc";
15
+ import { decodeTokenName, isAppToken } from "./auth-provider";
16
+
17
+ // Ten sam sekret dla obu tokenów — odwzorowuje produkcyjny fallback
18
+ // intakeSecret → USER_TOKEN_SECRET. Granica NIE może zależeć od sekretu.
19
+ const SECRET = "test-secret";
20
+
21
+ const appToken = token("userAccount", {
22
+ accountId: string(),
23
+ role: string(),
24
+ }).secret(SECRET);
25
+
26
+ const intakeToken = token("leadIntake", {
27
+ leadId: string(),
28
+ }).secret(SECRET);
29
+
30
+ describe("decodeTokenName", () => {
31
+ it("czyta tokenName z payloadu JWT", async () => {
32
+ const jwt = await appToken.generateJWT({ accountId: "a1", role: "user" });
33
+ expect(decodeTokenName(jwt)).toBe("userAccount");
34
+
35
+ const lead = await intakeToken.generateJWT({ leadId: "lead-1" });
36
+ expect(decodeTokenName(lead)).toBe("leadIntake");
37
+ });
38
+
39
+ it("zwraca null dla wejścia niebędącego JWT", () => {
40
+ expect(decodeTokenName("")).toBeNull();
41
+ expect(decodeTokenName("not-a-jwt")).toBeNull();
42
+ expect(decodeTokenName("a.b")).toBeNull();
43
+ expect(decodeTokenName("a.%%%.c")).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe("isAppToken", () => {
48
+ it("token lead/intake NIE jest tokenem aplikacji (mimo wspólnego sekretu)", async () => {
49
+ const lead = await intakeToken.generateJWT({ leadId: "lead-1" });
50
+ expect(isAppToken(lead, "userAccount")).toBe(false);
51
+ });
52
+
53
+ it("token userAccount jest tokenem aplikacji", async () => {
54
+ const jwt = await appToken.generateJWT({ accountId: "a1", role: "user" });
55
+ expect(isAppToken(jwt, "userAccount")).toBe(true);
56
+ });
57
+
58
+ it("pusty/nullowy token nigdy nie jest tokenem aplikacji", () => {
59
+ expect(isAppToken(null, "userAccount")).toBe(false);
60
+ expect(isAppToken(undefined, "userAccount")).toBe(false);
61
+ expect(isAppToken("", "userAccount")).toBe(false);
62
+ });
63
+
64
+ it("bez oczekiwanej nazwy zachowuje zgodność wsteczną (każdy niepusty token przechodzi)", async () => {
65
+ const lead = await intakeToken.generateJWT({ leadId: "lead-1" });
66
+ expect(isAppToken(lead)).toBe(true);
67
+ expect(isAppToken(null)).toBe(false);
68
+ });
69
+ });
@@ -18,6 +18,41 @@ export interface AuthContextType {
18
18
 
19
19
  const AuthContext = createContext<AuthContextType | null>(null);
20
20
 
21
+ /**
22
+ * Odczytuje `tokenName` z payloadu JWT BEZ weryfikacji podpisu — wyłącznie do
23
+ * decyzji „czy ten token w ogóle należy do tej aplikacji?" zanim zostanie
24
+ * przyjęty jako sesja. Serwer i tak weryfikuje podpis przy każdym żądaniu.
25
+ * Zwraca `null` dla niepoprawnego wejścia.
26
+ */
27
+ export function decodeTokenName(jwt: string): string | null {
28
+ try {
29
+ const seg = jwt.split(".")[1];
30
+ if (!seg) return null;
31
+ const json = JSON.parse(
32
+ atob(seg.replace(/-/g, "+").replace(/_/g, "/")),
33
+ );
34
+ return typeof json?.tokenName === "string" ? json.tokenName : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Czy aplikacja powinna PRZYJĄĆ `jwt` jako swój token sesji. Gdy podano
42
+ * `expectedTokenName`, `tokenName` tokenu MUSI się zgadzać — to blokuje
43
+ * promocję tokenu o innym przeznaczeniu (np. publicznego tokenu lead/intake,
44
+ * który dzieli parametr URL `?token=`) do sesji aplikacji. Bez oczekiwanej
45
+ * nazwy przyjmowany jest każdy niepusty token (zgodność wsteczna).
46
+ */
47
+ export function isAppToken(
48
+ jwt: string | null | undefined,
49
+ expectedTokenName?: string,
50
+ ): jwt is string {
51
+ if (!jwt) return false;
52
+ if (!expectedTokenName) return true;
53
+ return decodeTokenName(jwt) === expectedTokenName;
54
+ }
55
+
21
56
  export interface AuthProviderProps {
22
57
  children: ReactNode;
23
58
  /** Callback to set token on the arc model (scope.setToken from reactModel).
@@ -26,17 +61,39 @@ export interface AuthProviderProps {
26
61
  onTokenChange?: (token: string | null) => void | Promise<void>;
27
62
  /** Initial token from scope (loaded by adapter.loadPersisted on model init) */
28
63
  initialToken?: string | null;
64
+ /** Oczekiwana `tokenName` tokenu APLIKACJI (np. "userAccount"). Gdy podana,
65
+ * AuthProvider przyjmie token z URL/scope TYLKO jeśli jego `tokenName` się
66
+ * zgadza — token o innym przeznaczeniu (lead/intake, permit) NIE zaloguje do
67
+ * aplikacji, nawet jeśli trafi do parametru `?token=`. Niepasujący `?token=`
68
+ * jest ignorowany (nie usuwamy go z URL — mogą go czytać inne przepływy,
69
+ * np. publiczny kreator prospekta). */
70
+ expectedTokenName?: string;
29
71
  }
30
72
 
31
- export function AuthProvider({ children, onTokenChange, initialToken }: AuthProviderProps) {
32
- const [token, setToken] = useState<string | null>(initialToken ?? null);
73
+ export function AuthProvider({
74
+ children,
75
+ onTokenChange,
76
+ initialToken,
77
+ expectedTokenName,
78
+ }: AuthProviderProps) {
79
+ // Nie przyjmuj przekazanego initialToken, jeśli nie jest tokenem TEJ aplikacji
80
+ // (samonaprawa zatrutej sesji: gdy w scope usera osiadł token o innym
81
+ // przeznaczeniu). `isAppToken` bez expectedTokenName jest przezroczyste.
82
+ const [token, setToken] = useState<string | null>(
83
+ isAppToken(initialToken, expectedTokenName) ? initialToken! : null,
84
+ );
33
85
  const [isLoading, setIsLoading] = useState(true);
34
86
 
35
87
  useEffect(() => {
36
88
  // Check for token in URL (OAuth callback redirect)
37
89
  const params = new URLSearchParams(window.location.search);
38
90
  const urlToken = params.get("token");
39
- if (urlToken) {
91
+ // Przyjmij z URL TYLKO token aplikacji. Token o innym przeznaczeniu (np.
92
+ // publiczny lead/intake, który dzieli parametr `?token=`) zostaje
93
+ // zignorowany i NIE jest usuwany z URL — publiczne przepływy czytają go
94
+ // dalej. Bez tego dowolny podpisany token promowałby gościa do sesji
95
+ // aplikacji (`isAuthenticated` = true) mimo braku uprawnień.
96
+ if (urlToken && isAppToken(urlToken, expectedTokenName)) {
40
97
  // Gdy jest warstwa persystencji (scope.setToken → localStorage), utrwal
41
98
  // token i przeładuj CZYSTY URL. Hot-swap tokenu w miejscu ścigał się z
42
99
  // asynchronicznym syncModules/setActiveModules — `useQuery` (np. getMe)
@@ -62,8 +119,8 @@ export function AuthProvider({ children, onTokenChange, initialToken }: AuthProv
62
119
  }
63
120
 
64
121
  // Token loaded by adapter.loadPersisted() on model init → initialToken
65
- if (initialToken) {
66
- onTokenChange?.(initialToken);
122
+ if (isAppToken(initialToken, expectedTokenName)) {
123
+ onTokenChange?.(initialToken!);
67
124
  }
68
125
  setIsLoading(false);
69
126
  }, []);
@@ -1,4 +1,4 @@
1
- export { AuthProvider, useAuth } from "./auth-provider";
1
+ export { AuthProvider, useAuth, decodeTokenName, isAppToken } from "./auth-provider";
2
2
  export type { AuthContextType, AuthProviderProps } from "./auth-provider";
3
3
 
4
4
  export { ProtectedRoute } from "./protected-route";