feat(web): consistent rate limit information (#13510)

This drastically improves the user feedback surrounding rate limited
endpoints displaying information about how long the user should wait.
This also drastically improves the related tooling including adding a
react hook and go funcs to ensure the desired outcome is always
achieved.
This commit is contained in:
James Elliott authored and GitHub committed 2026-10-20 22:35:20 +00:00
1 parent 54cd7cb4c3
commit 37a0538aec
50 files changed
+1449 -717

No files matched your search

+27 -27
View File
@@ -714,9 +714,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
security:
- authelia_auth: []
/api/reset-password/identity/finish:
@@ -753,9 +753,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
security:
- authelia_auth: []
/api/reset-password:
@@ -791,9 +791,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
security:
- authelia_auth: []
delete:
@@ -826,9 +826,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
security:
- authelia_auth: []
{{- end }}
@@ -940,9 +940,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
security:
- authelia_auth: []
put:
@@ -983,9 +983,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
security:
- authelia_auth: []
/api/user/session/elevation/{id}:
@@ -1211,9 +1211,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
security:
- authelia_auth: []
delete:
@@ -1558,9 +1558,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
security:
- authelia_auth: []
/api/secondfactor/duo_devices:
@@ -2141,9 +2141,9 @@ paths:
headers:
Retry-After:
schema:
type: string
format: http-date
description: The date time that the request can be retried.
type: integer
minimum: 0
description: The number of seconds to wait before the request can be retried.
"500":
description: Internal Server Error
content:
+2
View File
@@ -28,6 +28,7 @@ func TestNewConfiguration(t *testing.T) {
paths: []string{"../../internal/configuration/test_resources/config.webauthn.yml"},
keys: []string{
"regulation.max_retries",
"server.endpoints.rate_limits.health.enable",
"server.endpoints.rate_limits.openid_connect_introspection.enable",
"server.endpoints.rate_limits.openid_connect_pushed_authorization_request.enable",
"server.endpoints.rate_limits.openid_connect_revocation.enable",
@@ -105,6 +106,7 @@ func TestNewConfiguration(t *testing.T) {
"server.endpoints.authz.forward-auth.authn_strategies[].name",
"server.endpoints.authz.forward-auth.implementation",
"server.endpoints.authz.legacy.implementation",
"server.endpoints.rate_limits.health.enable",
"server.endpoints.rate_limits.openid_connect_introspection.enable",
"server.endpoints.rate_limits.openid_connect_pushed_authorization_request.enable",
"server.endpoints.rate_limits.openid_connect_revocation.enable",
+21 -17
View File
@@ -4,23 +4,27 @@
package configuration
var defaults = map[string]any{
"regulation.max_retries": 3,
"server.endpoints.rate_limits.openid_connect_introspection.enable": true,
"server.endpoints.rate_limits.openid_connect_pushed_authorization_request.enable": true,
"server.endpoints.rate_limits.openid_connect_revocation.enable": true,
"server.endpoints.rate_limits.openid_connect_token.enable": true,
"server.endpoints.rate_limits.openid_connect_userinfo.enable": true,
"server.endpoints.rate_limits.reset_password_start.enable": true,
"server.endpoints.rate_limits.reset_password_finish.enable": true,
"server.endpoints.rate_limits.second_factor_totp.enable": true,
"server.endpoints.rate_limits.second_factor_duo.enable": true,
"server.endpoints.rate_limits.second_factor_password.enable": true,
"server.endpoints.rate_limits.session_elevation_start.enable": true,
"server.endpoints.rate_limits.session_elevation_finish.enable": true,
"webauthn.selection_criteria.discoverability": "preferred",
"webauthn.selection_criteria.user_verification": "preferred",
"webauthn.metadata.cache_policy": "strict",
import (
"github.com/authelia/authelia/v4/internal/configuration/schema"
)
var defaults = newDefaults()
func newDefaults() (values map[string]any) {
values = map[string]any{
"regulation.max_retries": 3,
"webauthn.selection_criteria.discoverability": "preferred",
"webauthn.selection_criteria.user_verification": "preferred",
"webauthn.metadata.cache_policy": "strict",
}
limits := schema.ServerEndpointRateLimits{}
for _, endpoint := range limits.Endpoints() {
values["server.endpoints.rate_limits."+endpoint.Name+".enable"] = true
}
return values
}
// Defaults returns a copy of the defaults.
+26
View File
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
package configuration
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"github.com/authelia/authelia/v4/internal/configuration/schema"
)
func TestShouldEnableAllServerEndpointRateLimitsByDefault(t *testing.T) {
rt := reflect.TypeFor[schema.ServerEndpointRateLimits]()
for i := range rt.NumField() {
name := rt.Field(i).Tag.Get("koanf")
t.Run(name, func(t *testing.T) {
assert.Equal(t, true, defaults["server.endpoints.rate_limits."+name+".enable"])
})
}
}
+40
View File
@@ -95,6 +95,46 @@ type ServerEndpointRateLimits struct {
OpenIDConnectRevocation ServerEndpointRateLimit `koanf:"openid_connect_revocation" yaml:"openid_connect_revocation,omitempty" toml:"openid_connect_revocation,omitempty" json:"openid_connect_revocation,omitempty" jsonschema:"title=OpenID Connect Revocation" jsonschema_description:"Configures the rate limiter which applies to the OpenID Connect 1.0 Revocation Endpoint."`
}
// Endpoints returns a ServerEndpointRateLimitEndpoint for every configurable rate limiter in field order.
func (r *ServerEndpointRateLimits) Endpoints() []ServerEndpointRateLimitEndpoint {
return []ServerEndpointRateLimitEndpoint{
{Name: "reset_password_start", Config: &r.ResetPasswordStart},
{Name: "reset_password_finish", Config: &r.ResetPasswordFinish},
{Name: "second_factor_totp", Config: &r.SecondFactorTOTP},
{Name: "second_factor_duo", Config: &r.SecondFactorDuo},
{Name: "second_factor_password", Config: &r.SecondFactorPassword},
{Name: "session_elevation_start", Config: &r.SessionElevationStart, Weight: weightElevatedSessionCodeLifespan},
{Name: "health", Config: &r.Health},
{Name: "session_elevation_finish", Config: &r.SessionElevationFinish, Weight: weightElevatedSessionElevationLifespan},
{Name: "openid_connect_token", Config: &r.OpenIDConnectToken},
{Name: "openid_connect_pushed_authorization_request", Config: &r.OpenIDConnectPushedAuthorizationRequest},
{Name: "openid_connect_userinfo", Config: &r.OpenIDConnectUserInfo},
{Name: "openid_connect_introspection", Config: &r.OpenIDConnectIntrospection},
{Name: "openid_connect_revocation", Config: &r.OpenIDConnectRevocation},
}
}
// ServerEndpointRateLimitEndpoint describes a single endpoint rate limit of ServerEndpointRateLimits.
type ServerEndpointRateLimitEndpoint struct {
// Name is the configuration key of the endpoint rate limit.
Name string
// Config is the endpoint rate limit configuration.
Config *ServerEndpointRateLimit
// Weight returns the duration each default bucket period is multiplied by. A nil Weight means the default bucket
// periods are absolute durations.
Weight func(config *Configuration) time.Duration
}
func weightElevatedSessionCodeLifespan(config *Configuration) time.Duration {
return config.IdentityValidation.ElevatedSession.CodeLifespan
}
func weightElevatedSessionElevationLifespan(config *Configuration) time.Duration {
return config.IdentityValidation.ElevatedSession.ElevationLifespan
}
// ServerEndpointRateLimit represents the rate limiter configuration for a single endpoint.
type ServerEndpointRateLimit struct {
Enable bool `koanf:"enable" yaml:"enable" toml:"enable" json:"enable" jsonschema:"default=true,title=Enable" jsonschema_description:"Enables the rate limiter for this endpoint."`
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"reflect"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServerEndpointRateLimitsEndpoints(t *testing.T) {
limits := &ServerEndpointRateLimits{}
endpoints := limits.Endpoints()
rv := reflect.ValueOf(limits).Elem()
rt := rv.Type()
require.Len(t, endpoints, rt.NumField())
for i := range rt.NumField() {
field := rt.Field(i)
t.Run(field.Name, func(t *testing.T) {
assert.Equal(t, field.Tag.Get("koanf"), endpoints[i].Name)
assert.Same(t, rv.Field(i).Addr().Interface().(*ServerEndpointRateLimit), endpoints[i].Config)
})
}
}
func TestServerEndpointRateLimitsEndpointsWeight(t *testing.T) {
config := &Configuration{
IdentityValidation: IdentityValidation{
ElevatedSession: IdentityValidationElevatedSession{
CodeLifespan: time.Minute,
ElevationLifespan: time.Hour,
},
},
}
testCases := []struct {
name string
expected time.Duration
}{
{"session_elevation_start", time.Minute},
{"session_elevation_finish", time.Hour},
}
limits := &ServerEndpointRateLimits{}
weighted := map[string]ServerEndpointRateLimitEndpoint{}
for _, endpoint := range limits.Endpoints() {
if endpoint.Weight != nil {
weighted[endpoint.Name] = endpoint
}
}
require.Len(t, weighted, len(testCases))
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
endpoint, ok := weighted[tc.name]
require.True(t, ok)
assert.Equal(t, tc.expected, endpoint.Weight(config))
})
}
}
+14 -31
View File
@@ -322,48 +322,31 @@ func validateServerEndpointsHealth(config *schema.Configuration, validator *sche
}
func validateServerEndpointsRateLimits(config *schema.Configuration, validator *schema.StructValidator) {
validateServerEndpointsRateLimitDefault("openid_connect_pushed_authorization_request", &config.Server.Endpoints.RateLimits.OpenIDConnectPushedAuthorizationRequest, schema.DefaultServerConfiguration.Endpoints.RateLimits.OpenIDConnectPushedAuthorizationRequest, validator)
validateServerEndpointsRateLimitDefault("openid_connect_token", &config.Server.Endpoints.RateLimits.OpenIDConnectToken, schema.DefaultServerConfiguration.Endpoints.RateLimits.OpenIDConnectToken, validator)
defaults := schema.DefaultServerConfiguration.Endpoints.RateLimits.Endpoints()
validateServerEndpointsRateLimitDefault("health", &config.Server.Endpoints.RateLimits.Health, schema.DefaultServerConfiguration.Endpoints.RateLimits.Health, validator)
validateServerEndpointsRateLimitDefault("reset_password_start", &config.Server.Endpoints.RateLimits.ResetPasswordStart, schema.DefaultServerConfiguration.Endpoints.RateLimits.ResetPasswordStart, validator)
validateServerEndpointsRateLimitDefault("reset_password_finish", &config.Server.Endpoints.RateLimits.ResetPasswordFinish, schema.DefaultServerConfiguration.Endpoints.RateLimits.ResetPasswordFinish, validator)
validateServerEndpointsRateLimitDefault("second_factor_totp", &config.Server.Endpoints.RateLimits.SecondFactorTOTP, schema.DefaultServerConfiguration.Endpoints.RateLimits.SecondFactorTOTP, validator)
validateServerEndpointsRateLimitDefault("second_factor_duo", &config.Server.Endpoints.RateLimits.SecondFactorDuo, schema.DefaultServerConfiguration.Endpoints.RateLimits.SecondFactorDuo, validator)
validateServerEndpointsRateLimitDefault("second_factor_password", &config.Server.Endpoints.RateLimits.SecondFactorPassword, schema.DefaultServerConfiguration.Endpoints.RateLimits.SecondFactorPassword, validator)
validateServerEndpointsRateLimitDefaultWeighted("session_elevation_start", &config.Server.Endpoints.RateLimits.SessionElevationStart, schema.DefaultServerConfiguration.Endpoints.RateLimits.SessionElevationStart, config.IdentityValidation.ElevatedSession.CodeLifespan, validator)
validateServerEndpointsRateLimitDefaultWeighted("session_elevation_finish", &config.Server.Endpoints.RateLimits.SessionElevationFinish, schema.DefaultServerConfiguration.Endpoints.RateLimits.SessionElevationFinish, config.IdentityValidation.ElevatedSession.ElevationLifespan, validator)
}
func validateServerEndpointsRateLimitDefault(name string, config *schema.ServerEndpointRateLimit, defaults schema.ServerEndpointRateLimit, validator *schema.StructValidator) {
if len(config.Buckets) == 0 {
config.Buckets = make([]schema.ServerEndpointRateLimitBucket, len(defaults.Buckets))
copy(config.Buckets, defaults.Buckets)
return
for i, endpoint := range config.Server.Endpoints.RateLimits.Endpoints() {
validateServerEndpointsRateLimitDefault(config, endpoint, defaults[i].Config.Buckets, validator)
}
validateServerEndpointsRateLimitBuckets(name, config, validator)
}
func validateServerEndpointsRateLimitDefaultWeighted(name string, config *schema.ServerEndpointRateLimit, defaults schema.ServerEndpointRateLimit, weight time.Duration, validator *schema.StructValidator) {
if len(config.Buckets) == 0 {
config.Buckets = make([]schema.ServerEndpointRateLimitBucket, len(defaults.Buckets))
func validateServerEndpointsRateLimitDefault(config *schema.Configuration, endpoint schema.ServerEndpointRateLimitEndpoint, defaults []schema.ServerEndpointRateLimitBucket, validator *schema.StructValidator) {
if len(endpoint.Config.Buckets) == 0 {
endpoint.Config.Buckets = make([]schema.ServerEndpointRateLimitBucket, len(defaults))
for i := range defaults.Buckets {
config.Buckets[i] = schema.ServerEndpointRateLimitBucket{
Period: weight * defaults.Buckets[i].Period,
Requests: defaults.Buckets[i].Requests,
copy(endpoint.Config.Buckets, defaults)
if endpoint.Weight != nil {
weight := endpoint.Weight(config)
for i := range endpoint.Config.Buckets {
endpoint.Config.Buckets[i].Period *= weight
}
}
return
}
validateServerEndpointsRateLimitBuckets(name, config, validator)
validateServerEndpointsRateLimitBuckets(endpoint.Name, endpoint.Config, validator)
}
func validateServerEndpointsRateLimitBuckets(name string, config *schema.ServerEndpointRateLimit, validator *schema.StructValidator) {
@@ -58,6 +58,11 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
},
},
expected: schema.ServerEndpointRateLimits{
Health: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: 1 * time.Minute, Requests: 60},
},
},
ResetPasswordStart: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: 10 * time.Minute, Requests: 5},
@@ -84,6 +89,12 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
{Period: 2 * time.Minute, Requests: 15},
},
},
SecondFactorPassword: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: 5 * time.Minute, Requests: 30},
{Period: 60 * time.Minute, Requests: 40},
},
},
SessionElevationStart: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: schema.DefaultIdentityValidation.ElevatedSession.CodeLifespan * 1, Requests: 3},
@@ -114,6 +125,30 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
{Period: time.Hour, Requests: 100},
},
},
OpenIDConnectUserInfo: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: 1 * time.Minute, Requests: 30},
{Period: 2 * time.Minute, Requests: 40},
{Period: 10 * time.Minute, Requests: 50},
{Period: time.Hour, Requests: 100},
},
},
OpenIDConnectIntrospection: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: 1 * time.Minute, Requests: 30},
{Period: 2 * time.Minute, Requests: 40},
{Period: 10 * time.Minute, Requests: 50},
{Period: time.Hour, Requests: 100},
},
},
OpenIDConnectRevocation: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: 1 * time.Minute, Requests: 30},
{Period: 2 * time.Minute, Requests: 40},
{Period: 10 * time.Minute, Requests: 50},
{Period: time.Hour, Requests: 100},
},
},
},
},
}
@@ -128,14 +163,7 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
assert.Len(t, validator.Errors(), 0)
assert.Len(t, validator.Warnings(), 0)
assert.Equal(t, tc.expected.OpenIDConnectPushedAuthorizationRequest, tc.config.Server.Endpoints.RateLimits.OpenIDConnectPushedAuthorizationRequest)
assert.Equal(t, tc.expected.OpenIDConnectToken, tc.config.Server.Endpoints.RateLimits.OpenIDConnectToken)
assert.Equal(t, tc.expected.ResetPasswordStart, tc.config.Server.Endpoints.RateLimits.ResetPasswordStart)
assert.Equal(t, tc.expected.ResetPasswordFinish, tc.config.Server.Endpoints.RateLimits.ResetPasswordFinish)
assert.Equal(t, tc.expected.SecondFactorTOTP, tc.config.Server.Endpoints.RateLimits.SecondFactorTOTP)
assert.Equal(t, tc.expected.SecondFactorDuo, tc.config.Server.Endpoints.RateLimits.SecondFactorDuo)
assert.Equal(t, tc.expected.SessionElevationStart, tc.config.Server.Endpoints.RateLimits.SessionElevationStart)
assert.Equal(t, tc.expected.SessionElevationFinish, tc.config.Server.Endpoints.RateLimits.SessionElevationFinish)
assert.Equal(t, tc.expected, tc.config.Server.Endpoints.RateLimits)
})
}
}
+6 -3
View File
@@ -7,7 +7,6 @@ package middlewares
import (
"context"
"math"
"net/http"
"slices"
"strconv"
"sync"
@@ -205,7 +204,7 @@ func WithRateLimitCollector(collector *GarbageCollector) RateLimiterOption {
func HandlerRateLimitAPI(ctx *AutheliaCtx, retryAfter time.Duration) {
ctx.SetStatusCode(fasthttp.StatusTooManyRequests)
ctx.Response.Header.SetBytesK(headerRetryAfter, time.Now().UTC().Add(retryAfter).Format(http.TimeFormat))
ctx.Response.Header.SetBytesK(headerRetryAfter, formatRetryAfter(retryAfter))
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueNoStore)
ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache)
@@ -216,7 +215,7 @@ func HandlerRateLimitAPI(ctx *AutheliaCtx, retryAfter time.Duration) {
func HandlerRateLimitOpenIDConnect(ctx *AutheliaCtx, retryAfter time.Duration) {
ctx.SetStatusCode(fasthttp.StatusTooManyRequests)
ctx.Response.Header.SetBytesK(headerRetryAfter, strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
ctx.Response.Header.SetBytesK(headerRetryAfter, formatRetryAfter(retryAfter))
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueNoStore)
ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache)
ctx.Response.Header.SetBytesKV(headerContentType, contentTypeApplicationJSON)
@@ -224,6 +223,10 @@ func HandlerRateLimitOpenIDConnect(ctx *AutheliaCtx, retryAfter time.Duration) {
ctx.Response.SetBodyRaw(bodyOpenIDConnectRateLimitExceeded)
}
func formatRetryAfter(retryAfter time.Duration) string {
return strconv.Itoa(int(math.Ceil(retryAfter.Seconds())))
}
func newRateLimiterHandler(next RequestHandler, buckets []RateLimitBucket, handler RateLimitRequestHandler, exemptStatusCodes []int) RequestHandler {
isRateLimitExempt := newIsRateLimitExempt(exemptStatusCodes)
+38 -6
View File
@@ -838,14 +838,46 @@ func TestNewIsRateLimitExempt(t *testing.T) {
}
func TestHandlerRateLimitAPI(t *testing.T) {
ctx := newTestAutheliaCtx("192.168.1.1")
testCases := []struct {
name string
retryAfter time.Duration
expected string
}{
{
"ShouldRenderWholeSeconds",
time.Second * 30,
"30",
},
{
"ShouldRoundPartialSecondsUp",
time.Millisecond * 1500,
"2",
},
{
"ShouldRenderSubSecondAsOne",
time.Millisecond,
"1",
},
{
"ShouldRenderZero",
0,
"0",
},
}
HandlerRateLimitAPI(ctx, 30*time.Second)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx := newTestAutheliaCtx("192.168.1.1")
body := string(ctx.Response.Body())
assert.Contains(t, body, "Too Many Requests")
assert.Equal(t, fasthttp.StatusTooManyRequests, ctx.Response.StatusCode())
assert.NotEmpty(t, ctx.Response.Header.Peek(fasthttp.HeaderRetryAfter))
HandlerRateLimitAPI(ctx, tc.retryAfter)
assert.Equal(t, fasthttp.StatusTooManyRequests, ctx.Response.StatusCode())
assert.Equal(t, tc.expected, string(ctx.Response.Header.Peek(fasthttp.HeaderRetryAfter)))
assert.Equal(t, HeaderCacheControlNotStore, string(ctx.Response.Header.Peek(fasthttp.HeaderCacheControl)))
assert.Equal(t, HeaderPragmaNoCache, string(ctx.Response.Header.Peek(fasthttp.HeaderPragma)))
assert.JSONEq(t, `{"status":"KO","message":"Too Many Requests"}`, string(ctx.Response.Body()))
})
}
}
func TestHandlerRateLimitOpenIDConnect(t *testing.T) {
+4
View File
@@ -103,12 +103,16 @@
"Time-based One-Time Password": "Time-based One-Time Password",
"Toggle password visibility": "Toggle password visibility",
"Touch the token of your security key": "Touch the token of your security key",
"Try again in {{count}} seconds_one": "Try again in {{count}} second",
"Try again in {{count}} seconds_other": "Try again in {{count}} seconds",
"Use OpenID to verify your identity": "Use OpenID to verify your identity",
"Username": "Username",
"Username is required": "Username is required",
"Warning": "Warning",
"You cancelled the assertion request": "You cancelled the assertion request",
"You have made too many requests": "You have made too many requests",
"You have made too many requests, try again in {{count}} seconds_one": "You have made too many requests, try again in {{count}} second",
"You have made too many requests, try again in {{count}} seconds_other": "You have made too many requests, try again in {{count}} seconds",
"You must view and accept the Privacy Policy before using {{authelia}}.": "You must view and accept the \u003cpolicy\u003ePrivacy Policy\u003c/policy\u003e before using {{authelia}}",
"You're being signed out and redirected": "You're being signed out and redirected",
"Your browser does not support the WebAuthn protocol": "Your browser does not support the WebAuthn protocol",
@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
import { render, screen } from "@testing-library/react";
import RateLimitCountdown from "@components/RateLimitCountdown";
const mocks = vi.hoisted(() => ({
translate: vi.fn((key: string, options?: { count?: number }) => `${key}:${options?.count}`),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: mocks.translate }),
}));
it("renders only an empty status region when there are no seconds remaining", () => {
render(<RateLimitCountdown secondsRemaining={0} />);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
});
it("renders the seconds remaining", () => {
render(<RateLimitCountdown secondsRemaining={42} />);
expect(document.getElementById("rate-limit-countdown")).toHaveTextContent("Try again in {{count}} seconds:42");
expect(mocks.translate).toHaveBeenCalledWith("Try again in {{count}} seconds", { count: 42 });
});
it("announces the seconds remaining once when the countdown starts", () => {
const { rerender } = render(<RateLimitCountdown secondsRemaining={0} />);
rerender(<RateLimitCountdown secondsRemaining={30} />);
expect(screen.getByRole("status")).toHaveTextContent("Try again in {{count}} seconds:30");
rerender(<RateLimitCountdown secondsRemaining={29} />);
expect(screen.getByRole("status")).toHaveTextContent("Try again in {{count}} seconds:30");
expect(document.getElementById("rate-limit-countdown")).toHaveTextContent("Try again in {{count}} seconds:29");
});
it("announces again when the countdown restarts", () => {
const { rerender } = render(<RateLimitCountdown secondsRemaining={10} />);
rerender(<RateLimitCountdown secondsRemaining={5} />);
rerender(<RateLimitCountdown secondsRemaining={20} />);
expect(screen.getByRole("status")).toHaveTextContent("Try again in {{count}} seconds:20");
});
it("clears the announcement when the countdown ends", () => {
const { rerender } = render(<RateLimitCountdown secondsRemaining={1} />);
rerender(<RateLimitCountdown secondsRemaining={0} />);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
});
+40
View File
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
export interface Props {
secondsRemaining: number;
}
const RateLimitCountdown = function (props: Props) {
const { t: translate } = useTranslation("portal");
const [previous, setPrevious] = useState(props.secondsRemaining);
const [announced, setAnnounced] = useState(props.secondsRemaining);
if (props.secondsRemaining !== previous) {
setPrevious(props.secondsRemaining);
if (props.secondsRemaining > previous || props.secondsRemaining <= 0) {
setAnnounced(props.secondsRemaining);
}
}
return (
<Fragment>
<span role="status" className="sr-only">
{announced > 0 ? translate("Try again in {{count}} seconds", { count: announced }) : null}
</span>
{props.secondsRemaining > 0 ? (
<p id="rate-limit-countdown" className="w-full text-center text-sm text-muted-foreground">
{translate("Try again in {{count}} seconds", { count: props.secondsRemaining })}
</p>
) : null}
</Fragment>
);
};
export default RateLimitCountdown;
+213
View File
@@ -0,0 +1,213 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
import { act, renderHook } from "@testing-library/react";
import { useRateLimit } from "@hooks/RateLimit";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("is not limited initially", () => {
const { result } = renderHook(() => useRateLimit());
expect(result.current.limited).toBe(false);
expect(result.current.secondsRemaining).toBe(0);
});
it("counts down the seconds remaining", () => {
const { result } = renderHook(() => useRateLimit());
act(() => result.current.trigger(3));
expect(result.current.secondsRemaining).toBe(3);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.secondsRemaining).toBe(2);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.secondsRemaining).toBe(1);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.secondsRemaining).toBe(0);
expect(result.current.limited).toBe(false);
});
it("rounds a partial second up", () => {
const { result } = renderHook(() => useRateLimit());
act(() => result.current.trigger(1.5));
expect(result.current.secondsRemaining).toBe(2);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.secondsRemaining).toBe(1);
expect(result.current.limited).toBe(true);
});
it("is limited until the retry period elapses", () => {
const onExpire = vi.fn();
const { result } = renderHook(() => useRateLimit(onExpire));
act(() => result.current.trigger(30));
expect(result.current.limited).toBe(true);
act(() => {
vi.advanceTimersByTime(29999);
});
expect(result.current.limited).toBe(true);
expect(onExpire).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1);
});
expect(result.current.limited).toBe(false);
expect(onExpire).toHaveBeenCalledTimes(1);
});
it("calls onExpire once when the countdown and the retry period end together", () => {
const onExpire = vi.fn();
const { result } = renderHook(() => useRateLimit(onExpire));
act(() => result.current.trigger(2));
act(() => {
vi.advanceTimersByTime(10000);
});
expect(result.current.limited).toBe(false);
expect(onExpire).toHaveBeenCalledTimes(1);
});
it("stays limited for a retry period longer than the maximum timeout delay", () => {
const onExpire = vi.fn();
const { result } = renderHook(() => useRateLimit(onExpire));
const retryAfter = 30 * 24 * 60 * 60;
act(() => result.current.trigger(retryAfter));
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.limited).toBe(true);
expect(result.current.secondsRemaining).toBe(retryAfter - 1);
expect(onExpire).not.toHaveBeenCalled();
vi.setSystemTime(Date.now() + (retryAfter - 3) * 1000);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.limited).toBe(true);
expect(result.current.secondsRemaining).toBe(1);
expect(onExpire).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.limited).toBe(false);
expect(onExpire).toHaveBeenCalledTimes(1);
});
it("restarts the retry period when triggered again", () => {
const onExpire = vi.fn();
const { result } = renderHook(() => useRateLimit(onExpire));
act(() => result.current.trigger(10));
act(() => {
vi.advanceTimersByTime(5000);
});
act(() => result.current.trigger(10));
act(() => {
vi.advanceTimersByTime(5000);
});
expect(result.current.limited).toBe(true);
expect(onExpire).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(result.current.limited).toBe(false);
expect(onExpire).toHaveBeenCalledTimes(1);
});
it("clears the limit without calling onExpire", () => {
const onExpire = vi.fn();
const { result } = renderHook(() => useRateLimit(onExpire));
act(() => result.current.trigger(30));
act(() => result.current.clear());
expect(result.current.limited).toBe(false);
expect(result.current.secondsRemaining).toBe(0);
act(() => {
vi.advanceTimersByTime(30000);
});
expect(onExpire).not.toHaveBeenCalled();
});
it("calls the latest onExpire", () => {
const first = vi.fn();
const second = vi.fn();
const { rerender, result } = renderHook(({ onExpire }) => useRateLimit(onExpire), {
initialProps: { onExpire: first },
});
act(() => result.current.trigger(10));
rerender({ onExpire: second });
act(() => {
vi.advanceTimersByTime(10000);
});
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
it("clears the timer on unmount", () => {
const onExpire = vi.fn();
const { result, unmount } = renderHook(() => useRateLimit(onExpire));
act(() => result.current.trigger(30));
unmount();
act(() => {
vi.advanceTimersByTime(30000);
});
expect(onExpire).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
+72
View File
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
import { useCallback, useEffect, useEffectEvent, useState } from "react";
const MaximumTimeoutDelay = 2_147_483_647;
function secondsUntil(deadline: number) {
return Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
}
export const useRateLimit = (onExpire?: () => void) => {
const [deadline, setDeadline] = useState<null | number>(null);
const [secondsRemaining, setSecondsRemaining] = useState(0);
const handleExpire = useEffectEvent(() => {
setDeadline(null);
setSecondsRemaining(0);
onExpire?.();
});
useEffect(() => {
if (deadline === null) return;
let interval: null | ReturnType<typeof setInterval> = null;
let timeout: null | ReturnType<typeof setTimeout> = null;
const stop = () => {
if (interval !== null) clearInterval(interval);
if (timeout !== null) clearTimeout(timeout);
interval = null;
timeout = null;
};
const expire = () => {
stop();
handleExpire();
};
interval = setInterval(() => {
const seconds = secondsUntil(deadline);
if (seconds === 0) {
expire();
} else {
setSecondsRemaining(seconds);
}
}, 1000);
const delay = Math.max(0, deadline - Date.now());
if (delay <= MaximumTimeoutDelay) timeout = setTimeout(expire, delay);
return stop;
}, [deadline]);
const clear = useCallback(() => {
setDeadline(null);
setSecondsRemaining(0);
}, []);
const trigger = useCallback((retryAfter: number) => {
setDeadline(Date.now() + retryAfter * 1000);
setSecondsRemaining(Math.ceil(retryAfter));
}, []);
return { clear, limited: deadline !== null, secondsRemaining, trigger };
};
export default useRateLimit;
+3
View File
@@ -9,6 +9,9 @@ import { createRoot } from "react-dom/client";
import "@root/index.css";
import App from "@root/App";
import "@i18n/index";
import { registerRateLimitInterceptor } from "@services/RateLimit";
registerRateLimitInterceptor();
createRoot(document.getElementById("root")!).render(
<StrictMode>
-72
View File
@@ -7,58 +7,11 @@ import { AxiosResponse } from "axios";
import {
hasServiceError,
toData,
toDataRateLimited,
validateStatusAuthentication,
validateStatusOneTimeCode,
validateStatusTooManyRequests,
validateStatusWebAuthnCreation,
} from "@services/Api";
it("throws for missing retry-after header", () => {
const resp = { data: { status: "KO" }, headers: {}, status: 429 } as any;
expect(() => toDataRateLimited(resp)).toThrow("Header Retry-After is missing");
});
it("handles numeric retry-after", () => {
const resp = { data: { status: "KO" }, headers: { "retry-after": "30" }, status: 429 } as any;
const result = toDataRateLimited(resp);
expect(result?.limited).toBe(true);
expect(result?.retryAfter).toBe(30);
});
it("handles date retry-after", () => {
const future = new Date(Date.now() + 60000).toUTCString();
const resp = { data: { status: "KO" }, headers: { "retry-after": future }, status: 429 } as any;
const result = toDataRateLimited(resp);
expect(result?.limited).toBe(true);
expect(result?.retryAfter).toBeGreaterThan(50);
});
it("throws for invalid date retry-after", () => {
const resp = { data: { status: "KO" }, headers: { "retry-after": "invalid-date" }, status: 429 } as any;
expect(() => toDataRateLimited(resp)).toThrow("Header Retry-After has an invalid date value");
});
it("returns data for ok status", () => {
const resp = { data: { data: "test", status: "OK" }, status: 200 } as AxiosResponse;
expect(toDataRateLimited(resp)).toEqual({ data: "test", limited: false, retryAfter: 0 });
});
it("returns limited for ko status with 429", () => {
const resp = { data: { status: "KO" }, headers: { "retry-after": "30" }, status: 429 } as any;
expect(toDataRateLimited(resp)).toEqual({ limited: true, retryAfter: 30 });
});
it("returns limited for 429 status", () => {
const resp = { data: { status: "OTHER" }, headers: { "retry-after": "30" }, status: 429 } as any;
expect(toDataRateLimited(resp)).toEqual({ limited: true, retryAfter: 30 });
});
it("returns undefined for no data", () => {
const resp = { data: null, status: 200 } as AxiosResponse;
expect(toDataRateLimited(resp)).toBeUndefined();
});
it("reports no error for ok response", () => {
const resp = { data: { data: "test", status: "OK" }, status: 200 } as AxiosResponse;
expect(hasServiceError(resp)).toEqual({ errored: false, message: null });
@@ -69,14 +22,6 @@ it("reports error for ko response", () => {
expect(hasServiceError(resp)).toEqual({ errored: true, message: "bad request" });
});
it("validates status for too many requests", () => {
expect(validateStatusTooManyRequests(200)).toBe(true);
expect(validateStatusTooManyRequests(299)).toBe(true);
expect(validateStatusTooManyRequests(429)).toBe(true);
expect(validateStatusTooManyRequests(400)).toBe(false);
expect(validateStatusTooManyRequests(500)).toBe(false);
});
it("validates status for authentication", () => {
expect(validateStatusAuthentication(200)).toBe(true);
expect(validateStatusAuthentication(401)).toBe(true);
@@ -123,20 +68,3 @@ describe("toData", () => {
expect(toData(resp)).toBeUndefined();
});
});
describe("toDataRateLimited", () => {
it("reports a failure that is not rate limited", () => {
const resp = { data: { status: "KO" }, headers: {}, status: 400 } as any;
expect(toDataRateLimited(resp)).toEqual({ limited: false, retryAfter: 0 });
});
it("returns undefined without data", () => {
const resp = { data: undefined, headers: {}, status: 200 } as any;
expect(toDataRateLimited(resp)).toBeUndefined();
});
it("returns undefined for an unknown status on a non 429 response", () => {
const resp = { data: { status: "MAYBE" }, headers: {}, status: 200 } as any;
expect(toDataRateLimited(resp)).toBeUndefined();
});
});
-57
View File
@@ -59,11 +59,6 @@ export interface ErrorResponse {
message: string;
}
export interface ErrorRateLimitResponse extends ErrorResponse {
limited: boolean;
retryAfter: number;
}
export interface Response<T> extends OKResponse {
data: T;
}
@@ -97,54 +92,6 @@ export function toData<T>(resp: AxiosResponse<ServiceResponse<T>>): T | undefine
return undefined;
}
export type RateLimitedData<T> = {
data?: T;
limited: boolean;
retryAfter: number;
};
function getRetryAfter(resp: AxiosResponse): number {
if (resp.status !== 429) {
return 0;
}
const valueRetryAfter = resp.headers["retry-after"];
if (valueRetryAfter) {
if (/^\d+$/.test(valueRetryAfter)) {
const retryAfter = Number.parseFloat(valueRetryAfter);
if (Number.isNaN(retryAfter)) {
throw new TypeError("Header Retry-After has an invalid number value");
}
return retryAfter;
} else {
const date = new Date(valueRetryAfter);
if (Number.isNaN(date.getTime())) {
throw new TypeError("Header Retry-After has an invalid date value");
}
return Math.max(0, (date.getTime() - Date.now()) / 1000);
}
}
throw new Error("Header Retry-After is missing");
}
export function toDataRateLimited<T>(resp: AxiosResponse<ServiceResponse<T>>): RateLimitedData<T> | undefined {
if (resp.data && "status" in resp.data) {
if (resp.data["status"] === "OK") {
return { data: resp.data.data, limited: false, retryAfter: 0 };
} else if (resp.data["status"] === "KO") {
return { limited: resp.status === 429, retryAfter: getRetryAfter(resp) };
} else if (resp.status === 429) {
return { limited: true, retryAfter: getRetryAfter(resp) };
}
}
return undefined;
}
function hasError(err: ErrorResponse | undefined) {
if (err?.status === "KO") {
return { errored: true, message: err.message };
@@ -159,10 +106,6 @@ export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
return hasError(errResp);
}
export function validateStatusTooManyRequests(status: number) {
return (status >= 200 && status < 300) || status === 429;
}
export function validateStatusAuthentication(status: number) {
return (status >= 200 && status < 300) || status === 401 || status === 403;
}
+1 -35
View File
@@ -4,7 +4,7 @@
import axios from "axios";
import { hasServiceError, toData, toDataRateLimited } from "@services/Api";
import { hasServiceError, toData } from "@services/Api";
import * as Client from "@services/Client";
vi.mock("axios");
@@ -30,26 +30,6 @@ it("handles successful post with optional response", async () => {
expect(result).toBe("test");
});
it("handles successful rate limited post", async () => {
const mockRes = { data: { data: "test", status: "OK" }, status: 200 };
(axios.post as any).mockResolvedValue(mockRes);
(hasServiceError as any).mockReturnValue({ errored: false });
(toDataRateLimited as any).mockReturnValue({ data: "test", limited: false, retryAfter: 0 });
const result = await Client.PostWithOptionalResponseRateLimited("/path", {});
expect(result).toEqual({ data: "test", limited: false, retryAfter: 0 });
});
it("handles rate limited post", async () => {
const mockRes = { data: { status: "KO" }, headers: { "retry-after": "30" }, status: 429 };
(axios.post as any).mockResolvedValue(mockRes);
(hasServiceError as any).mockReturnValue({ errored: true });
(toDataRateLimited as any).mockReturnValue({ limited: true, retryAfter: 30 });
const result = await Client.PostWithOptionalResponseRateLimited("/path", {});
expect(result).toEqual({ limited: true, retryAfter: 30 });
});
it("throws on post error", async () => {
const mockRes = { data: { message: "error", status: "KO" }, status: 400 };
(axios.post as any).mockResolvedValue(mockRes);
@@ -60,16 +40,6 @@ it("throws on post error", async () => {
);
});
it("throws on rate limited post error", async () => {
const mockRes = { data: { message: "error", status: "KO" }, status: 400 };
(axios.post as any).mockResolvedValue(mockRes);
(hasServiceError as any).mockReturnValue({ errored: true, message: "error" });
await expect(Client.PostWithOptionalResponseRateLimited("/path", {})).rejects.toThrow(
"Failed POST to /path. Code: 400. Message: error",
);
});
it("throws on post with no data", async () => {
const mockRes = { data: { status: "OK" }, status: 200 };
(axios.post as any).mockResolvedValue(mockRes);
@@ -211,7 +181,6 @@ it("forwards the abort signal to axios on every helper", async () => {
(axios.delete as any).mockResolvedValue(mockRes);
(hasServiceError as any).mockReturnValue({ errored: false });
(toData as any).mockReturnValue("test");
(toDataRateLimited as any).mockReturnValue({ data: "test", limited: false, retryAfter: 0 });
await Client.Get("/path", signal);
expect(axios.get).toHaveBeenLastCalledWith("/path", { signal });
@@ -225,9 +194,6 @@ it("forwards the abort signal to axios on every helper", async () => {
await Client.PostWithOptionalResponse("/path", { body: 1 }, signal);
expect(axios.post).toHaveBeenLastCalledWith("/path", { body: 1 }, { signal });
await Client.PostWithOptionalResponseRateLimited("/path", { body: 1 }, signal);
expect(axios.post).toHaveBeenLastCalledWith("/path", { body: 1 }, expect.objectContaining({ signal }));
await Client.Put("/path", { body: 1 }, signal);
expect(axios.put).toHaveBeenLastCalledWith("/path", { body: 1 }, { signal });
+1 -29
View File
@@ -4,14 +4,7 @@
import axios from "axios";
import {
RateLimitedData,
ServiceResponse,
hasServiceError,
toData,
toDataRateLimited,
validateStatusTooManyRequests,
} from "@services/Api";
import { ServiceResponse, hasServiceError, toData } from "@services/Api";
export async function PutWithOptionalResponse<T = undefined>(
path: string,
@@ -41,27 +34,6 @@ export async function PostWithOptionalResponse<T = undefined>(
return toData<T>(res);
}
export async function PostWithOptionalResponseRateLimited<T = undefined>(
path: string,
body?: any,
signal?: AbortSignal,
): Promise<RateLimitedData<T> | undefined> {
const res = await axios.post<ServiceResponse<T>>(path, body, {
signal,
validateStatus: validateStatusTooManyRequests,
});
if (res.status !== 200 || hasServiceError(res).errored) {
if (res.status === 429) {
return toDataRateLimited<T>(res);
}
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
}
return toDataRateLimited<T>(res);
}
export async function DeleteWithOptionalResponse<T = undefined>(
path: string,
body?: any,
+3 -8
View File
@@ -2,11 +2,7 @@
//
// SPDX-License-Identifier: Apache-2.0
import {
DeleteWithOptionalResponse,
PostWithOptionalResponse,
PostWithOptionalResponseRateLimited,
} from "@services/Client";
import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
import { completeTOTPRegister, completeTOTPSignIn, stopTOTPRegister } from "@services/OneTimePassword";
vi.mock("@services/Api", () => ({
@@ -16,13 +12,12 @@ vi.mock("@services/Api", () => ({
vi.mock("@services/Client", () => ({
DeleteWithOptionalResponse: vi.fn(),
PostWithOptionalResponse: vi.fn(),
PostWithOptionalResponseRateLimited: vi.fn(),
}));
it("completes totp sign in", async () => {
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
(PostWithOptionalResponse as any).mockResolvedValue("response");
const result = await completeTOTPSignIn("123456", "url", "flow", "flowtype", "sub", "code");
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith("/totp/signin", {
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/totp/signin", {
flow: "flowtype",
flowID: "flow",
subflow: "sub",
+2 -6
View File
@@ -3,11 +3,7 @@
// SPDX-License-Identifier: Apache-2.0
import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api";
import {
DeleteWithOptionalResponse,
PostWithOptionalResponse,
PostWithOptionalResponseRateLimited,
} from "@services/Client";
import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
import { SignInResponse } from "@services/SignIn";
interface CompleteTOTPSignInBody {
@@ -36,7 +32,7 @@ export function completeTOTPSignIn(
userCode,
};
return PostWithOptionalResponseRateLimited<SignInResponse>(CompleteTOTPSignInPath, body);
return PostWithOptionalResponse<SignInResponse>(CompleteTOTPSignInPath, body);
}
export function completeTOTPRegister(passcode: string) {
+2 -2
View File
@@ -61,7 +61,7 @@ it("posts first factor reauthenticate without response", async () => {
});
it("posts second factor with response", async () => {
const mockResponse = { token: "abc" };
const mockResponse = { redirect: "abc" };
(PostWithOptionalResponse as any).mockResolvedValue(mockResponse);
const result = await postSecondFactor("pass", "url", "flow", "flowtype", "sub");
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/password/signin", {
@@ -77,5 +77,5 @@ it("posts second factor with response", async () => {
it("posts second factor without response", async () => {
(PostWithOptionalResponse as any).mockResolvedValue(undefined);
const result = await postSecondFactor("pass");
expect(result).toEqual({});
expect(result).toBeUndefined();
});
+1 -2
View File
@@ -101,6 +101,5 @@ export async function postSecondFactor(
targetURL,
};
const res = await PostWithOptionalResponse<SignInResponse>(CompletePasswordSignInPath, data);
return res ?? ({} as SignInResponse);
return PostWithOptionalResponse<SignInResponse>(CompletePasswordSignInPath, data);
}
+5 -6
View File
@@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: Apache-2.0
import { Get, PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
import { Get, PostWithOptionalResponse } from "@services/Client";
import {
completeDuoDeviceSelectionProcess,
completePushNotificationSignIn,
@@ -17,13 +17,12 @@ vi.mock("@services/Api", () => ({
vi.mock("@services/Client", () => ({
Get: vi.fn(),
PostWithOptionalResponse: vi.fn(),
PostWithOptionalResponseRateLimited: vi.fn(),
}));
it("completes push notification sign in", async () => {
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
(PostWithOptionalResponse as any).mockResolvedValue("response");
const result = await completePushNotificationSignIn("url", "flow", "flowtype", "sub", "code");
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith(
expect(PostWithOptionalResponse).toHaveBeenCalledWith(
"/duo/signin",
{
flow: "flowtype",
@@ -69,12 +68,12 @@ it("gets preferred duo device", async () => {
it("forwards the abort signal when provided", async () => {
const { getPreferredDuoDevice } = await import("@services/PushNotification");
const signal = new AbortController().signal;
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
(PostWithOptionalResponse as any).mockResolvedValue("response");
(PostWithOptionalResponse as any).mockResolvedValue("response");
(Get as any).mockResolvedValue("response");
await completePushNotificationSignIn("url", "flow", "flowtype", "sub", "code", signal);
expect(PostWithOptionalResponseRateLimited).toHaveBeenLastCalledWith("/duo/signin", expect.any(Object), signal);
expect(PostWithOptionalResponse).toHaveBeenLastCalledWith("/duo/signin", expect.any(Object), signal);
await initiateDuoDeviceSelectionProcess(signal);
expect(Get).toHaveBeenLastCalledWith("/duo/devices", signal);
+2 -2
View File
@@ -7,7 +7,7 @@ import {
CompletePushNotificationSignInPath,
InitiateDuoDeviceSelectionPath,
} from "@services/Api";
import { Get, PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
import { Get, PostWithOptionalResponse } from "@services/Client";
interface CompletePushSignInBody {
targetURL?: string;
@@ -33,7 +33,7 @@ export function completePushNotificationSignIn(
userCode,
};
return PostWithOptionalResponseRateLimited<DuoSignInResponse>(CompletePushNotificationSignInPath, body, signal);
return PostWithOptionalResponse<DuoSignInResponse>(CompletePushNotificationSignInPath, body, signal);
}
export interface DuoSignInResponse {
+151
View File
@@ -0,0 +1,151 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
import axios, { AxiosAdapter, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios";
import {
DefaultRetryAfterSeconds,
MaximumRetryAfterSeconds,
MinimumRetryAfterSeconds,
RateLimitedError,
getRetryAfter,
registerRateLimitInterceptor,
} from "@services/RateLimit";
function newResponse(status: number, headers: Record<string, string> = {}): AxiosResponse {
return {
config: { headers: {} } as InternalAxiosRequestConfig,
data: { message: "Too Many Requests", status: "KO" },
headers,
status,
statusText: "",
};
}
function newInstance(status: number, headers: Record<string, string> = {}, validateStatus?: (s: number) => boolean) {
const adapter: AxiosAdapter = async (config) => {
const resp = { ...newResponse(status, headers), config };
if (config.validateStatus && !config.validateStatus(status)) {
throw new AxiosError("Request failed", undefined, config, undefined, resp);
}
return resp;
};
const instance = validateStatus ? axios.create({ adapter, validateStatus }) : axios.create({ adapter });
registerRateLimitInterceptor(instance);
return instance;
}
describe("getRetryAfter", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it.each([
{ expected: 30, headers: { "retry-after": "30" }, name: "delta seconds" },
{ expected: MinimumRetryAfterSeconds, headers: { "retry-after": "0" }, name: "zero delta seconds" },
{ expected: 60, headers: { "retry-after": "Thu, 01 Jan 2026 00:01:00 GMT" }, name: "http date" },
{
expected: MinimumRetryAfterSeconds,
headers: { "retry-after": "Thu, 01 Jan 2026 00:00:00 GMT" },
name: "http date now",
},
{
expected: MinimumRetryAfterSeconds,
headers: { "retry-after": "Wed, 31 Dec 2025 23:59:00 GMT" },
name: "http date in the past",
},
{
expected: MaximumRetryAfterSeconds,
headers: { "retry-after": String(MaximumRetryAfterSeconds) },
name: "maximum delta seconds",
},
{
expected: MaximumRetryAfterSeconds,
headers: { "retry-after": String(MaximumRetryAfterSeconds + 1) },
name: "delta seconds above the maximum",
},
{
expected: DefaultRetryAfterSeconds,
headers: { "retry-after": "9".repeat(400) },
name: "delta seconds too large to represent",
},
{
expected: MaximumRetryAfterSeconds,
headers: { "retry-after": "Sat, 01 Jan 2028 00:00:00 GMT" },
name: "http date beyond the maximum",
},
{ expected: DefaultRetryAfterSeconds, headers: {}, name: "missing header" },
{ expected: DefaultRetryAfterSeconds, headers: { "retry-after": "" }, name: "empty header" },
{ expected: DefaultRetryAfterSeconds, headers: { "retry-after": "invalid" }, name: "invalid header" },
] as { expected: number; headers: Record<string, string>; name: string }[])(
"returns the retry after for $name",
({ expected, headers }) => {
expect(getRetryAfter(newResponse(429, headers))).toBe(expected);
},
);
it("rounds a partial second up", () => {
vi.setSystemTime(new Date("2026-01-01T00:00:59.500Z"));
expect(getRetryAfter(newResponse(429, { "retry-after": "Thu, 01 Jan 2026 00:01:00 GMT" }))).toBe(1);
});
});
describe("registerRateLimitInterceptor", () => {
it("throws a RateLimitedError when the response is rejected with a 429", async () => {
const instance = newInstance(429, { "retry-after": "30" });
const err = await instance.get("/api").catch((e: unknown) => e);
expect(err).toBeInstanceOf(RateLimitedError);
expect((err as RateLimitedError).retryAfter).toBe(30);
expect((err as RateLimitedError).message).toBe("Too Many Requests");
});
it("throws a RateLimitedError when validateStatus accepts a 429", async () => {
const instance = newInstance(429, { "retry-after": "15" }, () => true);
const err = await instance.get("/api").catch((e: unknown) => e);
expect(err).toBeInstanceOf(RateLimitedError);
expect((err as RateLimitedError).retryAfter).toBe(15);
});
it("uses the default retry after when the header is missing", async () => {
const instance = newInstance(429);
const err = await instance.get("/api").catch((e: unknown) => e);
expect(err).toBeInstanceOf(RateLimitedError);
expect((err as RateLimitedError).retryAfter).toBe(DefaultRetryAfterSeconds);
});
it("passes through other errors unchanged", async () => {
const instance = newInstance(401);
const err = await instance.get("/api").catch((e: unknown) => e);
expect(err).toBeInstanceOf(AxiosError);
expect(err).not.toBeInstanceOf(RateLimitedError);
expect((err as AxiosError).response?.status).toBe(401);
});
it("passes through successful responses unchanged", async () => {
const instance = newInstance(200);
const resp = await instance.get("/api");
expect(resp.status).toBe(200);
});
});
+66
View File
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
import axios, { AxiosInstance, AxiosResponse } from "axios";
export const DefaultRetryAfterSeconds = 10;
export const MinimumRetryAfterSeconds = 1;
export const MaximumRetryAfterSeconds = 86400;
export class RateLimitedError extends Error {
readonly retryAfter: number;
constructor(retryAfter: number) {
super("Too Many Requests");
this.name = "RateLimitedError";
this.retryAfter = retryAfter;
}
}
export function getRetryAfter(resp: AxiosResponse): number {
const value = resp.headers?.["retry-after"];
if (typeof value !== "string" || value === "") {
return DefaultRetryAfterSeconds;
}
if (/^\d+$/.test(value)) {
return clampRetryAfter(Number.parseInt(value, 10));
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return DefaultRetryAfterSeconds;
}
return clampRetryAfter(Math.ceil((date.getTime() - Date.now()) / 1000));
}
function clampRetryAfter(seconds: number): number {
if (!Number.isFinite(seconds)) {
return DefaultRetryAfterSeconds;
}
return Math.min(MaximumRetryAfterSeconds, Math.max(MinimumRetryAfterSeconds, seconds));
}
export function registerRateLimitInterceptor(instance: AxiosInstance = axios) {
return instance.interceptors.response.use(
(resp) => {
if (resp.status === 429) {
throw new RateLimitedError(getRetryAfter(resp));
}
return resp;
},
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 429) {
throw new RateLimitedError(getRetryAfter(err.response));
}
throw err;
},
);
}
+5 -8
View File
@@ -4,7 +4,7 @@
import axios from "axios";
import { PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
import { PostWithOptionalResponse } from "@services/Client";
import {
completeResetPasswordProcess,
deleteResetPasswordToken,
@@ -17,24 +17,22 @@ vi.mock("@services/Api", () => ({
CompleteResetPasswordPath: "/reset-password/finish",
InitiateResetPasswordPath: "/reset-password/start",
ResetPasswordPath: "/reset-password",
validateStatusTooManyRequests: vi.fn(),
}));
vi.mock("@services/Client", () => ({
PostWithOptionalResponse: vi.fn(),
PostWithOptionalResponseRateLimited: vi.fn(),
}));
it("initiates reset password process", async () => {
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
(PostWithOptionalResponse as any).mockResolvedValue("response");
const result = await initiateResetPasswordProcess("user");
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith("/reset-password/start", { username: "user" });
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/reset-password/start", { username: "user" });
expect(result).toBe("response");
});
it("completes reset password process", async () => {
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
(PostWithOptionalResponse as any).mockResolvedValue("response");
const result = await completeResetPasswordProcess("token123");
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith("/reset-password/finish", { token: "token123" });
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/reset-password/finish", { token: "token123" });
expect(result).toBe("response");
});
@@ -53,7 +51,6 @@ it("deletes reset password token with ok", async () => {
data: { token: "token123" },
method: "DELETE",
url: "/reset-password",
validateStatus: expect.any(Function),
});
expect(result).toEqual({ ok: true, status: 200 });
});
+3 -5
View File
@@ -10,16 +10,15 @@ import {
InitiateResetPasswordPath,
OKResponse,
ResetPasswordPath,
validateStatusTooManyRequests,
} from "@services/Api";
import { PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
import { PostWithOptionalResponse } from "@services/Client";
export async function initiateResetPasswordProcess(username: string) {
return PostWithOptionalResponseRateLimited(InitiateResetPasswordPath, { username });
return PostWithOptionalResponse(InitiateResetPasswordPath, { username });
}
export async function completeResetPasswordProcess(token: string) {
return PostWithOptionalResponseRateLimited(CompleteResetPasswordPath, { token });
return PostWithOptionalResponse(CompleteResetPasswordPath, { token });
}
export async function resetPassword(newPassword: string) {
@@ -31,7 +30,6 @@ export async function deleteResetPasswordToken(token: string) {
data: { token: token },
method: "DELETE",
url: ResetPasswordPath,
validateStatus: validateStatusTooManyRequests,
});
return { ok: res.status === 200 && res.data.status === "OK", status: res.status };
+11 -12
View File
@@ -5,6 +5,7 @@
import axios from "axios";
import { hasServiceError, toData, validateStatusOneTimeCode } from "@services/Api";
import { PostWithOptionalResponse } from "@services/Client";
import {
deleteUserSessionElevation,
generateUserSessionElevation,
@@ -19,6 +20,9 @@ vi.mock("@services/Api", () => ({
UserSessionElevationPath: "/user/elevation",
validateStatusOneTimeCode: vi.fn(),
}));
vi.mock("@services/Client", () => ({
PostWithOptionalResponse: vi.fn(),
}));
it("gets user session elevation successfully", async () => {
const mockRes = { data: { data: "elevation", status: "OK" }, status: 200 };
@@ -45,23 +49,18 @@ it("gets user session elevation with error", async () => {
});
it("generates user session elevation successfully", async () => {
const mockRes = { data: { data: "generate", status: "OK" }, status: 200 };
(axios as any).mockResolvedValue(mockRes);
(hasServiceError as any).mockReturnValue({ errored: false });
(toData as any).mockReturnValue("generate");
const mockResponse = { delete_id: "abc" };
(PostWithOptionalResponse as any).mockResolvedValue(mockResponse);
const result = await generateUserSessionElevation();
expect(axios).toHaveBeenCalledWith({
method: "POST",
url: "/user/elevation",
});
expect(result).toBe("generate");
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/user/elevation");
expect(result).toEqual(mockResponse);
});
it("generates user session elevation with error", async () => {
const mockRes = { data: { message: "error", status: "KO" }, status: 400 };
(axios as any).mockResolvedValue(mockRes);
(hasServiceError as any).mockReturnValue({ errored: true, message: "error" });
(PostWithOptionalResponse as any).mockRejectedValue(
new Error("Failed POST to /user/elevation. Code: 400. Message: error"),
);
await expect(generateUserSessionElevation()).rejects.toThrow(
"Failed POST to /user/elevation. Code: 400. Message: error",
+2 -12
View File
@@ -13,6 +13,7 @@ import {
toData,
validateStatusOneTimeCode,
} from "@services/Api";
import { PostWithOptionalResponse } from "@services/Client";
export interface UserSessionElevation {
require_second_factor: boolean;
@@ -43,18 +44,7 @@ export async function getUserSessionElevation() {
}
export async function generateUserSessionElevation() {
const res = await axios<ServiceResponse<UserSessionElevationGenerateData>>({
method: "POST",
url: UserSessionElevationPath,
});
if (res.status !== 200 || hasServiceError(res).errored) {
throw new Error(
`Failed POST to ${UserSessionElevationPath}. Code: ${res.status}. Message: ${hasServiceError(res).message}`,
);
}
return toData<UserSessionElevationGenerateData>(res);
return PostWithOptionalResponse<UserSessionElevationGenerateData>(UserSessionElevationPath);
}
export async function verifyUserSessionElevation(otc: string) {
@@ -5,6 +5,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { completeTOTPSignIn } from "@services/OneTimePassword";
import { RateLimitedError } from "@services/RateLimit";
import { AuthenticationLevel } from "@services/State";
import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod";
@@ -101,7 +102,7 @@ beforeEach(() => {
mocks.configError = undefined;
mocks.queryParams = {};
mocks.userCode = null;
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any);
completeSignInMock.mockResolvedValue({ redirect: "https://example.com" });
});
afterEach(() => {
@@ -268,7 +269,7 @@ describe("sign in", () => {
});
it("reports success with no redirect when the response carries no data", async () => {
completeSignInMock.mockResolvedValue({} as any);
completeSignInMock.mockResolvedValue(undefined);
const { props } = renderMethod();
@@ -277,21 +278,6 @@ describe("sign in", () => {
await waitFor(() => expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined));
});
it("reports a wrong code when the response is empty", async () => {
completeSignInMock.mockResolvedValue(null as any);
const { props } = renderMethod();
fireEvent.click(screen.getByTestId("dial-full"));
await waitFor(() =>
expect(props.onSignInError).toHaveBeenCalledWith(
expect.objectContaining({ message: "The One-Time Password might be wrong" }),
),
);
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3"));
});
it("reports a wrong code when the request throws", async () => {
completeSignInMock.mockRejectedValue(new Error("boom"));
@@ -317,7 +303,7 @@ describe("sign in", () => {
it("reports rate limiting and recovers after the retry window", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { props } = renderMethod();
@@ -339,7 +325,7 @@ describe("sign in", () => {
it("clears the rate limit timer on unmount", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { unmount } = renderMethod();
@@ -6,12 +6,15 @@ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { RedirectionURL } from "@constants/SearchParams";
import { useFlow } from "@hooks/Flow";
import { useUserCode } from "@hooks/OpenIDConnect";
import { useQueryParam } from "@hooks/QueryParam";
import { useRateLimit } from "@hooks/RateLimit";
import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration";
import { completeTOTPSignIn } from "@services/OneTimePassword";
import { RateLimitedError } from "@services/RateLimit";
import { AuthenticationLevel } from "@services/State";
import LoadingPage from "@views/LoadingPage/LoadingPage";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
@@ -47,7 +50,7 @@ const OneTimePasswordMethod = function (props: Props) {
const { onSignInError, onSignInSuccess } = props;
const onSignInErrorCallbackRef = useRef(onSignInError);
const onSignInSuccessCallbackRef = useRef(onSignInSuccess);
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() => dispatch({ type: State.Idle }));
useEffect(() => {
onSignInErrorCallbackRef.current = onSignInError;
@@ -57,15 +60,6 @@ const OneTimePasswordMethod = function (props: Props) {
onSignInSuccessCallbackRef.current = onSignInSuccess;
}, [onSignInSuccess]);
useEffect(() => {
return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
};
}, []);
useEffect(() => {
if (err) {
console.error(err);
@@ -82,20 +76,13 @@ const OneTimePasswordMethod = function (props: Props) {
const handleRateLimited = useCallback(
(retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
dispatch({ type: State.RateLimited });
onSignInErrorCallbackRef.current(new Error(translate("You have made too many requests")));
timeoutRateLimitRef.current = setTimeout(() => {
dispatch({ type: State.Idle });
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
triggerRateLimit(retryAfter);
},
[onSignInErrorCallbackRef, translate],
[onSignInErrorCallbackRef, translate, triggerRateLimit],
);
useEffect(() => {
@@ -120,19 +107,16 @@ const OneTimePasswordMethod = function (props: Props) {
dispatch({ type: State.InProgress });
const res = await completeTOTPSignIn(passcodeStr, redirectionURL, flowID, flow, subflow, userCode);
if (!res) {
dispatch({ type: State.Success });
onSignInSuccessCallbackRef.current(res?.redirect);
} catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
} else {
console.error(err);
onSignInErrorCallbackRef.current(new Error(translate("The One-Time Password might be wrong")));
dispatch({ type: State.Failure });
} else if (res.limited) {
handleRateLimited(res.retryAfter);
} else {
dispatch({ type: State.Success });
onSignInSuccessCallbackRef.current(res?.data?.redirect);
}
} catch (err) {
console.error(err);
onSignInErrorCallbackRef.current(new Error(translate("The One-Time Password might be wrong")));
dispatch({ type: State.Failure });
}
setPasscode("");
};
@@ -183,6 +167,7 @@ const OneTimePasswordMethod = function (props: Props) {
) : (
<LoadingPage />
)}
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div>
</MethodContainer>
);
@@ -2,9 +2,10 @@
//
// SPDX-License-Identifier: Apache-2.0
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { postSecondFactor } from "@services/Password";
import { RateLimitedError } from "@services/RateLimit";
import PasswordForm from "@views/LoginPortal/SecondFactor/PasswordForm";
const mocks = vi.hoisted(() => ({
@@ -111,7 +112,7 @@ describe("validation", () => {
describe("sign in", () => {
it("posts the password and reports success", async () => {
postSecondFactorMock.mockResolvedValue({ redirect: "https://example.com" } as any);
postSecondFactorMock.mockResolvedValue({ redirect: "https://example.com" });
const { onAuthenticationSuccess } = renderForm();
@@ -187,6 +188,39 @@ describe("sign in", () => {
expect(getSubmit()).not.toBeDisabled();
expect(onAuthenticationSuccess).not.toHaveBeenCalled();
});
it("notifies and disables the form until the retry period elapses when rate limited", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
postSecondFactorMock.mockRejectedValue(new RateLimitedError(30));
const { onAuthenticationSuccess } = renderForm();
fireEvent.change(getPassword(), { target: { value: "secret" } });
fireEvent.click(getSubmit());
await waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
);
expect(getPassword()).toHaveValue("");
expect(getPassword()).toBeDisabled();
expect(getSubmit()).toBeDisabled();
expect(onAuthenticationSuccess).not.toHaveBeenCalled();
expect(document.getElementById("rate-limit-countdown")).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(30000);
});
await waitFor(() => expect(getPassword()).not.toBeDisabled());
expect(getPassword()).toHaveFocus();
expect(getSubmit()).not.toBeDisabled();
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});
describe("keyboard handling", () => {
@@ -6,6 +6,7 @@ import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { Alert, AlertTitle } from "@components/UI/Alert";
import { Button } from "@components/UI/Button";
import { Input } from "@components/UI/Input";
@@ -17,8 +18,10 @@ import { useNotifications } from "@contexts/NotificationsContext";
import { useFlow } from "@hooks/Flow";
import { usePasswordVisibility } from "@hooks/PasswordVisibility";
import { useQueryParam } from "@hooks/QueryParam";
import { useRateLimit } from "@hooks/RateLimit";
import { IsCapsLockModified } from "@services/CapsLock";
import { postSecondFactor } from "@services/Password";
import { RateLimitedError } from "@services/RateLimit";
export interface Props {
onAuthenticationSuccess: (_redirectURL: string | undefined) => void;
@@ -37,6 +40,7 @@ const PasswordForm = function (props: Props) {
const [passwordCapsLock, setPasswordCapsLock] = useState(false);
const [passwordCapsLockPartial, setPasswordCapsLockPartial] = useState(false);
const [passwordError, setPasswordError] = useState(false);
const { limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
const passwordRef = useRef<HTMLInputElement | null>(null);
@@ -51,7 +55,28 @@ const PasswordForm = function (props: Props) {
return () => clearTimeout(timeout);
}, [focusPassword]);
const wasRateLimitedRef = useRef(rateLimited);
useEffect(() => {
if (wasRateLimitedRef.current && !rateLimited) focusPassword();
wasRateLimitedRef.current = rateLimited;
}, [focusPassword, rateLimited]);
const handleRateLimited = useCallback(
(retryAfter: number) => {
triggerRateLimit(retryAfter);
setPassword("");
setLoading(false);
createErrorNotification(translate("You have made too many requests"));
},
[createErrorNotification, translate, triggerRateLimit],
);
const handleSignIn = useCallback(async () => {
if (rateLimited) return;
if (password === "") {
setPasswordError(true);
@@ -62,15 +87,34 @@ const PasswordForm = function (props: Props) {
try {
const res = await postSecondFactor(password, redirectionURL, flowID, flow, subflow);
props.onAuthenticationSuccess(res ? res.redirect : undefined);
props.onAuthenticationSuccess(res?.redirect);
} catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
return;
}
console.error(err);
createErrorNotification(translate("Incorrect password"));
setPassword("");
setLoading(false);
focusPassword();
}
}, [createErrorNotification, focusPassword, password, props, redirectionURL, translate, flowID, flow, subflow]);
}, [
createErrorNotification,
focusPassword,
handleRateLimited,
password,
props,
rateLimited,
redirectionURL,
translate,
flowID,
flow,
subflow,
]);
const handlePasswordKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
@@ -119,7 +163,7 @@ const PasswordForm = function (props: Props) {
ref={passwordRef}
id="password-textfield"
required
disabled={loading}
disabled={loading || rateLimited}
value={password}
className="pr-10"
error={passwordError}
@@ -152,13 +196,14 @@ const PasswordForm = function (props: Props) {
id="sign-in-button"
variant="default"
className="w-full"
disabled={loading}
disabled={loading || rateLimited}
onClick={handleSignIn}
>
{translate("Authenticate", { ns: "settings" })}
{loading ? <Spinner size={20} className="ml-2 h-5 w-5" /> : null}
</Button>
</div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div>
</form>
);
@@ -10,6 +10,7 @@ import {
getPreferredDuoDevice,
initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification";
import { RateLimitedError } from "@services/RateLimit";
import { AuthenticationLevel } from "@services/State";
import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod";
@@ -108,7 +109,7 @@ beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "debug").mockImplementation(() => {});
getPreferredMock.mockResolvedValue({} as any);
completeSignInMock.mockResolvedValue(null as any);
completeSignInMock.mockResolvedValue(undefined);
initiateSelectionMock.mockResolvedValue({} as any);
completeSelectionMock.mockResolvedValue(undefined as any);
});
@@ -180,7 +181,7 @@ describe("preferred device lookup", () => {
describe("sign in", () => {
it("reports success after the success delay", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any);
completeSignInMock.mockResolvedValue({ redirect: "https://example.com" } as any);
const { props } = renderMethod();
@@ -192,9 +193,9 @@ describe("sign in", () => {
expect(props.onSignInSuccess).toHaveBeenCalledWith("https://example.com");
});
it("reports success with no redirect when the response carries no data", async () => {
it("reports success with no redirect when the response carries no redirect", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ data: undefined } as any);
completeSignInMock.mockResolvedValue({} as any);
const { props } = renderMethod();
@@ -205,17 +206,18 @@ describe("sign in", () => {
expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined);
});
it("fails when the sign in response is missing", async () => {
completeSignInMock.mockResolvedValue(null as any);
it("reports success with no redirect when the sign in response is missing", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue(undefined);
const { props } = renderMethod();
await waitFor(() =>
expect(props.onSignInError).toHaveBeenCalledWith(
expect.objectContaining({ message: "There was an issue completing sign in process" }),
),
);
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
await vi.waitFor(() => expect(screen.getByTestId("success-icon")).toBeInTheDocument());
await vi.advanceTimersByTimeAsync(1500);
expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined);
expect(props.onSignInError).not.toHaveBeenCalled();
});
it("fails when the sign in request throws", async () => {
@@ -243,10 +245,8 @@ describe("sign in", () => {
it("shows the device selection when the result requires auth", async () => {
completeSignInMock.mockResolvedValue({
data: {
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
},
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
} as any);
renderMethod();
@@ -259,9 +259,7 @@ describe("sign in", () => {
});
it("moves to the enroll state when no compatible device exists", async () => {
completeSignInMock.mockResolvedValue({
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
} as any);
completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
const { props } = renderMethod({ duoSelfEnrollment: true });
@@ -275,9 +273,7 @@ describe("sign in", () => {
});
it("does not offer self enrollment when it is disabled", async () => {
completeSignInMock.mockResolvedValue({
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
} as any);
completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
renderMethod({ duoSelfEnrollment: false });
@@ -288,7 +284,7 @@ describe("sign in", () => {
});
it("does not offer self enrollment when no enroll url is returned", async () => {
completeSignInMock.mockResolvedValue({ data: { result: "enroll" } } as any);
completeSignInMock.mockResolvedValue({ result: "enroll" } as any);
renderMethod({ duoSelfEnrollment: true });
@@ -299,7 +295,7 @@ describe("sign in", () => {
});
it("fails when the Duo policy denies the device", async () => {
completeSignInMock.mockResolvedValue({ data: { result: "deny" } } as any);
completeSignInMock.mockResolvedValue({ result: "deny" } as any);
const { props } = renderMethod();
@@ -313,7 +309,7 @@ describe("sign in", () => {
it("reports rate limiting and recovers after the retry window", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { props } = renderMethod();
@@ -332,7 +328,7 @@ describe("sign in", () => {
});
it("retries the sign in from the failure state", async () => {
completeSignInMock.mockResolvedValue(null as any);
completeSignInMock.mockRejectedValue(new Error("boom"));
renderMethod();
@@ -468,10 +464,8 @@ describe("device selection", () => {
it("returns to the previous state when selection is dismissed", async () => {
completeSignInMock.mockResolvedValue({
data: {
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
},
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
} as any);
renderMethod();
@@ -485,10 +479,8 @@ describe("device selection", () => {
it("persists a newly selected device and signs in again", async () => {
completeSignInMock.mockResolvedValue({
data: {
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
},
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
} as any);
renderMethod({ registered: false });
@@ -507,10 +499,8 @@ describe("device selection", () => {
it("notifies the parent when an unregistered user picks a device", async () => {
completeSignInMock.mockResolvedValue({
data: {
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
},
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
} as any);
const { props } = renderMethod({ registered: false });
@@ -524,10 +514,8 @@ describe("device selection", () => {
it("does not notify the parent when the user is already registered", async () => {
completeSignInMock.mockResolvedValue({
data: {
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
},
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
} as any);
const { props } = renderMethod({ registered: true });
@@ -542,10 +530,8 @@ describe("device selection", () => {
it("reports failures updating the preferred Duo device", async () => {
completeSignInMock.mockResolvedValue({
data: {
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
},
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
} as any);
completeSelectionMock.mockRejectedValue(new Error("boom"));
@@ -564,10 +550,8 @@ describe("device selection", () => {
it("swallows cancelled preferred device updates", async () => {
completeSignInMock.mockResolvedValue({
data: {
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
},
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
} as any);
completeSelectionMock.mockRejectedValue({ __CANCEL__: true });
@@ -603,9 +587,7 @@ describe("device selection", () => {
describe("self enrollment", () => {
it("opens the enrollment url in a new tab", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
completeSignInMock.mockResolvedValue({
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
} as any);
completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
renderMethod({ duoSelfEnrollment: true });
@@ -622,11 +604,11 @@ describe("self enrollment", () => {
describe("cleanup and edge cases", () => {
it("clears both pending timers on unmount", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { unmount } = renderMethod();
await vi.waitFor(() => expect(completeSignInMock).toHaveBeenCalled());
await vi.waitFor(() => expect(document.getElementById("rate-limit-countdown")).toBeInTheDocument());
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
@@ -637,7 +619,7 @@ describe("cleanup and edge cases", () => {
it("clears the success timer on unmount", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any);
completeSignInMock.mockResolvedValue({ redirect: "https://example.com" } as any);
const { props, unmount } = renderMethod();
@@ -654,7 +636,7 @@ describe("cleanup and edge cases", () => {
it("replaces an in flight rate limit timer", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { props } = renderMethod();
@@ -705,7 +687,7 @@ describe("cleanup and edge cases", () => {
});
it("returns to the pre-selection state after browsing devices from the method container", async () => {
completeSignInMock.mockResolvedValue(null as any);
completeSignInMock.mockRejectedValue(new Error("boom"));
initiateSelectionMock.mockResolvedValue({
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth",
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import FailureIcon from "@components/FailureIcon";
import PushNotificationIcon from "@components/PushNotificationIcon";
import RateLimitCountdown from "@components/RateLimitCountdown";
import SuccessIcon from "@components/SuccessIcon";
import { Button } from "@components/UI/Button";
import { RedirectionURL } from "@constants/SearchParams";
@@ -16,6 +17,7 @@ import { useAbortSignal } from "@hooks/Abort";
import { useFlow } from "@hooks/Flow";
import { useUserCode } from "@hooks/OpenIDConnect";
import { useQueryParam } from "@hooks/QueryParam";
import { useRateLimit } from "@hooks/RateLimit";
import {
DuoDevicePostRequest,
completeDuoDeviceSelectionProcess,
@@ -23,6 +25,7 @@ import {
getPreferredDuoDevice,
initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification";
import { RateLimitedError } from "@services/RateLimit";
import { AuthenticationLevel } from "@services/State";
import DeviceSelectionContainer, {
SelectableDevice,
@@ -68,15 +71,11 @@ const PushNotificationMethod = function (props: Props) {
const { onSignInError, onSignInSuccess } = props;
const stateRef = useRef<null | State>(null);
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() => setState(State.Failure));
const timeoutSuccessRef = useRef<null | ReturnType<typeof setTimeout>>(null);
useEffect(() => {
return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
if (timeoutSuccessRef.current !== null) {
clearTimeout(timeoutSuccessRef.current);
timeoutSuccessRef.current = null;
@@ -120,20 +119,13 @@ const PushNotificationMethod = function (props: Props) {
const handleRateLimited = useCallback(
(retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
setState(State.RateLimited);
onSignInError(new Error(translate("You have made too many requests")));
timeoutRateLimitRef.current = setTimeout(() => {
setState(State.Failure);
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
triggerRateLimit(retryAfter);
},
[onSignInError, translate],
[onSignInError, translate, triggerRateLimit],
);
const handleFetchDuoDevices = useCallback(async () => {
@@ -186,24 +178,17 @@ const PushNotificationMethod = function (props: Props) {
getSignal(),
);
if (!res) {
throw new Error(translate("There was an issue completing sign in process"));
}
if (res.limited) {
handleRateLimited(res.retryAfter);
return;
}
if (!res.data) {
handleSuccess(undefined);
return;
}
switch (res.data.result) {
switch (res.result) {
case "auth":
setDevices(processDevices(res.data.devices));
setDevices(processDevices(res.devices));
setState(State.Selection);
break;
case "enroll":
onSignInError(new Error(translate("No compatible device found")));
if (res.data.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.data.enroll_url);
if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
setState(State.Enroll);
break;
case "deny":
@@ -211,10 +196,14 @@ const PushNotificationMethod = function (props: Props) {
setState(State.Failure);
break;
default:
handleSuccess(res.data.redirect);
handleSuccess(res.redirect);
}
} catch (err) {
if (axios.isCancel(err)) return;
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
return;
}
console.error(err);
onSignInError(new Error(translate("There was an issue completing sign in process")));
setState(State.Failure);
@@ -336,6 +325,7 @@ const PushNotificationMethod = function (props: Props) {
onRegisterClick={() => window.open(enrollUrl, "_blank", "noopener,noreferrer")}
>
<div className="flex w-full justify-center">{icon}</div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
<div className={state === State.Failure ? "" : "hidden"}>
<Button variant={"ghost"} color={"secondary"} onClick={handleSignIn}>
{translate("Retry")}
@@ -4,6 +4,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RateLimitedError } from "@services/RateLimit";
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
const mockNavigate = vi.fn();
@@ -71,7 +72,7 @@ it("shows error when username is empty", async () => {
it("navigates to index on successful reset", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: false, retryAfter: 0 });
vi.mocked(initiateResetPasswordProcess).mockResolvedValue(undefined);
render(<ResetPasswordStep1 />);
@@ -89,7 +90,7 @@ it("navigates to index on successful reset", async () => {
it("shows rate limited error", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 });
vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
render(<ResetPasswordStep1 />);
@@ -131,7 +132,7 @@ describe("rate limiting", () => {
it("re-enables the reset button after the retry window", async () => {
vi.useFakeTimers();
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 });
vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
render(<ResetPasswordStep1 />);
@@ -154,7 +155,7 @@ describe("rate limiting", () => {
it("replaces an in flight rate limit timer", async () => {
vi.useFakeTimers();
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 });
vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
render(<ResetPasswordStep1 />);
@@ -196,7 +197,7 @@ describe("rate limiting", () => {
it("clears the rate limit timer on unmount", async () => {
vi.useFakeTimers();
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 });
vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
const { unmount } = render(<ResetPasswordStep1 />);
@@ -219,7 +220,7 @@ describe("rate limiting", () => {
describe("keyboard handling", () => {
it("submits on Enter", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: false, retryAfter: 0 });
vi.mocked(initiateResetPasswordProcess).mockResolvedValue(undefined);
render(<ResetPasswordStep1 />);
@@ -243,20 +244,6 @@ describe("keyboard handling", () => {
});
describe("responses", () => {
it("reports a missing response", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue(undefined as any);
render(<ResetPasswordStep1 />);
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "john" } });
fireEvent.click(screen.getByText("Reset"));
await waitFor(() =>
expect(mockCreateError).toHaveBeenCalledWith("There was an issue initiating the password reset process"),
);
});
it("disables the form while loading", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
let resolve: (value: unknown) => void = () => {};
@@ -270,7 +257,7 @@ describe("responses", () => {
await waitFor(() => expect(screen.getByLabelText("Username")).toBeDisabled());
expect(document.getElementById("cancel-button")).toBeDisabled();
resolve({ limited: false, retryAfter: 0 });
resolve(undefined);
await waitFor(() => expect(screen.getByLabelText("Username")).not.toBeDisabled());
});
});
@@ -2,19 +2,22 @@
//
// SPDX-License-Identifier: Apache-2.0
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import ComponentWithTooltip from "@components/ComponentWithTooltip";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { Button } from "@components/UI/Button";
import { Input } from "@components/UI/Input";
import { Label } from "@components/UI/Label";
import { Spinner } from "@components/UI/Spinner";
import { IndexRoute } from "@constants/Routes";
import { useNotifications } from "@contexts/NotificationsContext";
import { useRateLimit } from "@hooks/RateLimit";
import MinimalLayout from "@layouts/MinimalLayout";
import { RateLimitedError } from "@services/RateLimit";
import { initiateResetPasswordProcess } from "@services/ResetPassword";
const ResetPasswordStep1 = function () {
@@ -22,38 +25,19 @@ const ResetPasswordStep1 = function () {
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [rateLimited, setRateLimited] = useState(false);
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const { limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
const { createErrorNotification, createInfoNotification } = useNotifications();
const navigate = useNavigate();
const { t: translate } = useTranslation();
useEffect(() => {
return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
};
}, []);
const handleRateLimited = useCallback(
(retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
setRateLimited(true);
triggerRateLimit(retryAfter);
createErrorNotification(translate("You have made too many requests"));
timeoutRateLimitRef.current = setTimeout(() => {
setRateLimited(false);
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
},
[createErrorNotification, translate],
[createErrorNotification, translate, triggerRateLimit],
);
const doInitiateResetPasswordProcess = async () => {
@@ -68,17 +52,15 @@ const ResetPasswordStep1 = function () {
}
try {
const response = await initiateResetPasswordProcess(username);
if (response?.limited === false) {
createInfoNotification(translate("An email has been sent to your address to complete the process"));
navigate(IndexRoute);
} else if (response?.limited) {
handleRateLimited(response.retryAfter);
await initiateResetPasswordProcess(username);
createInfoNotification(translate("An email has been sent to your address to complete the process"));
navigate(IndexRoute);
} catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
} else {
createErrorNotification(translate("There was an issue initiating the password reset process"));
}
} catch {
createErrorNotification(translate("There was an issue initiating the password reset process"));
}
setLoading(false);
};
@@ -141,6 +123,7 @@ const ResetPasswordStep1 = function () {
</Button>
</div>
</div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div>
</div>
</MinimalLayout>
@@ -7,6 +7,7 @@ import { MemoryRouter } from "react-router";
import { PasswordPolicyMode } from "@models/PasswordPolicy";
import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration";
import { RateLimitedError } from "@services/RateLimit";
import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword";
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
@@ -102,7 +103,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
queryParam.token = "test-token";
completeProcessMock.mockResolvedValue({} as any);
completeProcessMock.mockResolvedValue(undefined);
getPolicyMock.mockResolvedValue(standardPolicy);
resetPasswordMock.mockResolvedValue(undefined as any);
});
@@ -192,7 +193,7 @@ describe("process initiation", () => {
});
it("notifies and keeps the form disabled when rate limited", async () => {
completeProcessMock.mockResolvedValue({ limited: true, retryAfter: 30 } as any);
completeProcessMock.mockRejectedValue(new RateLimitedError(30));
renderStep();
@@ -203,6 +204,33 @@ describe("process initiation", () => {
expect(getPolicyMock).not.toHaveBeenCalled();
});
it("shows a countdown and retries the process once the retry period elapses when rate limited", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
completeProcessMock.mockRejectedValueOnce(new RateLimitedError(30));
renderStep();
await waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
);
expect(document.getElementById("rate-limit-countdown")).toBeInTheDocument();
expect(completeProcessMock).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(30000);
});
await waitFor(() => expect(completeProcessMock).toHaveBeenCalledTimes(2));
expect(completeProcessMock).toHaveBeenLastCalledWith("test-token");
await waitFor(() => expect(getReset()).not.toBeDisabled());
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it("notifies and keeps the form disabled when the token expired", async () => {
completeProcessMock.mockRejectedValue(new Error("expired"));
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import PasswordMeter from "@components/PasswordMeter";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { Button } from "@components/UI/Button";
import { Input } from "@components/UI/Input";
import { Label } from "@components/UI/Label";
@@ -17,9 +18,11 @@ import { IdentityToken } from "@constants/SearchParams";
import { useNotifications } from "@contexts/NotificationsContext";
import { usePasswordVisibility } from "@hooks/PasswordVisibility";
import { useQueryParam } from "@hooks/QueryParam";
import { useRateLimit } from "@hooks/RateLimit";
import MinimalLayout from "@layouts/MinimalLayout";
import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy";
import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration";
import { RateLimitedError } from "@services/RateLimit";
import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword";
const uninitiated = Symbol("uninitiated");
@@ -52,20 +55,27 @@ const ResetPasswordStep2 = function () {
const processToken = useQueryParam(IdentityToken);
const resetInitiatedRef = useRef<null | string | typeof uninitiated | undefined>(uninitiated);
const [attempt, setAttempt] = useState(0);
const attemptInitiatedRef = useRef(attempt);
const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() => setAttempt((value) => value + 1));
const handleRateLimited = useCallback(
(_retryAfter: number) => {
createErrorNotification(translate("You have made too many requests")); // TODO: Do we want to add the amount of seconds a user should retry in the message?
(retryAfter: number) => {
triggerRateLimit(retryAfter);
createErrorNotification(translate("You have made too many requests"));
},
[createErrorNotification, translate],
[createErrorNotification, translate, triggerRateLimit],
);
useEffect(() => {
if (resetInitiatedRef.current === processToken) {
if (resetInitiatedRef.current === processToken && attemptInitiatedRef.current === attempt) {
return;
}
resetInitiatedRef.current = processToken;
attemptInitiatedRef.current = attempt;
const submitReset = async () => {
if (!processToken) {
@@ -75,17 +85,17 @@ const ResetPasswordStep2 = function () {
}
try {
const response = await completeResetPasswordProcess(processToken);
if (response?.limited) {
handleRateLimited(response.retryAfter);
return;
}
await completeResetPasswordProcess(processToken);
const policy = await getPasswordPolicyConfiguration();
setPPolicy(policy);
setFormDisabled(false);
} catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
return;
}
console.error(err);
createErrorNotification(
translate("There was an issue completing the process the verification token might have expired"),
@@ -95,7 +105,7 @@ const ResetPasswordStep2 = function () {
};
submitReset().catch(console.error);
}, [processToken, createErrorNotification, translate, handleRateLimited]);
}, [attempt, processToken, createErrorNotification, translate, handleRateLimited]);
const doResetPassword = async () => {
setPassword1("");
@@ -217,6 +227,7 @@ const ResetPasswordStep2 = function () {
</Button>
</div>
</div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div>
</div>
</MinimalLayout>
@@ -4,10 +4,14 @@
import { act, render, screen } from "@testing-library/react";
import { RateLimitedError } from "@services/RateLimit";
import RevokeResetPasswordTokenView from "@views/Revoke/RevokeResetPasswordTokenView";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, options?: { count?: number }) =>
options?.count === undefined ? key : `${key}:${options.count}`,
}),
}));
const mockNavigate = vi.fn();
@@ -68,15 +72,15 @@ it("shows error when token is not provided", async () => {
expect(mockCreateError).toHaveBeenCalledWith("The Token was not provided");
});
it("shows rate limited error on 429 response", async () => {
it("shows rate limited error when the request is rate limited", async () => {
const { deleteResetPasswordToken } = await import("@services/ResetPassword");
vi.mocked(deleteResetPasswordToken).mockResolvedValueOnce({ ok: false, status: 429 });
vi.mocked(deleteResetPasswordToken).mockRejectedValueOnce(new RateLimitedError(30));
await act(async () => {
render(<RevokeResetPasswordTokenView />);
});
expect(mockCreateError).toHaveBeenCalledWith("You have made too many requests");
expect(mockCreateError).toHaveBeenCalledWith("You have made too many requests, try again in {{count}} seconds:30");
});
it("reports a successful revocation", async () => {
@@ -102,6 +106,17 @@ it("reports a failed revocation", async () => {
expect(mockCreateError).toHaveBeenCalledWith("Failed to revoke the Token");
});
it("reports a failed revocation when the request throws", async () => {
const { deleteResetPasswordToken } = await import("@services/ResetPassword");
vi.mocked(deleteResetPasswordToken).mockRejectedValueOnce(new Error("network error"));
await act(async () => {
render(<RevokeResetPasswordTokenView />);
});
expect(mockCreateError).toHaveBeenCalledWith("Failed to revoke the Token");
});
it("navigates back to the index route after the delay", async () => {
vi.useFakeTimers();
@@ -10,6 +10,7 @@ import { IndexRoute } from "@constants/Routes";
import { useNotifications } from "@contexts/NotificationsContext";
import { useToken } from "@hooks/Revoke";
import { useRouterNavigate } from "@hooks/RouterNavigate";
import { RateLimitedError } from "@services/RateLimit";
import { deleteResetPasswordToken } from "@services/ResetPassword";
import LoadingPage from "@views/LoadingPage/LoadingPage";
@@ -29,14 +30,25 @@ const RevokeResetPasswordTokenView = function () {
const handleRevoke = useCallback(async () => {
if (!token) return;
const { ok, status } = await deleteResetPasswordToken(token);
try {
const { ok } = await deleteResetPasswordToken(token);
if (ok) {
createSuccessNotification(translate("Successfully revoked the Token"));
} else if (status === 429) {
createErrorNotification(translate("You have made too many requests"));
} else {
createErrorNotification(translate("Failed to revoke the Token"));
if (ok) {
createSuccessNotification(translate("Successfully revoked the Token"));
} else {
createErrorNotification(translate("Failed to revoke the Token"));
}
} catch (err) {
if (err instanceof RateLimitedError) {
createErrorNotification(
translate("You have made too many requests, try again in {{count}} seconds", {
count: err.retryAfter,
}),
);
} else {
console.error(err);
createErrorNotification(translate("Failed to revoke the Token"));
}
}
handleRedirect();
@@ -4,6 +4,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RateLimitedError } from "@services/RateLimit";
import {
deleteUserSessionElevation,
generateUserSessionElevation,
@@ -16,7 +17,10 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, options?: { count?: number }) =>
options?.count === undefined ? key : `${key}:${options.count}`,
}),
}));
vi.mock("@contexts/NotificationsContext", () => ({
@@ -86,8 +90,8 @@ function clickCancel() {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
generateMock.mockResolvedValue({ delete_id: "delete-id" } as any);
verifyMock.mockResolvedValue(true as any);
generateMock.mockResolvedValue({ delete_id: "delete-id" });
verifyMock.mockResolvedValue(true);
deleteMock.mockResolvedValue(undefined as any);
});
@@ -162,6 +166,19 @@ describe("code generation failures", () => {
expect(handleClosed).toHaveBeenCalledWith(false);
});
it("notifies and closes when generation is rate limited", async () => {
generateMock.mockRejectedValue(new RateLimitedError(30));
const { handleClosed } = renderDialog();
await waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith(
"You have made too many requests, try again in {{count}} seconds:30",
),
);
expect(handleClosed).toHaveBeenCalledWith(false);
});
it("notifies and closes when generation returns nothing", async () => {
generateMock.mockResolvedValue(null as any);
@@ -239,7 +256,7 @@ describe("verification", () => {
});
it("notifies, clears and refocuses the field on a wrong code", async () => {
verifyMock.mockResolvedValue(false as any);
verifyMock.mockResolvedValue(false);
renderDialog();
await screen.findByText("Verify");
@@ -257,6 +274,44 @@ describe("verification", () => {
expect(getCode()).toHaveFocus();
});
it("notifies and disables the controls until the retry period elapses when rate limited", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
verifyMock.mockRejectedValue(new RateLimitedError(30));
const { handleClosed } = renderDialog();
await screen.findByText("Verify");
fireEvent.change(getCode(), { target: { value: "123456" } });
clickVerify();
await waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
);
expect(getCode()).toHaveValue("");
expect(getCode()).toBeDisabled();
expect(document.getElementById("dialog-verify")).toBeDisabled();
expect(handleClosed).not.toHaveBeenCalled();
act(() => {
document.getElementById("dialog-cancel")!.focus();
});
expect(getCode()).not.toHaveFocus();
await act(async () => {
await vi.advanceTimersByTimeAsync(30000);
});
await waitFor(() => expect(getCode()).not.toBeDisabled());
expect(getCode()).toHaveFocus();
expect(document.getElementById("dialog-verify")).not.toBeDisabled();
} finally {
vi.useRealTimers();
}
});
it("notifies, clears and re-enables the field when verification fails", async () => {
verifyMock.mockRejectedValue(new Error("Network Error"));
@@ -277,7 +332,7 @@ describe("verification", () => {
});
it("clears the error once the user retypes", async () => {
verifyMock.mockResolvedValue(false as any);
verifyMock.mockResolvedValue(false);
renderDialog();
await screen.findByText("Verify");
@@ -7,6 +7,7 @@ import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, us
import { useTranslation } from "react-i18next";
import OneTimeCodeTextField from "@components/OneTimeCodeTextField";
import RateLimitCountdown from "@components/RateLimitCountdown";
import SuccessIcon from "@components/SuccessIcon";
import { Button } from "@components/UI/Button";
import {
@@ -19,6 +20,8 @@ import {
} from "@components/UI/Dialog";
import { Spinner } from "@components/UI/Spinner";
import { useNotifications } from "@contexts/NotificationsContext";
import { useRateLimit } from "@hooks/RateLimit";
import { RateLimitedError } from "@services/RateLimit";
import {
UserSessionElevation,
deleteUserSessionElevation,
@@ -46,6 +49,7 @@ const IdentityVerificationDialog = function (props: Props) {
const [codeDelete, setCodeDelete] = useState<string>();
const [codeError, setCodeError] = useState(false);
const [ready, setReady] = useState(false);
const { clear: clearRateLimit, limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
const codeRef = useRef<HTMLInputElement>(null);
const timeoutSuccessRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const attemptRef = useRef(0);
@@ -61,6 +65,14 @@ const IdentityVerificationDialog = function (props: Props) {
const open = useMemo(() => ready && !closing && opening && !!elevation, [ready, closing, opening, elevation]);
const wasRateLimitedRef = useRef(rateLimited);
useEffect(() => {
if (wasRateLimitedRef.current && !rateLimited && open) codeRef.current?.focus();
wasRateLimitedRef.current = rateLimited;
}, [open, rateLimited]);
const handleClose = useCallback(
(ok: boolean) => {
setCodeInput("");
@@ -70,9 +82,10 @@ const IdentityVerificationDialog = function (props: Props) {
setSuccess(false);
setClosing(false);
setReady(false);
clearRateLimit();
handleClosed(ok);
},
[handleClosed],
[clearRateLimit, handleClosed],
);
const handleDelete = useCallback(async () => {
@@ -114,25 +127,36 @@ const IdentityVerificationDialog = function (props: Props) {
codeRef.current?.focus();
}, [createErrorNotification, translate]);
const handleRateLimited = useCallback(
(retryAfter: number) => {
setCodeInput("");
setLoading(false);
triggerRateLimit(retryAfter);
createErrorNotification(translate("You have made too many requests", { ns: "portal" }));
},
[createErrorNotification, translate, triggerRateLimit],
);
const handleSubmit = useCallback(async () => {
if (codeInput === "") return;
if (codeInput === "" || rateLimited) return;
setLoading(true);
const attempt = ++attemptRef.current;
const success = await verifyUserSessionElevation(codeInput).catch((err) => {
console.error(err);
return false;
});
const result = await verifyUserSessionElevation(codeInput).catch((err: unknown) => err);
if (attempt !== attemptRef.current) return;
if (success) {
if (result instanceof RateLimitedError) {
handleRateLimited(result.retryAfter);
} else if (result === true) {
handleSuccess();
} else {
if (result instanceof Error) console.error(result);
handleFailure();
}
}, [codeInput, handleFailure, handleSuccess]);
}, [codeInput, handleFailure, handleRateLimited, handleSuccess, rateLimited]);
const handleSubmitKeyDown = useCallback(
async (event: KeyboardEvent<HTMLDivElement>) => {
@@ -163,8 +187,18 @@ const IdentityVerificationDialog = function (props: Props) {
setReady(true);
})
.catch((error) => {
console.error(error);
createErrorNotification(translate("Failed to generate the One-Time Code. Please try again later."));
if (error instanceof RateLimitedError) {
createErrorNotification(
translate("You have made too many requests, try again in {{count}} seconds", {
count: error.retryAfter,
ns: "portal",
}),
);
} else {
console.error(error);
createErrorNotification(translate("Failed to generate the One-Time Code. Please try again later."));
}
handleClose(false);
});
}, [closing, opening, elevation, ready, translate, handleClose, handleOpened, createErrorNotification]);
@@ -206,11 +240,12 @@ const IdentityVerificationDialog = function (props: Props) {
value={codeInput}
onChange={handleChange}
error={codeError}
disabled={loading}
disabled={loading || rateLimited}
inputRef={codeRef}
onKeyDown={handleSubmitKeyDown}
/>
</div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div>
)}
{success ? null : (
@@ -228,7 +263,7 @@ const IdentityVerificationDialog = function (props: Props) {
id={"dialog-verify"}
variant={"ghost"}
color={"primary"}
disabled={loading}
disabled={loading || rateLimited}
onClick={handleSubmit}
>
{loading ? <Spinner size={20} /> : null}
@@ -9,6 +9,7 @@ import {
completePushNotificationSignIn,
initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification";
import { RateLimitedError } from "@services/RateLimit";
import SecondFactorMethodMobilePush from "@views/Settings/Common/SecondFactorMethodMobilePush";
const mocks = vi.hoisted(() => ({
@@ -53,13 +54,11 @@ const initiateSelectionMock = vi.mocked(initiateDuoDeviceSelectionProcess);
const completeSelectionMock = vi.mocked(completeDuoDeviceSelectionProcess);
const authResponse = {
data: {
devices: [
{ capabilities: ["push", "sms"], device: "device-a", display_name: "Phone" },
{ capabilities: ["push"], device: "device-b", display_name: "Tablet" },
],
result: "auth",
},
devices: [
{ capabilities: ["push", "sms"], device: "device-a", display_name: "Phone" },
{ capabilities: ["push"], device: "device-b", display_name: "Tablet" },
],
result: "auth",
};
function renderPush(onSecondFactorSuccess = vi.fn()) {
@@ -72,7 +71,7 @@ function renderPush(onSecondFactorSuccess = vi.fn()) {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
completeSignInMock.mockResolvedValue(null as any);
completeSignInMock.mockRejectedValue(new Error("boom"));
initiateSelectionMock.mockResolvedValue({} as any);
completeSelectionMock.mockResolvedValue(undefined as any);
});
@@ -103,7 +102,7 @@ describe("rendering", () => {
describe("push response handling", () => {
it("reports success and hides the device link", async () => {
completeSignInMock.mockResolvedValue({ data: { result: "allow" } } as any);
completeSignInMock.mockResolvedValue({ result: "allow" } as any);
const { onSecondFactorSuccess } = renderPush();
@@ -128,7 +127,7 @@ describe("push response handling", () => {
});
it("fails when the response requires enrollment", async () => {
completeSignInMock.mockResolvedValue({ data: { result: "enroll" } } as any);
completeSignInMock.mockResolvedValue({ result: "enroll" } as any);
renderPush();
@@ -137,7 +136,7 @@ describe("push response handling", () => {
});
it("fails when the Duo policy denies the request", async () => {
completeSignInMock.mockResolvedValue({ data: { result: "deny" } } as any);
completeSignInMock.mockResolvedValue({ result: "deny" } as any);
renderPush();
@@ -147,26 +146,27 @@ describe("push response handling", () => {
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
});
it("fails when the response is missing", async () => {
completeSignInMock.mockResolvedValue(null as any);
it("reports success when the response carries no data", async () => {
completeSignInMock.mockResolvedValue(undefined);
renderPush();
const { onSecondFactorSuccess } = renderPush();
await waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("There was an issue completing sign in process"),
);
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
await waitFor(() => expect(onSecondFactorSuccess).toHaveBeenCalled());
expect(mocks.createErrorNotification).not.toHaveBeenCalled();
expect(screen.queryByTestId("failure-icon")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Select a Device" })).not.toBeInTheDocument();
});
it("fails when the response carries no data", async () => {
it("fails when the response carries an empty payload", async () => {
completeSignInMock.mockResolvedValue({} as any);
renderPush();
const { onSecondFactorSuccess } = renderPush();
await waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("There was an issue completing sign in process"),
);
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
});
it("fails when the push request throws", async () => {
@@ -182,7 +182,7 @@ describe("push response handling", () => {
it("reports rate limiting and recovers after the retry window", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 3 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(3));
renderPush();
@@ -198,20 +198,8 @@ describe("push response handling", () => {
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
});
it("treats a limited response carrying data as rate limited", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ data: { result: "allow" }, limited: true, retryAfter: 1 } as any);
const { onSecondFactorSuccess } = renderPush();
await vi.waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
);
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
});
it("retries the push from the failure state", async () => {
completeSignInMock.mockResolvedValue(null as any);
completeSignInMock.mockRejectedValue(new Error("boom"));
renderPush();
@@ -314,6 +302,40 @@ describe("device selection", () => {
await waitFor(() => expect(completeSignInMock).toHaveBeenCalledTimes(2));
});
it("clears the rate limit when a device is selected during the retry window", async () => {
vi.useFakeTimers();
completeSignInMock.mockRejectedValueOnce(new RateLimitedError(3));
initiateSelectionMock.mockResolvedValue(authResponse as any);
const { onSecondFactorSuccess } = renderPush();
await vi.waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
);
completeSignInMock.mockResolvedValue({ result: "allow" } as any);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Select a Device" }));
});
await vi.waitFor(() => expect(screen.getByTestId("device-selection")).toBeInTheDocument());
await act(async () => {
fireEvent.click(screen.getByTestId("device-select"));
});
await vi.waitFor(() => expect(onSecondFactorSuccess).toHaveBeenCalled());
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
expect(screen.queryByTestId("failure-icon")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Select a Device" })).not.toBeInTheDocument();
});
it("logs failures updating the preferred Duo device", async () => {
completeSignInMock.mockResolvedValue(authResponse as any);
completeSelectionMock.mockRejectedValue(new Error("boom"));
@@ -335,7 +357,7 @@ describe("device selection", () => {
describe("cleanup", () => {
it("clears the rate limit timer on unmount", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 3 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(3));
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
@@ -345,7 +367,6 @@ describe("cleanup", () => {
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
);
// Identify the component's own rate limit timer rather than any timer React happens to clear.
const index = setTimeoutSpy.mock.calls.findIndex(([, timeout]) => timeout === 3000);
expect(index).toBeGreaterThanOrEqual(0);
@@ -2,20 +2,24 @@
//
// SPDX-License-Identifier: Apache-2.0
import { Fragment, ReactNode, useCallback, useEffect, useReducer, useRef } from "react";
import { Fragment, ReactNode, useCallback, useEffect, useReducer } from "react";
import { useTranslation } from "react-i18next";
import FailureIcon from "@components/FailureIcon";
import PushNotificationIcon from "@components/PushNotificationIcon";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { Button } from "@components/UI/Button";
import { useNotifications } from "@contexts/NotificationsContext";
import { useRateLimit } from "@hooks/RateLimit";
import {
DuoDevicePostRequest,
DuoSignInResponse,
completeDuoDeviceSelectionProcess,
completePushNotificationSignIn,
initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification";
import { RateLimitedError } from "@services/RateLimit";
import DeviceSelectionContainer, {
SelectableDevice,
SelectedDevice,
@@ -30,6 +34,7 @@ type Action =
| { type: "pushFailure" }
| { type: "pushSuccess" }
| { type: "rateLimited" }
| { type: "rateLimitExpired" }
| { type: "selectDevices"; devices: SelectableDevice[] }
| { type: "setDevices"; devices: SelectableDevice[] }
| { type: "setStatus"; status: ComponentState["status"] }
@@ -56,6 +61,8 @@ function reducer(state: ComponentState, action: Action): ComponentState {
return { ...state, devices: action.devices, status: "selecting" };
case "rateLimited":
return { ...state, status: "rate_limited" };
case "rateLimitExpired":
return state.status === "rate_limited" ? { ...state, status: "failure" } : state;
default:
return state;
}
@@ -72,73 +79,55 @@ const SecondFactorMethodMobilePush = function (props: Props) {
const { createErrorNotification } = useNotifications();
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
useEffect(() => {
return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
};
}, []);
const {
clear: clearRateLimit,
secondsRemaining,
trigger: triggerRateLimit,
} = useRateLimit(() => dispatch({ type: "rateLimitExpired" }));
const handleRateLimited = useCallback(
(retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
dispatch({ type: "rateLimited" });
createErrorNotification(translate("You have made too many requests"));
timeoutRateLimitRef.current = setTimeout(() => {
dispatch({ type: "pushFailure" });
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
triggerRateLimit(retryAfter);
},
[createErrorNotification, translate],
[createErrorNotification, translate, triggerRateLimit],
);
const handlePushResponse = useCallback(
(res: any) => {
if (res) {
if (res.data && !res.limited) {
switch (res.data.result) {
case "auth": {
const selectableDevices = [] as SelectableDevice[];
for (const d of res.data.devices) {
selectableDevices.push({ id: d.device, methods: d.capabilities, name: d.display_name });
}
dispatch({ devices: selectableDevices, type: "selectDevices" });
break;
}
case "enroll":
createErrorNotification(translate("No compatible device found"));
dispatch({ type: "pushFailure" });
break;
case "deny":
createErrorNotification(translate("Device selection was denied by Duo policy"));
dispatch({ type: "pushFailure" });
break;
default:
dispatch({ type: "pushSuccess" });
props.onSecondFactorSuccess();
break;
}
} else if (res.limited) {
handleRateLimited(res.retryAfter);
} else {
createErrorNotification(translate("There was an issue completing sign in process"));
dispatch({ type: "pushFailure" });
}
} else {
(res: DuoSignInResponse | undefined) => {
if (res !== undefined && typeof res.result !== "string") {
createErrorNotification(translate("There was an issue completing sign in process"));
dispatch({ type: "pushFailure" });
return;
}
switch (res?.result) {
case "auth": {
const selectableDevices = [] as SelectableDevice[];
for (const d of res.devices) {
selectableDevices.push({ id: d.device, methods: d.capabilities, name: d.display_name });
}
dispatch({ devices: selectableDevices, type: "selectDevices" });
break;
}
case "enroll":
createErrorNotification(translate("No compatible device found"));
dispatch({ type: "pushFailure" });
break;
case "deny":
createErrorNotification(translate("Device selection was denied by Duo policy"));
dispatch({ type: "pushFailure" });
break;
default:
dispatch({ type: "pushSuccess" });
props.onSecondFactorSuccess();
break;
}
},
[createErrorNotification, handleRateLimited, props, translate],
[createErrorNotification, props, translate],
);
const handleSelectDevice = useCallback(async () => {
@@ -177,23 +166,29 @@ const SecondFactorMethodMobilePush = function (props: Props) {
const res = await completePushNotificationSignIn();
handlePushResponse(res);
} catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
return;
}
console.error(err);
createErrorNotification(translate("There was an issue completing sign in process"));
dispatch({ type: "pushFailure" });
}
}, [handlePushResponse, createErrorNotification, translate]);
}, [handlePushResponse, handleRateLimited, createErrorNotification, translate]);
const updateDuoDevice = useCallback(
async function (device: DuoDevicePostRequest) {
try {
await completeDuoDeviceSelectionProcess(device);
clearRateLimit();
dispatch({ type: "startPush" });
} catch (err) {
console.error(err);
console.error(new Error(translate("There was an issue updating preferred Duo device")));
}
},
[translate],
[clearRateLimit, translate],
);
const handleDuoDeviceSelected = useCallback(
@@ -232,6 +227,7 @@ const SecondFactorMethodMobilePush = function (props: Props) {
<Fragment>
<div className="h-[120px]">
<div className="inline-block size-17.5">{icon}</div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
<div className={state.status === "failure" ? "" : "hidden"}>
<Button variant={"ghost"} color={"secondary"} onClick={() => dispatch({ type: "startPush" })}>
{translate("Retry")}
@@ -5,6 +5,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { completeTOTPSignIn } from "@services/OneTimePassword";
import { RateLimitedError } from "@services/RateLimit";
import SecondFactorMethodOneTimePassword from "@views/Settings/Common/SecondFactorMethodOneTimePassword";
const mocks = vi.hoisted(() => ({
@@ -66,7 +67,7 @@ beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
mocks.config = { digits: 6, period: 30 };
mocks.configError = null;
completeSignInMock.mockResolvedValue({} as any);
completeSignInMock.mockResolvedValue({ redirect: "https://example.com" });
});
afterEach(() => {
@@ -133,34 +134,33 @@ describe("sign in", () => {
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-passcode", ""));
});
it("notifies and fails when the code is rejected", async () => {
completeSignInMock.mockResolvedValue(null as any);
it("succeeds when the response carries no data", async () => {
completeSignInMock.mockResolvedValue(undefined);
const { onSecondFactorSuccess } = renderMethod();
fireEvent.click(screen.getByTestId("dial-full"));
await waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("The One-Time Password might be wrong"),
);
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3"));
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
await waitFor(() => expect(onSecondFactorSuccess).toHaveBeenCalled());
expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "2");
});
it("fails when the request throws", async () => {
completeSignInMock.mockRejectedValue(new Error("boom"));
renderMethod();
const { onSecondFactorSuccess } = renderMethod();
fireEvent.click(screen.getByTestId("dial-full"));
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3"));
expect(console.error).toHaveBeenCalled();
expect(mocks.createErrorNotification).not.toHaveBeenCalled();
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
});
it("reports rate limiting and recovers after the retry window", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(4));
renderMethod();
@@ -180,7 +180,7 @@ describe("sign in", () => {
it("ignores new submissions while rate limited", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(4));
renderMethod();
@@ -205,7 +205,7 @@ describe("sign in", () => {
expect(completeSignInMock).toHaveBeenCalledTimes(1);
resolve({});
resolve(undefined);
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "2"));
});
@@ -222,7 +222,7 @@ describe("sign in", () => {
it("clears the rate limit timer on unmount", async () => {
vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any);
completeSignInMock.mockRejectedValue(new RateLimitedError(4));
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
@@ -231,7 +231,6 @@ describe("sign in", () => {
fireEvent.click(screen.getByTestId("dial-full"));
await vi.waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "4"));
// Identify the component's own rate limit timer rather than any timer React happens to clear.
const index = setTimeoutSpy.mock.calls.findIndex(([, timeout]) => timeout === 4000);
expect(index).toBeGreaterThanOrEqual(0);
@@ -2,13 +2,16 @@
//
// SPDX-License-Identifier: Apache-2.0
import { Fragment, useCallback, useEffect, useReducer, useRef } from "react";
import { Fragment, useCallback, useEffect, useReducer } from "react";
import { useTranslation } from "react-i18next";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { useNotifications } from "@contexts/NotificationsContext";
import { useRateLimit } from "@hooks/RateLimit";
import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration";
import { completeTOTPSignIn } from "@services/OneTimePassword";
import { RateLimitedError } from "@services/RateLimit";
import LoadingPage from "@views/LoadingPage/LoadingPage";
import OTPDial, { State } from "@views/LoginPortal/SecondFactor/OTPDial";
@@ -49,16 +52,9 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
const [config, fetchConfig, , fetchConfigError] = useUserInfoTOTPConfiguration();
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
useEffect(() => {
return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
};
}, []);
const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() =>
dispatch({ status: State.Idle, type: "setStatus" }),
);
useEffect(() => {
if (fetchConfigError) {
@@ -73,20 +69,13 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
const handleRateLimited = useCallback(
(retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
dispatch({ status: State.RateLimited, type: "setStatus" });
createErrorNotification(translate("You have made too many requests", { ns: "portal" }));
timeoutRateLimitRef.current = setTimeout(() => {
dispatch({ status: State.Idle, type: "setStatus" });
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
triggerRateLimit(retryAfter);
},
[createErrorNotification, translate],
[createErrorNotification, translate, triggerRateLimit],
);
const handleSignIn = useCallback(
@@ -102,27 +91,22 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
try {
dispatch({ status: State.InProgress, type: "setStatus" });
const res = await completeTOTPSignIn(passcodeStr);
await completeTOTPSignIn(passcodeStr);
if (res) {
if (res.limited) {
handleRateLimited(res.retryAfter);
} else {
dispatch({ status: State.Success, type: "setStatus" });
onSecondFactorSuccess();
}
dispatch({ status: State.Success, type: "setStatus" });
onSecondFactorSuccess();
} catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
} else {
createErrorNotification(translate("The One-Time Password might be wrong", { ns: "portal" }));
console.error(err);
dispatch({ status: State.Failure, type: "setStatus" });
}
} catch (err) {
console.error(err);
dispatch({ status: State.Failure, type: "setStatus" });
}
dispatch({ passcode: "", type: "setPasscode" });
},
[config, handleRateLimited, createErrorNotification, translate, onSecondFactorSuccess],
[config, handleRateLimited, onSecondFactorSuccess],
);
const handlePasscodeChange = useCallback(
@@ -153,6 +137,7 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
) : (
<LoadingPage />
)}
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</Fragment>
);
};