Skip to content

Commit 36e563d

Browse files
committed
fix: add OTP validate rate limiting, standardize error responses, i18n compliance
1 parent 560f8da commit 36e563d

11 files changed

Lines changed: 120 additions & 97 deletions

File tree

src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ public async Task<IActionResult> GetConfig([FromRoute] string code, Cancellation
6464
var authEndpoint = root.TryGetProperty("authorization_endpoint", out var ae) ? ae.GetString() : null;
6565
var tokenEndpoint = root.TryGetProperty("token_endpoint", out var te) ? te.GetString() : null;
6666
if (string.IsNullOrEmpty(authEndpoint) || string.IsNullOrEmpty(tokenEndpoint))
67-
return StatusCode(StatusCodes.Status502BadGateway, new { error = "Invalid discovery document." });
67+
return StatusCode(StatusCodes.Status502BadGateway, new ErrorResponse("Invalid discovery document."));
6868
var languageParam = config["Oidc:LanguageParam"] ?? "en";
6969
return Ok(new { authorizationEndpoint = authEndpoint, tokenEndpoint, clientId, redirectUri, languageParam });
7070
}
7171
catch (Exception ex)
7272
{
7373
logger.LogWarning(ex, "Failed to fetch OIDC discovery document");
74-
return StatusCode(StatusCodes.Status502BadGateway, new { error = "Unable to load OIDC config." });
74+
return StatusCode(StatusCodes.Status502BadGateway, new ErrorResponse("Unable to load OIDC config."));
7575
}
7676
}
7777

