@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,177 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import type { GitHubOAuthProfile } from "./types.ts";
|
|
7
|
+
import { GitHubAuthClient } from "./client.ts";
|
|
8
|
+
import { handleGitHubCallback as defaultHandleCallback } from "./default-client.ts";
|
|
9
|
+
import type { GitHubAuthConfig } from "./config.ts";
|
|
10
|
+
|
|
11
|
+
export interface GitHubAuthCallbackProps {
|
|
12
|
+
/** Authorization code from URL query params */
|
|
13
|
+
code: string;
|
|
14
|
+
/** State parameter from URL query params (optional) */
|
|
15
|
+
state?: string;
|
|
16
|
+
/** Backend API base URL (default: auto-detect) */
|
|
17
|
+
apiBaseUrl?: string;
|
|
18
|
+
/** Callback when user profile is successfully retrieved */
|
|
19
|
+
onSuccess: (profile: GitHubOAuthProfile) => void | Promise<void>;
|
|
20
|
+
/** Callback when an error occurs */
|
|
21
|
+
onError: (error: Error) => void;
|
|
22
|
+
/** Custom GitHub Auth configuration (if not provided, uses default-client) */
|
|
23
|
+
config?: GitHubAuthConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Configurable GitHub Auth Callback component
|
|
28
|
+
* Handles OAuth callback and calls appropriate callbacks
|
|
29
|
+
*/
|
|
30
|
+
export function GitHubAuthCallback({
|
|
31
|
+
code,
|
|
32
|
+
state,
|
|
33
|
+
apiBaseUrl,
|
|
34
|
+
onSuccess,
|
|
35
|
+
onError,
|
|
36
|
+
config,
|
|
37
|
+
}: GitHubAuthCallbackProps) {
|
|
38
|
+
const processedRef = useRef(false);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (processedRef.current) return;
|
|
42
|
+
processedRef.current = true;
|
|
43
|
+
|
|
44
|
+
const handleCallback = async () => {
|
|
45
|
+
try {
|
|
46
|
+
let profile: GitHubOAuthProfile;
|
|
47
|
+
if (config) {
|
|
48
|
+
const client = new GitHubAuthClient(config);
|
|
49
|
+
profile = await client.handleCallback(code, state);
|
|
50
|
+
} else {
|
|
51
|
+
profile = await defaultHandleCallback(code, state);
|
|
52
|
+
}
|
|
53
|
+
await onSuccess(profile);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
void handleCallback();
|
|
60
|
+
}, [code, state, apiBaseUrl, onSuccess, onError, config]);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex items-center justify-center min-h-screen bg-background">
|
|
64
|
+
<div className="text-center">
|
|
65
|
+
<h1 className="text-2xl font-bold mb-4">Completing sign in...</h1>
|
|
66
|
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface DefaultGitHubAuthCallbackProps {
|
|
73
|
+
/** Authorization code from URL query params */
|
|
74
|
+
code: string;
|
|
75
|
+
/** State parameter from URL query params (optional) */
|
|
76
|
+
state?: string;
|
|
77
|
+
/** Callback when existing user is found */
|
|
78
|
+
onUserFound: (profile: GitHubOAuthProfile) => void | Promise<void>;
|
|
79
|
+
/** Callback when new user (not found in system) */
|
|
80
|
+
onUserNotFound: (profile: GitHubOAuthProfile) => void | Promise<void>;
|
|
81
|
+
/** Callback when an error occurs */
|
|
82
|
+
onError: (error: Error) => void;
|
|
83
|
+
/** Backend API endpoint for user lookup (default: /api/users) */
|
|
84
|
+
userLookupEndpoint?: string;
|
|
85
|
+
/** Redirect path after successful login (default: /home) */
|
|
86
|
+
redirectPath?: string;
|
|
87
|
+
/** Redirect path for new users (default: /create-user) */
|
|
88
|
+
newUserRedirectPath?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Default GitHub Auth Callback component with convention-over-configuration
|
|
93
|
+
* Handles common flow: callback -> lookup user -> redirect
|
|
94
|
+
*/
|
|
95
|
+
export function DefaultGitHubAuthCallback({
|
|
96
|
+
code,
|
|
97
|
+
state,
|
|
98
|
+
onUserFound,
|
|
99
|
+
onUserNotFound,
|
|
100
|
+
onError,
|
|
101
|
+
userLookupEndpoint,
|
|
102
|
+
redirectPath,
|
|
103
|
+
newUserRedirectPath,
|
|
104
|
+
}: DefaultGitHubAuthCallbackProps) {
|
|
105
|
+
const processedRef = useRef(false);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (processedRef.current) return;
|
|
109
|
+
processedRef.current = true;
|
|
110
|
+
|
|
111
|
+
const handleCallback = async () => {
|
|
112
|
+
try {
|
|
113
|
+
// Get user profile from backend
|
|
114
|
+
const profile = await defaultHandleCallback(code, state);
|
|
115
|
+
|
|
116
|
+
// Look up existing user
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetch(userLookupEndpoint ?? "");
|
|
119
|
+
if (response.ok) {
|
|
120
|
+
const userResponse = (await response.json()) as {
|
|
121
|
+
value?: { githubOAuthId?: number }[];
|
|
122
|
+
};
|
|
123
|
+
const users = userResponse.value ?? [];
|
|
124
|
+
const existingUser = users.find(
|
|
125
|
+
(u: { githubOAuthId?: number }) => u.githubOAuthId === profile.id
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (existingUser) {
|
|
129
|
+
await onUserFound(profile);
|
|
130
|
+
// Store redirect path if using localStorage
|
|
131
|
+
const storedRedirect =
|
|
132
|
+
localStorage.getItem("redirectAfterLogin") ?? redirectPath;
|
|
133
|
+
if (storedRedirect) {
|
|
134
|
+
// Redirect logic would go here
|
|
135
|
+
}
|
|
136
|
+
localStorage.removeItem("redirectAfterLogin");
|
|
137
|
+
// Note: Actual navigation should be handled by the app's router
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch (lookupError) {
|
|
142
|
+
console.warn(
|
|
143
|
+
"User lookup failed, treating as new user:",
|
|
144
|
+
lookupError
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// New user flow
|
|
149
|
+
localStorage.setItem("oauth_profile", JSON.stringify(profile));
|
|
150
|
+
await onUserNotFound(profile);
|
|
151
|
+
// Note: Actual navigation should be handled by the app's router
|
|
152
|
+
} catch (error) {
|
|
153
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
void handleCallback();
|
|
158
|
+
}, [
|
|
159
|
+
code,
|
|
160
|
+
state,
|
|
161
|
+
onUserFound,
|
|
162
|
+
onUserNotFound,
|
|
163
|
+
onError,
|
|
164
|
+
userLookupEndpoint,
|
|
165
|
+
redirectPath,
|
|
166
|
+
newUserRedirectPath,
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex items-center justify-center min-h-screen bg-background">
|
|
171
|
+
<div className="text-center">
|
|
172
|
+
<h1 className="text-2xl font-bold mb-4">Completing sign in...</h1>
|
|
173
|
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
import React, { useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { GitHubAuthClient } from "./client.ts";
|
|
7
|
+
import { initiateGitHubLogin as defaultInitiateLogin } from "./default-client.ts";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type AuthUser,
|
|
11
|
+
GitHubAuthContext,
|
|
12
|
+
type GitHubAuthProviderProps,
|
|
13
|
+
} from "./context.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generic GitHub Auth Provider component
|
|
17
|
+
* Manages user state and provides auth context to children
|
|
18
|
+
*/
|
|
19
|
+
export function GitHubAuthProvider({
|
|
20
|
+
children,
|
|
21
|
+
storageKey,
|
|
22
|
+
onUserUpdate,
|
|
23
|
+
config,
|
|
24
|
+
}: GitHubAuthProviderProps) {
|
|
25
|
+
const [user, setUser] = useState<AuthUser | null>(null);
|
|
26
|
+
const [loading, setLoading] = useState(true);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
// Initial load from localStorage
|
|
30
|
+
const key = storageKey ?? "user";
|
|
31
|
+
const storedUser = localStorage.getItem(key);
|
|
32
|
+
if (storedUser) {
|
|
33
|
+
try {
|
|
34
|
+
const parsedUser = JSON.parse(storedUser) as AuthUser;
|
|
35
|
+
// Validate that parsed user is actually a valid user object
|
|
36
|
+
// Reject null, empty objects, or invalid values
|
|
37
|
+
if (
|
|
38
|
+
typeof parsedUser === "object" &&
|
|
39
|
+
Object.keys(parsedUser).length > 0
|
|
40
|
+
) {
|
|
41
|
+
setUser(parsedUser);
|
|
42
|
+
} else {
|
|
43
|
+
// Invalid user data, clear it
|
|
44
|
+
localStorage.removeItem(key);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error("Error parsing stored user:", error);
|
|
48
|
+
localStorage.removeItem(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
setLoading(false);
|
|
52
|
+
}, [storageKey]);
|
|
53
|
+
|
|
54
|
+
const updateUser = useCallback(
|
|
55
|
+
(newUser: AuthUser | null) => {
|
|
56
|
+
setUser(newUser);
|
|
57
|
+
const key = storageKey ?? "user";
|
|
58
|
+
if (newUser) {
|
|
59
|
+
localStorage.setItem(key, JSON.stringify(newUser));
|
|
60
|
+
} else {
|
|
61
|
+
localStorage.removeItem(key);
|
|
62
|
+
}
|
|
63
|
+
onUserUpdate?.(newUser);
|
|
64
|
+
},
|
|
65
|
+
[storageKey, onUserUpdate]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const signInWithGitHub = useCallback(() => {
|
|
69
|
+
if (config) {
|
|
70
|
+
const client = new GitHubAuthClient(config);
|
|
71
|
+
client.initiateLogin();
|
|
72
|
+
} else {
|
|
73
|
+
defaultInitiateLogin();
|
|
74
|
+
}
|
|
75
|
+
}, [config]);
|
|
76
|
+
|
|
77
|
+
const signOut = useCallback(() => {
|
|
78
|
+
setLoading(true);
|
|
79
|
+
const key = storageKey ?? "user";
|
|
80
|
+
localStorage.removeItem(key);
|
|
81
|
+
setUser(null);
|
|
82
|
+
onUserUpdate?.(null);
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}, [storageKey, onUserUpdate]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<GitHubAuthContext.Provider
|
|
88
|
+
value={{ user, loading, signInWithGitHub, signOut, updateUser }}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</GitHubAuthContext.Provider>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Default GitHub Auth Provider with convention-over-configuration
|
|
97
|
+
* Uses sensible defaults: /api/auth/github/callback, storage key "user"
|
|
98
|
+
*/
|
|
99
|
+
export function DefaultGitHubAuthProvider({
|
|
100
|
+
children,
|
|
101
|
+
onUserUpdate,
|
|
102
|
+
}: {
|
|
103
|
+
children: React.ReactNode;
|
|
104
|
+
onUserUpdate?: (user: AuthUser | null) => void;
|
|
105
|
+
}) {
|
|
106
|
+
return (
|
|
107
|
+
<GitHubAuthProvider storageKey="user" onUserUpdate={onUserUpdate}>
|
|
108
|
+
{children}
|
|
109
|
+
</GitHubAuthProvider>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# GitHub OAuth Authentication
|
|
2
|
+
|
|
3
|
+
This package provides frontend components and utilities for GitHub 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
|
+
- 🔧 Support for both frontend-initiated and backend-handled OAuth flows
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
This package is part of the `@chesapeake/shared-components` package. Import
|
|
19
|
+
from:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { ... } from "@shared/components/ux/auth/github";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start (Convention-over-Configuration)
|
|
26
|
+
|
|
27
|
+
For most use cases, the default client provides sensible defaults:
|
|
28
|
+
|
|
29
|
+
### 1. Environment Variables
|
|
30
|
+
|
|
31
|
+
Add to your `.env` file:
|
|
32
|
+
|
|
33
|
+
```env
|
|
34
|
+
VITE_GITHUB_CLIENT_ID=your_github_client_id_here
|
|
35
|
+
VITE_REDIRECT_URI=http://localhost:5173/auth/callback
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or, if your backend handles OAuth initiation:
|
|
39
|
+
|
|
40
|
+
```env
|
|
41
|
+
VITE_GITHUB_BACKEND_INIT_ENDPOINT=/api/auth/github
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. Setup Auth Provider
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import {
|
|
48
|
+
DefaultGitHubAuthProvider,
|
|
49
|
+
useGitHubAuth,
|
|
50
|
+
} from "@shared/components/ux/auth/github";
|
|
51
|
+
import { defaultInitiateGitHubLogin } from "@shared/components/ux/auth/github";
|
|
52
|
+
|
|
53
|
+
function App() {
|
|
54
|
+
return (
|
|
55
|
+
<DefaultGitHubAuthProvider>
|
|
56
|
+
<YourApp />
|
|
57
|
+
</DefaultGitHubAuthProvider>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function LoginButton() {
|
|
62
|
+
const { signInWithGitHub } = useGitHubAuth();
|
|
63
|
+
|
|
64
|
+
return <button onClick={signInWithGitHub}>Sign in with GitHub</button>;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Backend Implementation
|
|
69
|
+
|
|
70
|
+
### API Contract
|
|
71
|
+
|
|
72
|
+
**Endpoint:** `POST /api/auth/github/callback`
|
|
73
|
+
|
|
74
|
+
**Request:**
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"code": "authorization_code_from_github",
|
|
79
|
+
"state": "optional_state_parameter"
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Response:**
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"user": {
|
|
88
|
+
"id": 12345678,
|
|
89
|
+
"login": "username",
|
|
90
|
+
"name": "User Name",
|
|
91
|
+
"email": "user@example.com",
|
|
92
|
+
"avatar_url": "https://avatars.githubusercontent.com/u/12345678"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Backend-Handled OAuth Flow
|
|
98
|
+
|
|
99
|
+
If your backend handles the entire OAuth flow (initiation and callback),
|
|
100
|
+
configure the client with `backendInitEndpoint`:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { GitHubAuthProvider } from "@shared/components/ux/auth/github";
|
|
104
|
+
|
|
105
|
+
function App() {
|
|
106
|
+
return (
|
|
107
|
+
<GitHubAuthProvider
|
|
108
|
+
config={{
|
|
109
|
+
clientId: "", // Not needed when using backend endpoint
|
|
110
|
+
redirectUri: window.location.origin + "/api/auth/github/callback",
|
|
111
|
+
backendInitEndpoint: "/api/auth/github", // Backend handles OAuth initiation
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<YourApp />
|
|
115
|
+
</GitHubAuthProvider>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Minimal Deno Implementation
|
|
121
|
+
|
|
122
|
+
Here's a complete example for Deno:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
// Backend OAuth initiation endpoint
|
|
126
|
+
async function handleGitHubAuth(req: Request): Promise<Response> {
|
|
127
|
+
const GITHUB_CLIENT_ID = Deno.env.get("GITHUB_CLIENT_ID");
|
|
128
|
+
const GITHUB_REDIRECT_URI = Deno.env.get("GITHUB_REDIRECT_URI");
|
|
129
|
+
|
|
130
|
+
const state = crypto.randomUUID();
|
|
131
|
+
const authUrl = new URL("https://github.com/login/oauth/authorize");
|
|
132
|
+
authUrl.searchParams.set("client_id", GITHUB_CLIENT_ID!);
|
|
133
|
+
authUrl.searchParams.set("redirect_uri", GITHUB_REDIRECT_URI);
|
|
134
|
+
authUrl.searchParams.set("scope", "user:email");
|
|
135
|
+
authUrl.searchParams.set("state", state);
|
|
136
|
+
|
|
137
|
+
// Store state for validation
|
|
138
|
+
await kv.set(["oauth_state", state], { expiresAt: Date.now() + 600000 });
|
|
139
|
+
|
|
140
|
+
return Response.redirect(authUrl.toString());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Backend OAuth callback endpoint
|
|
144
|
+
async function handleGitHubCallback(req: Request): Promise<Response> {
|
|
145
|
+
const url = new URL(req.url);
|
|
146
|
+
const code = url.searchParams.get("code");
|
|
147
|
+
const state = url.searchParams.get("state");
|
|
148
|
+
|
|
149
|
+
// Validate state
|
|
150
|
+
const stateResult = await kv.get(["oauth_state", state!]);
|
|
151
|
+
if (!stateResult.value) {
|
|
152
|
+
return new Response("Invalid state", { status: 400 });
|
|
153
|
+
}
|
|
154
|
+
await kv.delete(["oauth_state", state!]);
|
|
155
|
+
|
|
156
|
+
// Exchange code for token
|
|
157
|
+
const tokenResponse = await fetch(
|
|
158
|
+
"https://github.com/login/oauth/access_token",
|
|
159
|
+
{
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: {
|
|
162
|
+
Accept: "application/json",
|
|
163
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
164
|
+
},
|
|
165
|
+
body: new URLSearchParams({
|
|
166
|
+
client_id: Deno.env.get("GITHUB_CLIENT_ID")!,
|
|
167
|
+
client_secret: Deno.env.get("GITHUB_CLIENT_SECRET")!,
|
|
168
|
+
code: code!,
|
|
169
|
+
redirect_uri: Deno.env.get("GITHUB_REDIRECT_URI")!,
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const tokenData = await tokenResponse.json();
|
|
175
|
+
const accessToken = tokenData.access_token;
|
|
176
|
+
|
|
177
|
+
// Get user data
|
|
178
|
+
const userResponse = await fetch("https://api.github.com/user", {
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: `Bearer ${accessToken}`,
|
|
181
|
+
Accept: "application/vnd.github.v3+json",
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const user = await userResponse.json();
|
|
186
|
+
|
|
187
|
+
// Return user profile
|
|
188
|
+
return new Response(
|
|
189
|
+
JSON.stringify({
|
|
190
|
+
user: {
|
|
191
|
+
id: user.id,
|
|
192
|
+
login: user.login,
|
|
193
|
+
name: user.name,
|
|
194
|
+
email: user.email,
|
|
195
|
+
avatar_url: user.avatar_url,
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
{
|
|
199
|
+
headers: { "Content-Type": "application/json" },
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Advanced Usage
|
|
206
|
+
|
|
207
|
+
See the main [auth README](../README.md) for multi-provider setup and advanced
|
|
208
|
+
configuration options.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { GitHubAuthCallbackResponse, GitHubAuthConfig } from "./config.ts";
|
|
5
|
+
import type { GitHubOAuthProfile } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configurable GitHub OAuth client (config-over-convention)
|
|
9
|
+
* Provides full control over all OAuth parameters
|
|
10
|
+
*/
|
|
11
|
+
export class GitHubAuthClient {
|
|
12
|
+
private config: GitHubAuthConfig;
|
|
13
|
+
|
|
14
|
+
constructor(config: GitHubAuthConfig) {
|
|
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 GitHub OAuth login flow by redirecting to GitHub 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 GitHub
|
|
35
|
+
const params = new URLSearchParams({
|
|
36
|
+
client_id: this.config.clientId,
|
|
37
|
+
redirect_uri: this.config.redirectUri,
|
|
38
|
+
scope: this.config.scope ?? "user:email",
|
|
39
|
+
state: this.generateState(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Store state in sessionStorage for validation
|
|
43
|
+
const state = params.get("state");
|
|
44
|
+
if (state) {
|
|
45
|
+
sessionStorage.setItem(`github_oauth_state_${state}`, "pending");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
globalThis.location.href = `https://github.com/login/oauth/authorize?${params.toString()}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generates a secure random state parameter for OAuth
|
|
53
|
+
*/
|
|
54
|
+
private generateState(): string {
|
|
55
|
+
return crypto.randomUUID();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Handles OAuth callback by exchanging code for user profile via backend
|
|
60
|
+
* @param code Authorization code from GitHub OAuth callback
|
|
61
|
+
* @param state State parameter from GitHub OAuth callback (optional, for validation)
|
|
62
|
+
* @returns User profile from backend
|
|
63
|
+
*/
|
|
64
|
+
async handleCallback(
|
|
65
|
+
code: string,
|
|
66
|
+
state?: string
|
|
67
|
+
): Promise<GitHubOAuthProfile> {
|
|
68
|
+
// Validate state if provided
|
|
69
|
+
if (state) {
|
|
70
|
+
const storedState = sessionStorage.getItem(`github_oauth_state_${state}`);
|
|
71
|
+
if (!storedState) {
|
|
72
|
+
throw new Error("Invalid state parameter");
|
|
73
|
+
}
|
|
74
|
+
sessionStorage.removeItem(`github_oauth_state_${state}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const apiBaseUrl = this.config.apiBaseUrl ?? "";
|
|
78
|
+
const endpoint = `${apiBaseUrl}/api/auth/github/callback`;
|
|
79
|
+
|
|
80
|
+
const response = await fetch(endpoint, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({ code, state }),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
const error = (await response.json().catch(() => ({
|
|
90
|
+
error: "Failed to exchange code for token",
|
|
91
|
+
}))) as {
|
|
92
|
+
error_description?: string;
|
|
93
|
+
error?: string;
|
|
94
|
+
};
|
|
95
|
+
throw new Error(
|
|
96
|
+
error.error_description ??
|
|
97
|
+
error.error ??
|
|
98
|
+
"Failed to exchange code for token"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const data = (await response.json()) as GitHubAuthCallbackResponse;
|
|
103
|
+
return data.user;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Updates the configuration
|
|
108
|
+
*/
|
|
109
|
+
updateConfig(config: Partial<GitHubAuthConfig>): void {
|
|
110
|
+
this.config = { ...this.config, ...config };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Gets the current configuration
|
|
115
|
+
*/
|
|
116
|
+
getConfig(): GitHubAuthConfig {
|
|
117
|
+
return { ...this.config };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Standalone function to initiate GitHub OAuth login with configuration
|
|
123
|
+
*/
|
|
124
|
+
export function initiateGitHubLogin(config: GitHubAuthConfig): void {
|
|
125
|
+
const client = new GitHubAuthClient(config);
|
|
126
|
+
client.initiateLogin();
|
|
127
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { GitHubOAuthProfile } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for GitHub OAuth client
|
|
8
|
+
*/
|
|
9
|
+
export interface GitHubAuthConfig {
|
|
10
|
+
/** GitHub OAuth client ID */
|
|
11
|
+
clientId: string;
|
|
12
|
+
/** Redirect URI after OAuth authorization */
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
/** Backend API base URL (e.g., "https://api.example.com" or "/api") */
|
|
15
|
+
apiBaseUrl?: string;
|
|
16
|
+
/** OAuth scopes (default: "user:email") */
|
|
17
|
+
scope?: string;
|
|
18
|
+
/** Storage key for user data in localStorage (default: "user") */
|
|
19
|
+
storageKey?: string;
|
|
20
|
+
/** Backend endpoint for OAuth initiation (if backend handles OAuth flow) */
|
|
21
|
+
backendInitEndpoint?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default OAuth scopes
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_SCOPE = "user:email";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a GitHubAuthConfig from environment variables
|
|
31
|
+
* Note: This function expects Vite environment variables (import.meta.env)
|
|
32
|
+
*/
|
|
33
|
+
export function createGitHubAuthConfig(): GitHubAuthConfig {
|
|
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_GITHUB_CLIENT_ID?: string;
|
|
38
|
+
VITE_REDIRECT_URI?: string;
|
|
39
|
+
VITE_API_BASE_URL?: string;
|
|
40
|
+
VITE_GITHUB_BACKEND_INIT_ENDPOINT?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const clientId = env.VITE_GITHUB_CLIENT_ID;
|
|
44
|
+
if (!clientId && !env.VITE_GITHUB_BACKEND_INIT_ENDPOINT) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"VITE_GITHUB_CLIENT_ID or VITE_GITHUB_BACKEND_INIT_ENDPOINT environment variable is required"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const redirectUri =
|
|
51
|
+
env.VITE_REDIRECT_URI ?? `${globalThis.location.origin}/auth/callback`;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
clientId: clientId ?? "",
|
|
55
|
+
redirectUri,
|
|
56
|
+
apiBaseUrl: env.VITE_API_BASE_URL,
|
|
57
|
+
scope: DEFAULT_SCOPE,
|
|
58
|
+
storageKey: "user",
|
|
59
|
+
backendInitEndpoint: env.VITE_GITHUB_BACKEND_INIT_ENDPOINT,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Backend API response from OAuth callback endpoint
|
|
65
|
+
*/
|
|
66
|
+
export interface GitHubAuthCallbackResponse {
|
|
67
|
+
user: GitHubOAuthProfile;
|
|
68
|
+
}
|