@djangocfg/api 2.1.598 → 2.1.600
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/README.md +2 -1
- package/dist/OAuthProvidersResponse-QZ566OYR.mjs +25 -0
- package/dist/OAuthProvidersResponse-QZ566OYR.mjs.map +1 -0
- package/dist/auth.cjs +526 -288
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +144 -31
- package/dist/auth.d.ts +144 -31
- package/dist/auth.mjs +409 -213
- package/dist/auth.mjs.map +1 -1
- package/dist/cfg_accounts_oauth_connections_response_200_AutoRef-N532BRPN.mjs +28 -0
- package/dist/cfg_accounts_oauth_connections_response_200_AutoRef-N532BRPN.mjs.map +1 -0
- package/dist/chunk-PCOSFQTH.mjs +10 -0
- package/dist/chunk-PCOSFQTH.mjs.map +1 -0
- package/dist/clients.cjs +34 -3
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +135 -14
- package/dist/clients.d.ts +135 -14
- package/dist/clients.mjs +34 -3
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +1 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +1 -0
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +34 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +169 -17
- package/dist/index.d.ts +169 -17
- package/dist/index.mjs +34 -3
- package/dist/index.mjs.map +1 -1
- package/dist/storage.cjs +1 -0
- package/dist/storage.cjs.map +1 -1
- package/dist/storage.d.cts +1 -0
- package/dist/storage.d.ts +1 -0
- package/dist/storage.mjs +1 -0
- package/dist/storage.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/noStorageLiterals.test.ts +1 -1
- package/src/_api/generated/_cfg_accounts/hooks/index.ts +2 -0
- package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsOauthGoogleAuthorizeCreate.ts +68 -0
- package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsOauthGoogleCallbackCreate.ts +68 -0
- package/src/_api/generated/_cfg_accounts/openapi.json +263 -19
- package/src/_api/generated/_cfg_accounts/schemas/OAuthCallbackRequestRequest.ts +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/OAuthError.ts +2 -1
- package/src/_api/generated/_cfg_accounts/schemas/OAuthErrorCodeEnum.ts +9 -0
- package/src/_api/generated/_cfg_accounts/schemas/OAuthProviderEnum.ts +1 -1
- package/src/_api/generated/_cfg_accounts/schemas/OAuthProviderInfo.ts +14 -0
- package/src/_api/generated/_cfg_accounts/schemas/OAuthProvidersResponse.ts +2 -1
- package/src/_api/generated/_cfg_accounts/schemas/index.ts +2 -0
- package/src/_api/generated/helpers/keys.ts +1 -0
- package/src/_api/generated/openapi.json +263 -19
- package/src/_api/generated/sdk.gen.ts +36 -4
- package/src/_api/generated/types.gen.ts +136 -11
- package/src/auth/__tests__/oauthErrors.test.ts +23 -0
- package/src/auth/__tests__/passwordLogin.test.tsx +1 -0
- package/src/auth/__tests__/useOAuthLogin.test.tsx +387 -0
- package/src/auth/hooks/index.ts +12 -1
- package/src/auth/hooks/useGithubAuth.ts +15 -225
- package/src/auth/hooks/useOAuthLogin.ts +237 -0
- package/src/auth/hooks/useOAuthProviders.ts +76 -0
- package/src/auth/utils/index.ts +8 -0
- package/src/auth/utils/oauthErrors.ts +45 -0
|
@@ -1,45 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { CFG_KEYS } from '../../_api/generated/helpers/keys';
|
|
7
|
-
import { CfgAccountsOauth } from '../../_api/generated/sdk.gen';
|
|
8
|
-
import type { OAuthTokenResponse } from '../../_api/generated/types.gen';
|
|
9
|
-
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
10
|
-
import { formatAuthError } from '../utils/errors';
|
|
11
|
-
import { authLogger } from '../utils/logger';
|
|
12
|
-
import { useCfgRouter } from './useCfgRouter';
|
|
5
|
+
import { type UseOAuthLoginOptions, useOAuthLogin } from './useOAuthLogin';
|
|
13
6
|
|
|
14
|
-
|
|
15
|
-
const OAUTH_PROVIDER_KEY = CFG_KEYS.oauthProvider.key;
|
|
7
|
+
export type { OAuthUser } from './useOAuthLogin';
|
|
16
8
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
* Derived from the generated response rather than restated, so a schema change
|
|
21
|
-
* reaches callers through `make gen` instead of drifting silently. Today the
|
|
22
|
-
* backend types it as a free-form object; when it names a real user model, this
|
|
23
|
-
* narrows on its own.
|
|
24
|
-
*/
|
|
25
|
-
export type OAuthUser = NonNullable<OAuthTokenResponse['user']>;
|
|
26
|
-
|
|
27
|
-
export interface UseGithubAuthOptions {
|
|
28
|
-
sourceUrl?: string;
|
|
29
|
-
/**
|
|
30
|
-
* The OAuth endpoint's OpenAPI schema types `user` as a free-form object, so
|
|
31
|
-
* this is what the server actually promises — not a `User`. Narrow it at the
|
|
32
|
-
* call site; typing it tighter here would be an assertion, not a fact.
|
|
33
|
-
*/
|
|
34
|
-
onSuccess?: (user: OAuthUser, isNewUser: boolean) => void;
|
|
35
|
-
onError?: (error: string) => void;
|
|
36
|
-
/** Callback when 2FA is required */
|
|
37
|
-
onRequires2FA?: (sessionId: string, shouldPrompt2FA: boolean) => void;
|
|
38
|
-
redirectUrl?: string;
|
|
39
|
-
/** Skip automatic redirect after success (caller handles navigation) */
|
|
40
|
-
skipRedirect?: boolean;
|
|
41
|
-
}
|
|
9
|
+
/** @deprecated Use `UseOAuthLoginOptions`. */
|
|
10
|
+
export type UseGithubAuthOptions = UseOAuthLoginOptions;
|
|
42
11
|
|
|
12
|
+
/** @deprecated Use `UseOAuthLoginReturn`. */
|
|
43
13
|
export interface UseGithubAuthReturn {
|
|
44
14
|
isLoading: boolean;
|
|
45
15
|
error: string | null;
|
|
@@ -48,195 +18,15 @@ export interface UseGithubAuthReturn {
|
|
|
48
18
|
}
|
|
49
19
|
|
|
50
20
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* Usage:
|
|
54
|
-
* 1. Call startGithubAuth() to redirect user to GitHub
|
|
55
|
-
* 2. After GitHub redirects back, call handleGithubCallback(code, state)
|
|
56
|
-
*
|
|
57
|
-
* @example
|
|
58
|
-
* ```tsx
|
|
59
|
-
* const { isLoading, error, startGithubAuth } = useGithubAuth({
|
|
60
|
-
* onSuccess: (user) => router.push('/dashboard'),
|
|
61
|
-
* onError: (error) => console.error(error),
|
|
62
|
-
* });
|
|
63
|
-
*
|
|
64
|
-
* <Button onClick={startGithubAuth} disabled={isLoading}>
|
|
65
|
-
* Continue with GitHub
|
|
66
|
-
* </Button>
|
|
67
|
-
* ```
|
|
21
|
+
* @deprecated Use `useOAuthLogin` (`start('github')`, `complete('github', code, state)`).
|
|
22
|
+
* Kept for one minor release; behaviour is `useOAuthLogin` with the provider fixed.
|
|
68
23
|
*/
|
|
69
24
|
export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuthReturn => {
|
|
70
|
-
const {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
* Start GitHub OAuth flow - redirects user to GitHub authorization page.
|
|
78
|
-
*/
|
|
79
|
-
const startGithubAuth = useCallback(async () => {
|
|
80
|
-
setIsLoading(true);
|
|
81
|
-
setError(null);
|
|
82
|
-
// Match the OTP flow: a new login must not leave an older persistent
|
|
83
|
-
// session available after this browser is intentionally made session-only.
|
|
84
|
-
auth.clearSession();
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
authLogger.info('Starting GitHub OAuth flow...');
|
|
88
|
-
|
|
89
|
-
// Track OAuth start
|
|
90
|
-
Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
|
|
91
|
-
category: AnalyticsCategory.AUTH,
|
|
92
|
-
label: 'github',
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// Call API to get authorization URL
|
|
96
|
-
// The API will auto-generate redirect_uri from config if not provided
|
|
97
|
-
const result = await CfgAccountsOauth.cfgAccountsOauthGithubAuthorizeCreate({
|
|
98
|
-
body: {
|
|
99
|
-
source_url: sourceUrl || (typeof window !== 'undefined' ? window.location.href : ''),
|
|
100
|
-
},
|
|
101
|
-
throwOnError: true,
|
|
102
|
-
});
|
|
103
|
-
const response = result.data;
|
|
104
|
-
|
|
105
|
-
if (!response.authorization_url) {
|
|
106
|
-
throw new Error('Failed to get authorization URL');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
authLogger.info('Redirecting to GitHub...', response.authorization_url);
|
|
110
|
-
|
|
111
|
-
if (typeof window !== 'undefined') {
|
|
112
|
-
sessionStorage.setItem(OAUTH_STATE_KEY, response.state);
|
|
113
|
-
sessionStorage.setItem(OAUTH_PROVIDER_KEY, 'github');
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
window.location.href = response.authorization_url;
|
|
117
|
-
|
|
118
|
-
} catch (err) {
|
|
119
|
-
const errorMessage = formatAuthError(err, 'Failed to start GitHub authentication');
|
|
120
|
-
authLogger.error('GitHub OAuth start error:', err);
|
|
121
|
-
setError(errorMessage);
|
|
122
|
-
onError?.(errorMessage);
|
|
123
|
-
|
|
124
|
-
// Track OAuth error
|
|
125
|
-
Analytics.event(AnalyticsEvent.AUTH_OAUTH_FAIL, {
|
|
126
|
-
category: AnalyticsCategory.AUTH,
|
|
127
|
-
label: 'github',
|
|
128
|
-
});
|
|
129
|
-
} finally {
|
|
130
|
-
setIsLoading(false);
|
|
131
|
-
}
|
|
132
|
-
}, [sourceUrl, onError]);
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Handle GitHub OAuth callback - exchanges code for JWT tokens.
|
|
136
|
-
*
|
|
137
|
-
* @param code - Authorization code from GitHub callback
|
|
138
|
-
* @param state - State token for CSRF verification
|
|
139
|
-
*/
|
|
140
|
-
const handleGithubCallback = useCallback(async (code: string, state: string) => {
|
|
141
|
-
setIsLoading(true);
|
|
142
|
-
setError(null);
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
authLogger.info('Processing GitHub OAuth callback...');
|
|
146
|
-
|
|
147
|
-
// Verify state matches what we stored
|
|
148
|
-
if (typeof window !== 'undefined') {
|
|
149
|
-
const storedState = sessionStorage.getItem(OAUTH_STATE_KEY);
|
|
150
|
-
if (storedState && storedState !== state) {
|
|
151
|
-
throw new Error('Invalid OAuth state - possible CSRF attack');
|
|
152
|
-
}
|
|
153
|
-
// Clear stored state
|
|
154
|
-
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
|
155
|
-
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const result = await CfgAccountsOauth.cfgAccountsOauthGithubCallbackCreate({
|
|
160
|
-
body: { code, state },
|
|
161
|
-
throwOnError: true,
|
|
162
|
-
});
|
|
163
|
-
const response = result.data;
|
|
164
|
-
|
|
165
|
-
// Check if 2FA is required
|
|
166
|
-
if (response.requires_2fa && response.session_id) {
|
|
167
|
-
authLogger.info('GitHub OAuth requires 2FA, session:', response.session_id);
|
|
168
|
-
|
|
169
|
-
// Track 2FA requirement
|
|
170
|
-
Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
|
|
171
|
-
category: AnalyticsCategory.AUTH,
|
|
172
|
-
label: 'github-2fa-required',
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// Call 2FA callback
|
|
176
|
-
onRequires2FA?.(response.session_id, response.should_prompt_2fa || false);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (!response.access || !response.refresh) {
|
|
181
|
-
throw new Error('Invalid response from OAuth callback');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
authLogger.info('GitHub OAuth successful, user:', response.user);
|
|
185
|
-
|
|
186
|
-
// The ONE write path — atomic pair + session notification. Storage is
|
|
187
|
-
// always localStorage; session length is the server's call.
|
|
188
|
-
// See AccountsContext.verifyOTP.
|
|
189
|
-
auth.setSession({ access: response.access, refresh: response.refresh });
|
|
190
|
-
|
|
191
|
-
// Track successful OAuth
|
|
192
|
-
Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
|
|
193
|
-
category: AnalyticsCategory.AUTH,
|
|
194
|
-
label: 'github',
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
// Set user ID for future tracking
|
|
198
|
-
if (response.user?.id) {
|
|
199
|
-
Analytics.setUser(String(response.user.id));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Call success callback.
|
|
203
|
-
//
|
|
204
|
-
// Guarded because the schema allows `user: null` — the 2FA branch returns
|
|
205
|
-
// no user — and five lines up this same value is read through `?.`. The
|
|
206
|
-
// old `any` let one line be careful and the next not.
|
|
207
|
-
if (response.user) {
|
|
208
|
-
onSuccess?.(response.user, response.is_new_user || false);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Redirect (unless skipRedirect is true)
|
|
212
|
-
if (!skipRedirect) {
|
|
213
|
-
// Use hardPush for full page reload - ensures all React contexts reinitialize
|
|
214
|
-
const finalRedirectUrl = redirectUrl || '/private';
|
|
215
|
-
router.hardPush(finalRedirectUrl);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return;
|
|
219
|
-
|
|
220
|
-
} catch (err) {
|
|
221
|
-
const errorMessage = formatAuthError(err, 'GitHub authentication failed');
|
|
222
|
-
authLogger.error('GitHub OAuth callback error:', err);
|
|
223
|
-
setError(errorMessage);
|
|
224
|
-
onError?.(errorMessage);
|
|
225
|
-
|
|
226
|
-
// Track OAuth error
|
|
227
|
-
Analytics.event(AnalyticsEvent.AUTH_OAUTH_FAIL, {
|
|
228
|
-
category: AnalyticsCategory.AUTH,
|
|
229
|
-
label: 'github',
|
|
230
|
-
});
|
|
231
|
-
} finally {
|
|
232
|
-
setIsLoading(false);
|
|
233
|
-
}
|
|
234
|
-
}, [onSuccess, onError, onRequires2FA, redirectUrl, router, skipRedirect]);
|
|
235
|
-
|
|
236
|
-
return {
|
|
237
|
-
isLoading,
|
|
238
|
-
error,
|
|
239
|
-
startGithubAuth,
|
|
240
|
-
handleGithubCallback,
|
|
241
|
-
};
|
|
25
|
+
const { isLoading, error, start, complete } = useOAuthLogin(options);
|
|
26
|
+
const startGithubAuth = useCallback(() => start('github'), [start]);
|
|
27
|
+
const handleGithubCallback = useCallback(
|
|
28
|
+
(code: string, state: string) => complete('github', code, state),
|
|
29
|
+
[complete],
|
|
30
|
+
);
|
|
31
|
+
return { isLoading, error, startGithubAuth, handleGithubCallback };
|
|
242
32
|
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { auth } from '../../_api/generated/helpers/auth';
|
|
6
|
+
import { CFG_KEYS, cfgStorage } from '../../_api/generated/helpers/keys';
|
|
7
|
+
import { CfgAccountsOauth } from '../../_api/generated/sdk.gen';
|
|
8
|
+
import type {
|
|
9
|
+
OAuthAuthorizeRequestRequest,
|
|
10
|
+
OAuthAuthorizeResponse,
|
|
11
|
+
OAuthCallbackRequestRequest,
|
|
12
|
+
OAuthTokenResponse,
|
|
13
|
+
} from '../../_api/generated/types.gen';
|
|
14
|
+
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
15
|
+
import { authLogger } from '../utils/logger';
|
|
16
|
+
import { type OAuthFailureCode, type OAuthProviderId, readOAuthFailure } from '../utils/oauthErrors';
|
|
17
|
+
import { consumeSavedRedirect } from './useAuthRedirect';
|
|
18
|
+
import { useCfgRouter } from './useCfgRouter';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The user payload as the OAuth endpoint declares it.
|
|
22
|
+
*
|
|
23
|
+
* Derived from the generated response rather than restated, so a schema change
|
|
24
|
+
* reaches callers through `make generate` instead of drifting silently.
|
|
25
|
+
*/
|
|
26
|
+
export type OAuthUser = NonNullable<OAuthTokenResponse['user']>;
|
|
27
|
+
|
|
28
|
+
interface OAuthAdapter {
|
|
29
|
+
authorize: (body: OAuthAuthorizeRequestRequest) => Promise<OAuthAuthorizeResponse>;
|
|
30
|
+
callback: (body: OAuthCallbackRequestRequest) => Promise<OAuthTokenResponse>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One entry per provider the backend can offer. Keyed by the generated enum:
|
|
35
|
+
* when the backend adds a provider, this stops compiling until it is wired.
|
|
36
|
+
*/
|
|
37
|
+
const ADAPTERS: Record<OAuthProviderId, OAuthAdapter> = {
|
|
38
|
+
github: {
|
|
39
|
+
authorize: async (body) =>
|
|
40
|
+
(await CfgAccountsOauth.cfgAccountsOauthGithubAuthorizeCreate({ body, throwOnError: true })).data,
|
|
41
|
+
callback: async (body) =>
|
|
42
|
+
(await CfgAccountsOauth.cfgAccountsOauthGithubCallbackCreate({ body, throwOnError: true })).data,
|
|
43
|
+
},
|
|
44
|
+
google: {
|
|
45
|
+
authorize: async (body) =>
|
|
46
|
+
(await CfgAccountsOauth.cfgAccountsOauthGoogleAuthorizeCreate({ body, throwOnError: true })).data,
|
|
47
|
+
callback: async (body) =>
|
|
48
|
+
(await CfgAccountsOauth.cfgAccountsOauthGoogleCallbackCreate({ body, throwOnError: true })).data,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Provider ids this client can run, in the order the adapters are declared. */
|
|
53
|
+
export const OAUTH_PROVIDER_IDS = Object.keys(ADAPTERS) as OAuthProviderId[];
|
|
54
|
+
|
|
55
|
+
export const isOAuthProviderId = (value: unknown): value is OAuthProviderId =>
|
|
56
|
+
typeof value === 'string' && Object.hasOwn(ADAPTERS, value);
|
|
57
|
+
|
|
58
|
+
const STATE_KEY = CFG_KEYS.oauthState.key;
|
|
59
|
+
const PROVIDER_KEY = CFG_KEYS.oauthProvider.key;
|
|
60
|
+
|
|
61
|
+
/** Forget the stored state: the attempt is over without an exchange (Cancel, a provider error). */
|
|
62
|
+
export const clearOAuthAttempt = (): void => {
|
|
63
|
+
cfgStorage.remove(STATE_KEY);
|
|
64
|
+
cfgStorage.remove(PROVIDER_KEY);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface UseOAuthLoginOptions {
|
|
68
|
+
sourceUrl?: string;
|
|
69
|
+
/** The endpoint types `user` as a free-form object; narrow it at the call site. */
|
|
70
|
+
onSuccess?: (user: OAuthUser, isNewUser: boolean, provider: OAuthProviderId) => void;
|
|
71
|
+
/** `message` is server prose or an English fallback; `code` lets the caller choose its own copy. */
|
|
72
|
+
onError?: (message: string, code?: OAuthFailureCode) => void;
|
|
73
|
+
/** Called when 2FA is required instead of a session. */
|
|
74
|
+
onRequires2FA?: (sessionId: string, shouldPrompt2FA: boolean) => void;
|
|
75
|
+
redirectUrl?: string;
|
|
76
|
+
/** Skip the automatic redirect after success (the caller navigates). */
|
|
77
|
+
skipRedirect?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface UseOAuthLoginReturn {
|
|
81
|
+
/** True from the click until the page leaves (start) or the exchange settles (complete). */
|
|
82
|
+
isLoading: boolean;
|
|
83
|
+
/** The provider whose flow is in flight; disable the other buttons, show progress on this one. */
|
|
84
|
+
loadingProvider: OAuthProviderId | null;
|
|
85
|
+
error: string | null;
|
|
86
|
+
errorCode: OAuthFailureCode | null;
|
|
87
|
+
/** Redirects the tab to the provider. Resolves only if it failed to start. */
|
|
88
|
+
start: (provider: OAuthProviderId) => Promise<void>;
|
|
89
|
+
/** Exchanges the `code` the provider returned. The stored `state` must match; there is no unchecked path. */
|
|
90
|
+
complete: (provider: OAuthProviderId, code: string, state: string) => Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sign in with a third-party provider: one hook for every provider.
|
|
95
|
+
*
|
|
96
|
+
* Flow, full page in the same tab so the saved return URL survives:
|
|
97
|
+
* `start(provider)` stores the server's `state`, then navigates to the provider.
|
|
98
|
+
* The provider returns to `/auth?provider=<id>&code&state`; `complete(...)`
|
|
99
|
+
* verifies that `state` against the stored one (login CSRF defence), exchanges
|
|
100
|
+
* the code and writes the session.
|
|
101
|
+
*/
|
|
102
|
+
export const useOAuthLogin = (options: UseOAuthLoginOptions = {}): UseOAuthLoginReturn => {
|
|
103
|
+
const router = useCfgRouter();
|
|
104
|
+
|
|
105
|
+
const optionsRef = useRef(options);
|
|
106
|
+
optionsRef.current = options;
|
|
107
|
+
const routerRef = useRef(router);
|
|
108
|
+
routerRef.current = router;
|
|
109
|
+
|
|
110
|
+
const [loadingProvider, setLoadingProvider] = useState<OAuthProviderId | null>(null);
|
|
111
|
+
const [error, setError] = useState<string | null>(null);
|
|
112
|
+
const [errorCode, setErrorCode] = useState<OAuthFailureCode | null>(null);
|
|
113
|
+
|
|
114
|
+
// Coming back with the back button restores this page from the bfcache with
|
|
115
|
+
// the spinner still on; the provider page was left, so the flow is over.
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const onShow = (event: PageTransitionEvent) => {
|
|
118
|
+
if (event.persisted) setLoadingProvider(null);
|
|
119
|
+
};
|
|
120
|
+
window.addEventListener('pageshow', onShow);
|
|
121
|
+
return () => window.removeEventListener('pageshow', onShow);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const fail = useCallback((provider: OAuthProviderId, message: string, code?: OAuthFailureCode) => {
|
|
125
|
+
setError(message);
|
|
126
|
+
setErrorCode(code ?? null);
|
|
127
|
+
setLoadingProvider(null);
|
|
128
|
+
optionsRef.current.onError?.(message, code);
|
|
129
|
+
Analytics.event(AnalyticsEvent.AUTH_OAUTH_FAIL, {
|
|
130
|
+
category: AnalyticsCategory.AUTH,
|
|
131
|
+
label: provider,
|
|
132
|
+
});
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
const start = useCallback(async (provider: OAuthProviderId) => {
|
|
136
|
+
setLoadingProvider(provider);
|
|
137
|
+
setError(null);
|
|
138
|
+
setErrorCode(null);
|
|
139
|
+
// Match the OTP flow: a new login must not leave an older persistent
|
|
140
|
+
// session available after this browser is intentionally made session-only.
|
|
141
|
+
auth.clearSession();
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
|
|
145
|
+
category: AnalyticsCategory.AUTH,
|
|
146
|
+
label: provider,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// No `redirect_uri`: the server derives it from the request origin.
|
|
150
|
+
const sourceUrl = optionsRef.current.sourceUrl || window.location.href;
|
|
151
|
+
const response = await ADAPTERS[provider].authorize({ source_url: sourceUrl });
|
|
152
|
+
if (!response.authorization_url || !response.state) {
|
|
153
|
+
throw new Error('Failed to get authorization URL');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
cfgStorage.write(STATE_KEY, response.state);
|
|
157
|
+
cfgStorage.write(PROVIDER_KEY, provider);
|
|
158
|
+
authLogger.info(`Redirecting to ${provider}...`);
|
|
159
|
+
// Loading stays on: the tab is leaving.
|
|
160
|
+
window.location.href = response.authorization_url;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
authLogger.error(`${provider} OAuth start error:`, err);
|
|
163
|
+
const failure = readOAuthFailure(err, 'Failed to start authentication');
|
|
164
|
+
fail(provider, failure.message, failure.code);
|
|
165
|
+
}
|
|
166
|
+
}, [fail]);
|
|
167
|
+
|
|
168
|
+
const complete = useCallback(async (provider: OAuthProviderId, code: string, state: string) => {
|
|
169
|
+
setLoadingProvider(provider);
|
|
170
|
+
setError(null);
|
|
171
|
+
setErrorCode(null);
|
|
172
|
+
|
|
173
|
+
// The stored state is the proof that THIS browser started the flow. A
|
|
174
|
+
// missing one is as wrong as a different one: an attacker's link carries a
|
|
175
|
+
// valid code and state for the attacker's account. Spent before any await.
|
|
176
|
+
const storedState = cfgStorage.read(STATE_KEY);
|
|
177
|
+
const storedProvider = cfgStorage.read(PROVIDER_KEY);
|
|
178
|
+
clearOAuthAttempt();
|
|
179
|
+
if (!storedState || storedState !== state || (storedProvider && storedProvider !== provider)) {
|
|
180
|
+
authLogger.warn(`${provider} OAuth callback rejected: state does not match this browser`);
|
|
181
|
+
fail(provider, 'Invalid sign-in attempt. Please try again.', 'invalid_state');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const response = await ADAPTERS[provider].callback({ code, state });
|
|
187
|
+
|
|
188
|
+
if (response.requires_2fa && response.session_id) {
|
|
189
|
+
Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
|
|
190
|
+
category: AnalyticsCategory.AUTH,
|
|
191
|
+
label: `${provider}-2fa-required`,
|
|
192
|
+
});
|
|
193
|
+
optionsRef.current.onRequires2FA?.(response.session_id, response.should_prompt_2fa || false);
|
|
194
|
+
setLoadingProvider(null);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!response.access || !response.refresh) {
|
|
199
|
+
throw new Error('Invalid response from OAuth callback');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The ONE write path — atomic pair + session notification.
|
|
203
|
+
auth.setSession({ access: response.access, refresh: response.refresh });
|
|
204
|
+
|
|
205
|
+
Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
|
|
206
|
+
category: AnalyticsCategory.AUTH,
|
|
207
|
+
label: provider,
|
|
208
|
+
});
|
|
209
|
+
if (response.user?.id) {
|
|
210
|
+
Analytics.setUser(String(response.user.id));
|
|
211
|
+
}
|
|
212
|
+
if (response.user) {
|
|
213
|
+
optionsRef.current.onSuccess?.(response.user, response.is_new_user || false, provider);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setLoadingProvider(null);
|
|
217
|
+
if (!optionsRef.current.skipRedirect) {
|
|
218
|
+
// Full reload so every React context reinitialises with the new session.
|
|
219
|
+
// The saved back-url (where a guard bounced the visitor from) comes first.
|
|
220
|
+
routerRef.current.hardPush(consumeSavedRedirect() || optionsRef.current.redirectUrl || '/private');
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
authLogger.error(`${provider} OAuth callback error:`, err);
|
|
224
|
+
const failure = readOAuthFailure(err, 'Authentication failed');
|
|
225
|
+
fail(provider, failure.message, failure.code);
|
|
226
|
+
}
|
|
227
|
+
}, [fail]);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
isLoading: loadingProvider !== null,
|
|
231
|
+
loadingProvider,
|
|
232
|
+
error,
|
|
233
|
+
errorCode,
|
|
234
|
+
start,
|
|
235
|
+
complete,
|
|
236
|
+
};
|
|
237
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useCfgAccountsOauthProvidersRetrieve } from '../../_api/generated/_cfg_accounts/hooks/useCfgAccountsOauthProvidersRetrieve';
|
|
6
|
+
import { CFG_KEYS, cfgStorage } from '../../_api/generated/helpers/keys';
|
|
7
|
+
import type { OAuthProviderInfo } from '../../_api/generated/types.gen';
|
|
8
|
+
import { OAUTH_PROVIDER_IDS } from './useOAuthLogin';
|
|
9
|
+
|
|
10
|
+
export interface UseOAuthProvidersReturn {
|
|
11
|
+
/** Providers the server has configured, in the server's display order. Empty until ready and on failure. */
|
|
12
|
+
providers: OAuthProviderInfo[];
|
|
13
|
+
/** False only while the first request is in flight and nothing is cached. */
|
|
14
|
+
isReady: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const EMPTY: OAuthProviderInfo[] = [];
|
|
18
|
+
const CACHE_KEY = CFG_KEYS.oauthProviders.key;
|
|
19
|
+
|
|
20
|
+
const isInfo = (value: unknown): value is OAuthProviderInfo =>
|
|
21
|
+
typeof value === 'object' && value !== null && typeof (value as { id?: unknown }).id === 'string';
|
|
22
|
+
|
|
23
|
+
// useSyncExternalStore needs a stable snapshot: parse again only when the stored text changed.
|
|
24
|
+
let lastRaw: string | null = null;
|
|
25
|
+
let lastParsed: OAuthProviderInfo[] | undefined;
|
|
26
|
+
|
|
27
|
+
/** The list the previous page of this tab fetched; `undefined` when none or unreadable. */
|
|
28
|
+
const readCached = (): OAuthProviderInfo[] | undefined => {
|
|
29
|
+
const raw = cfgStorage.read(CACHE_KEY);
|
|
30
|
+
if (raw === lastRaw) return lastParsed;
|
|
31
|
+
lastRaw = raw;
|
|
32
|
+
try {
|
|
33
|
+
const parsed: unknown = raw === null ? null : JSON.parse(raw);
|
|
34
|
+
lastParsed = Array.isArray(parsed) && parsed.every(isInfo) ? parsed : undefined;
|
|
35
|
+
} catch {
|
|
36
|
+
lastParsed = undefined;
|
|
37
|
+
}
|
|
38
|
+
return lastParsed;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const subscribe = () => () => {};
|
|
42
|
+
const serverSnapshot = () => undefined;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The sign-in providers the server offers (`GET /cfg/accounts/oauth/providers/`).
|
|
46
|
+
*
|
|
47
|
+
* Fetched once per page session and kept in `sessionStorage`, so a reload or
|
|
48
|
+
* "Try again" after a failed callback shows the buttons at once; the server
|
|
49
|
+
* answer always wins. A failure yields the cached list, else `[]`, with
|
|
50
|
+
* `isReady: true`: the email form never waits on, or breaks because of, this
|
|
51
|
+
* list. Ids this client has no adapter for (a newer backend) are dropped.
|
|
52
|
+
*/
|
|
53
|
+
export const useOAuthProviders = (): UseOAuthProvidersReturn => {
|
|
54
|
+
// Server and first client render see `undefined`, so hydration matches.
|
|
55
|
+
const cached = useSyncExternalStore(subscribe, readCached, serverSnapshot);
|
|
56
|
+
|
|
57
|
+
const { data, error, isLoading } = useCfgAccountsOauthProvidersRetrieve(undefined, {
|
|
58
|
+
revalidateOnFocus: false,
|
|
59
|
+
revalidateOnReconnect: false,
|
|
60
|
+
revalidateOnMount: true,
|
|
61
|
+
shouldRetryOnError: false,
|
|
62
|
+
dedupingInterval: 60 * 60 * 1000,
|
|
63
|
+
fallbackData: cached && { providers: cached },
|
|
64
|
+
onSuccess: (response) => {
|
|
65
|
+
cfgStorage.writeJSON(CACHE_KEY, response.providers);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const providers = useMemo(
|
|
70
|
+
() => data?.providers.filter((p) => OAUTH_PROVIDER_IDS.includes(p.id)) ?? EMPTY,
|
|
71
|
+
[data],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// SWR keeps `isLoading` on while it revalidates fallback data; a cached list is already usable.
|
|
75
|
+
return { providers, isReady: !isLoading || Boolean(error) || cached !== undefined };
|
|
76
|
+
};
|
package/src/auth/utils/index.ts
CHANGED
|
@@ -7,6 +7,14 @@ export {
|
|
|
7
7
|
type AuthErrorCopy,
|
|
8
8
|
type AuthErrorKey,
|
|
9
9
|
} from './errors';
|
|
10
|
+
export {
|
|
11
|
+
readOAuthFailure,
|
|
12
|
+
type OAuthClientErrorCode,
|
|
13
|
+
type OAuthErrorCode,
|
|
14
|
+
type OAuthFailure,
|
|
15
|
+
type OAuthFailureCode,
|
|
16
|
+
type OAuthProviderId,
|
|
17
|
+
} from './oauthErrors';
|
|
10
18
|
export {
|
|
11
19
|
resolveGuardIsLoading,
|
|
12
20
|
shouldRedirectToAuth,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { OAuthErrorCodeEnum, type OAuthProviderEnum } from '../../_api/generated/types.gen';
|
|
2
|
+
import { formatAuthError } from './errors';
|
|
3
|
+
|
|
4
|
+
/** A provider the server can offer. Derived from the generated enum, so a new backend provider is a type error until it gets an adapter. */
|
|
5
|
+
export type OAuthProviderId = `${OAuthProviderEnum}`;
|
|
6
|
+
|
|
7
|
+
/** Every `error` value the OAuth endpoints can return (closed set from the OpenAPI schema). */
|
|
8
|
+
export type OAuthErrorCode = `${OAuthErrorCodeEnum}`;
|
|
9
|
+
|
|
10
|
+
/** Failures the browser detects itself, before or instead of a server answer. */
|
|
11
|
+
export type OAuthClientErrorCode = 'cancelled' | 'unknown_provider';
|
|
12
|
+
|
|
13
|
+
export type OAuthFailureCode = OAuthErrorCode | OAuthClientErrorCode;
|
|
14
|
+
|
|
15
|
+
export interface OAuthFailure {
|
|
16
|
+
/** Set when the cause is one the UI can explain; absent for transport faults. */
|
|
17
|
+
code?: OAuthFailureCode;
|
|
18
|
+
/** English text for callers without a translator: server prose, else a generic sentence. */
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Read from the generated enum: a code the backend adds arrives with `make generate`.
|
|
23
|
+
const SERVER_CODES: ReadonlySet<string> = new Set<string>(Object.values(OAuthErrorCodeEnum));
|
|
24
|
+
|
|
25
|
+
const record = (value: unknown): Record<string, unknown> | null =>
|
|
26
|
+
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read `{error, error_description}` off a thrown API error.
|
|
30
|
+
*
|
|
31
|
+
* The transport keeps the parsed body on `response`; `formatAuthError` alone
|
|
32
|
+
* would surface the machine code (`invalid_state`) as if it were prose.
|
|
33
|
+
*/
|
|
34
|
+
export const readOAuthFailure = (err: unknown, fallback: string): OAuthFailure => {
|
|
35
|
+
const body = record(record(err)?.response);
|
|
36
|
+
const code = body?.error;
|
|
37
|
+
if (typeof code === 'string' && SERVER_CODES.has(code)) {
|
|
38
|
+
const description = body?.error_description;
|
|
39
|
+
return {
|
|
40
|
+
code: code as OAuthErrorCode,
|
|
41
|
+
message: typeof description === 'string' && description ? description : fallback,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { message: formatAuthError(err, fallback) };
|
|
45
|
+
};
|