@arcote.tech/arc-auth 0.8.1 → 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 +3 -3
- package/src/index.ts +8 -1
- package/src/react/auth-provider.test.ts +69 -0
- package/src/react/auth-provider.tsx +62 -5
- package/src/react/index.ts +1 -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.8.
|
|
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.
|
|
14
|
-
"@arcote.tech/platform": "^0.8.
|
|
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 {
|
|
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({
|
|
32
|
-
|
|
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
|
-
|
|
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
|
}, []);
|
package/src/react/index.ts
CHANGED
|
@@ -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";
|