@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.
- package/package.json +20 -0
- package/src/aggregates/account.ts +301 -0
- package/src/aggregates/oauth-identity.ts +131 -0
- package/src/arc.d.ts +14 -0
- package/src/auth-builder.ts +132 -0
- package/src/ids/account.ts +13 -0
- package/src/ids/oauth-identity.ts +15 -0
- package/src/index.ts +125 -0
- package/src/providers/facebook.ts +82 -0
- package/src/providers/google.ts +113 -0
- package/src/providers/index.ts +10 -0
- package/src/providers/types.ts +53 -0
- package/src/react/auth-page.tsx +258 -0
- package/src/react/auth-provider.tsx +82 -0
- package/src/react/index.ts +11 -0
- package/src/react/protected-route.tsx +60 -0
- package/src/react/sign-in-page.tsx +161 -0
- package/src/routes/oauth-routes.ts +276 -0
- package/src/tokens/token.ts +15 -0
- package/tsconfig.json +4 -0
|
@@ -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