@startsimpli/auth 0.4.5 → 0.4.7
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 +2 -1
- package/src/__tests__/auth-client.test.ts +1 -1
- package/src/__tests__/auth-fetch.test.ts +2 -2
- package/src/__tests__/auth-functions.test.ts +21 -15
- package/src/__tests__/token.test.ts +3 -3
- package/src/client/functions.ts +2 -6
- package/src/components/google-sign-in-button.tsx +78 -0
- package/src/components/index.ts +4 -0
- package/src/components/oauth-callback.tsx +95 -0
- package/src/components/oauth-connection-card.tsx +117 -0
- package/src/components/use-oauth-callback.ts +123 -0
- package/src/types/index.ts +2 -2
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/auth",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Shared authentication package for StartSimpli Next.js apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./client": "./src/client/index.ts",
|
|
10
|
+
"./components": "./src/components/index.ts",
|
|
10
11
|
"./server": "./src/server/index.ts",
|
|
11
12
|
"./types": "./src/types/index.ts",
|
|
12
13
|
"./email": "./src/email/index.ts"
|
|
@@ -4,7 +4,7 @@ import { AuthClient } from '../client/auth-client';
|
|
|
4
4
|
// Helpers
|
|
5
5
|
function makeToken(exp: number): string {
|
|
6
6
|
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
7
|
-
const body = btoa(JSON.stringify({
|
|
7
|
+
const body = btoa(JSON.stringify({ tokenType: 'access', exp, iat: exp - 3600, jti: 'test', userId: '123' }));
|
|
8
8
|
return `${header}.${body}.signature`;
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -36,8 +36,8 @@ function makeJwt(payload: object): string {
|
|
|
36
36
|
return `${encode({ alg: 'HS256' })}.${encode(payload)}.sig`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const VALID_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600,
|
|
40
|
-
const REFRESHED_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 7200,
|
|
39
|
+
const VALID_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, userId: '1' });
|
|
40
|
+
const REFRESHED_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 7200, userId: '1' });
|
|
41
41
|
|
|
42
42
|
// Stub the CSRF token helper so refreshAccessToken() doesn't fail
|
|
43
43
|
vi.mock('../utils/cookies', () => ({
|
|
@@ -83,20 +83,17 @@ function makeJwt(payload: object): string {
|
|
|
83
83
|
return `${encode({ alg: 'HS256' })}.${encode(payload)}.sig`;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
const VALID_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600,
|
|
86
|
+
const VALID_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, userId: '1' });
|
|
87
87
|
|
|
88
|
-
describe('CSRF
|
|
88
|
+
describe('CSRF not required for signin/register (endpoints are @csrf_exempt)', () => {
|
|
89
89
|
beforeEach(() => {
|
|
90
90
|
vi.clearAllMocks();
|
|
91
91
|
mockSessionStorage.clear();
|
|
92
92
|
mockLocalStorage.clear();
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
it('signInWithCredentials
|
|
96
|
-
mockFetch.mockImplementation((
|
|
97
|
-
if (url.includes('/csrf/')) {
|
|
98
|
-
return Promise.resolve({ ok: true });
|
|
99
|
-
}
|
|
95
|
+
it('signInWithCredentials does not fetch or send CSRF token', async () => {
|
|
96
|
+
mockFetch.mockImplementation(() => {
|
|
100
97
|
return Promise.resolve({
|
|
101
98
|
ok: true,
|
|
102
99
|
status: 200,
|
|
@@ -106,20 +103,23 @@ describe('CSRF token on signin/register', () => {
|
|
|
106
103
|
|
|
107
104
|
await signInWithCredentials('test@test.com', 'password');
|
|
108
105
|
|
|
109
|
-
//
|
|
106
|
+
// Should NOT call the CSRF endpoint
|
|
107
|
+
const csrfCall = mockFetch.mock.calls.find(
|
|
108
|
+
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/csrf/')
|
|
109
|
+
);
|
|
110
|
+
expect(csrfCall).toBeUndefined();
|
|
111
|
+
|
|
112
|
+
// Token endpoint call should not include X-CSRFToken header
|
|
110
113
|
const tokenCall = mockFetch.mock.calls.find(
|
|
111
114
|
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/auth/token/') && !(c[0] as string).includes('csrf') && !(c[0] as string).includes('refresh')
|
|
112
115
|
);
|
|
113
116
|
expect(tokenCall).toBeDefined();
|
|
114
117
|
const headers = tokenCall![1]?.headers;
|
|
115
|
-
expect(headers['X-CSRFToken']).
|
|
118
|
+
expect(headers['X-CSRFToken']).toBeUndefined();
|
|
116
119
|
});
|
|
117
120
|
|
|
118
|
-
it('registerAccount
|
|
119
|
-
mockFetch.mockImplementation((
|
|
120
|
-
if (url.includes('/csrf/')) {
|
|
121
|
-
return Promise.resolve({ ok: true });
|
|
122
|
-
}
|
|
121
|
+
it('registerAccount does not fetch or send CSRF token', async () => {
|
|
122
|
+
mockFetch.mockImplementation(() => {
|
|
123
123
|
return Promise.resolve({
|
|
124
124
|
ok: true,
|
|
125
125
|
status: 200,
|
|
@@ -133,12 +133,18 @@ describe('CSRF token on signin/register', () => {
|
|
|
133
133
|
passwordConfirm: 'securepassword',
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
// Should NOT call the CSRF endpoint
|
|
137
|
+
const csrfCall = mockFetch.mock.calls.find(
|
|
138
|
+
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/csrf/')
|
|
139
|
+
);
|
|
140
|
+
expect(csrfCall).toBeUndefined();
|
|
141
|
+
|
|
136
142
|
const registerCall = mockFetch.mock.calls.find(
|
|
137
143
|
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/auth/register/')
|
|
138
144
|
);
|
|
139
145
|
expect(registerCall).toBeDefined();
|
|
140
146
|
const headers = registerCall![1]?.headers;
|
|
141
|
-
expect(headers['X-CSRFToken']).
|
|
147
|
+
expect(headers['X-CSRFToken']).toBeUndefined();
|
|
142
148
|
});
|
|
143
149
|
});
|
|
144
150
|
|
|
@@ -10,16 +10,16 @@ describe('Token utilities', () => {
|
|
|
10
10
|
describe('decodeToken', () => {
|
|
11
11
|
it('should decode valid JWT token', () => {
|
|
12
12
|
const token =
|
|
13
|
-
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
|
|
13
|
+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlblR5cGUiOiJhY2Nlc3MiLCJleHAiOjE3MzkyMjcyMDAsImlhdCI6MTczOTE0MDgwMCwianRpIjoidGVzdC1qdGkiLCJ1c2VySWQiOiIxMjM0NSJ9.test-signature';
|
|
14
14
|
|
|
15
15
|
const payload = decodeToken(token);
|
|
16
16
|
|
|
17
17
|
expect(payload).toEqual({
|
|
18
|
-
|
|
18
|
+
tokenType: 'access',
|
|
19
19
|
exp: 1739227200,
|
|
20
20
|
iat: 1739140800,
|
|
21
21
|
jti: 'test-jti',
|
|
22
|
-
|
|
22
|
+
userId: '12345',
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
25
|
|
package/src/client/functions.ts
CHANGED
|
@@ -243,13 +243,11 @@ function parseAuthResponse(data: unknown): { access?: string; user?: AuthUser }
|
|
|
243
243
|
// --- Auth functions ---
|
|
244
244
|
|
|
245
245
|
export async function signInWithCredentials(email: string, password: string) {
|
|
246
|
-
|
|
247
|
-
const csrfToken = getCsrfToken();
|
|
246
|
+
// No CSRF needed — Django's /auth/token/ is @csrf_exempt (JWT endpoint)
|
|
248
247
|
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN), {
|
|
249
248
|
method: 'POST',
|
|
250
249
|
headers: {
|
|
251
250
|
'Content-Type': 'application/json',
|
|
252
|
-
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
|
253
251
|
},
|
|
254
252
|
credentials: 'include',
|
|
255
253
|
body: JSON.stringify({ email, password }),
|
|
@@ -286,13 +284,11 @@ export async function registerAccount(payload: {
|
|
|
286
284
|
const [firstFromName, ...rest] = rawName ? rawName.split(/\s+/) : [];
|
|
287
285
|
const lastFromName = rest.length ? rest.join(' ') : undefined;
|
|
288
286
|
|
|
289
|
-
|
|
290
|
-
const csrfToken = getCsrfToken();
|
|
287
|
+
// No CSRF needed — Django's /auth/register/ is @csrf_exempt
|
|
291
288
|
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.REGISTER), {
|
|
292
289
|
method: 'POST',
|
|
293
290
|
headers: {
|
|
294
291
|
'Content-Type': 'application/json',
|
|
295
|
-
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
|
296
292
|
},
|
|
297
293
|
credentials: 'include',
|
|
298
294
|
body: JSON.stringify({
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { initiateGoogleOAuth } from '../client/functions'
|
|
5
|
+
|
|
6
|
+
export interface GoogleSignInButtonProps {
|
|
7
|
+
/** Path for the OAuth callback page (default: "/auth/callback") */
|
|
8
|
+
callbackPath?: string
|
|
9
|
+
/** URL to redirect to after successful auth */
|
|
10
|
+
redirectTo?: string
|
|
11
|
+
/** Error handler */
|
|
12
|
+
onError?: (message: string) => void
|
|
13
|
+
/** Button text (default: "Continue with Google") */
|
|
14
|
+
children?: React.ReactNode
|
|
15
|
+
/** Additional class names */
|
|
16
|
+
className?: string
|
|
17
|
+
/** Disabled state */
|
|
18
|
+
disabled?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Google Sign-In button that handles the full OAuth initiate flow:
|
|
23
|
+
* 1. Calls backend to get authorization URL with PKCE
|
|
24
|
+
* 2. Stores state nonce in sessionStorage for CSRF protection
|
|
25
|
+
* 3. Redirects to Google consent screen
|
|
26
|
+
*/
|
|
27
|
+
export function GoogleSignInButton({
|
|
28
|
+
callbackPath = '/auth/callback',
|
|
29
|
+
redirectTo,
|
|
30
|
+
onError,
|
|
31
|
+
children,
|
|
32
|
+
className,
|
|
33
|
+
disabled,
|
|
34
|
+
}: GoogleSignInButtonProps) {
|
|
35
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
36
|
+
|
|
37
|
+
const handleClick = async () => {
|
|
38
|
+
setIsLoading(true)
|
|
39
|
+
try {
|
|
40
|
+
// Use a clean callback URL with no query params — Google requires exact redirect_uri match
|
|
41
|
+
const redirectUri = new URL(callbackPath, window.location.origin).toString()
|
|
42
|
+
|
|
43
|
+
const data = await initiateGoogleOAuth(redirectUri)
|
|
44
|
+
if (!data?.authorization_url) {
|
|
45
|
+
throw new Error('Missing authorization URL from server')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Store state nonce for CSRF validation on the callback page
|
|
49
|
+
const authUrlParams = new URL(data.authorization_url).searchParams
|
|
50
|
+
const stateNonce = authUrlParams.get('state')
|
|
51
|
+
if (stateNonce) {
|
|
52
|
+
sessionStorage.setItem('oauth_state_nonce', stateNonce)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Store post-auth redirect separately (not in the redirect_uri)
|
|
56
|
+
if (redirectTo) {
|
|
57
|
+
sessionStorage.setItem('oauth_redirect_to', redirectTo)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
window.location.href = data.authorization_url
|
|
61
|
+
} catch (err) {
|
|
62
|
+
setIsLoading(false)
|
|
63
|
+
const message = err instanceof Error ? err.message : 'Failed to initiate Google sign-in'
|
|
64
|
+
onError?.(message)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={handleClick}
|
|
72
|
+
disabled={disabled || isLoading}
|
|
73
|
+
className={className}
|
|
74
|
+
>
|
|
75
|
+
{children ?? 'Continue with Google'}
|
|
76
|
+
</button>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { GoogleSignInButton, type GoogleSignInButtonProps } from './google-sign-in-button'
|
|
2
|
+
export { OAuthCallback, type OAuthCallbackProps } from './oauth-callback'
|
|
3
|
+
export { useOAuthCallback, type UseOAuthCallbackOptions, type UseOAuthCallbackReturn, type OAuthCallbackResult } from './use-oauth-callback'
|
|
4
|
+
export { OAuthConnectionCard, type OAuthConnectionCardProps } from './oauth-connection-card'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AuthUser } from '../types'
|
|
4
|
+
import { useOAuthCallback, type OAuthCallbackResult } from './use-oauth-callback'
|
|
5
|
+
|
|
6
|
+
export interface OAuthCallbackProps {
|
|
7
|
+
/** Code from OAuth redirect URL params */
|
|
8
|
+
code: string | null
|
|
9
|
+
/** State from OAuth redirect URL params */
|
|
10
|
+
state: string | null
|
|
11
|
+
/** Called on successful authentication. redirectTo is the path stored by GoogleSignInButton. */
|
|
12
|
+
onSuccess: (result: OAuthCallbackResult & { redirectTo: string }) => void
|
|
13
|
+
/** Called on error */
|
|
14
|
+
onError?: (message: string) => void
|
|
15
|
+
/** Path to redirect to on "Back to Sign In" (default: "/auth/signin") */
|
|
16
|
+
signInPath?: string
|
|
17
|
+
/** Custom loading content */
|
|
18
|
+
loadingContent?: React.ReactNode
|
|
19
|
+
/** Custom error content renderer */
|
|
20
|
+
renderError?: (error: string, signInPath: string) => React.ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* OAuth callback handler component with built-in loading/error UI.
|
|
25
|
+
*
|
|
26
|
+
* Handles the full callback flow: state validation, token exchange, user fetch.
|
|
27
|
+
* Drop into your /auth/callback page:
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const searchParams = useSearchParams()
|
|
31
|
+
* <OAuthCallback
|
|
32
|
+
* code={searchParams.get('code')}
|
|
33
|
+
* state={searchParams.get('state')}
|
|
34
|
+
* onSuccess={({ access, user }) => {
|
|
35
|
+
* setAccessToken(access); setUser(user); router.replace('/dashboard')
|
|
36
|
+
* }}
|
|
37
|
+
* />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function OAuthCallback({
|
|
41
|
+
code,
|
|
42
|
+
state,
|
|
43
|
+
onSuccess,
|
|
44
|
+
onError,
|
|
45
|
+
signInPath = '/auth/signin',
|
|
46
|
+
loadingContent,
|
|
47
|
+
renderError,
|
|
48
|
+
}: OAuthCallbackProps) {
|
|
49
|
+
const { error, isProcessing, redirectTo } = useOAuthCallback(
|
|
50
|
+
{ code, state },
|
|
51
|
+
{
|
|
52
|
+
onSuccess: (result) => onSuccess({ ...result, redirectTo }),
|
|
53
|
+
onError,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (error) {
|
|
58
|
+
if (renderError) {
|
|
59
|
+
return <>{renderError(error, signInPath)}</>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
|
64
|
+
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl shadow-slate-200/50 ring-1 ring-slate-200 text-center">
|
|
65
|
+
<h1 className="text-2xl font-bold text-slate-900 mb-2">Authentication failed</h1>
|
|
66
|
+
<p className="text-slate-600 mb-6">{error}</p>
|
|
67
|
+
<a
|
|
68
|
+
href={signInPath}
|
|
69
|
+
className="inline-block w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
|
70
|
+
>
|
|
71
|
+
Back to Sign In
|
|
72
|
+
</a>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (isProcessing) {
|
|
79
|
+
if (loadingContent) {
|
|
80
|
+
return <>{loadingContent}</>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
|
85
|
+
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl shadow-slate-200/50 ring-1 ring-slate-200 text-center">
|
|
86
|
+
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-indigo-200 border-t-indigo-600" />
|
|
87
|
+
<h1 className="text-xl font-semibold text-slate-900">Finishing sign in...</h1>
|
|
88
|
+
<p className="text-slate-600 mt-2">This will only take a moment.</p>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface OAuthConnectionCardProps {
|
|
6
|
+
providerName: string
|
|
7
|
+
providerIcon?: React.ReactNode
|
|
8
|
+
description?: string
|
|
9
|
+
connected: boolean
|
|
10
|
+
accountLabel?: string
|
|
11
|
+
lastSync?: string
|
|
12
|
+
connectUrl?: string
|
|
13
|
+
onConnect?: () => void | Promise<void>
|
|
14
|
+
onDisconnect?: () => void | Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Card showing OAuth service connection status with connect/disconnect actions.
|
|
19
|
+
* Used for email (Gmail/Outlook), calendar, CRM integrations.
|
|
20
|
+
*/
|
|
21
|
+
export function OAuthConnectionCard({
|
|
22
|
+
providerName,
|
|
23
|
+
providerIcon,
|
|
24
|
+
description,
|
|
25
|
+
connected,
|
|
26
|
+
accountLabel,
|
|
27
|
+
lastSync,
|
|
28
|
+
connectUrl,
|
|
29
|
+
onConnect,
|
|
30
|
+
onDisconnect,
|
|
31
|
+
}: OAuthConnectionCardProps) {
|
|
32
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
33
|
+
|
|
34
|
+
const handleConnect = useCallback(async () => {
|
|
35
|
+
if (connectUrl) {
|
|
36
|
+
window.location.href = connectUrl
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (onConnect) {
|
|
40
|
+
setIsLoading(true)
|
|
41
|
+
try {
|
|
42
|
+
await onConnect()
|
|
43
|
+
} finally {
|
|
44
|
+
setIsLoading(false)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}, [connectUrl, onConnect])
|
|
48
|
+
|
|
49
|
+
const handleDisconnect = useCallback(async () => {
|
|
50
|
+
if (onDisconnect) {
|
|
51
|
+
setIsLoading(true)
|
|
52
|
+
try {
|
|
53
|
+
await onDisconnect()
|
|
54
|
+
} finally {
|
|
55
|
+
setIsLoading(false)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}, [onDisconnect])
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
|
62
|
+
<div className="flex items-start gap-4">
|
|
63
|
+
{providerIcon && (
|
|
64
|
+
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
|
65
|
+
{providerIcon}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
<div className="flex-1 min-w-0">
|
|
69
|
+
<div className="flex items-center justify-between mb-1">
|
|
70
|
+
<h3 className="font-medium text-gray-900">{providerName}</h3>
|
|
71
|
+
<span
|
|
72
|
+
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
73
|
+
connected
|
|
74
|
+
? 'bg-green-100 text-green-700'
|
|
75
|
+
: 'bg-gray-100 text-gray-500'
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
{connected ? 'Connected' : 'Not connected'}
|
|
79
|
+
</span>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{description && (
|
|
83
|
+
<p className="text-sm text-gray-600 mb-2">{description}</p>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{connected && accountLabel && (
|
|
87
|
+
<p className="text-sm text-gray-700 mb-1">{accountLabel}</p>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{connected && lastSync && (
|
|
91
|
+
<p className="text-xs text-gray-400 mb-2">Last sync: {lastSync}</p>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
<div className="mt-3">
|
|
95
|
+
{connected ? (
|
|
96
|
+
<button
|
|
97
|
+
onClick={handleDisconnect}
|
|
98
|
+
disabled={isLoading}
|
|
99
|
+
className="px-3 py-1.5 text-sm font-medium text-red-700 bg-red-50 hover:bg-red-100 rounded-md disabled:opacity-50 transition-colors"
|
|
100
|
+
>
|
|
101
|
+
{isLoading ? 'Disconnecting...' : 'Disconnect'}
|
|
102
|
+
</button>
|
|
103
|
+
) : (
|
|
104
|
+
<button
|
|
105
|
+
onClick={handleConnect}
|
|
106
|
+
disabled={isLoading}
|
|
107
|
+
className="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 transition-colors"
|
|
108
|
+
>
|
|
109
|
+
{isLoading ? 'Connecting...' : `Connect ${providerName}`}
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { completeGoogleOAuth, getMe } from '../client/functions'
|
|
5
|
+
import type { AuthUser } from '../types'
|
|
6
|
+
|
|
7
|
+
export interface OAuthCallbackResult {
|
|
8
|
+
/** Access token from successful auth */
|
|
9
|
+
access: string
|
|
10
|
+
/** Authenticated user */
|
|
11
|
+
user: AuthUser
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UseOAuthCallbackOptions {
|
|
15
|
+
/** Called on successful authentication */
|
|
16
|
+
onSuccess: (result: OAuthCallbackResult) => void
|
|
17
|
+
/** Called on error */
|
|
18
|
+
onError?: (message: string) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseOAuthCallbackReturn {
|
|
22
|
+
/** Error message if callback failed */
|
|
23
|
+
error: string
|
|
24
|
+
/** Whether the callback is still processing */
|
|
25
|
+
isProcessing: boolean
|
|
26
|
+
/** Post-auth redirect path stored by GoogleSignInButton (default: '/dashboard') */
|
|
27
|
+
redirectTo: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hook that handles the OAuth callback flow:
|
|
32
|
+
* 1. Reads code + state from URL search params
|
|
33
|
+
* 2. Validates state nonce against sessionStorage (CSRF protection)
|
|
34
|
+
* 3. Exchanges code for tokens via backend
|
|
35
|
+
* 4. Fetches user if not returned with tokens
|
|
36
|
+
* 5. Calls onSuccess with access token + user
|
|
37
|
+
*
|
|
38
|
+
* Usage in a Next.js callback page:
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const searchParams = useSearchParams()
|
|
41
|
+
* const { error, isProcessing } = useOAuthCallback({
|
|
42
|
+
* code: searchParams.get('code'),
|
|
43
|
+
* state: searchParams.get('state'),
|
|
44
|
+
* onSuccess: ({ access, user }) => {
|
|
45
|
+
* setAccessToken(access)
|
|
46
|
+
* setUser(user)
|
|
47
|
+
* router.replace('/dashboard')
|
|
48
|
+
* },
|
|
49
|
+
* })
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function useOAuthCallback(
|
|
53
|
+
params: {
|
|
54
|
+
code: string | null
|
|
55
|
+
state: string | null
|
|
56
|
+
},
|
|
57
|
+
options: UseOAuthCallbackOptions,
|
|
58
|
+
): UseOAuthCallbackReturn {
|
|
59
|
+
const [error, setError] = useState('')
|
|
60
|
+
const [isProcessing, setIsProcessing] = useState(true)
|
|
61
|
+
const [redirectTo] = useState(() => {
|
|
62
|
+
if (typeof window === 'undefined') return '/dashboard'
|
|
63
|
+
const stored = sessionStorage.getItem('oauth_redirect_to')
|
|
64
|
+
sessionStorage.removeItem('oauth_redirect_to')
|
|
65
|
+
return stored || '/dashboard'
|
|
66
|
+
})
|
|
67
|
+
const ranRef = useRef(false)
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
// Prevent double-execution in React strict mode
|
|
71
|
+
if (ranRef.current) return
|
|
72
|
+
ranRef.current = true
|
|
73
|
+
|
|
74
|
+
const handleCallback = async () => {
|
|
75
|
+
const { code, state } = params
|
|
76
|
+
if (!code || !state) {
|
|
77
|
+
setError('Invalid OAuth callback — missing code or state')
|
|
78
|
+
setIsProcessing(false)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// CSRF check: validate state nonce against pre-auth sessionStorage value
|
|
83
|
+
const storedNonce = sessionStorage.getItem('oauth_state_nonce')
|
|
84
|
+
sessionStorage.removeItem('oauth_state_nonce')
|
|
85
|
+
if (!storedNonce || storedNonce !== state) {
|
|
86
|
+
setError('Invalid OAuth state — possible CSRF attempt')
|
|
87
|
+
setIsProcessing(false)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const result = await completeGoogleOAuth(code, state)
|
|
93
|
+
const access = result?.access
|
|
94
|
+
|
|
95
|
+
if (!access) {
|
|
96
|
+
throw new Error('No access token returned')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let user = result?.user as AuthUser | undefined
|
|
100
|
+
if (!user) {
|
|
101
|
+
user = await getMe() as AuthUser | undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!user) {
|
|
105
|
+
throw new Error('Failed to retrieve user after authentication')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setIsProcessing(false)
|
|
109
|
+
options.onSuccess({ access, user })
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : 'Authentication failed'
|
|
112
|
+
setError(message)
|
|
113
|
+
setIsProcessing(false)
|
|
114
|
+
options.onError?.(message)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
handleCallback()
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
return { error, isProcessing, redirectTo }
|
|
123
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -58,11 +58,11 @@ export interface AuthUser {
|
|
|
58
58
|
* JWT token payload structure
|
|
59
59
|
*/
|
|
60
60
|
export interface TokenPayload {
|
|
61
|
-
|
|
61
|
+
tokenType: 'access';
|
|
62
62
|
exp: number;
|
|
63
63
|
iat: number;
|
|
64
64
|
jti: string;
|
|
65
|
-
|
|
65
|
+
userId: string;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|