@@ -89,7 +89,7 @@ public async Task<IActionResult> CompleteLogin(
8989
CancellationToken cancellationToken)
9090
{
9191
if (body == null || string.IsNullOrEmpty(body.StateCode) || string.IsNullOrEmpty(body.CallbackToken))
92-
return BadRequest(new { error = "Missing stateCode or callbackToken." });
92+
return BadRequest(new ErrorResponse("Missing stateCode or callbackToken."));
9393

9494
var stateKey = body.StateCode.ToLowerInvariant();
9595
var signingKey = config["Oidc:CompleteLoginSigningKey"];
@@ -122,7 +122,7 @@ public async Task<IActionResult> CompleteLogin(
122122
catch (Exception ex)
123123
{
124124
logger.LogWarning(ex, "Invalid or expired callback token for state {StateCode}", body.StateCode);
125-
return BadRequest(new { error = "Invalid or expired callback token." });
125+
return BadRequest(new ErrorResponse("Invalid or expired callback token."));
126126
}
127127

128128
// Copy non-common IdP claims into the portal JWT (e.g. phone, givenName, familyName, userId, email, sub)
@@ -137,7 +137,7 @@ public async Task<IActionResult> CompleteLogin(
137137
if (string.IsNullOrWhiteSpace(email))
138138
{
139139
logger.LogWarning("Callback token had no email claim");
140-
return BadRequest(new { error = "Callback token must contain an email claim." });
140+
return BadRequest(new ErrorResponse("Callback token must contain an email claim."));
141141
}
142142

143143
var normalizedEmail = EmailNormalizer.Normalize(email);

src/SEBT.Portal.Api/Controllers/Auth/OtpController.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public async Task<IActionResult> RequestOtp(
3636
{
3737
if (command == null)
3838
{
39-
return BadRequest(new { Error = "Request body is required." });
39+
return BadRequest(new ErrorResponse("Request body is required."));
4040
}
4141

4242
logger.LogInformation("OTP request received for email {Email}", command.Email);
@@ -49,7 +49,7 @@ public async Task<IActionResult> RequestOtp(
4949
}
5050
else
5151
{
52-
return BadRequest(new { Error = result.Message });
52+
return BadRequest(new ErrorResponse(result.Message));
5353
}
5454

5555
}
@@ -64,16 +64,18 @@ public async Task<IActionResult> RequestOtp(
6464
/// <response code="400">Invalid OTP or request.</response>
6565
/// <response code="500">An error occurred while generating the authentication token.</response>
6666
[HttpPost("validate")]
67+
[EnableRateLimiting(RateLimitPolicies.Otp)]
6768
[ProducesResponseType(typeof(ValidateOtpResponse), StatusCodes.Status200OK)]
6869
[ProducesResponseType(StatusCodes.Status400BadRequest)]
70+
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
6971
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
7072
public async Task<IActionResult> ValidateOtp(
7173
[FromBody] ValidateOtpCommand command,
7274
[FromServices] ICommandHandler<ValidateOtpCommand, string> handler)
7375
{
7476
if (command == null)
7577
{
76-
return BadRequest(new { Error = "Request body is required." });
78+
return BadRequest(new ErrorResponse("Request body is required."));
7779
}
7880

7981
logger.LogInformation("OTP validation request received for email {Email}", command.Email);
@@ -88,7 +90,7 @@ public async Task<IActionResult> ValidateOtp(
8890
else
8991
{
9092
logger.LogWarning("OTP validation failed for email {Email}: {Message}", command.Email, result.Message);
91-
return BadRequest(new { Error = result.Message });
93+
return BadRequest(new ErrorResponse(result.Message));
9294
}
9395
}
9496
}

src/SEBT.Portal.Infrastructure/Dependencies.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,6 @@ public static IServiceCollection AddPortalInfrastructureAppSettings(this IServic
164164
services.AddOptionsWithValidateOnStart<EnrollmentCheckRateLimitSettings>()
165165
.BindConfiguration(EnrollmentCheckRateLimitSettings.SectionName);
166166

167-
services.AddOptionsWithValidateOnStart<EnrollmentCheckRateLimitSettings>()
168-
.BindConfiguration(EnrollmentCheckRateLimitSettings.SectionName);
169-
170167
services.AddOptions<SeedingSettings>()
171168
.BindConfiguration(SeedingSettings.SectionName);
172169

src/SEBT.Portal.Web/src/api/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ export async function apiFetch<T>(endpoint: string, options: ApiFetchOptions<T>
8282
}
8383

8484
if (response.status === 201 || response.status === 204) {
85-
return undefined as T
85+
// 201/204 responses have no body. Callers for these endpoints
86+
// should expect void or ignore the return value.
87+
return undefined as unknown as T
8688
}
8789

8890
let data: T | ApiErrorResponse | undefined

src/SEBT.Portal.Web/src/features/auth/components/doc-verify/DocVerifyInterstitial.tsx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use client'
22

3+
import { useTranslation } from 'react-i18next'
4+
35
import { Button } from '@sebt/design-system'
46

57
interface DocVerifyInterstitialProps {
@@ -17,33 +19,36 @@ export function DocVerifyInterstitial({
1719
onEnterIdNumber,
1820
contactLink
1921
}: DocVerifyInterstitialProps) {
22+
const { t } = useTranslation('idProofing')
23+
2024
return (
2125
<section aria-labelledby="doc-verify-title">
22-
{/* TODO: Use t('docVerify.title') once key is available in dc.csv */}
2326
<h1
2427
id="doc-verify-title"
2528
className="font-sans-xl text-bold line-height-sans-1 margin-bottom-3"
2629
>
27-
We want to keep your account safe
30+
{t('interstitialHeading', 'We want to keep your account safe')}
2831
</h1>
2932

30-
{/* TODO: Use t('docVerify.body') once key is available in dc.csv */}
3133
<p className="font-sans-sm">
32-
In order to confirm your identity we need to ask for additional documentation. On the next
33-
screen, we&apos;ll ask you to upload a photo of your:
34+
{t(
35+
'interstitialBody',
36+
"In order to confirm your identity we need to ask for additional documentation. On the next screen, we'll ask you to upload a photo of your:"
37+
)}
3438
</p>
3539

3640
<ul className="usa-list font-sans-sm">
37-
{/* TODO: Use t('docVerify.docTypes.*') once keys are available in dc.csv */}
38-
<li>driver&apos;s license</li>
39-
<li>foreign passport</li>
40-
<li>or another photo ID</li>
41+
<li>{t('interstitialIdTypeDriversLicense', "driver's license")}</li>
42+
<li>{t('interstitialIdTypeForeignPassport', 'foreign passport')}</li>
43+
<li>{t('interstitialIdTypeOtherPhotoId', 'or another photo ID')}</li>
4144
</ul>
4245

4346
{allowIdRetry && (
4447
<p className="font-sans-sm">
45-
{/* TODO: Use t('docVerify.skipHint') once key is available in dc.csv */}
46-
You can skip this step by going back and typing in your ID number instead.
48+
{t(
49+
'interstitialSkipHint',
50+
'You can skip this step by going back and typing in your ID number instead.'
51+
)}
4752
</p>
4853
)}
4954

@@ -54,42 +59,39 @@ export function DocVerifyInterstitial({
5459
className="usa-button--outline margin-right-2"
5560
onClick={onEnterIdNumber}
5661
>
57-
{/* TODO: Use t('docVerify.actionEnterId') once key is available in dc.csv */}
58-
Enter an ID number
62+
{t('interstitialActionEnterId', 'Enter an ID number')}
5963
</Button>
6064
)}
6165

6266
<Button
6367
type="button"
6468
onClick={onContinue}
6569
isLoading={isStartingChallenge}
66-
loadingText="Loading..."
70+
loadingText={t('interstitialLoading', 'Loading...')}
6771
disabled={isStartingChallenge}
6872
>
69-
{/* TODO: Use t('docVerify.actionContinue') once key is available in dc.csv */}
70-
Continue
73+
{t('interstitialActionContinue', 'Continue')}
7174
</Button>
7275
</div>
7376

7477
{/* FAQs placeholder */}
7578
<div className="margin-top-6">
76-
{/* TODO: Use t('docVerify.faqs') once key is available in dc.csv */}
77-
<h2 className="font-sans-lg text-bold">FAQs</h2>
79+
<h2 className="font-sans-lg text-bold">{t('interstitialFaqsHeading', 'FAQs')}</h2>
7880
</div>
7981

8082
{/* Contact Us */}
8183
<div className="margin-top-4">
82-
{/* TODO: Use t('docVerify.contactUs') once key is available in dc.csv */}
83-
<h2 className="font-sans-lg text-bold">Contact Us</h2>
84+
<h2 className="font-sans-lg text-bold">
85+
{t('interstitialContactUsHeading', 'Contact Us')}
86+
</h2>
8487
<p className="font-sans-sm">
8588
<a
8689
href={contactLink}
8790
target="_blank"
8891
rel="noopener noreferrer"
8992
className="usa-link"
9093
>
91-
{/* TODO: Use t('docVerify.contactUsLink') once key is available in dc.csv */}
92-
Need help? Contact us.
94+
{t('interstitialContactUsLink', 'Need help? Contact us.')}
9395
</a>
9496
</p>
9597
</div>

src/SEBT.Portal.Web/src/features/auth/components/doc-verify/DocVerifyPage.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useRouter, useSearchParams } from 'next/navigation'
44
import { useCallback, useEffect, useRef, useState } from 'react'
5+
import { useTranslation } from 'react-i18next'
56

67
import { Alert } from '@sebt/design-system'
78

@@ -40,6 +41,7 @@ function clearChallengeContext(): void {
4041
export function DocVerifyPage({ contactLink, sdkKey }: DocVerifyPageProps) {
4142
const router = useRouter()
4243
const searchParams = useSearchParams()
44+
const { t } = useTranslation('idProofing')
4345
const startChallenge = useStartChallenge()
4446

4547
const [subState, setSubState] = useState<SubState>('interstitial')
@@ -118,8 +120,12 @@ export function DocVerifyPage({ contactLink, sdkKey }: DocVerifyPageProps) {
118120
sessionStorage.setItem(SK_SUB_STATE, 'capture')
119121
setSubState('capture')
120122
} catch {
121-
// TODO: Use t('docVerify.errorStartChallenge') once key is available in dc.csv
122-
setError('Something went wrong starting document verification. Please try again.')
123+
setError(
124+
t(
125+
'docVerifyStartError',
126+
'Something went wrong starting document verification. Please try again.'
127+
)
128+
)
123129
}
124130
}
125131

src/SEBT.Portal.Web/src/features/auth/components/doc-verify/VerificationPending.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { useEffect, useState } from 'react'
4+
import { useTranslation } from 'react-i18next'
45

56
import { Button } from '@sebt/design-system'
67

@@ -20,6 +21,7 @@ export function VerificationPending({
2021
onVerified,
2122
onRejected
2223
}: VerificationPendingProps) {
24+
const { t } = useTranslation('idProofing')
2325
const [timerExpired, setTimerExpired] = useState(false)
2426

2527
const { data, error, refetch } = useVerificationStatus(challengeId)
@@ -46,39 +48,42 @@ export function VerificationPending({
4648
}, [data?.status, data?.offboardingReason, onVerified, onRejected])
4749

4850
return (
49-
<section aria-label="Verification status">
51+
<section aria-label={t('verificationPendingAriaLabel', 'Verification status')}>
5052
{!showStillChecking ? (
5153
<div className="text-center padding-y-6">
52-
{/* TODO: Use t('docVerify.verifying') once key is available in dc.csv */}
53-
<p className="font-sans-lg text-bold margin-bottom-2">Verifying your document...</p>
54+
<p className="font-sans-lg text-bold margin-bottom-2">
55+
{t('verificationPendingHeading', 'Verifying your document...')}
56+
</p>
5457
<p className="font-sans-sm text-base-dark">
55-
This may take a moment. Please don&apos;t close this page.
58+
{t('verificationPendingBody', "This may take a moment. Please don't close this page.")}
5659
</p>
5760
{/* USWDS loading indicator */}
5861
<div
5962
className="margin-top-3"
6063
aria-busy="true"
6164
aria-live="polite"
6265
>
63-
<span className="text-base-dark">Checking verification status</span>
66+
<span className="text-base-dark">
67+
{t('verificationPendingStatusLabel', 'Checking verification status')}
68+
</span>
6469
</div>
6570
</div>
6671
) : (
6772
<div className="text-center padding-y-6">
68-
{/* TODO: Use t('docVerify.stillChecking') once key is available in dc.csv */}
6973
<p className="font-sans-lg text-bold margin-bottom-2">
70-
We&apos;re still checking your document
74+
{t('verificationPendingStillCheckingHeading', "We're still checking your document")}
7175
</p>
7276
<p className="font-sans-sm text-base-dark margin-bottom-3">
73-
Verification is taking longer than expected. You can check the status or try again
74-
later.
77+
{t(
78+
'verificationPendingStillCheckingBody',
79+
'Verification is taking longer than expected. You can check the status or try again later.'
80+
)}
7581
</p>
7682
<Button
7783
type="button"
7884
onClick={() => refetch()}
7985
>
80-
{/* TODO: Use t('docVerify.actionCheckStatus') once key is available in dc.csv */}
81-
Check status
86+
{t('verificationPendingActionCheckStatus', 'Check status')}
8287
</Button>
8388
</div>
8489
)}

src/SEBT.Portal.Web/src/features/auth/components/id-proofing/IdProofingForm.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
107107

108108
if (response.result === 'documentVerificationRequired') {
109109
if (!response.challengeId) {
110-
// TODO: Use t('idProofing.errorNoChallengeId') once key is available in dc.csv
111-
setSubmitError('Unable to start document verification. Please try again.')
110+
setSubmitError(
111+
t('idProofingStartError', 'Unable to start document verification. Please try again.')
112+
)
112113
return
113114
}
114115
sessionStorage.setItem(SK_CHALLENGE_ID, response.challengeId)
@@ -124,11 +125,10 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
124125
router.push('/dashboard')
125126
}
126127
} catch (err) {
127-
// TODO: Use t('errorUnexpected') once key is available in dc.csv
128128
// All errors get the same user-facing message. Raw ApiError.message may contain
129129
// backend wording not intended for end users — avoid displaying it directly.
130130
void err
131-
setSubmitError('Something went wrong. Please try again.')
131+
setSubmitError(t('idProofingGenericError', 'Something went wrong. Please try again.'))
132132
}
133133
}
134134

0 commit comments

Comments
 (0)