@chesapeake/stack 0.1.0
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/CHANGELOG.md +26 -0
- package/LICENSE +190 -0
- package/README.md +440 -0
- package/components/ui/avatar.tsx +87 -0
- package/components/ui/badge.tsx +49 -0
- package/components/ui/badgeVariants.ts +26 -0
- package/components/ui/button-group.tsx +67 -0
- package/components/ui/button.tsx +70 -0
- package/components/ui/buttonGroupVariants.ts +23 -0
- package/components/ui/buttonVariants.ts +38 -0
- package/components/ui/calendar.tsx +220 -0
- package/components/ui/card.tsx +162 -0
- package/components/ui/dialog.tsx +290 -0
- package/components/ui/drawer.tsx +227 -0
- package/components/ui/dropdown-menu.tsx +260 -0
- package/components/ui/index.ts +86 -0
- package/components/ui/input.tsx +59 -0
- package/components/ui/label.tsx +47 -0
- package/components/ui/location-input.md +194 -0
- package/components/ui/location-input.tsx +384 -0
- package/components/ui/popover.tsx +110 -0
- package/components/ui/select.tsx +306 -0
- package/components/ui/separator.tsx +50 -0
- package/components/ui/sonner-api.ts +8 -0
- package/components/ui/sonner.tsx +69 -0
- package/components/ui/textarea.tsx +53 -0
- package/components/ui/tooltip.tsx +125 -0
- package/components/ux/ActivityAnimation.tsx +135 -0
- package/components/ux/EmptyState.tsx +22 -0
- package/components/ux/FeedbackDrawer.tsx +141 -0
- package/components/ux/FireAnimation.tsx +189 -0
- package/components/ux/FriendList.tsx +111 -0
- package/components/ux/FriendRequestList.tsx +104 -0
- package/components/ux/GifSearch.tsx +264 -0
- package/components/ux/ImageUpload.tsx +829 -0
- package/components/ux/SelectedUsersList.tsx +91 -0
- package/components/ux/StandardLayout/PageLayout.tsx +107 -0
- package/components/ux/StandardLayout/ProfilePage.tsx +452 -0
- package/components/ux/StandardLayout/StandardLayout.tsx +345 -0
- package/components/ux/StandardLayout/index.ts +22 -0
- package/components/ux/TutorialCarousel.tsx +128 -0
- package/components/ux/UserSearch.tsx +324 -0
- package/components/ux/UserSelector.tsx +116 -0
- package/components/ux/auth/AuthSelector.tsx +154 -0
- package/components/ux/auth/MultiAuthProvider.tsx +196 -0
- package/components/ux/auth/README.md +317 -0
- package/components/ux/auth/context.ts +46 -0
- package/components/ux/auth/github/AuthCallback.tsx +177 -0
- package/components/ux/auth/github/AuthProvider.tsx +111 -0
- package/components/ux/auth/github/README.md +208 -0
- package/components/ux/auth/github/client.ts +127 -0
- package/components/ux/auth/github/config.ts +68 -0
- package/components/ux/auth/github/context.ts +34 -0
- package/components/ux/auth/github/default-client.ts +74 -0
- package/components/ux/auth/github/index.ts +43 -0
- package/components/ux/auth/github/types.ts +44 -0
- package/components/ux/auth/github/useGitHubAuth.ts +16 -0
- package/components/ux/auth/google/AuthCallback.tsx +170 -0
- package/components/ux/auth/google/AuthProvider.tsx +108 -0
- package/components/ux/auth/google/README.md +450 -0
- package/components/ux/auth/google/client.ts +109 -0
- package/components/ux/auth/google/config.ts +65 -0
- package/components/ux/auth/google/context.ts +34 -0
- package/components/ux/auth/google/default-client.ts +69 -0
- package/components/ux/auth/google/index.ts +43 -0
- package/components/ux/auth/google/types.ts +42 -0
- package/components/ux/auth/google/useGoogleAuth.ts +16 -0
- package/components/ux/auth/index.ts +72 -0
- package/components/ux/auth/useAuth.ts +16 -0
- package/components/ux/index.ts +46 -0
- package/components/ux/theme-provider-context.ts +20 -0
- package/components/ux/theme-provider.tsx +114 -0
- package/components/ux/useTheme.ts +42 -0
- package/index.ts +29 -0
- package/lib/utils.ts +57 -0
- package/package.json +170 -0
- package/styles/base.css +38 -0
- package/styles/components.css +54 -0
- package/styles/index.css +7 -0
- package/styles/tokens.css +189 -0
- package/tailwind.preset.ts +169 -0
- package/types/user.ts +69 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
# Google OAuth Authentication
|
|
2
|
+
|
|
3
|
+
This package provides frontend components and utilities for Google OAuth
|
|
4
|
+
authentication. It supports both **config-over-convention** (full control) and
|
|
5
|
+
**convention-over-configuration** (sensible defaults) approaches.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- 🔐 Secure OAuth flow (client secret never exposed to frontend)
|
|
10
|
+
- ⚙️ Two API styles: configurable and default
|
|
11
|
+
- 🎨 React components for auth provider and callback handling
|
|
12
|
+
- 📦 TypeScript types for type safety
|
|
13
|
+
- 🔄 Automatic user state management with localStorage
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
This package is part of `@chesapeake/stack`. Import from:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { ... } from "@shared/components/ux/auth/google";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start (Convention-over-Configuration)
|
|
24
|
+
|
|
25
|
+
For most use cases, the default client provides sensible defaults:
|
|
26
|
+
|
|
27
|
+
### 1. Environment Variables
|
|
28
|
+
|
|
29
|
+
Add to your `.env` file:
|
|
30
|
+
|
|
31
|
+
```env
|
|
32
|
+
VITE_GOOGLE_CLIENT_ID=your_google_client_id_here
|
|
33
|
+
VITE_REDIRECT_URI=http://localhost:5173/auth/callback
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Setup Auth Provider
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import {
|
|
40
|
+
DefaultGoogleAuthProvider,
|
|
41
|
+
useGoogleAuth,
|
|
42
|
+
} from "@shared/components/ux/auth/google";
|
|
43
|
+
import { defaultInitiateGoogleLogin } from "@shared/components/ux/auth/google";
|
|
44
|
+
|
|
45
|
+
function App() {
|
|
46
|
+
return (
|
|
47
|
+
<DefaultGoogleAuthProvider>
|
|
48
|
+
<YourApp />
|
|
49
|
+
</DefaultGoogleAuthProvider>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function LoginButton() {
|
|
54
|
+
const { signInWithGoogle } = useGoogleAuth();
|
|
55
|
+
|
|
56
|
+
return <button onClick={signInWithGoogle}>Sign in with Google</button>;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3. Handle OAuth Callback
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { DefaultGoogleAuthCallback } from "@shared/components/ux/auth/google";
|
|
64
|
+
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
65
|
+
import { useGoogleAuth } from "@shared/components/ux/auth/google";
|
|
66
|
+
|
|
67
|
+
function AuthCallbackPage() {
|
|
68
|
+
const navigate = useNavigate();
|
|
69
|
+
const [searchParams] = useSearchParams();
|
|
70
|
+
const { updateUser } = useGoogleAuth();
|
|
71
|
+
const code = searchParams.get("code");
|
|
72
|
+
|
|
73
|
+
if (!code) {
|
|
74
|
+
return <div>No authorization code received</div>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<DefaultGoogleAuthCallback
|
|
79
|
+
code={code}
|
|
80
|
+
onUserFound={async (profile) => {
|
|
81
|
+
// User exists in your system
|
|
82
|
+
// Update user state
|
|
83
|
+
updateUser(profile);
|
|
84
|
+
navigate("/home");
|
|
85
|
+
}}
|
|
86
|
+
onUserNotFound={async (profile) => {
|
|
87
|
+
// New user - redirect to create account page
|
|
88
|
+
navigate("/create-user");
|
|
89
|
+
}}
|
|
90
|
+
onError={(error) => {
|
|
91
|
+
console.error("Auth error:", error);
|
|
92
|
+
navigate("/login");
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 4. Backend Implementation
|
|
100
|
+
|
|
101
|
+
You need to implement a backend endpoint `POST /api/auth/google/callback` that:
|
|
102
|
+
|
|
103
|
+
1. Receives `{ code: string }` in the request body
|
|
104
|
+
2. Exchanges the code for tokens using Google's OAuth API (server-side)
|
|
105
|
+
3. Fetches user info from Google
|
|
106
|
+
4. Returns `{ user: GoogleOAuthProfile }`
|
|
107
|
+
|
|
108
|
+
See "Backend Implementation" section below for a complete Deno example.
|
|
109
|
+
|
|
110
|
+
## Advanced Usage (Config-over-Convention)
|
|
111
|
+
|
|
112
|
+
For full control over the OAuth flow:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import {
|
|
116
|
+
GoogleAuthClient,
|
|
117
|
+
GoogleAuthProvider,
|
|
118
|
+
} from "@shared/components/ux/auth/google";
|
|
119
|
+
import type { GoogleAuthConfig } from "@shared/components/ux/auth/google";
|
|
120
|
+
|
|
121
|
+
const config: GoogleAuthConfig = {
|
|
122
|
+
clientId: "your-client-id",
|
|
123
|
+
redirectUri: "https://yourapp.com/auth/callback",
|
|
124
|
+
apiBaseUrl: "https://api.yourapp.com",
|
|
125
|
+
scope: "openid email profile",
|
|
126
|
+
storageKey: "myAppUser",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function App() {
|
|
130
|
+
return (
|
|
131
|
+
<GoogleAuthProvider config={config}>
|
|
132
|
+
<YourApp />
|
|
133
|
+
</GoogleAuthProvider>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## API Reference
|
|
139
|
+
|
|
140
|
+
### Types
|
|
141
|
+
|
|
142
|
+
#### `GoogleOAuthProfile`
|
|
143
|
+
|
|
144
|
+
User profile returned from Google's userinfo endpoint:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
interface GoogleOAuthProfile {
|
|
148
|
+
sub: string; // Google user ID
|
|
149
|
+
name: string;
|
|
150
|
+
email: string;
|
|
151
|
+
picture?: string;
|
|
152
|
+
email_verified?: boolean;
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### `GoogleAuthConfig`
|
|
157
|
+
|
|
158
|
+
Configuration for OAuth client:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
interface GoogleAuthConfig {
|
|
162
|
+
clientId: string;
|
|
163
|
+
redirectUri: string;
|
|
164
|
+
apiBaseUrl?: string;
|
|
165
|
+
scope?: string;
|
|
166
|
+
storageKey?: string;
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Default Client Functions
|
|
171
|
+
|
|
172
|
+
#### `initiateGoogleLogin()`
|
|
173
|
+
|
|
174
|
+
Initiates OAuth flow with default configuration. Reads from
|
|
175
|
+
`VITE_GOOGLE_CLIENT_ID` and `VITE_REDIRECT_URI`.
|
|
176
|
+
|
|
177
|
+
#### `handleGoogleCallback(code: string): Promise<GoogleOAuthProfile>`
|
|
178
|
+
|
|
179
|
+
Handles OAuth callback by calling backend endpoint. Calls
|
|
180
|
+
`POST /api/auth/google/callback` by default.
|
|
181
|
+
|
|
182
|
+
### Components
|
|
183
|
+
|
|
184
|
+
#### `DefaultGoogleAuthProvider`
|
|
185
|
+
|
|
186
|
+
Auth provider with sensible defaults:
|
|
187
|
+
|
|
188
|
+
- Storage key: `"user"`
|
|
189
|
+
- API endpoint: `/api/auth/google/callback` (auto-detected)
|
|
190
|
+
|
|
191
|
+
#### `GoogleAuthProvider`
|
|
192
|
+
|
|
193
|
+
Configurable auth provider with full control over all parameters.
|
|
194
|
+
|
|
195
|
+
#### `useGoogleAuth()`
|
|
196
|
+
|
|
197
|
+
Hook to access auth context:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
const { user, loading, signInWithGoogle, signOut, updateUser } =
|
|
201
|
+
useGoogleAuth();
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### `DefaultGoogleAuthCallback`
|
|
205
|
+
|
|
206
|
+
Callback component with sensible defaults for common flow.
|
|
207
|
+
|
|
208
|
+
#### `GoogleAuthCallback`
|
|
209
|
+
|
|
210
|
+
Configurable callback component for custom flows.
|
|
211
|
+
|
|
212
|
+
## Backend Implementation
|
|
213
|
+
|
|
214
|
+
### API Contract
|
|
215
|
+
|
|
216
|
+
**Endpoint:** `POST /api/auth/google/callback`
|
|
217
|
+
|
|
218
|
+
**Request:**
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"code": "authorization_code_from_google"
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Response:**
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{
|
|
230
|
+
"user": {
|
|
231
|
+
"sub": "google_user_id",
|
|
232
|
+
"name": "User Name",
|
|
233
|
+
"email": "user@example.com",
|
|
234
|
+
"picture": "https://...",
|
|
235
|
+
"email_verified": true
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Minimal Deno Implementation
|
|
241
|
+
|
|
242
|
+
Here's a complete example for Deno:
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
/// <reference lib="deno.unstable" />
|
|
246
|
+
|
|
247
|
+
interface GoogleTokenResponse {
|
|
248
|
+
access_token: string;
|
|
249
|
+
token_type: string;
|
|
250
|
+
expires_in: number;
|
|
251
|
+
refresh_token?: string;
|
|
252
|
+
id_token?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface GoogleUserInfo {
|
|
256
|
+
sub: string;
|
|
257
|
+
name: string;
|
|
258
|
+
email: string;
|
|
259
|
+
picture?: string;
|
|
260
|
+
email_verified?: boolean;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Handles Google OAuth callback
|
|
265
|
+
* POST /api/auth/google/callback
|
|
266
|
+
*/
|
|
267
|
+
export async function googleAuthHandler(req: Request): Promise<Response> {
|
|
268
|
+
if (req.method !== "POST") {
|
|
269
|
+
return new Response("Method not allowed", { status: 405 });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// Parse request body
|
|
274
|
+
const { code } = await req.json();
|
|
275
|
+
if (!code) {
|
|
276
|
+
return new Response(
|
|
277
|
+
JSON.stringify({ error: "Authorization code is required" }),
|
|
278
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Get environment variables
|
|
283
|
+
const clientId = Deno.env.get("GOOGLE_CLIENT_ID");
|
|
284
|
+
const clientSecret = Deno.env.get("GOOGLE_CLIENT_SECRET");
|
|
285
|
+
const redirectUri = Deno.env.get("GOOGLE_REDIRECT_URI") ||
|
|
286
|
+
"http://localhost:5173/auth/callback";
|
|
287
|
+
|
|
288
|
+
if (!clientId || !clientSecret) {
|
|
289
|
+
return new Response(
|
|
290
|
+
JSON.stringify({ error: "OAuth configuration missing" }),
|
|
291
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Exchange authorization code for tokens
|
|
296
|
+
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: {
|
|
299
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
300
|
+
},
|
|
301
|
+
body: new URLSearchParams({
|
|
302
|
+
code,
|
|
303
|
+
client_id: clientId,
|
|
304
|
+
client_secret: clientSecret,
|
|
305
|
+
redirect_uri: redirectUri,
|
|
306
|
+
grant_type: "authorization_code",
|
|
307
|
+
}),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (!tokenResponse.ok) {
|
|
311
|
+
const error = await tokenResponse.json();
|
|
312
|
+
return new Response(
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
error: error.error_description ||
|
|
315
|
+
error.error ||
|
|
316
|
+
"Failed to exchange code for token",
|
|
317
|
+
}),
|
|
318
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const tokens: GoogleTokenResponse = await tokenResponse.json();
|
|
323
|
+
|
|
324
|
+
if (!tokens.access_token) {
|
|
325
|
+
return new Response(
|
|
326
|
+
JSON.stringify({ error: "No access token received" }),
|
|
327
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Fetch user info from Google
|
|
332
|
+
const userResponse = await fetch(
|
|
333
|
+
"https://www.googleapis.com/oauth2/v3/userinfo",
|
|
334
|
+
{
|
|
335
|
+
headers: {
|
|
336
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (!userResponse.ok) {
|
|
342
|
+
const error = await userResponse.json();
|
|
343
|
+
return new Response(
|
|
344
|
+
JSON.stringify({
|
|
345
|
+
error: error.error_description ||
|
|
346
|
+
error.error ||
|
|
347
|
+
"Failed to fetch user info",
|
|
348
|
+
}),
|
|
349
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const userInfo: GoogleUserInfo = await userResponse.json();
|
|
354
|
+
|
|
355
|
+
// Return user profile
|
|
356
|
+
return new Response(JSON.stringify({ user: userInfo }), {
|
|
357
|
+
status: 200,
|
|
358
|
+
headers: { "Content-Type": "application/json" },
|
|
359
|
+
});
|
|
360
|
+
} catch (error) {
|
|
361
|
+
console.error("OAuth error:", error);
|
|
362
|
+
return new Response(
|
|
363
|
+
JSON.stringify({
|
|
364
|
+
error: error instanceof Error ? error.message : "Internal server error",
|
|
365
|
+
}),
|
|
366
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Adding to Your Server
|
|
373
|
+
|
|
374
|
+
In your Deno server routing:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
// In your main server file (e.g., serve_deno_deploy.ts)
|
|
378
|
+
import { googleAuthHandler } from "./auth.ts";
|
|
379
|
+
|
|
380
|
+
async function routeRequest(
|
|
381
|
+
req: Request,
|
|
382
|
+
kv: Deno.Kv,
|
|
383
|
+
config: ServerConfig,
|
|
384
|
+
): Promise<Response> {
|
|
385
|
+
const url = new URL(req.url);
|
|
386
|
+
|
|
387
|
+
// Handle Google OAuth callback
|
|
388
|
+
if (url.pathname === "/api/auth/google/callback") {
|
|
389
|
+
return googleAuthHandler(req);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ... other routes
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Environment Variables
|
|
397
|
+
|
|
398
|
+
Add to your server environment:
|
|
399
|
+
|
|
400
|
+
```env
|
|
401
|
+
GOOGLE_CLIENT_ID=your_google_client_id
|
|
402
|
+
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
403
|
+
GOOGLE_REDIRECT_URI=http://localhost:5173/auth/callback
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
## Security Notes
|
|
407
|
+
|
|
408
|
+
1. **Never expose client secret in frontend code** - Always exchange tokens
|
|
409
|
+
server-side
|
|
410
|
+
2. **Use HTTPS in production** - OAuth requires secure connections
|
|
411
|
+
3. **Validate redirect URIs** - Ensure redirect URIs match your Google OAuth app
|
|
412
|
+
configuration
|
|
413
|
+
4. **Store tokens securely** - If storing refresh tokens, use secure server-side
|
|
414
|
+
storage
|
|
415
|
+
|
|
416
|
+
## Google OAuth Setup
|
|
417
|
+
|
|
418
|
+
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
419
|
+
2. Create a new project or select existing
|
|
420
|
+
3. Enable Google+ API
|
|
421
|
+
4. Go to "Credentials" → "Create Credentials" → "OAuth 2.0 Client ID"
|
|
422
|
+
5. Configure:
|
|
423
|
+
- Application type: Web application
|
|
424
|
+
- Authorized redirect URIs: `http://localhost:5173/auth/callback` (dev) and
|
|
425
|
+
your production URL
|
|
426
|
+
6. Copy Client ID and Client Secret
|
|
427
|
+
|
|
428
|
+
## Troubleshooting
|
|
429
|
+
|
|
430
|
+
### "Failed to exchange code for token"
|
|
431
|
+
|
|
432
|
+
- Check that `GOOGLE_CLIENT_SECRET` is set correctly on the server
|
|
433
|
+
- Verify redirect URI matches exactly (including trailing slashes)
|
|
434
|
+
- Ensure the authorization code hasn't expired (codes expire quickly)
|
|
435
|
+
|
|
436
|
+
### "No authorization code received"
|
|
437
|
+
|
|
438
|
+
- Check that the callback URL is configured correctly in Google Cloud Console
|
|
439
|
+
- Verify the redirect URI in your frontend matches the one in Google Cloud
|
|
440
|
+
Console
|
|
441
|
+
|
|
442
|
+
### CORS errors
|
|
443
|
+
|
|
444
|
+
- Ensure your backend allows requests from your frontend origin
|
|
445
|
+
- Check that the API endpoint is correctly configured
|
|
446
|
+
|
|
447
|
+
## Examples
|
|
448
|
+
|
|
449
|
+
A demo that uses this package is available in this repo: run `npm run dev` from
|
|
450
|
+
the stack package root and see the component showcase.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { GoogleAuthCallbackResponse, GoogleAuthConfig } from "./config.ts";
|
|
5
|
+
import type { GoogleOAuthProfile } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configurable Google OAuth client (config-over-convention)
|
|
9
|
+
* Provides full control over all OAuth parameters
|
|
10
|
+
*/
|
|
11
|
+
export class GoogleAuthClient {
|
|
12
|
+
private config: GoogleAuthConfig;
|
|
13
|
+
|
|
14
|
+
constructor(config: GoogleAuthConfig) {
|
|
15
|
+
// Validate config
|
|
16
|
+
if (!config.backendInitEndpoint && !config.clientId) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
"Either clientId or backendInitEndpoint must be provided"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
this.config = config;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initiates Google OAuth login flow by redirecting to Google or backend endpoint
|
|
26
|
+
*/
|
|
27
|
+
initiateLogin(): void {
|
|
28
|
+
// If backend handles OAuth initiation, redirect to backend endpoint
|
|
29
|
+
if (this.config.backendInitEndpoint) {
|
|
30
|
+
globalThis.location.href = this.config.backendInitEndpoint;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Otherwise, redirect directly to Google
|
|
35
|
+
if (!this.config.clientId) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"clientId is required when backendInitEndpoint is not provided"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const params = new URLSearchParams({
|
|
42
|
+
client_id: this.config.clientId,
|
|
43
|
+
redirect_uri: this.config.redirectUri,
|
|
44
|
+
response_type: "code",
|
|
45
|
+
scope: this.config.scope ?? "openid email profile",
|
|
46
|
+
access_type: "offline",
|
|
47
|
+
prompt: "consent",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
globalThis.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handles OAuth callback by exchanging code for user profile via backend
|
|
55
|
+
* @param code Authorization code from Google OAuth callback
|
|
56
|
+
* @returns User profile from backend
|
|
57
|
+
*/
|
|
58
|
+
async handleCallback(code: string): Promise<GoogleOAuthProfile> {
|
|
59
|
+
const apiBaseUrl = this.config.apiBaseUrl ?? "";
|
|
60
|
+
const endpoint = `${apiBaseUrl}/api/auth/google/callback`;
|
|
61
|
+
|
|
62
|
+
const response = await fetch(endpoint, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({ code }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const error = (await response.json().catch(() => ({
|
|
72
|
+
error: "Failed to exchange code for token",
|
|
73
|
+
}))) as {
|
|
74
|
+
error_description?: string;
|
|
75
|
+
error?: string;
|
|
76
|
+
};
|
|
77
|
+
throw new Error(
|
|
78
|
+
error.error_description ??
|
|
79
|
+
error.error ??
|
|
80
|
+
"Failed to exchange code for token"
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const data = (await response.json()) as GoogleAuthCallbackResponse;
|
|
85
|
+
return data.user;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Updates the configuration
|
|
90
|
+
*/
|
|
91
|
+
updateConfig(config: Partial<GoogleAuthConfig>): void {
|
|
92
|
+
this.config = { ...this.config, ...config };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Gets the current configuration
|
|
97
|
+
*/
|
|
98
|
+
getConfig(): GoogleAuthConfig {
|
|
99
|
+
return { ...this.config };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Standalone function to initiate Google OAuth login with configuration
|
|
105
|
+
*/
|
|
106
|
+
export function initiateGoogleLogin(config: GoogleAuthConfig): void {
|
|
107
|
+
const client = new GoogleAuthClient(config);
|
|
108
|
+
client.initiateLogin();
|
|
109
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { GoogleOAuthProfile } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for Google OAuth client
|
|
8
|
+
*/
|
|
9
|
+
export interface GoogleAuthConfig {
|
|
10
|
+
/** Google OAuth client ID (required if backendInitEndpoint is not provided) */
|
|
11
|
+
clientId?: string;
|
|
12
|
+
/** Redirect URI after OAuth authorization */
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
/** Backend endpoint that initiates OAuth flow (e.g., "/api/auth/google") */
|
|
15
|
+
backendInitEndpoint?: string;
|
|
16
|
+
/** Backend API base URL (e.g., "https://api.example.com" or "/api") */
|
|
17
|
+
apiBaseUrl?: string;
|
|
18
|
+
/** OAuth scopes (default: "openid email profile") */
|
|
19
|
+
scope?: string;
|
|
20
|
+
/** Storage key for user data in localStorage (default: "user") */
|
|
21
|
+
storageKey?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default OAuth scopes
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_SCOPE = "openid email profile";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a GoogleAuthConfig from environment variables
|
|
31
|
+
* Note: This function expects Vite environment variables (import.meta.env)
|
|
32
|
+
*/
|
|
33
|
+
export function createGoogleAuthConfig(): GoogleAuthConfig {
|
|
34
|
+
// Type assertion for Vite's import.meta.env
|
|
35
|
+
const env = (import.meta as { env?: Record<string, string | undefined> })
|
|
36
|
+
.env as {
|
|
37
|
+
VITE_GOOGLE_CLIENT_ID?: string;
|
|
38
|
+
VITE_REDIRECT_URI?: string;
|
|
39
|
+
VITE_API_BASE_URL?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const clientId = env.VITE_GOOGLE_CLIENT_ID;
|
|
43
|
+
if (!clientId) {
|
|
44
|
+
throw new Error("VITE_GOOGLE_CLIENT_ID environment variable is required");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const redirectUri =
|
|
48
|
+
env.VITE_REDIRECT_URI ??
|
|
49
|
+
`${globalThis.location.origin}/api/auth/google/callback`;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
clientId,
|
|
53
|
+
redirectUri,
|
|
54
|
+
apiBaseUrl: env.VITE_API_BASE_URL,
|
|
55
|
+
scope: DEFAULT_SCOPE,
|
|
56
|
+
storageKey: "user",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Backend API response from OAuth callback endpoint
|
|
62
|
+
*/
|
|
63
|
+
export interface GoogleAuthCallbackResponse {
|
|
64
|
+
user: GoogleOAuthProfile;
|
|
65
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import React, { createContext } from "react";
|
|
5
|
+
import type { GoogleAuthConfig } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generic user type that applications can extend
|
|
9
|
+
*/
|
|
10
|
+
export type AuthUser = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
export interface GoogleAuthContextType {
|
|
13
|
+
user: AuthUser | null;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
signInWithGoogle: () => void;
|
|
16
|
+
signOut: () => void;
|
|
17
|
+
updateUser: (user: AuthUser | null) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const GoogleAuthContext = createContext<GoogleAuthContextType | null>(
|
|
21
|
+
null
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export interface GoogleAuthProviderProps {
|
|
25
|
+
children: React.ReactNode;
|
|
26
|
+
/** Backend API base URL (default: auto-detect from window.location) */
|
|
27
|
+
apiBaseUrl?: string;
|
|
28
|
+
/** Storage key for user data in localStorage (default: "user") */
|
|
29
|
+
storageKey?: string;
|
|
30
|
+
/** Callback when user is updated */
|
|
31
|
+
onUserUpdate?: (user: AuthUser | null) => void;
|
|
32
|
+
/** Custom Google Auth configuration (if not provided, uses default-client) */
|
|
33
|
+
config?: GoogleAuthConfig;
|
|
34
|
+
}
|