@authagonal/login 0.3.7 → 0.3.9

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/dist/types.d.ts CHANGED
@@ -28,6 +28,8 @@ export interface ExternalProvider {
28
28
  }
29
29
  export interface ProvidersResponse {
30
30
  providers: ExternalProvider[];
31
+ /** Cloudflare Turnstile site key when configured; absent = Turnstile disabled (render no widget). */
32
+ turnstileSiteKey?: string;
31
33
  }
32
34
  export interface PasswordPolicyRule {
33
35
  rule: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authagonal/login",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Default login UI for Authagonal — runtime-configurable via branding.json",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api.ts CHANGED
@@ -45,18 +45,18 @@ function setupTokenHeaders(token?: string): Record<string, string> {
45
45
  return token ? { 'X-MFA-Setup-Token': token } : {};
46
46
  }
47
47
 
48
- export function login(email: string, password: string, returnUrl?: string): Promise<MfaLoginResponse> {
48
+ export function login(email: string, password: string, returnUrl?: string, turnstileToken?: string): Promise<MfaLoginResponse> {
49
49
  const url = returnUrl ? `/api/auth/login?returnUrl=${encodeURIComponent(returnUrl)}` : '/api/auth/login';
50
50
  return api<MfaLoginResponse>(url, {
51
51
  method: 'POST',
52
- body: JSON.stringify({ email, password }),
52
+ body: JSON.stringify({ email, password, turnstileToken }),
53
53
  });
54
54
  }
55
55
 
56
- export function register(email: string, password: string, firstName?: string, lastName?: string): Promise<RegisterResponse> {
56
+ export function register(email: string, password: string, firstName?: string, lastName?: string, turnstileToken?: string): Promise<RegisterResponse> {
57
57
  return api<RegisterResponse>('/api/auth/register', {
58
58
  method: 'POST',
59
- body: JSON.stringify({ email, password, firstName, lastName }),
59
+ body: JSON.stringify({ email, password, firstName, lastName, turnstileToken }),
60
60
  });
61
61
  }
62
62
 
@@ -66,17 +66,17 @@ export function logout(): Promise<{ success: true }> {
66
66
  });
67
67
  }
68
68
 
69
- export function forgotPassword(email: string): Promise<{ success: true }> {
69
+ export function forgotPassword(email: string, turnstileToken?: string): Promise<{ success: true }> {
70
70
  return api<{ success: true }>('/api/auth/forgot-password', {
71
71
  method: 'POST',
72
- body: JSON.stringify({ email }),
72
+ body: JSON.stringify({ email, turnstileToken }),
73
73
  });
74
74
  }
75
75
 
76
- export function resetPassword(token: string, newPassword: string): Promise<{ success: true }> {
76
+ export function resetPassword(token: string, newPassword: string, turnstileToken?: string): Promise<{ success: true }> {
77
77
  return api<{ success: true }>('/api/auth/reset-password', {
78
78
  method: 'POST',
79
- body: JSON.stringify({ token, newPassword }),
79
+ body: JSON.stringify({ token, newPassword, turnstileToken }),
80
80
  });
81
81
  }
82
82
 
@@ -0,0 +1,76 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ // Cloudflare Turnstile ("I'm human"). Renders the managed widget and reports the
4
+ // token via onToken. Only mount this when a site key is configured (opt-in) — the
5
+ // server returns turnstileSiteKey on /api/auth/providers when Turnstile is enabled.
6
+
7
+ declare global {
8
+ interface Window {
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ turnstile?: any;
11
+ }
12
+ }
13
+
14
+ const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
15
+ let scriptPromise: Promise<void> | null = null;
16
+
17
+ function loadTurnstileScript(): Promise<void> {
18
+ if (typeof window !== 'undefined' && window.turnstile) return Promise.resolve();
19
+ if (scriptPromise) return scriptPromise;
20
+ scriptPromise = new Promise<void>((resolve, reject) => {
21
+ const s = document.createElement('script');
22
+ s.src = SCRIPT_SRC;
23
+ s.async = true;
24
+ s.defer = true;
25
+ s.onload = () => resolve();
26
+ s.onerror = () => {
27
+ scriptPromise = null;
28
+ reject(new Error('Failed to load Cloudflare Turnstile'));
29
+ };
30
+ document.head.appendChild(s);
31
+ });
32
+ return scriptPromise;
33
+ }
34
+
35
+ interface TurnstileProps {
36
+ siteKey: string;
37
+ /** Called with the token on success, or null when it expires / errors / is reset. */
38
+ onToken: (token: string | null) => void;
39
+ theme?: 'auto' | 'light' | 'dark';
40
+ }
41
+
42
+ export function Turnstile({ siteKey, onToken, theme = 'auto' }: TurnstileProps) {
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+ const widgetIdRef = useRef<string | null>(null);
45
+
46
+ useEffect(() => {
47
+ let cancelled = false;
48
+ loadTurnstileScript()
49
+ .then(() => {
50
+ if (cancelled || !containerRef.current || !window.turnstile) return;
51
+ widgetIdRef.current = window.turnstile.render(containerRef.current, {
52
+ sitekey: siteKey,
53
+ theme,
54
+ callback: (token: string) => onToken(token),
55
+ 'expired-callback': () => onToken(null),
56
+ 'error-callback': () => onToken(null),
57
+ });
58
+ })
59
+ .catch(() => onToken(null));
60
+
61
+ return () => {
62
+ cancelled = true;
63
+ if (widgetIdRef.current && window.turnstile) {
64
+ try {
65
+ window.turnstile.remove(widgetIdRef.current);
66
+ } catch {
67
+ /* widget already gone */
68
+ }
69
+ widgetIdRef.current = null;
70
+ }
71
+ };
72
+ // siteKey is stable for the page lifetime; re-render only if it changes.
73
+ }, [siteKey, theme, onToken]);
74
+
75
+ return <div ref={containerRef} className="flex justify-center" />;
76
+ }
@@ -1,7 +1,8 @@
1
- import { useState } from 'react';
1
+ import { useState, useEffect } from 'react';
2
2
  import { useSearchParams, Link } from 'react-router-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { forgotPassword } from '../api';
4
+ import { forgotPassword, getProviders } from '../api';
5
+ import { Turnstile } from '../components/Turnstile';
5
6
  import { Button } from '@/components/ui/button';
6
7
  import { Input } from '@/components/ui/input';
7
8
  import { Label } from '@/components/ui/label';
@@ -17,6 +18,16 @@ export default function ForgotPasswordPage() {
17
18
  const [loading, setLoading] = useState(false);
18
19
  const [submitted, setSubmitted] = useState(false);
19
20
  const [error, setError] = useState('');
21
+ const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>(undefined);
22
+ const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
23
+ const [turnstileKey, setTurnstileKey] = useState(0); // bump to re-mount the widget for a fresh challenge
24
+
25
+ // Surface the Turnstile site key (opt-in; empty when not configured for the tenant).
26
+ useEffect(() => {
27
+ getProviders()
28
+ .then((res) => setTurnstileSiteKey(res.turnstileSiteKey))
29
+ .catch(() => {});
30
+ }, []);
20
31
 
21
32
  const loginLink = returnUrl
22
33
  ? `/login?returnUrl=${encodeURIComponent(returnUrl)}`
@@ -28,11 +39,16 @@ export default function ForgotPasswordPage() {
28
39
  setLoading(true);
29
40
 
30
41
  try {
31
- await forgotPassword(email);
42
+ await forgotPassword(email, turnstileToken || undefined);
32
43
  setSubmitted(true);
33
44
  } catch {
34
45
  // The API always returns 200 for anti-enumeration, but handle errors just in case
35
46
  setError(t('errorUnexpected'));
47
+ // Turnstile tokens are single-use — reset so a retry gets a fresh challenge.
48
+ if (turnstileSiteKey) {
49
+ setTurnstileToken(null);
50
+ setTurnstileKey((k) => k + 1);
51
+ }
36
52
  } finally {
37
53
  setLoading(false);
38
54
  }
@@ -75,7 +91,13 @@ export default function ForgotPasswordPage() {
75
91
  />
76
92
  </div>
77
93
 
78
- <Button type="submit" loading={loading}>
94
+ {turnstileSiteKey && (
95
+ <div className="mb-4">
96
+ <Turnstile key={turnstileKey} siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
97
+ </div>
98
+ )}
99
+
100
+ <Button type="submit" loading={loading} disabled={!!turnstileSiteKey && !turnstileToken}>
79
101
  {loading ? t('sending') : t('sendResetLink')}
80
102
  </Button>
81
103
 
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import { useSearchParams, Link, useNavigate } from 'react-router-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { login, logout, ssoCheck, getProviders, getSession, ApiRequestError } from '../api';
5
+ import { Turnstile } from '../components/Turnstile';
5
6
  import { useBranding } from '../branding';
6
7
  import type { ExternalProvider } from '../types';
7
8
  import { Button } from '@/components/ui/button';
@@ -45,6 +46,9 @@ export default function LoginPage() {
45
46
  const [ssoChecked, setSsoChecked] = useState(false);
46
47
  const [ssoChecking, setSsoChecking] = useState(false);
47
48
  const [providers, setProviders] = useState<ExternalProvider[]>([]);
49
+ const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>(undefined);
50
+ const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
51
+ const [turnstileKey, setTurnstileKey] = useState(0); // bump to re-mount the widget for a fresh challenge
48
52
  const [session, setSession] = useState<{ name: string; email: string } | null>(null);
49
53
  const [mfaPrompt, setMfaPrompt] = useState<{ returnUrl: string; userId: string; clientId: string } | null>(null);
50
54
 
@@ -92,7 +96,10 @@ export default function LoginPage() {
92
96
  // Fetch available external providers
93
97
  useEffect(() => {
94
98
  getProviders()
95
- .then((res) => setProviders(res.providers ?? []))
99
+ .then((res) => {
100
+ setProviders(res.providers ?? []);
101
+ setTurnstileSiteKey(res.turnstileSiteKey);
102
+ })
96
103
  .catch(() => {});
97
104
  }, []);
98
105
 
@@ -148,7 +155,7 @@ export default function LoginPage() {
148
155
  setLoading(true);
149
156
 
150
157
  try {
151
- const result = await login(email, password, returnUrl || undefined);
158
+ const result = await login(email, password, returnUrl || undefined, turnstileToken || undefined);
152
159
 
153
160
  if (result.mfaRequired && result.challengeId) {
154
161
  // Redirect to MFA challenge page
@@ -216,12 +223,20 @@ export default function LoginPage() {
216
223
  case 'password_required':
217
224
  setError(t('errorPasswordRequired'));
218
225
  break;
226
+ case 'captcha_failed':
227
+ setError(t('errorUnexpected'));
228
+ break;
219
229
  default:
220
230
  setError(err.message || t('errorUnexpected'));
221
231
  }
222
232
  } else {
223
233
  setError(t('errorUnexpected'));
224
234
  }
235
+ // Turnstile tokens are single-use — reset so a retry gets a fresh challenge.
236
+ if (turnstileSiteKey) {
237
+ setTurnstileToken(null);
238
+ setTurnstileKey((k) => k + 1);
239
+ }
225
240
  } finally {
226
241
  setLoading(false);
227
242
  }
@@ -393,7 +408,13 @@ export default function LoginPage() {
393
408
  />
394
409
  </div>
395
410
 
396
- <Button type="submit" loading={loading} data-auth="submit-button">
411
+ {turnstileSiteKey && (
412
+ <div className="mb-4">
413
+ <Turnstile key={turnstileKey} siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
414
+ </div>
415
+ )}
416
+
417
+ <Button type="submit" loading={loading} disabled={!!turnstileSiteKey && !turnstileToken} data-auth="submit-button">
397
418
  {loading ? t('signingIn') : t('signIn')}
398
419
  </Button>
399
420
 
@@ -1,8 +1,9 @@
1
- import { useState } from 'react';
1
+ import { useState, useEffect } from 'react';
2
2
  import { useSearchParams, Link, useNavigate } from 'react-router-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { register, getPasswordPolicy, ApiRequestError } from '../api';
4
+ import { register, getPasswordPolicy, getProviders, ApiRequestError } from '../api';
5
5
  import type { PasswordPolicyRule } from '../types';
6
+ import { Turnstile } from '../components/Turnstile';
6
7
  import { Button } from '@/components/ui/button';
7
8
  import { Input } from '@/components/ui/input';
8
9
  import { Label } from '@/components/ui/label';
@@ -23,6 +24,16 @@ export default function RegisterPage() {
23
24
  const [loading, setLoading] = useState(false);
24
25
  const [policyRules, setPolicyRules] = useState<PasswordPolicyRule[]>([]);
25
26
  const [policyLoaded, setPolicyLoaded] = useState(false);
27
+ const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>(undefined);
28
+ const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
29
+ const [turnstileKey, setTurnstileKey] = useState(0);
30
+
31
+ // Turnstile site key is surfaced on /providers; absent = Turnstile disabled.
32
+ useEffect(() => {
33
+ getProviders()
34
+ .then((res) => setTurnstileSiteKey(res.turnstileSiteKey))
35
+ .catch(() => {});
36
+ }, []);
26
37
 
27
38
  function loadPolicy() {
28
39
  if (policyLoaded) return;
@@ -38,7 +49,7 @@ export default function RegisterPage() {
38
49
  setLoading(true);
39
50
 
40
51
  try {
41
- await register(email, password, firstName || undefined, lastName || undefined);
52
+ await register(email, password, firstName || undefined, lastName || undefined, turnstileToken || undefined);
42
53
 
43
54
  // Redirect to login with success message
44
55
  const params = new URLSearchParams();
@@ -58,12 +69,20 @@ export default function RegisterPage() {
58
69
  case 'email_and_password_required':
59
70
  setError(t('errorEmailAndPasswordRequired'));
60
71
  break;
72
+ case 'captcha_failed':
73
+ setError(t('errorRegistrationFailed'));
74
+ break;
61
75
  default:
62
76
  setError(err.message || t('errorRegistrationFailed'));
63
77
  }
64
78
  } else {
65
79
  setError(t('errorRegistrationFailed'));
66
80
  }
81
+ // Turnstile tokens are single-use — reset so a retry gets a fresh challenge.
82
+ if (turnstileSiteKey) {
83
+ setTurnstileToken(null);
84
+ setTurnstileKey((k) => k + 1);
85
+ }
67
86
  } finally {
68
87
  setLoading(false);
69
88
  }
@@ -145,7 +164,13 @@ export default function RegisterPage() {
145
164
  </ul>
146
165
  )}
147
166
 
148
- <Button type="submit" loading={loading}>
167
+ {turnstileSiteKey && (
168
+ <div className="mb-4">
169
+ <Turnstile key={turnstileKey} siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
170
+ </div>
171
+ )}
172
+
173
+ <Button type="submit" loading={loading} disabled={!!turnstileSiteKey && !turnstileToken}>
149
174
  {loading ? t('registering') : t('registerButton')}
150
175
  </Button>
151
176
 
@@ -1,7 +1,8 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import { useSearchParams, Link } from 'react-router-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { resetPassword, ApiRequestError } from '../api';
4
+ import { resetPassword, getProviders, ApiRequestError } from '../api';
5
+ import { Turnstile } from '../components/Turnstile';
5
6
  import { Button } from '@/components/ui/button';
6
7
  import { Input } from '@/components/ui/input';
7
8
  import { Label } from '@/components/ui/label';
@@ -57,6 +58,9 @@ export default function ResetPasswordPage() {
57
58
  const [success, setSuccess] = useState(false);
58
59
  const [validationError, setValidationError] = useState('');
59
60
  const [rules, setRules] = useState<PasswordRule[]>(defaultRules);
61
+ const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>(undefined);
62
+ const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
63
+ const [turnstileKey, setTurnstileKey] = useState(0); // bump to re-mount the widget for a fresh challenge
60
64
 
61
65
  useEffect(() => {
62
66
  fetch(`${API_URL}/api/auth/password-policy`)
@@ -65,6 +69,13 @@ export default function ResetPasswordPage() {
65
69
  .catch(() => { /* use defaults */ });
66
70
  }, []);
67
71
 
72
+ // Surface the Turnstile site key (opt-in; empty when not configured for the tenant).
73
+ useEffect(() => {
74
+ getProviders()
75
+ .then((res) => setTurnstileSiteKey(res.turnstileSiteKey))
76
+ .catch(() => {});
77
+ }, []);
78
+
68
79
  function getRuleLabel(rule: PasswordRule): string {
69
80
  switch (rule.rule) {
70
81
  case 'minLength': return t('ruleMinLength', { count: rule.value ?? 8 });
@@ -102,7 +113,7 @@ export default function ResetPasswordPage() {
102
113
  setLoading(true);
103
114
 
104
115
  try {
105
- await resetPassword(token, newPassword);
116
+ await resetPassword(token, newPassword, turnstileToken || undefined);
106
117
  setSuccess(true);
107
118
  } catch (err) {
108
119
  if (err instanceof ApiRequestError) {
@@ -117,12 +128,20 @@ export default function ResetPasswordPage() {
117
128
  case 'password_required':
118
129
  setError(t('errorPasswordRequired'));
119
130
  break;
131
+ case 'captcha_failed':
132
+ setError(t('errorUnexpected'));
133
+ break;
120
134
  default:
121
135
  setError(err.message || t('errorUnexpected'));
122
136
  }
123
137
  } else {
124
138
  setError(t('errorUnexpected'));
125
139
  }
140
+ // Turnstile tokens are single-use — reset so a retry gets a fresh challenge.
141
+ if (turnstileSiteKey) {
142
+ setTurnstileToken(null);
143
+ setTurnstileKey((k) => k + 1);
144
+ }
126
145
  } finally {
127
146
  setLoading(false);
128
147
  }
@@ -204,7 +223,13 @@ export default function ResetPasswordPage() {
204
223
  />
205
224
  </div>
206
225
 
207
- <Button type="submit" loading={loading} disabled={!allRequirementsMet}>
226
+ {turnstileSiteKey && (
227
+ <div className="mb-4">
228
+ <Turnstile key={turnstileKey} siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
229
+ </div>
230
+ )}
231
+
232
+ <Button type="submit" loading={loading} disabled={!allRequirementsMet || (!!turnstileSiteKey && !turnstileToken)}>
208
233
  {loading ? t('resetting') : t('resetPassword')}
209
234
  </Button>
210
235
 
package/src/types.ts CHANGED
@@ -33,6 +33,8 @@ export interface ExternalProvider {
33
33
 
34
34
  export interface ProvidersResponse {
35
35
  providers: ExternalProvider[];
36
+ /** Cloudflare Turnstile site key when configured; absent = Turnstile disabled (render no widget). */
37
+ turnstileSiteKey?: string;
36
38
  }
37
39
 
38
40
  export interface PasswordPolicyRule {