@arcote.tech/arc-auth 0.4.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.
@@ -0,0 +1,82 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useState,
6
+ type ReactNode,
7
+ } from "react";
8
+
9
+ export interface AuthContextType {
10
+ token: string | null;
11
+ isAuthenticated: boolean;
12
+ isLoading: boolean;
13
+ setAccessToken: (token: string) => void;
14
+ logout: () => void;
15
+ }
16
+
17
+ const AuthContext = createContext<AuthContextType | null>(null);
18
+
19
+ export interface AuthProviderProps {
20
+ children: ReactNode;
21
+ /** Callback to set token on the arc model (setAuthToken from reactModel) */
22
+ onTokenChange?: (token: string | null) => void;
23
+ /** Initial token from scope (loaded by adapter.loadPersisted on model init) */
24
+ initialToken?: string | null;
25
+ }
26
+
27
+ export function AuthProvider({ children, onTokenChange, initialToken }: AuthProviderProps) {
28
+ const [token, setToken] = useState<string | null>(initialToken ?? null);
29
+ const [isLoading, setIsLoading] = useState(true);
30
+
31
+ useEffect(() => {
32
+ // Check for token in URL (OAuth callback redirect)
33
+ const params = new URLSearchParams(window.location.search);
34
+ const urlToken = params.get("token");
35
+ if (urlToken) {
36
+ setToken(urlToken);
37
+ onTokenChange?.(urlToken); // scope.setToken → adapter auto-persists
38
+ // Clean token from URL
39
+ const url = new URL(window.location.href);
40
+ url.searchParams.delete("token");
41
+ window.history.replaceState({}, "", url.toString());
42
+ setIsLoading(false);
43
+ return;
44
+ }
45
+
46
+ // Token loaded by adapter.loadPersisted() on model init → initialToken
47
+ if (initialToken) {
48
+ onTokenChange?.(initialToken);
49
+ }
50
+ setIsLoading(false);
51
+ }, []);
52
+
53
+ const setAccessToken = (newToken: string) => {
54
+ setToken(newToken);
55
+ onTokenChange?.(newToken); // scope.setToken → adapter auto-persists
56
+ };
57
+
58
+ const logout = () => {
59
+ setToken(null);
60
+ onTokenChange?.(null); // scope.setToken(null) → adapter clears this scope
61
+ };
62
+
63
+ return (
64
+ <AuthContext.Provider
65
+ value={{
66
+ token,
67
+ isAuthenticated: !!token,
68
+ isLoading,
69
+ setAccessToken,
70
+ logout,
71
+ }}
72
+ >
73
+ {children}
74
+ </AuthContext.Provider>
75
+ );
76
+ }
77
+
78
+ export function useAuth() {
79
+ const ctx = useContext(AuthContext);
80
+ if (!ctx) throw new Error("useAuth must be used within AuthProvider");
81
+ return ctx;
82
+ }
@@ -0,0 +1,11 @@
1
+ export { AuthProvider, useAuth } from "./auth-provider";
2
+ export type { AuthContextType, AuthProviderProps } from "./auth-provider";
3
+
4
+ export { ProtectedRoute } from "./protected-route";
5
+ export type { ProtectedRouteProps } from "./protected-route";
6
+
7
+ export { SignInPage } from "./sign-in-page";
8
+ export type { SignInPageProps, SignInRenderProps } from "./sign-in-page";
9
+
10
+ export { AuthPage } from "./auth-page";
11
+ export type { AuthPageProps, AuthPageRenderProps } from "./auth-page";
@@ -0,0 +1,60 @@
1
+ import { useEffect, type ReactNode } from "react";
2
+ import { useAuth } from "./auth-provider";
3
+
4
+ export interface ProtectedRouteProps {
5
+ children: ReactNode;
6
+ /** Current path — used for redirectTo param */
7
+ currentPath: string;
8
+ /** Navigate function — called to redirect to sign-in */
9
+ navigate: (path: string) => void;
10
+ /** Sign-in path, defaults to "/sign-in" */
11
+ signInPath?: string;
12
+ /** Loading fallback */
13
+ loadingFallback?: ReactNode;
14
+ }
15
+
16
+ export function ProtectedRoute({
17
+ children,
18
+ currentPath,
19
+ navigate,
20
+ signInPath = "/sign-in",
21
+ loadingFallback,
22
+ }: ProtectedRouteProps) {
23
+ const { isAuthenticated, isLoading } = useAuth();
24
+
25
+ useEffect(() => {
26
+ if (!isLoading && !isAuthenticated && !currentPath.startsWith(signInPath)) {
27
+ navigate(`${signInPath}?redirectTo=${encodeURIComponent(currentPath)}`);
28
+ }
29
+ }, [isLoading, isAuthenticated, currentPath, navigate, signInPath]);
30
+
31
+ if (isLoading) {
32
+ return (
33
+ loadingFallback ?? (
34
+ <div
35
+ style={{
36
+ display: "flex",
37
+ minHeight: "100vh",
38
+ alignItems: "center",
39
+ justifyContent: "center",
40
+ }}
41
+ >
42
+ <div
43
+ style={{
44
+ width: 32,
45
+ height: 32,
46
+ border: "2px solid #ccc",
47
+ borderTopColor: "transparent",
48
+ borderRadius: "50%",
49
+ animation: "spin 1s linear infinite",
50
+ }}
51
+ />
52
+ </div>
53
+ )
54
+ );
55
+ }
56
+
57
+ if (!isAuthenticated) return null;
58
+
59
+ return <>{children}</>;
60
+ }
@@ -0,0 +1,161 @@
1
+ import { Trans } from "@arcote.tech/platform";
2
+ import { useState, type ReactNode } from "react";
3
+ import { useAuth } from "./auth-provider";
4
+
5
+ export interface SignInPageProps {
6
+ /** signIn command from useCommands().accounts.signIn */
7
+ signIn: (params: { email: string; password: string }) => Promise<any>;
8
+ /** Navigate function */
9
+ navigate: (path: string) => void;
10
+ /** Custom render — receives form state and handlers, return your own UI */
11
+ render?: (props: SignInRenderProps) => ReactNode;
12
+ }
13
+
14
+ export interface SignInRenderProps {
15
+ email: string;
16
+ setEmail: (v: string) => void;
17
+ password: string;
18
+ setPassword: (v: string) => void;
19
+ error: string;
20
+ isLoading: boolean;
21
+ handleSubmit: (e: { preventDefault: () => void }) => void;
22
+ }
23
+
24
+ /**
25
+ * Reusable sign-in page logic.
26
+ * Handles form state, error mapping, token setting, and redirect.
27
+ *
28
+ * Pass `render` for custom UI, or use the default minimal form.
29
+ */
30
+ export function SignInPage({ signIn, navigate, render }: SignInPageProps) {
31
+ const { setAccessToken } = useAuth();
32
+
33
+ const [email, setEmail] = useState("");
34
+ const [password, setPassword] = useState("");
35
+ const [error, setError] = useState("");
36
+ const [isLoading, setIsLoading] = useState(false);
37
+
38
+ const redirectTo =
39
+ new URLSearchParams(window.location.search).get("redirectTo") || "/";
40
+
41
+ const handleSubmit = async (e: { preventDefault: () => void }) => {
42
+ e.preventDefault();
43
+ setError("");
44
+ setIsLoading(true);
45
+
46
+ try {
47
+ const result = await signIn({ email, password });
48
+
49
+ if (result && "error" in result) {
50
+ if (result.error === "INVALID_EMAIL_OR_PASSWORD") {
51
+ setError("Nieprawidłowy email lub hasło.");
52
+ } else if (result.error === "EMAIL_NOT_VERIFIED") {
53
+ setError("Email nie został zweryfikowany.");
54
+ } else {
55
+ setError("Wystąpił błąd podczas logowania.");
56
+ }
57
+ return;
58
+ }
59
+
60
+ setAccessToken(result.token);
61
+ setTimeout(() => navigate(redirectTo), 50);
62
+ } catch (err) {
63
+ console.error("[SignInPage] signIn error:", err);
64
+ setError("Nie udało się połączyć z serwerem.");
65
+ } finally {
66
+ setIsLoading(false);
67
+ }
68
+ };
69
+
70
+ const renderProps: SignInRenderProps = {
71
+ email,
72
+ setEmail,
73
+ password,
74
+ setPassword,
75
+ error,
76
+ isLoading,
77
+ handleSubmit,
78
+ };
79
+
80
+ if (render) return <>{render(renderProps)}</>;
81
+
82
+ // Default minimal form (no design system dependency)
83
+ return (
84
+ <div
85
+ style={{
86
+ display: "flex",
87
+ minHeight: "100vh",
88
+ alignItems: "center",
89
+ justifyContent: "center",
90
+ padding: 16,
91
+ }}
92
+ >
93
+ <form
94
+ onSubmit={handleSubmit}
95
+ style={{
96
+ width: "100%",
97
+ maxWidth: 360,
98
+ display: "flex",
99
+ flexDirection: "column",
100
+ gap: 16,
101
+ }}
102
+ >
103
+ <h1 style={{ fontSize: 24, textAlign: "center" }}><Trans>Zaloguj się</Trans></h1>
104
+
105
+ {error && (
106
+ <div
107
+ style={{
108
+ padding: 12,
109
+ background: "#fef2f2",
110
+ border: "1px solid #fecaca",
111
+ borderRadius: 6,
112
+ color: "#dc2626",
113
+ fontSize: 14,
114
+ }}
115
+ >
116
+ {error}
117
+ </div>
118
+ )}
119
+
120
+ <input
121
+ type="email"
122
+ placeholder="Email"
123
+ value={email}
124
+ onChange={(e) => setEmail(e.target.value)}
125
+ required
126
+ disabled={isLoading}
127
+ autoComplete="email"
128
+ style={{ padding: 8, border: "1px solid #d1d5db", borderRadius: 6 }}
129
+ />
130
+
131
+ <input
132
+ type="password"
133
+ placeholder="Hasło"
134
+ value={password}
135
+ onChange={(e) => setPassword(e.target.value)}
136
+ required
137
+ disabled={isLoading}
138
+ minLength={6}
139
+ maxLength={32}
140
+ autoComplete="current-password"
141
+ style={{ padding: 8, border: "1px solid #d1d5db", borderRadius: 6 }}
142
+ />
143
+
144
+ <button
145
+ type="submit"
146
+ disabled={isLoading}
147
+ style={{
148
+ padding: "8px 16px",
149
+ background: "#2563eb",
150
+ color: "white",
151
+ border: "none",
152
+ borderRadius: 6,
153
+ cursor: "pointer",
154
+ }}
155
+ >
156
+ {isLoading ? "Logowanie..." : "Zaloguj się"}
157
+ </button>
158
+ </form>
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,276 @@
1
+ import { route, type ArcAggregateElement } from "@arcote.tech/arc";
2
+ import type { AccountAggregate } from "../aggregates/account";
3
+ import type { OAuthIdentityAggregate } from "../aggregates/oauth-identity";
4
+ import {
5
+ DEFAULT_FACEBOOK_SCOPES,
6
+ facebookProvider,
7
+ } from "../providers/facebook";
8
+ import { DEFAULT_GOOGLE_SCOPES, googleProvider } from "../providers/google";
9
+ import type {
10
+ OAuthProviderAdapter,
11
+ OAuthProvidersConfig,
12
+ } from "../providers/types";
13
+ import type { Token } from "../tokens/token";
14
+
15
+ export type OAuthRoutesData = {
16
+ providers: OAuthProvidersConfig;
17
+ baseUrl: string;
18
+ token: Token;
19
+ accountElement: ArcAggregateElement<AccountAggregate>;
20
+ oauthIdentityElement: ArcAggregateElement<OAuthIdentityAggregate>;
21
+ };
22
+
23
+ const providerAdapters: Record<string, OAuthProviderAdapter> = {
24
+ google: googleProvider,
25
+ facebook: facebookProvider,
26
+ };
27
+
28
+ const defaultScopes: Record<string, string[]> = {
29
+ google: DEFAULT_GOOGLE_SCOPES,
30
+ facebook: DEFAULT_FACEBOOK_SCOPES,
31
+ };
32
+
33
+ function generateState(): string {
34
+ const bytes = new Uint8Array(32);
35
+ crypto.getRandomValues(bytes);
36
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
37
+ }
38
+
39
+ export function createOAuthRoutes(data: OAuthRoutesData) {
40
+ const { providers, baseUrl, token, accountElement, oauthIdentityElement } =
41
+ data;
42
+
43
+ const enabledProviders = Object.keys(providers).filter(
44
+ (p) => providers[p as keyof OAuthProvidersConfig],
45
+ );
46
+
47
+ const oauthStart = route("oauthStart")
48
+ .path("/auth/oauth/:provider/start")
49
+ .public()
50
+ .handle({
51
+ GET: async (_ctx, _req, params, url) => {
52
+ const providerName = params.provider;
53
+ const adapter = providerAdapters[providerName];
54
+ const config = providers[providerName as keyof OAuthProvidersConfig];
55
+
56
+ if (!adapter || !config || !enabledProviders.includes(providerName)) {
57
+ return Response.json(
58
+ { error: `Unknown or disabled provider: ${providerName}` },
59
+ { status: 400 },
60
+ );
61
+ }
62
+
63
+ const state = generateState();
64
+ const redirectTo = url.searchParams.get("redirectTo") || "/";
65
+
66
+ const redirectUri = `${baseUrl}/route/auth/oauth/${providerName}/callback`;
67
+ const scopes = config.scopes || defaultScopes[providerName] || [];
68
+
69
+ const authorizationUrl = adapter.buildAuthorizationUrl({
70
+ clientId: config.clientId,
71
+ redirectUri,
72
+ state: `${state}:${encodeURIComponent(redirectTo)}`,
73
+ scopes,
74
+ });
75
+
76
+ const response = Response.redirect(authorizationUrl, 302);
77
+ // Set state cookie for CSRF protection
78
+ const headers = new Headers(response.headers);
79
+ headers.append(
80
+ "Set-Cookie",
81
+ `oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`,
82
+ );
83
+ return new Response(response.body, {
84
+ status: 302,
85
+ headers,
86
+ });
87
+ },
88
+ });
89
+
90
+ const oauthCallback = route("oauthCallback")
91
+ .path("/auth/oauth/:provider/callback")
92
+ .public()
93
+ .mutate([accountElement, oauthIdentityElement])
94
+ .handle({
95
+ GET: async (ctx, req, params, url) => {
96
+ const providerName = params.provider;
97
+ const adapter = providerAdapters[providerName];
98
+ const config = providers[providerName as keyof OAuthProvidersConfig];
99
+
100
+ if (!adapter || !config || !enabledProviders.includes(providerName)) {
101
+ return Response.json(
102
+ { error: `Unknown or disabled provider: ${providerName}` },
103
+ { status: 400 },
104
+ );
105
+ }
106
+
107
+ // --- CSRF validation ---
108
+ const stateParam = url.searchParams.get("state");
109
+ if (!stateParam) {
110
+ return Response.json(
111
+ { error: "Missing state parameter" },
112
+ { status: 400 },
113
+ );
114
+ }
115
+
116
+ const cookieHeader = req.headers.get("cookie") || "";
117
+ const stateCookie = parseCookie(cookieHeader, "oauth_state");
118
+
119
+ const [stateValue, redirectTo] = stateParam.split(":");
120
+ if (!stateCookie || stateCookie !== stateValue) {
121
+ return Response.json(
122
+ { error: "Invalid state — possible CSRF attack" },
123
+ { status: 403 },
124
+ );
125
+ }
126
+
127
+ // --- Check for provider error ---
128
+ const errorParam = url.searchParams.get("error");
129
+ if (errorParam) {
130
+ const decodedRedirect = decodeURIComponent(redirectTo || "/");
131
+ return Response.redirect(
132
+ `${baseUrl}${decodedRedirect}?auth_error=${encodeURIComponent(errorParam)}`,
133
+ 302,
134
+ );
135
+ }
136
+
137
+ // --- Exchange code for tokens ---
138
+ const code = url.searchParams.get("code");
139
+ if (!code) {
140
+ return Response.json(
141
+ { error: "Missing authorization code" },
142
+ { status: 400 },
143
+ );
144
+ }
145
+
146
+ const redirectUri = `${baseUrl}/route/auth/oauth/${providerName}/callback`;
147
+
148
+ let tokenResponse;
149
+ try {
150
+ tokenResponse = await adapter.exchangeCode({
151
+ code,
152
+ clientId: config.clientId,
153
+ clientSecret: config.clientSecret,
154
+ redirectUri,
155
+ });
156
+ } catch (err) {
157
+ console.error(
158
+ `[OAuth] Token exchange failed for ${providerName}:`,
159
+ err,
160
+ );
161
+ return Response.json(
162
+ { error: "OAuth token exchange failed" },
163
+ { status: 502 },
164
+ );
165
+ }
166
+
167
+ // --- Fetch user profile ---
168
+ let profile;
169
+ try {
170
+ profile = await adapter.fetchUserProfile(
171
+ tokenResponse.accessToken,
172
+ tokenResponse.idToken,
173
+ );
174
+ } catch (err) {
175
+ console.error(
176
+ `[OAuth] Profile fetch failed for ${providerName}:`,
177
+ err,
178
+ );
179
+ return Response.json(
180
+ { error: "Failed to fetch user profile from provider" },
181
+ { status: 502 },
182
+ );
183
+ }
184
+
185
+ // --- Find or create account ---
186
+ const accounts = ctx.mutate(accountElement);
187
+ const oauthIdentityQueries = ctx.query(oauthIdentityElement);
188
+ const oauthIdentities = ctx.mutate(oauthIdentityElement);
189
+
190
+ // 1. Check if this OAuth identity already exists
191
+ const existingIdentity = await oauthIdentityQueries.findByProvider({
192
+ provider: providerName,
193
+ providerUserId: profile.providerUserId,
194
+ });
195
+
196
+ let jwtToken: string;
197
+
198
+ if (existingIdentity) {
199
+ // Returning user — sign in and update last used
200
+ const signInResult = await accounts.signInViaOAuth({
201
+ email: profile.email,
202
+ provider: providerName,
203
+ providerUserId: profile.providerUserId,
204
+ });
205
+
206
+ if ("error" in signInResult) {
207
+ return Response.json(
208
+ { error: signInResult.error },
209
+ { status: 400 },
210
+ );
211
+ }
212
+
213
+ jwtToken = signInResult.token;
214
+
215
+ await oauthIdentities.markUsed({
216
+ oauthIdentityId: existingIdentity._id,
217
+ });
218
+ } else {
219
+ // Try to register — registerViaOAuth uses internal $query (bypasses protectBy)
220
+ // and returns EMAIL_ALREADY_TAKEN with accountId if account exists
221
+ const registerResult = await accounts.registerViaOAuth({
222
+ email: profile.email,
223
+ provider: providerName,
224
+ providerUserId: profile.providerUserId,
225
+ ...(profile.displayName
226
+ ? { displayName: profile.displayName }
227
+ : {}),
228
+ ...(profile.avatarUrl
229
+ ? { avatarUrl: profile.avatarUrl }
230
+ : {}),
231
+ });
232
+
233
+ // Both branches return accountId — extract it directly
234
+ const accountId = registerResult.accountId;
235
+
236
+ // Link the OAuth identity
237
+ await oauthIdentities.linkIdentity({
238
+ accountId,
239
+ provider: providerName,
240
+ providerUserId: profile.providerUserId,
241
+ providerEmail: profile.email,
242
+ });
243
+
244
+ // Generate JWT
245
+ jwtToken = token.generateJWT({ accountId });
246
+ }
247
+
248
+ // --- Redirect with token ---
249
+ const decodedRedirect = decodeURIComponent(redirectTo || "/");
250
+ const targetUrl = new URL(decodedRedirect, baseUrl);
251
+ targetUrl.searchParams.set("token", jwtToken);
252
+
253
+ // Clear state cookie
254
+ const response = Response.redirect(targetUrl.toString(), 302);
255
+ const headers = new Headers(response.headers);
256
+ headers.append(
257
+ "Set-Cookie",
258
+ `oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
259
+ );
260
+ return new Response(response.body, {
261
+ status: 302,
262
+ headers,
263
+ });
264
+ },
265
+ });
266
+
267
+ return { oauthStart, oauthCallback, enabledProviders };
268
+ }
269
+
270
+ function parseCookie(cookieHeader: string, name: string): string | null {
271
+ const match = cookieHeader
272
+ .split(";")
273
+ .map((c) => c.trim())
274
+ .find((c) => c.startsWith(`${name}=`));
275
+ return match ? match.slice(name.length + 1) : null;
276
+ }
@@ -0,0 +1,15 @@
1
+ import { string, token } from "@arcote.tech/arc";
2
+
3
+ export type TokenData = {
4
+ name: string;
5
+ secret: string | undefined;
6
+ };
7
+
8
+ export const createToken = <const Data extends TokenData>(data: Data) =>
9
+ token(`${data.name}Account`, {
10
+ accountId: string(),
11
+ }).secret(data.secret);
12
+
13
+ export type Token<Data extends TokenData = TokenData> = ReturnType<
14
+ typeof createToken<Data>
15
+ >;
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "include": ["src/**/*"]
4
+ }