@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/src/index.ts ADDED
@@ -0,0 +1,125 @@
1
+ import {
2
+ aggregateContextElement,
3
+ context,
4
+ type ArcRawShape,
5
+ } from "@arcote.tech/arc";
6
+ import { createAccountAggregate } from "./aggregates/account";
7
+ import { createOAuthIdentityAggregate } from "./aggregates/oauth-identity";
8
+ import { createAccountId } from "./ids/account";
9
+ import { createOAuthIdentityId } from "./ids/oauth-identity";
10
+ import { createToken } from "./tokens/token";
11
+ import { createOAuthRoutes } from "./routes/oauth-routes";
12
+ import type { OAuthProvidersConfig } from "./providers/types";
13
+
14
+ // --- New typed builder API ---
15
+ export { auth, AuthBuilder } from "./auth-builder";
16
+
17
+ // --- Deprecated: use auth().useOAuth().build() instead ---
18
+
19
+ export type AuthMode = "email" | "oauth" | "both";
20
+
21
+ export type AuthContextData = {
22
+ name: string;
23
+ customFields: ArcRawShape;
24
+ secret: string | undefined;
25
+ mode?: AuthMode;
26
+ providers?: OAuthProvidersConfig;
27
+ baseUrl?: string;
28
+ };
29
+
30
+ /** @deprecated Use `auth({...}).useOAuth({...}).build()` instead */
31
+ export const createAuthContext = <const Data extends AuthContextData>(
32
+ data: Data,
33
+ ) => {
34
+ const mode = data.mode ?? "both";
35
+ const accountId = createAccountId({ name: data.name });
36
+ const token = createToken({ name: data.name, secret: data.secret });
37
+
38
+ const Account = createAccountAggregate({
39
+ name: data.name,
40
+ accountId,
41
+ token,
42
+ customFields: data.customFields,
43
+ });
44
+
45
+ const accountElement = aggregateContextElement(Account);
46
+
47
+ const elements: any[] = [accountElement];
48
+
49
+ let OAuthIdentity: ReturnType<typeof createOAuthIdentityAggregate> | undefined;
50
+ let enabledProviders: string[] = [];
51
+
52
+ if (mode !== "email" && data.providers) {
53
+ const oauthIdentityId = createOAuthIdentityId({ name: data.name });
54
+
55
+ OAuthIdentity = createOAuthIdentityAggregate({
56
+ name: data.name,
57
+ oauthIdentityId,
58
+ accountId,
59
+ });
60
+
61
+ const oauthIdentityElement = aggregateContextElement(OAuthIdentity);
62
+ elements.push(oauthIdentityElement);
63
+
64
+ if (data.baseUrl) {
65
+ const oauthRoutes = createOAuthRoutes({
66
+ providers: data.providers,
67
+ baseUrl: data.baseUrl,
68
+ token,
69
+ accountElement,
70
+ oauthIdentityElement,
71
+ });
72
+ elements.push(oauthRoutes.oauthStart);
73
+ elements.push(oauthRoutes.oauthCallback);
74
+ enabledProviders = oauthRoutes.enabledProviders;
75
+ }
76
+ }
77
+
78
+ const authContext = context(elements);
79
+
80
+ return {
81
+ context: authContext,
82
+ accountId,
83
+ token,
84
+ Account,
85
+ OAuthIdentity,
86
+ mode,
87
+ enabledProviders,
88
+ };
89
+ };
90
+
91
+ export type AuthContext<Data extends AuthContextData = AuthContextData> =
92
+ ReturnType<typeof createAuthContext<Data>>;
93
+
94
+ // Re-exports
95
+ export { createAccountAggregate } from "./aggregates/account";
96
+ export type { AccountAggregate } from "./aggregates/account";
97
+ export { createOAuthIdentityAggregate } from "./aggregates/oauth-identity";
98
+ export type { OAuthIdentityAggregate } from "./aggregates/oauth-identity";
99
+ export { createAccountId } from "./ids/account";
100
+ export type { AccountId } from "./ids/account";
101
+ export { createOAuthIdentityId } from "./ids/oauth-identity";
102
+ export type { OAuthIdentityId } from "./ids/oauth-identity";
103
+ export { createToken } from "./tokens/token";
104
+ export type { Token } from "./tokens/token";
105
+
106
+ // Provider types
107
+ export type {
108
+ OAuthProviderConfig,
109
+ OAuthProvidersConfig,
110
+ OAuthProviderName,
111
+ } from "./providers/types";
112
+
113
+ // React components
114
+ export { AuthProvider, ProtectedRoute, SignInPage, useAuth } from "./react";
115
+ export type {
116
+ AuthContextType,
117
+ AuthProviderProps,
118
+ ProtectedRouteProps,
119
+ SignInPageProps,
120
+ SignInRenderProps,
121
+ } from "./react";
122
+
123
+ // New auth page
124
+ export { AuthPage } from "./react/auth-page";
125
+ export type { AuthPageProps, AuthPageRenderProps } from "./react/auth-page";
@@ -0,0 +1,82 @@
1
+ import type {
2
+ OAuthProviderAdapter,
3
+ OAuthTokenResponse,
4
+ OAuthUserProfile,
5
+ } from "./types";
6
+
7
+ const FB_AUTH_URL = "https://www.facebook.com/v21.0/dialog/oauth";
8
+ const FB_TOKEN_URL = "https://graph.facebook.com/v21.0/oauth/access_token";
9
+ const FB_USERINFO_URL = "https://graph.facebook.com/me";
10
+
11
+ export const DEFAULT_FACEBOOK_SCOPES = ["email", "public_profile"];
12
+
13
+ export const facebookProvider: OAuthProviderAdapter = {
14
+ name: "facebook",
15
+
16
+ buildAuthorizationUrl({ clientId, redirectUri, state, scopes }) {
17
+ const params = new URLSearchParams({
18
+ client_id: clientId,
19
+ redirect_uri: redirectUri,
20
+ scope: scopes.join(","),
21
+ state,
22
+ response_type: "code",
23
+ });
24
+ return `${FB_AUTH_URL}?${params.toString()}`;
25
+ },
26
+
27
+ async exchangeCode({
28
+ code,
29
+ clientId,
30
+ clientSecret,
31
+ redirectUri,
32
+ }): Promise<OAuthTokenResponse> {
33
+ const params = new URLSearchParams({
34
+ code,
35
+ client_id: clientId,
36
+ client_secret: clientSecret,
37
+ redirect_uri: redirectUri,
38
+ });
39
+
40
+ const response = await fetch(`${FB_TOKEN_URL}?${params.toString()}`);
41
+
42
+ if (!response.ok) {
43
+ const text = await response.text();
44
+ throw new Error(
45
+ `Facebook token exchange failed: ${response.status} ${text}`,
46
+ );
47
+ }
48
+
49
+ const data = await response.json();
50
+ return {
51
+ accessToken: data.access_token,
52
+ };
53
+ },
54
+
55
+ async fetchUserProfile(accessToken: string): Promise<OAuthUserProfile> {
56
+ const params = new URLSearchParams({
57
+ fields: "id,name,email,picture.width(200).height(200)",
58
+ access_token: accessToken,
59
+ });
60
+
61
+ const response = await fetch(`${FB_USERINFO_URL}?${params.toString()}`);
62
+
63
+ if (!response.ok) {
64
+ throw new Error(`Facebook userinfo fetch failed: ${response.status}`);
65
+ }
66
+
67
+ const data = await response.json();
68
+
69
+ if (!data.email) {
70
+ throw new Error(
71
+ "Facebook did not return an email. User may not have an email associated with their account.",
72
+ );
73
+ }
74
+
75
+ return {
76
+ providerUserId: data.id,
77
+ email: data.email,
78
+ displayName: data.name,
79
+ avatarUrl: data.picture?.data?.url,
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,113 @@
1
+ import type {
2
+ OAuthProviderAdapter,
3
+ OAuthTokenResponse,
4
+ OAuthUserProfile,
5
+ } from "./types";
6
+
7
+ const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
8
+ const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
9
+ const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
10
+
11
+ export const DEFAULT_GOOGLE_SCOPES = ["openid", "email", "profile"];
12
+
13
+ export const googleProvider: OAuthProviderAdapter = {
14
+ name: "google",
15
+
16
+ buildAuthorizationUrl({ clientId, redirectUri, state, scopes }) {
17
+ const params = new URLSearchParams({
18
+ client_id: clientId,
19
+ redirect_uri: redirectUri,
20
+ response_type: "code",
21
+ scope: scopes.join(" "),
22
+ state,
23
+ access_type: "offline",
24
+ prompt: "consent",
25
+ });
26
+ return `${GOOGLE_AUTH_URL}?${params.toString()}`;
27
+ },
28
+
29
+ async exchangeCode({
30
+ code,
31
+ clientId,
32
+ clientSecret,
33
+ redirectUri,
34
+ }): Promise<OAuthTokenResponse> {
35
+ const response = await fetch(GOOGLE_TOKEN_URL, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
38
+ body: new URLSearchParams({
39
+ code,
40
+ client_id: clientId,
41
+ client_secret: clientSecret,
42
+ redirect_uri: redirectUri,
43
+ grant_type: "authorization_code",
44
+ }),
45
+ });
46
+
47
+ if (!response.ok) {
48
+ const text = await response.text();
49
+ throw new Error(`Google token exchange failed: ${response.status} ${text}`);
50
+ }
51
+
52
+ const data = await response.json();
53
+ return {
54
+ accessToken: data.access_token,
55
+ refreshToken: data.refresh_token,
56
+ idToken: data.id_token,
57
+ };
58
+ },
59
+
60
+ async fetchUserProfile(
61
+ accessToken: string,
62
+ idToken?: string,
63
+ ): Promise<OAuthUserProfile> {
64
+ // Try decoding id_token first (avoids extra network call)
65
+ if (idToken) {
66
+ const profile = decodeIdToken(idToken);
67
+ if (profile) return profile;
68
+ }
69
+
70
+ // Fallback: call userinfo endpoint
71
+ const response = await fetch(GOOGLE_USERINFO_URL, {
72
+ headers: { Authorization: `Bearer ${accessToken}` },
73
+ });
74
+
75
+ if (!response.ok) {
76
+ throw new Error(`Google userinfo fetch failed: ${response.status}`);
77
+ }
78
+
79
+ const data = await response.json();
80
+ return {
81
+ providerUserId: data.id,
82
+ email: data.email,
83
+ displayName: data.name,
84
+ avatarUrl: data.picture,
85
+ };
86
+ },
87
+ };
88
+
89
+ /**
90
+ * Decode Google id_token JWT payload (no signature verification needed —
91
+ * we already verified the token exchange with Google's server).
92
+ */
93
+ function decodeIdToken(idToken: string): OAuthUserProfile | null {
94
+ try {
95
+ const parts = idToken.split(".");
96
+ if (parts.length !== 3) return null;
97
+
98
+ const payload = JSON.parse(
99
+ Buffer.from(parts[1], "base64url").toString("utf-8"),
100
+ );
101
+
102
+ if (!payload.sub || !payload.email) return null;
103
+
104
+ return {
105
+ providerUserId: payload.sub,
106
+ email: payload.email,
107
+ displayName: payload.name,
108
+ avatarUrl: payload.picture,
109
+ };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
@@ -0,0 +1,10 @@
1
+ export { googleProvider, DEFAULT_GOOGLE_SCOPES } from "./google";
2
+ export { facebookProvider, DEFAULT_FACEBOOK_SCOPES } from "./facebook";
3
+ export type {
4
+ OAuthProviderAdapter,
5
+ OAuthTokenResponse,
6
+ OAuthUserProfile,
7
+ OAuthProviderName,
8
+ OAuthProviderConfig,
9
+ OAuthProvidersConfig,
10
+ } from "./types";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * OAuth provider adapter interface.
3
+ * Each provider (Google, Facebook) implements this to handle
4
+ * authorization URL building, code exchange, and profile fetching.
5
+ */
6
+ export interface OAuthProviderAdapter {
7
+ name: "google" | "facebook";
8
+
9
+ buildAuthorizationUrl(params: {
10
+ clientId: string;
11
+ redirectUri: string;
12
+ state: string;
13
+ scopes: string[];
14
+ }): string;
15
+
16
+ exchangeCode(params: {
17
+ code: string;
18
+ clientId: string;
19
+ clientSecret: string;
20
+ redirectUri: string;
21
+ }): Promise<OAuthTokenResponse>;
22
+
23
+ fetchUserProfile(
24
+ accessToken: string,
25
+ idToken?: string,
26
+ ): Promise<OAuthUserProfile>;
27
+ }
28
+
29
+ export interface OAuthTokenResponse {
30
+ accessToken: string;
31
+ refreshToken?: string;
32
+ idToken?: string;
33
+ }
34
+
35
+ export interface OAuthUserProfile {
36
+ providerUserId: string;
37
+ email: string;
38
+ displayName?: string;
39
+ avatarUrl?: string;
40
+ }
41
+
42
+ export type OAuthProviderName = "google" | "facebook";
43
+
44
+ export type OAuthProviderConfig = {
45
+ clientId: string;
46
+ clientSecret: string;
47
+ scopes?: string[];
48
+ };
49
+
50
+ export type OAuthProvidersConfig = {
51
+ google?: OAuthProviderConfig;
52
+ facebook?: OAuthProviderConfig;
53
+ };
@@ -0,0 +1,258 @@
1
+ import { Trans } from "@arcote.tech/platform";
2
+ import { useState, useCallback, type ReactNode } from "react";
3
+ import { useAuth } from "./auth-provider";
4
+
5
+ type AuthMode = "email" | "oauth" | "both";
6
+
7
+ export interface AuthPageProps {
8
+ mode: AuthMode;
9
+ enabledProviders?: Array<"google" | "facebook">;
10
+ /** signIn command — required when mode is "email" or "both" */
11
+ signIn?: (params: { email: string; password: string }) => Promise<any>;
12
+ navigate: (path: string) => void;
13
+ /** Base URL for OAuth start links (e.g. "/api" or "") */
14
+ apiBaseUrl?: string;
15
+ /** Custom render — receives form state and handlers */
16
+ render?: (props: AuthPageRenderProps) => ReactNode;
17
+ }
18
+
19
+ export interface AuthPageRenderProps {
20
+ // Email/password fields (present only when mode != "oauth")
21
+ email?: string;
22
+ setEmail?: (v: string) => void;
23
+ password?: string;
24
+ setPassword?: (v: string) => void;
25
+ handleEmailSubmit?: (e: { preventDefault: () => void }) => void;
26
+
27
+ // OAuth
28
+ enabledProviders: Array<"google" | "facebook">;
29
+ getOAuthUrl: (provider: string) => string;
30
+
31
+ // Shared
32
+ error: string;
33
+ isLoading: boolean;
34
+ mode: AuthMode;
35
+ }
36
+
37
+ export function AuthPage({
38
+ mode,
39
+ enabledProviders = [],
40
+ signIn,
41
+ navigate,
42
+ apiBaseUrl = "",
43
+ render,
44
+ }: AuthPageProps) {
45
+ const { setAccessToken } = useAuth();
46
+
47
+ const [email, setEmail] = useState("");
48
+ const [password, setPassword] = useState("");
49
+ const [error, setError] = useState("");
50
+ const [isLoading, setIsLoading] = useState(false);
51
+
52
+ const redirectTo =
53
+ new URLSearchParams(window.location.search).get("redirectTo") || "/";
54
+
55
+ const getOAuthUrl = useCallback(
56
+ (provider: string) => {
57
+ return `${apiBaseUrl}/route/auth/oauth/${provider}/start?redirectTo=${encodeURIComponent(redirectTo)}`;
58
+ },
59
+ [apiBaseUrl, redirectTo],
60
+ );
61
+
62
+ const handleEmailSubmit = async (e: { preventDefault: () => void }) => {
63
+ e.preventDefault();
64
+ if (!signIn) return;
65
+
66
+ setError("");
67
+ setIsLoading(true);
68
+
69
+ try {
70
+ const result = await signIn({ email, password });
71
+
72
+ if (result && "error" in result) {
73
+ if (result.error === "INVALID_EMAIL_OR_PASSWORD") {
74
+ setError("Nieprawidłowy email lub hasło.");
75
+ } else if (result.error === "EMAIL_NOT_VERIFIED") {
76
+ setError("Email nie został zweryfikowany.");
77
+ } else {
78
+ setError("Wystąpił błąd podczas logowania.");
79
+ }
80
+ return;
81
+ }
82
+
83
+ setAccessToken(result.token);
84
+ setTimeout(() => navigate(redirectTo), 50);
85
+ } catch (err) {
86
+ console.error("[AuthPage] signIn error:", err);
87
+ setError("Nie udało się połączyć z serwerem.");
88
+ } finally {
89
+ setIsLoading(false);
90
+ }
91
+ };
92
+
93
+ // Check for auth_error from OAuth callback
94
+ const authError = new URLSearchParams(window.location.search).get("auth_error");
95
+ const displayError = authError
96
+ ? `Błąd logowania OAuth: ${authError}`
97
+ : error;
98
+
99
+ const includeEmail = mode === "email" || mode === "both";
100
+
101
+ const renderProps: AuthPageRenderProps = {
102
+ ...(includeEmail
103
+ ? {
104
+ email,
105
+ setEmail,
106
+ password,
107
+ setPassword,
108
+ handleEmailSubmit,
109
+ }
110
+ : {}),
111
+ enabledProviders,
112
+ getOAuthUrl,
113
+ error: displayError,
114
+ isLoading,
115
+ mode,
116
+ };
117
+
118
+ if (render) return <>{render(renderProps)}</>;
119
+
120
+ // Default minimal form
121
+ return (
122
+ <div
123
+ style={{
124
+ display: "flex",
125
+ minHeight: "100vh",
126
+ alignItems: "center",
127
+ justifyContent: "center",
128
+ padding: 16,
129
+ }}
130
+ >
131
+ <div
132
+ style={{
133
+ width: "100%",
134
+ maxWidth: 360,
135
+ display: "flex",
136
+ flexDirection: "column",
137
+ gap: 16,
138
+ }}
139
+ >
140
+ <h1 style={{ fontSize: 24, textAlign: "center" }}><Trans>Zaloguj się</Trans></h1>
141
+
142
+ {displayError && (
143
+ <div
144
+ style={{
145
+ padding: 12,
146
+ background: "#fef2f2",
147
+ border: "1px solid #fecaca",
148
+ borderRadius: 6,
149
+ color: "#dc2626",
150
+ fontSize: 14,
151
+ }}
152
+ >
153
+ {displayError}
154
+ </div>
155
+ )}
156
+
157
+ {/* OAuth buttons */}
158
+ {enabledProviders.length > 0 && (
159
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
160
+ {enabledProviders.map((provider) => (
161
+ <a
162
+ key={provider}
163
+ href={getOAuthUrl(provider)}
164
+ style={{
165
+ display: "flex",
166
+ alignItems: "center",
167
+ justifyContent: "center",
168
+ gap: 8,
169
+ padding: "10px 16px",
170
+ border: "1px solid #d1d5db",
171
+ borderRadius: 6,
172
+ textDecoration: "none",
173
+ color: "#374151",
174
+ fontSize: 14,
175
+ cursor: "pointer",
176
+ }}
177
+ >
178
+ Zaloguj się przez {provider === "google" ? "Google" : "Facebook"}
179
+ </a>
180
+ ))}
181
+ </div>
182
+ )}
183
+
184
+ {/* Separator */}
185
+ {includeEmail && enabledProviders.length > 0 && (
186
+ <div
187
+ style={{
188
+ display: "flex",
189
+ alignItems: "center",
190
+ gap: 12,
191
+ color: "#9ca3af",
192
+ fontSize: 12,
193
+ }}
194
+ >
195
+ <div style={{ flex: 1, height: 1, background: "#e5e7eb" }} />
196
+ <Trans>lub</Trans>
197
+ <div style={{ flex: 1, height: 1, background: "#e5e7eb" }} />
198
+ </div>
199
+ )}
200
+
201
+ {/* Email/password form */}
202
+ {includeEmail && (
203
+ <form
204
+ onSubmit={handleEmailSubmit}
205
+ style={{ display: "flex", flexDirection: "column", gap: 16 }}
206
+ >
207
+ <input
208
+ type="email"
209
+ placeholder="Email"
210
+ value={email}
211
+ onChange={(e) => setEmail(e.target.value)}
212
+ required
213
+ disabled={isLoading}
214
+ autoComplete="email"
215
+ style={{
216
+ padding: 8,
217
+ border: "1px solid #d1d5db",
218
+ borderRadius: 6,
219
+ }}
220
+ />
221
+
222
+ <input
223
+ type="password"
224
+ placeholder="Hasło"
225
+ value={password}
226
+ onChange={(e) => setPassword(e.target.value)}
227
+ required
228
+ disabled={isLoading}
229
+ minLength={6}
230
+ maxLength={32}
231
+ autoComplete="current-password"
232
+ style={{
233
+ padding: 8,
234
+ border: "1px solid #d1d5db",
235
+ borderRadius: 6,
236
+ }}
237
+ />
238
+
239
+ <button
240
+ type="submit"
241
+ disabled={isLoading}
242
+ style={{
243
+ padding: "8px 16px",
244
+ background: "#2563eb",
245
+ color: "white",
246
+ border: "none",
247
+ borderRadius: 6,
248
+ cursor: "pointer",
249
+ }}
250
+ >
251
+ {isLoading ? "Logowanie..." : "Zaloguj się"}
252
+ </button>
253
+ </form>
254
+ )}
255
+ </div>
256
+ </div>
257
+ );
258
+ }