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: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
security: security:
- authelia_auth: [] - authelia_auth: []
/api/reset-password/identity/finish: /api/reset-password/identity/finish:
@@ -753,9 +753,9 @@ paths:
headers: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
security: security:
- authelia_auth: [] - authelia_auth: []
/api/reset-password: /api/reset-password:
@@ -791,9 +791,9 @@ paths:
headers: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
security: security:
- authelia_auth: [] - authelia_auth: []
delete: delete:
@@ -826,9 +826,9 @@ paths:
headers: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
security: security:
- authelia_auth: [] - authelia_auth: []
{{- end }} {{- end }}
@@ -940,9 +940,9 @@ paths:
headers: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
security: security:
- authelia_auth: [] - authelia_auth: []
put: put:
@@ -983,9 +983,9 @@ paths:
headers: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
security: security:
- authelia_auth: [] - authelia_auth: []
/api/user/session/elevation/{id}: /api/user/session/elevation/{id}:
@@ -1211,9 +1211,9 @@ paths:
headers: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
security: security:
- authelia_auth: [] - authelia_auth: []
delete: delete:
@@ -1558,9 +1558,9 @@ paths:
headers: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
security: security:
- authelia_auth: [] - authelia_auth: []
/api/secondfactor/duo_devices: /api/secondfactor/duo_devices:
@@ -2141,9 +2141,9 @@ paths:
headers: headers:
Retry-After: Retry-After:
schema: schema:
type: string type: integer
format: http-date minimum: 0
description: The date time that the request can be retried. description: The number of seconds to wait before the request can be retried.
"500": "500":
description: Internal Server Error description: Internal Server Error
content: content:
+2
View File
@@ -28,6 +28,7 @@ func TestNewConfiguration(t *testing.T) {
paths: []string{"../../internal/configuration/test_resources/config.webauthn.yml"}, paths: []string{"../../internal/configuration/test_resources/config.webauthn.yml"},
keys: []string{ keys: []string{
"regulation.max_retries", "regulation.max_retries",
"server.endpoints.rate_limits.health.enable",
"server.endpoints.rate_limits.openid_connect_introspection.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_pushed_authorization_request.enable",
"server.endpoints.rate_limits.openid_connect_revocation.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.authn_strategies[].name",
"server.endpoints.authz.forward-auth.implementation", "server.endpoints.authz.forward-auth.implementation",
"server.endpoints.authz.legacy.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_introspection.enable",
"server.endpoints.rate_limits.openid_connect_pushed_authorization_request.enable", "server.endpoints.rate_limits.openid_connect_pushed_authorization_request.enable",
"server.endpoints.rate_limits.openid_connect_revocation.enable", "server.endpoints.rate_limits.openid_connect_revocation.enable",
+21 -17
View File
@@ -4,23 +4,27 @@
package configuration package configuration
var defaults = map[string]any{ import (
"regulation.max_retries": 3, "github.com/authelia/authelia/v4/internal/configuration/schema"
"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, var defaults = newDefaults()
"server.endpoints.rate_limits.openid_connect_token.enable": true,
"server.endpoints.rate_limits.openid_connect_userinfo.enable": true, func newDefaults() (values map[string]any) {
"server.endpoints.rate_limits.reset_password_start.enable": true, values = map[string]any{
"server.endpoints.rate_limits.reset_password_finish.enable": true, "regulation.max_retries": 3,
"server.endpoints.rate_limits.second_factor_totp.enable": true, "webauthn.selection_criteria.discoverability": "preferred",
"server.endpoints.rate_limits.second_factor_duo.enable": true, "webauthn.selection_criteria.user_verification": "preferred",
"server.endpoints.rate_limits.second_factor_password.enable": true, "webauthn.metadata.cache_policy": "strict",
"server.endpoints.rate_limits.session_elevation_start.enable": true, }
"server.endpoints.rate_limits.session_elevation_finish.enable": true,
"webauthn.selection_criteria.discoverability": "preferred", limits := schema.ServerEndpointRateLimits{}
"webauthn.selection_criteria.user_verification": "preferred",
"webauthn.metadata.cache_policy": "strict", for _, endpoint := range limits.Endpoints() {
values["server.endpoints.rate_limits."+endpoint.Name+".enable"] = true
}
return values
} }
// Defaults returns a copy of the defaults. // 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."` 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. // ServerEndpointRateLimit represents the rate limiter configuration for a single endpoint.
type ServerEndpointRateLimit struct { 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."` 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) { 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) defaults := schema.DefaultServerConfiguration.Endpoints.RateLimits.Endpoints()
validateServerEndpointsRateLimitDefault("openid_connect_token", &config.Server.Endpoints.RateLimits.OpenIDConnectToken, schema.DefaultServerConfiguration.Endpoints.RateLimits.OpenIDConnectToken, validator)
validateServerEndpointsRateLimitDefault("health", &config.Server.Endpoints.RateLimits.Health, schema.DefaultServerConfiguration.Endpoints.RateLimits.Health, validator) for i, endpoint := range config.Server.Endpoints.RateLimits.Endpoints() {
validateServerEndpointsRateLimitDefault("reset_password_start", &config.Server.Endpoints.RateLimits.ResetPasswordStart, schema.DefaultServerConfiguration.Endpoints.RateLimits.ResetPasswordStart, validator) validateServerEndpointsRateLimitDefault(config, endpoint, defaults[i].Config.Buckets, 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
} }
validateServerEndpointsRateLimitBuckets(name, config, validator)
} }
func validateServerEndpointsRateLimitDefaultWeighted(name string, config *schema.ServerEndpointRateLimit, defaults schema.ServerEndpointRateLimit, weight time.Duration, validator *schema.StructValidator) { func validateServerEndpointsRateLimitDefault(config *schema.Configuration, endpoint schema.ServerEndpointRateLimitEndpoint, defaults []schema.ServerEndpointRateLimitBucket, validator *schema.StructValidator) {
if len(config.Buckets) == 0 { if len(endpoint.Config.Buckets) == 0 {
config.Buckets = make([]schema.ServerEndpointRateLimitBucket, len(defaults.Buckets)) endpoint.Config.Buckets = make([]schema.ServerEndpointRateLimitBucket, len(defaults))
for i := range defaults.Buckets { copy(endpoint.Config.Buckets, defaults)
config.Buckets[i] = schema.ServerEndpointRateLimitBucket{
Period: weight * defaults.Buckets[i].Period, if endpoint.Weight != nil {
Requests: defaults.Buckets[i].Requests, weight := endpoint.Weight(config)
for i := range endpoint.Config.Buckets {
endpoint.Config.Buckets[i].Period *= weight
} }
} }
return return
} }
validateServerEndpointsRateLimitBuckets(name, config, validator) validateServerEndpointsRateLimitBuckets(endpoint.Name, endpoint.Config, validator)
} }
func validateServerEndpointsRateLimitBuckets(name string, config *schema.ServerEndpointRateLimit, validator *schema.StructValidator) { func validateServerEndpointsRateLimitBuckets(name string, config *schema.ServerEndpointRateLimit, validator *schema.StructValidator) {
@@ -58,6 +58,11 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
}, },
}, },
expected: schema.ServerEndpointRateLimits{ expected: schema.ServerEndpointRateLimits{
Health: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: 1 * time.Minute, Requests: 60},
},
},
ResetPasswordStart: schema.ServerEndpointRateLimit{ ResetPasswordStart: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{ Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: 10 * time.Minute, Requests: 5}, {Period: 10 * time.Minute, Requests: 5},
@@ -84,6 +89,12 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
{Period: 2 * time.Minute, Requests: 15}, {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{ SessionElevationStart: schema.ServerEndpointRateLimit{
Buckets: []schema.ServerEndpointRateLimitBucket{ Buckets: []schema.ServerEndpointRateLimitBucket{
{Period: schema.DefaultIdentityValidation.ElevatedSession.CodeLifespan * 1, Requests: 3}, {Period: schema.DefaultIdentityValidation.ElevatedSession.CodeLifespan * 1, Requests: 3},
@@ -114,6 +125,30 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
{Period: time.Hour, Requests: 100}, {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.Errors(), 0)
assert.Len(t, validator.Warnings(), 0) assert.Len(t, validator.Warnings(), 0)
assert.Equal(t, tc.expected.OpenIDConnectPushedAuthorizationRequest, tc.config.Server.Endpoints.RateLimits.OpenIDConnectPushedAuthorizationRequest) assert.Equal(t, tc.expected, tc.config.Server.Endpoints.RateLimits)
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)
}) })
} }
} }
+6 -3
View File
@@ -7,7 +7,6 @@ package middlewares
import ( import (
"context" "context"
"math" "math"
"net/http"
"slices" "slices"
"strconv" "strconv"
"sync" "sync"
@@ -205,7 +204,7 @@ func WithRateLimitCollector(collector *GarbageCollector) RateLimiterOption {
func HandlerRateLimitAPI(ctx *AutheliaCtx, retryAfter time.Duration) { func HandlerRateLimitAPI(ctx *AutheliaCtx, retryAfter time.Duration) {
ctx.SetStatusCode(fasthttp.StatusTooManyRequests) 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(headerCacheControl, headerValueNoStore)
ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache) ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache)
@@ -216,7 +215,7 @@ func HandlerRateLimitAPI(ctx *AutheliaCtx, retryAfter time.Duration) {
func HandlerRateLimitOpenIDConnect(ctx *AutheliaCtx, retryAfter time.Duration) { func HandlerRateLimitOpenIDConnect(ctx *AutheliaCtx, retryAfter time.Duration) {
ctx.SetStatusCode(fasthttp.StatusTooManyRequests) 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(headerCacheControl, headerValueNoStore)
ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache) ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache)
ctx.Response.Header.SetBytesKV(headerContentType, contentTypeApplicationJSON) ctx.Response.Header.SetBytesKV(headerContentType, contentTypeApplicationJSON)
@@ -224,6 +223,10 @@ func HandlerRateLimitOpenIDConnect(ctx *AutheliaCtx, retryAfter time.Duration) {
ctx.Response.SetBodyRaw(bodyOpenIDConnectRateLimitExceeded) 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 { func newRateLimiterHandler(next RequestHandler, buckets []RateLimitBucket, handler RateLimitRequestHandler, exemptStatusCodes []int) RequestHandler {
isRateLimitExempt := newIsRateLimitExempt(exemptStatusCodes) isRateLimitExempt := newIsRateLimitExempt(exemptStatusCodes)
+38 -6
View File
@@ -838,14 +838,46 @@ func TestNewIsRateLimitExempt(t *testing.T) {
} }
func TestHandlerRateLimitAPI(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()) HandlerRateLimitAPI(ctx, tc.retryAfter)
assert.Contains(t, body, "Too Many Requests")
assert.Equal(t, fasthttp.StatusTooManyRequests, ctx.Response.StatusCode()) assert.Equal(t, fasthttp.StatusTooManyRequests, ctx.Response.StatusCode())
assert.NotEmpty(t, ctx.Response.Header.Peek(fasthttp.HeaderRetryAfter)) 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) { func TestHandlerRateLimitOpenIDConnect(t *testing.T) {
+4
View File
@@ -103,12 +103,16 @@
"Time-based One-Time Password": "Time-based One-Time Password", "Time-based One-Time Password": "Time-based One-Time Password",
"Toggle password visibility": "Toggle password visibility", "Toggle password visibility": "Toggle password visibility",
"Touch the token of your security key": "Touch the token of your security key", "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", "Use OpenID to verify your identity": "Use OpenID to verify your identity",
"Username": "Username", "Username": "Username",
"Username is required": "Username is required", "Username is required": "Username is required",
"Warning": "Warning", "Warning": "Warning",
"You cancelled the assertion request": "You cancelled the assertion request", "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": "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 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", "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", "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 "@root/index.css";
import App from "@root/App"; import App from "@root/App";
import "@i18n/index"; import "@i18n/index";
import { registerRateLimitInterceptor } from "@services/RateLimit";
registerRateLimitInterceptor();
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
-72
View File
@@ -7,58 +7,11 @@ import { AxiosResponse } from "axios";
import { import {
hasServiceError, hasServiceError,
toData, toData,
toDataRateLimited,
validateStatusAuthentication, validateStatusAuthentication,
validateStatusOneTimeCode, validateStatusOneTimeCode,
validateStatusTooManyRequests,
validateStatusWebAuthnCreation, validateStatusWebAuthnCreation,
} from "@services/Api"; } 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", () => { it("reports no error for ok response", () => {
const resp = { data: { data: "test", status: "OK" }, status: 200 } as AxiosResponse; const resp = { data: { data: "test", status: "OK" }, status: 200 } as AxiosResponse;
expect(hasServiceError(resp)).toEqual({ errored: false, message: null }); 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" }); 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", () => { it("validates status for authentication", () => {
expect(validateStatusAuthentication(200)).toBe(true); expect(validateStatusAuthentication(200)).toBe(true);
expect(validateStatusAuthentication(401)).toBe(true); expect(validateStatusAuthentication(401)).toBe(true);
@@ -123,20 +68,3 @@ describe("toData", () => {
expect(toData(resp)).toBeUndefined(); 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; message: string;
} }
export interface ErrorRateLimitResponse extends ErrorResponse {
limited: boolean;
retryAfter: number;
}
export interface Response<T> extends OKResponse { export interface Response<T> extends OKResponse {
data: T; data: T;
} }
@@ -97,54 +92,6 @@ export function toData<T>(resp: AxiosResponse<ServiceResponse<T>>): T | undefine
return undefined; 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) { function hasError(err: ErrorResponse | undefined) {
if (err?.status === "KO") { if (err?.status === "KO") {
return { errored: true, message: err.message }; return { errored: true, message: err.message };
@@ -159,10 +106,6 @@ export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
return hasError(errResp); return hasError(errResp);
} }
export function validateStatusTooManyRequests(status: number) {
return (status >= 200 && status < 300) || status === 429;
}
export function validateStatusAuthentication(status: number) { export function validateStatusAuthentication(status: number) {
return (status >= 200 && status < 300) || status === 401 || status === 403; return (status >= 200 && status < 300) || status === 401 || status === 403;
} }
+1 -35
View File
@@ -4,7 +4,7 @@
import axios from "axios"; import axios from "axios";
import { hasServiceError, toData, toDataRateLimited } from "@services/Api"; import { hasServiceError, toData } from "@services/Api";
import * as Client from "@services/Client"; import * as Client from "@services/Client";
vi.mock("axios"); vi.mock("axios");
@@ -30,26 +30,6 @@ it("handles successful post with optional response", async () => {
expect(result).toBe("test"); 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 () => { it("throws on post error", async () => {
const mockRes = { data: { message: "error", status: "KO" }, status: 400 }; const mockRes = { data: { message: "error", status: "KO" }, status: 400 };
(axios.post as any).mockResolvedValue(mockRes); (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 () => { it("throws on post with no data", async () => {
const mockRes = { data: { status: "OK" }, status: 200 }; const mockRes = { data: { status: "OK" }, status: 200 };
(axios.post as any).mockResolvedValue(mockRes); (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); (axios.delete as any).mockResolvedValue(mockRes);
(hasServiceError as any).mockReturnValue({ errored: false }); (hasServiceError as any).mockReturnValue({ errored: false });
(toData as any).mockReturnValue("test"); (toData as any).mockReturnValue("test");
(toDataRateLimited as any).mockReturnValue({ data: "test", limited: false, retryAfter: 0 });
await Client.Get("/path", signal); await Client.Get("/path", signal);
expect(axios.get).toHaveBeenLastCalledWith("/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); await Client.PostWithOptionalResponse("/path", { body: 1 }, signal);
expect(axios.post).toHaveBeenLastCalledWith("/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); await Client.Put("/path", { body: 1 }, signal);
expect(axios.put).toHaveBeenLastCalledWith("/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 axios from "axios";
import { import { ServiceResponse, hasServiceError, toData } from "@services/Api";
RateLimitedData,
ServiceResponse,
hasServiceError,
toData,
toDataRateLimited,
validateStatusTooManyRequests,
} from "@services/Api";
export async function PutWithOptionalResponse<T = undefined>( export async function PutWithOptionalResponse<T = undefined>(
path: string, path: string,
@@ -41,27 +34,6 @@ export async function PostWithOptionalResponse<T = undefined>(
return toData<T>(res); 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>( export async function DeleteWithOptionalResponse<T = undefined>(
path: string, path: string,
body?: any, body?: any,
+3 -8
View File
@@ -2,11 +2,7 @@
// //
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
DeleteWithOptionalResponse,
PostWithOptionalResponse,
PostWithOptionalResponseRateLimited,
} from "@services/Client";
import { completeTOTPRegister, completeTOTPSignIn, stopTOTPRegister } from "@services/OneTimePassword"; import { completeTOTPRegister, completeTOTPSignIn, stopTOTPRegister } from "@services/OneTimePassword";
vi.mock("@services/Api", () => ({ vi.mock("@services/Api", () => ({
@@ -16,13 +12,12 @@ vi.mock("@services/Api", () => ({
vi.mock("@services/Client", () => ({ vi.mock("@services/Client", () => ({
DeleteWithOptionalResponse: vi.fn(), DeleteWithOptionalResponse: vi.fn(),
PostWithOptionalResponse: vi.fn(), PostWithOptionalResponse: vi.fn(),
PostWithOptionalResponseRateLimited: vi.fn(),
})); }));
it("completes totp sign in", async () => { 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"); const result = await completeTOTPSignIn("123456", "url", "flow", "flowtype", "sub", "code");
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith("/totp/signin", { expect(PostWithOptionalResponse).toHaveBeenCalledWith("/totp/signin", {
flow: "flowtype", flow: "flowtype",
flowID: "flow", flowID: "flow",
subflow: "sub", subflow: "sub",
+2 -6
View File
@@ -3,11 +3,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api"; import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api";
import { import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
DeleteWithOptionalResponse,
PostWithOptionalResponse,
PostWithOptionalResponseRateLimited,
} from "@services/Client";
import { SignInResponse } from "@services/SignIn"; import { SignInResponse } from "@services/SignIn";
interface CompleteTOTPSignInBody { interface CompleteTOTPSignInBody {
@@ -36,7 +32,7 @@ export function completeTOTPSignIn(
userCode, userCode,
}; };
return PostWithOptionalResponseRateLimited<SignInResponse>(CompleteTOTPSignInPath, body); return PostWithOptionalResponse<SignInResponse>(CompleteTOTPSignInPath, body);
} }
export function completeTOTPRegister(passcode: string) { 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 () => { it("posts second factor with response", async () => {
const mockResponse = { token: "abc" }; const mockResponse = { redirect: "abc" };
(PostWithOptionalResponse as any).mockResolvedValue(mockResponse); (PostWithOptionalResponse as any).mockResolvedValue(mockResponse);
const result = await postSecondFactor("pass", "url", "flow", "flowtype", "sub"); const result = await postSecondFactor("pass", "url", "flow", "flowtype", "sub");
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/password/signin", { expect(PostWithOptionalResponse).toHaveBeenCalledWith("/password/signin", {
@@ -77,5 +77,5 @@ it("posts second factor with response", async () => {
it("posts second factor without response", async () => { it("posts second factor without response", async () => {
(PostWithOptionalResponse as any).mockResolvedValue(undefined); (PostWithOptionalResponse as any).mockResolvedValue(undefined);
const result = await postSecondFactor("pass"); const result = await postSecondFactor("pass");
expect(result).toEqual({}); expect(result).toBeUndefined();
}); });
+1 -2
View File
@@ -101,6 +101,5 @@ export async function postSecondFactor(
targetURL, targetURL,
}; };
const res = await PostWithOptionalResponse<SignInResponse>(CompletePasswordSignInPath, data); return PostWithOptionalResponse<SignInResponse>(CompletePasswordSignInPath, data);
return res ?? ({} as SignInResponse);
} }
+5 -6
View File
@@ -2,7 +2,7 @@
// //
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { Get, PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client"; import { Get, PostWithOptionalResponse } from "@services/Client";
import { import {
completeDuoDeviceSelectionProcess, completeDuoDeviceSelectionProcess,
completePushNotificationSignIn, completePushNotificationSignIn,
@@ -17,13 +17,12 @@ vi.mock("@services/Api", () => ({
vi.mock("@services/Client", () => ({ vi.mock("@services/Client", () => ({
Get: vi.fn(), Get: vi.fn(),
PostWithOptionalResponse: vi.fn(), PostWithOptionalResponse: vi.fn(),
PostWithOptionalResponseRateLimited: vi.fn(),
})); }));
it("completes push notification sign in", async () => { 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"); const result = await completePushNotificationSignIn("url", "flow", "flowtype", "sub", "code");
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith( expect(PostWithOptionalResponse).toHaveBeenCalledWith(
"/duo/signin", "/duo/signin",
{ {
flow: "flowtype", flow: "flowtype",
@@ -69,12 +68,12 @@ it("gets preferred duo device", async () => {
it("forwards the abort signal when provided", async () => { it("forwards the abort signal when provided", async () => {
const { getPreferredDuoDevice } = await import("@services/PushNotification"); const { getPreferredDuoDevice } = await import("@services/PushNotification");
const signal = new AbortController().signal; const signal = new AbortController().signal;
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response"); (PostWithOptionalResponse as any).mockResolvedValue("response");
(PostWithOptionalResponse as any).mockResolvedValue("response"); (PostWithOptionalResponse as any).mockResolvedValue("response");
(Get as any).mockResolvedValue("response"); (Get as any).mockResolvedValue("response");
await completePushNotificationSignIn("url", "flow", "flowtype", "sub", "code", signal); 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); await initiateDuoDeviceSelectionProcess(signal);
expect(Get).toHaveBeenLastCalledWith("/duo/devices", signal); expect(Get).toHaveBeenLastCalledWith("/duo/devices", signal);
+2 -2
View File
@@ -7,7 +7,7 @@ import {
CompletePushNotificationSignInPath, CompletePushNotificationSignInPath,
InitiateDuoDeviceSelectionPath, InitiateDuoDeviceSelectionPath,
} from "@services/Api"; } from "@services/Api";
import { Get, PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client"; import { Get, PostWithOptionalResponse } from "@services/Client";
interface CompletePushSignInBody { interface CompletePushSignInBody {
targetURL?: string; targetURL?: string;
@@ -33,7 +33,7 @@ export function completePushNotificationSignIn(
userCode, userCode,
}; };
return PostWithOptionalResponseRateLimited<DuoSignInResponse>(CompletePushNotificationSignInPath, body, signal); return PostWithOptionalResponse<DuoSignInResponse>(CompletePushNotificationSignInPath, body, signal);
} }
export interface DuoSignInResponse { 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 axios from "axios";
import { PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client"; import { PostWithOptionalResponse } from "@services/Client";
import { import {
completeResetPasswordProcess, completeResetPasswordProcess,
deleteResetPasswordToken, deleteResetPasswordToken,
@@ -17,24 +17,22 @@ vi.mock("@services/Api", () => ({
CompleteResetPasswordPath: "/reset-password/finish", CompleteResetPasswordPath: "/reset-password/finish",
InitiateResetPasswordPath: "/reset-password/start", InitiateResetPasswordPath: "/reset-password/start",
ResetPasswordPath: "/reset-password", ResetPasswordPath: "/reset-password",
validateStatusTooManyRequests: vi.fn(),
})); }));
vi.mock("@services/Client", () => ({ vi.mock("@services/Client", () => ({
PostWithOptionalResponse: vi.fn(), PostWithOptionalResponse: vi.fn(),
PostWithOptionalResponseRateLimited: vi.fn(),
})); }));
it("initiates reset password process", async () => { it("initiates reset password process", async () => {
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response"); (PostWithOptionalResponse as any).mockResolvedValue("response");
const result = await initiateResetPasswordProcess("user"); 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"); expect(result).toBe("response");
}); });
it("completes reset password process", async () => { it("completes reset password process", async () => {
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response"); (PostWithOptionalResponse as any).mockResolvedValue("response");
const result = await completeResetPasswordProcess("token123"); 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"); expect(result).toBe("response");
}); });
@@ -53,7 +51,6 @@ it("deletes reset password token with ok", async () => {
data: { token: "token123" }, data: { token: "token123" },
method: "DELETE", method: "DELETE",
url: "/reset-password", url: "/reset-password",
validateStatus: expect.any(Function),
}); });
expect(result).toEqual({ ok: true, status: 200 }); expect(result).toEqual({ ok: true, status: 200 });
}); });
+3 -5
View File
@@ -10,16 +10,15 @@ import {
InitiateResetPasswordPath, InitiateResetPasswordPath,
OKResponse, OKResponse,
ResetPasswordPath, ResetPasswordPath,
validateStatusTooManyRequests,
} from "@services/Api"; } from "@services/Api";
import { PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client"; import { PostWithOptionalResponse } from "@services/Client";
export async function initiateResetPasswordProcess(username: string) { export async function initiateResetPasswordProcess(username: string) {
return PostWithOptionalResponseRateLimited(InitiateResetPasswordPath, { username }); return PostWithOptionalResponse(InitiateResetPasswordPath, { username });
} }
export async function completeResetPasswordProcess(token: string) { export async function completeResetPasswordProcess(token: string) {
return PostWithOptionalResponseRateLimited(CompleteResetPasswordPath, { token }); return PostWithOptionalResponse(CompleteResetPasswordPath, { token });
} }
export async function resetPassword(newPassword: string) { export async function resetPassword(newPassword: string) {
@@ -31,7 +30,6 @@ export async function deleteResetPasswordToken(token: string) {
data: { token: token }, data: { token: token },
method: "DELETE", method: "DELETE",
url: ResetPasswordPath, url: ResetPasswordPath,
validateStatus: validateStatusTooManyRequests,
}); });
return { ok: res.status === 200 && res.data.status === "OK", status: res.status }; 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 axios from "axios";
import { hasServiceError, toData, validateStatusOneTimeCode } from "@services/Api"; import { hasServiceError, toData, validateStatusOneTimeCode } from "@services/Api";
import { PostWithOptionalResponse } from "@services/Client";
import { import {
deleteUserSessionElevation, deleteUserSessionElevation,
generateUserSessionElevation, generateUserSessionElevation,
@@ -19,6 +20,9 @@ vi.mock("@services/Api", () => ({
UserSessionElevationPath: "/user/elevation", UserSessionElevationPath: "/user/elevation",
validateStatusOneTimeCode: vi.fn(), validateStatusOneTimeCode: vi.fn(),
})); }));
vi.mock("@services/Client", () => ({
PostWithOptionalResponse: vi.fn(),
}));
it("gets user session elevation successfully", async () => { it("gets user session elevation successfully", async () => {
const mockRes = { data: { data: "elevation", status: "OK" }, status: 200 }; 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 () => { it("generates user session elevation successfully", async () => {
const mockRes = { data: { data: "generate", status: "OK" }, status: 200 }; const mockResponse = { delete_id: "abc" };
(axios as any).mockResolvedValue(mockRes); (PostWithOptionalResponse as any).mockResolvedValue(mockResponse);
(hasServiceError as any).mockReturnValue({ errored: false });
(toData as any).mockReturnValue("generate");
const result = await generateUserSessionElevation(); const result = await generateUserSessionElevation();
expect(axios).toHaveBeenCalledWith({ expect(PostWithOptionalResponse).toHaveBeenCalledWith("/user/elevation");
method: "POST", expect(result).toEqual(mockResponse);
url: "/user/elevation",
});
expect(result).toBe("generate");
}); });
it("generates user session elevation with error", async () => { it("generates user session elevation with error", async () => {
const mockRes = { data: { message: "error", status: "KO" }, status: 400 }; (PostWithOptionalResponse as any).mockRejectedValue(
(axios as any).mockResolvedValue(mockRes); new Error("Failed POST to /user/elevation. Code: 400. Message: error"),
(hasServiceError as any).mockReturnValue({ errored: true, message: "error" }); );
await expect(generateUserSessionElevation()).rejects.toThrow( await expect(generateUserSessionElevation()).rejects.toThrow(
"Failed POST to /user/elevation. Code: 400. Message: error", "Failed POST to /user/elevation. Code: 400. Message: error",
+2 -12
View File
@@ -13,6 +13,7 @@ import {
toData, toData,
validateStatusOneTimeCode, validateStatusOneTimeCode,
} from "@services/Api"; } from "@services/Api";
import { PostWithOptionalResponse } from "@services/Client";
export interface UserSessionElevation { export interface UserSessionElevation {
require_second_factor: boolean; require_second_factor: boolean;
@@ -43,18 +44,7 @@ export async function getUserSessionElevation() {
} }
export async function generateUserSessionElevation() { export async function generateUserSessionElevation() {
const res = await axios<ServiceResponse<UserSessionElevationGenerateData>>({ return PostWithOptionalResponse<UserSessionElevationGenerateData>(UserSessionElevationPath);
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);
} }
export async function verifyUserSessionElevation(otc: string) { export async function verifyUserSessionElevation(otc: string) {
@@ -5,6 +5,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { completeTOTPSignIn } from "@services/OneTimePassword"; import { completeTOTPSignIn } from "@services/OneTimePassword";
import { RateLimitedError } from "@services/RateLimit";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod"; import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod";
@@ -101,7 +102,7 @@ beforeEach(() => {
mocks.configError = undefined; mocks.configError = undefined;
mocks.queryParams = {}; mocks.queryParams = {};
mocks.userCode = null; mocks.userCode = null;
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any); completeSignInMock.mockResolvedValue({ redirect: "https://example.com" });
}); });
afterEach(() => { afterEach(() => {
@@ -268,7 +269,7 @@ describe("sign in", () => {
}); });
it("reports success with no redirect when the response carries no data", async () => { it("reports success with no redirect when the response carries no data", async () => {
completeSignInMock.mockResolvedValue({} as any); completeSignInMock.mockResolvedValue(undefined);
const { props } = renderMethod(); const { props } = renderMethod();
@@ -277,21 +278,6 @@ describe("sign in", () => {
await waitFor(() => expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined)); 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 () => { it("reports a wrong code when the request throws", async () => {
completeSignInMock.mockRejectedValue(new Error("boom")); completeSignInMock.mockRejectedValue(new Error("boom"));
@@ -317,7 +303,7 @@ describe("sign in", () => {
it("reports rate limiting and recovers after the retry window", async () => { it("reports rate limiting and recovers after the retry window", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { props } = renderMethod(); const { props } = renderMethod();
@@ -339,7 +325,7 @@ describe("sign in", () => {
it("clears the rate limit timer on unmount", async () => { it("clears the rate limit timer on unmount", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { unmount } = renderMethod(); const { unmount } = renderMethod();
@@ -6,12 +6,15 @@ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { RedirectionURL } from "@constants/SearchParams"; import { RedirectionURL } from "@constants/SearchParams";
import { useFlow } from "@hooks/Flow"; import { useFlow } from "@hooks/Flow";
import { useUserCode } from "@hooks/OpenIDConnect"; import { useUserCode } from "@hooks/OpenIDConnect";
import { useQueryParam } from "@hooks/QueryParam"; import { useQueryParam } from "@hooks/QueryParam";
import { useRateLimit } from "@hooks/RateLimit";
import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration"; import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration";
import { completeTOTPSignIn } from "@services/OneTimePassword"; import { completeTOTPSignIn } from "@services/OneTimePassword";
import { RateLimitedError } from "@services/RateLimit";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import LoadingPage from "@views/LoadingPage/LoadingPage"; import LoadingPage from "@views/LoadingPage/LoadingPage";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer"; import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
@@ -47,7 +50,7 @@ const OneTimePasswordMethod = function (props: Props) {
const { onSignInError, onSignInSuccess } = props; const { onSignInError, onSignInSuccess } = props;
const onSignInErrorCallbackRef = useRef(onSignInError); const onSignInErrorCallbackRef = useRef(onSignInError);
const onSignInSuccessCallbackRef = useRef(onSignInSuccess); const onSignInSuccessCallbackRef = useRef(onSignInSuccess);
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null); const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() => dispatch({ type: State.Idle }));
useEffect(() => { useEffect(() => {
onSignInErrorCallbackRef.current = onSignInError; onSignInErrorCallbackRef.current = onSignInError;
@@ -57,15 +60,6 @@ const OneTimePasswordMethod = function (props: Props) {
onSignInSuccessCallbackRef.current = onSignInSuccess; onSignInSuccessCallbackRef.current = onSignInSuccess;
}, [onSignInSuccess]); }, [onSignInSuccess]);
useEffect(() => {
return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
};
}, []);
useEffect(() => { useEffect(() => {
if (err) { if (err) {
console.error(err); console.error(err);
@@ -82,20 +76,13 @@ const OneTimePasswordMethod = function (props: Props) {
const handleRateLimited = useCallback( const handleRateLimited = useCallback(
(retryAfter: number) => { (retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
dispatch({ type: State.RateLimited }); dispatch({ type: State.RateLimited });
onSignInErrorCallbackRef.current(new Error(translate("You have made too many requests"))); onSignInErrorCallbackRef.current(new Error(translate("You have made too many requests")));
timeoutRateLimitRef.current = setTimeout(() => { triggerRateLimit(retryAfter);
dispatch({ type: State.Idle });
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
}, },
[onSignInErrorCallbackRef, translate], [onSignInErrorCallbackRef, translate, triggerRateLimit],
); );
useEffect(() => { useEffect(() => {
@@ -120,19 +107,16 @@ const OneTimePasswordMethod = function (props: Props) {
dispatch({ type: State.InProgress }); dispatch({ type: State.InProgress });
const res = await completeTOTPSignIn(passcodeStr, redirectionURL, flowID, flow, subflow, userCode); 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"))); onSignInErrorCallbackRef.current(new Error(translate("The One-Time Password might be wrong")));
dispatch({ type: State.Failure }); 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(""); setPasscode("");
}; };
@@ -183,6 +167,7 @@ const OneTimePasswordMethod = function (props: Props) {
) : ( ) : (
<LoadingPage /> <LoadingPage />
)} )}
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div> </div>
</MethodContainer> </MethodContainer>
); );
@@ -2,9 +2,10 @@
// //
// SPDX-License-Identifier: Apache-2.0 // 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 { postSecondFactor } from "@services/Password";
import { RateLimitedError } from "@services/RateLimit";
import PasswordForm from "@views/LoginPortal/SecondFactor/PasswordForm"; import PasswordForm from "@views/LoginPortal/SecondFactor/PasswordForm";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -111,7 +112,7 @@ describe("validation", () => {
describe("sign in", () => { describe("sign in", () => {
it("posts the password and reports success", async () => { 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(); const { onAuthenticationSuccess } = renderForm();
@@ -187,6 +188,39 @@ describe("sign in", () => {
expect(getSubmit()).not.toBeDisabled(); expect(getSubmit()).not.toBeDisabled();
expect(onAuthenticationSuccess).not.toHaveBeenCalled(); 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", () => { describe("keyboard handling", () => {
@@ -6,6 +6,7 @@ import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { Alert, AlertTitle } from "@components/UI/Alert"; import { Alert, AlertTitle } from "@components/UI/Alert";
import { Button } from "@components/UI/Button"; import { Button } from "@components/UI/Button";
import { Input } from "@components/UI/Input"; import { Input } from "@components/UI/Input";
@@ -17,8 +18,10 @@ import { useNotifications } from "@contexts/NotificationsContext";
import { useFlow } from "@hooks/Flow"; import { useFlow } from "@hooks/Flow";
import { usePasswordVisibility } from "@hooks/PasswordVisibility"; import { usePasswordVisibility } from "@hooks/PasswordVisibility";
import { useQueryParam } from "@hooks/QueryParam"; import { useQueryParam } from "@hooks/QueryParam";
import { useRateLimit } from "@hooks/RateLimit";
import { IsCapsLockModified } from "@services/CapsLock"; import { IsCapsLockModified } from "@services/CapsLock";
import { postSecondFactor } from "@services/Password"; import { postSecondFactor } from "@services/Password";
import { RateLimitedError } from "@services/RateLimit";
export interface Props { export interface Props {
onAuthenticationSuccess: (_redirectURL: string | undefined) => void; onAuthenticationSuccess: (_redirectURL: string | undefined) => void;
@@ -37,6 +40,7 @@ const PasswordForm = function (props: Props) {
const [passwordCapsLock, setPasswordCapsLock] = useState(false); const [passwordCapsLock, setPasswordCapsLock] = useState(false);
const [passwordCapsLockPartial, setPasswordCapsLockPartial] = useState(false); const [passwordCapsLockPartial, setPasswordCapsLockPartial] = useState(false);
const [passwordError, setPasswordError] = useState(false); const [passwordError, setPasswordError] = useState(false);
const { limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
const passwordRef = useRef<HTMLInputElement | null>(null); const passwordRef = useRef<HTMLInputElement | null>(null);
@@ -51,7 +55,28 @@ const PasswordForm = function (props: Props) {
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [focusPassword]); }, [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 () => { const handleSignIn = useCallback(async () => {
if (rateLimited) return;
if (password === "") { if (password === "") {
setPasswordError(true); setPasswordError(true);
@@ -62,15 +87,34 @@ const PasswordForm = function (props: Props) {
try { try {
const res = await postSecondFactor(password, redirectionURL, flowID, flow, subflow); const res = await postSecondFactor(password, redirectionURL, flowID, flow, subflow);
props.onAuthenticationSuccess(res ? res.redirect : undefined);
props.onAuthenticationSuccess(res?.redirect);
} catch (err) { } catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
return;
}
console.error(err); console.error(err);
createErrorNotification(translate("Incorrect password")); createErrorNotification(translate("Incorrect password"));
setPassword(""); setPassword("");
setLoading(false); setLoading(false);
focusPassword(); focusPassword();
} }
}, [createErrorNotification, focusPassword, password, props, redirectionURL, translate, flowID, flow, subflow]); }, [
createErrorNotification,
focusPassword,
handleRateLimited,
password,
props,
rateLimited,
redirectionURL,
translate,
flowID,
flow,
subflow,
]);
const handlePasswordKeyDown = useCallback( const handlePasswordKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => { (event: KeyboardEvent<HTMLInputElement>) => {
@@ -119,7 +163,7 @@ const PasswordForm = function (props: Props) {
ref={passwordRef} ref={passwordRef}
id="password-textfield" id="password-textfield"
required required
disabled={loading} disabled={loading || rateLimited}
value={password} value={password}
className="pr-10" className="pr-10"
error={passwordError} error={passwordError}
@@ -152,13 +196,14 @@ const PasswordForm = function (props: Props) {
id="sign-in-button" id="sign-in-button"
variant="default" variant="default"
className="w-full" className="w-full"
disabled={loading} disabled={loading || rateLimited}
onClick={handleSignIn} onClick={handleSignIn}
> >
{translate("Authenticate", { ns: "settings" })} {translate("Authenticate", { ns: "settings" })}
{loading ? <Spinner size={20} className="ml-2 h-5 w-5" /> : null} {loading ? <Spinner size={20} className="ml-2 h-5 w-5" /> : null}
</Button> </Button>
</div> </div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div> </div>
</form> </form>
); );
@@ -10,6 +10,7 @@ import {
getPreferredDuoDevice, getPreferredDuoDevice,
initiateDuoDeviceSelectionProcess, initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification"; } from "@services/PushNotification";
import { RateLimitedError } from "@services/RateLimit";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod"; import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod";
@@ -108,7 +109,7 @@ beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "debug").mockImplementation(() => {}); vi.spyOn(console, "debug").mockImplementation(() => {});
getPreferredMock.mockResolvedValue({} as any); getPreferredMock.mockResolvedValue({} as any);
completeSignInMock.mockResolvedValue(null as any); completeSignInMock.mockResolvedValue(undefined);
initiateSelectionMock.mockResolvedValue({} as any); initiateSelectionMock.mockResolvedValue({} as any);
completeSelectionMock.mockResolvedValue(undefined as any); completeSelectionMock.mockResolvedValue(undefined as any);
}); });
@@ -180,7 +181,7 @@ describe("preferred device lookup", () => {
describe("sign in", () => { describe("sign in", () => {
it("reports success after the success delay", async () => { it("reports success after the success delay", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any); completeSignInMock.mockResolvedValue({ redirect: "https://example.com" } as any);
const { props } = renderMethod(); const { props } = renderMethod();
@@ -192,9 +193,9 @@ describe("sign in", () => {
expect(props.onSignInSuccess).toHaveBeenCalledWith("https://example.com"); 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(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ data: undefined } as any); completeSignInMock.mockResolvedValue({} as any);
const { props } = renderMethod(); const { props } = renderMethod();
@@ -205,17 +206,18 @@ describe("sign in", () => {
expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined); expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined);
}); });
it("fails when the sign in response is missing", async () => { it("reports success with no redirect when the sign in response is missing", async () => {
completeSignInMock.mockResolvedValue(null as any); vi.useFakeTimers();
completeSignInMock.mockResolvedValue(undefined);
const { props } = renderMethod(); const { props } = renderMethod();
await waitFor(() => await vi.waitFor(() => expect(screen.getByTestId("success-icon")).toBeInTheDocument());
expect(props.onSignInError).toHaveBeenCalledWith(
expect.objectContaining({ message: "There was an issue completing sign in process" }), await vi.advanceTimersByTimeAsync(1500);
),
); expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined);
expect(screen.getByTestId("failure-icon")).toBeInTheDocument(); expect(props.onSignInError).not.toHaveBeenCalled();
}); });
it("fails when the sign in request throws", async () => { 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 () => { it("shows the device selection when the result requires auth", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({
data: { devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }], result: "auth",
result: "auth",
},
} as any); } as any);
renderMethod(); renderMethod();
@@ -259,9 +259,7 @@ describe("sign in", () => {
}); });
it("moves to the enroll state when no compatible device exists", async () => { it("moves to the enroll state when no compatible device exists", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
} as any);
const { props } = renderMethod({ duoSelfEnrollment: true }); const { props } = renderMethod({ duoSelfEnrollment: true });
@@ -275,9 +273,7 @@ describe("sign in", () => {
}); });
it("does not offer self enrollment when it is disabled", async () => { it("does not offer self enrollment when it is disabled", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
} as any);
renderMethod({ duoSelfEnrollment: false }); renderMethod({ duoSelfEnrollment: false });
@@ -288,7 +284,7 @@ describe("sign in", () => {
}); });
it("does not offer self enrollment when no enroll url is returned", async () => { 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 }); renderMethod({ duoSelfEnrollment: true });
@@ -299,7 +295,7 @@ describe("sign in", () => {
}); });
it("fails when the Duo policy denies the device", async () => { 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(); const { props } = renderMethod();
@@ -313,7 +309,7 @@ describe("sign in", () => {
it("reports rate limiting and recovers after the retry window", async () => { it("reports rate limiting and recovers after the retry window", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { props } = renderMethod(); const { props } = renderMethod();
@@ -332,7 +328,7 @@ describe("sign in", () => {
}); });
it("retries the sign in from the failure state", async () => { it("retries the sign in from the failure state", async () => {
completeSignInMock.mockResolvedValue(null as any); completeSignInMock.mockRejectedValue(new Error("boom"));
renderMethod(); renderMethod();
@@ -468,10 +464,8 @@ describe("device selection", () => {
it("returns to the previous state when selection is dismissed", async () => { it("returns to the previous state when selection is dismissed", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({
data: { devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }], result: "auth",
result: "auth",
},
} as any); } as any);
renderMethod(); renderMethod();
@@ -485,10 +479,8 @@ describe("device selection", () => {
it("persists a newly selected device and signs in again", async () => { it("persists a newly selected device and signs in again", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({
data: { devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }], result: "auth",
result: "auth",
},
} as any); } as any);
renderMethod({ registered: false }); renderMethod({ registered: false });
@@ -507,10 +499,8 @@ describe("device selection", () => {
it("notifies the parent when an unregistered user picks a device", async () => { it("notifies the parent when an unregistered user picks a device", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({
data: { devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }], result: "auth",
result: "auth",
},
} as any); } as any);
const { props } = renderMethod({ registered: false }); 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 () => { it("does not notify the parent when the user is already registered", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({
data: { devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }], result: "auth",
result: "auth",
},
} as any); } as any);
const { props } = renderMethod({ registered: true }); const { props } = renderMethod({ registered: true });
@@ -542,10 +530,8 @@ describe("device selection", () => {
it("reports failures updating the preferred Duo device", async () => { it("reports failures updating the preferred Duo device", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({
data: { devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }], result: "auth",
result: "auth",
},
} as any); } as any);
completeSelectionMock.mockRejectedValue(new Error("boom")); completeSelectionMock.mockRejectedValue(new Error("boom"));
@@ -564,10 +550,8 @@ describe("device selection", () => {
it("swallows cancelled preferred device updates", async () => { it("swallows cancelled preferred device updates", async () => {
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({
data: { devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }], result: "auth",
result: "auth",
},
} as any); } as any);
completeSelectionMock.mockRejectedValue({ __CANCEL__: true }); completeSelectionMock.mockRejectedValue({ __CANCEL__: true });
@@ -603,9 +587,7 @@ describe("device selection", () => {
describe("self enrollment", () => { describe("self enrollment", () => {
it("opens the enrollment url in a new tab", async () => { it("opens the enrollment url in a new tab", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null); const open = vi.spyOn(window, "open").mockImplementation(() => null);
completeSignInMock.mockResolvedValue({ completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
} as any);
renderMethod({ duoSelfEnrollment: true }); renderMethod({ duoSelfEnrollment: true });
@@ -622,11 +604,11 @@ describe("self enrollment", () => {
describe("cleanup and edge cases", () => { describe("cleanup and edge cases", () => {
it("clears both pending timers on unmount", async () => { it("clears both pending timers on unmount", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { unmount } = renderMethod(); 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"); const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
@@ -637,7 +619,7 @@ describe("cleanup and edge cases", () => {
it("clears the success timer on unmount", async () => { it("clears the success timer on unmount", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any); completeSignInMock.mockResolvedValue({ redirect: "https://example.com" } as any);
const { props, unmount } = renderMethod(); const { props, unmount } = renderMethod();
@@ -654,7 +636,7 @@ describe("cleanup and edge cases", () => {
it("replaces an in flight rate limit timer", async () => { it("replaces an in flight rate limit timer", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(5));
const { props } = renderMethod(); 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 () => { 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({ initiateSelectionMock.mockResolvedValue({
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }], devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
result: "auth", result: "auth",
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import FailureIcon from "@components/FailureIcon"; import FailureIcon from "@components/FailureIcon";
import PushNotificationIcon from "@components/PushNotificationIcon"; import PushNotificationIcon from "@components/PushNotificationIcon";
import RateLimitCountdown from "@components/RateLimitCountdown";
import SuccessIcon from "@components/SuccessIcon"; import SuccessIcon from "@components/SuccessIcon";
import { Button } from "@components/UI/Button"; import { Button } from "@components/UI/Button";
import { RedirectionURL } from "@constants/SearchParams"; import { RedirectionURL } from "@constants/SearchParams";
@@ -16,6 +17,7 @@ import { useAbortSignal } from "@hooks/Abort";
import { useFlow } from "@hooks/Flow"; import { useFlow } from "@hooks/Flow";
import { useUserCode } from "@hooks/OpenIDConnect"; import { useUserCode } from "@hooks/OpenIDConnect";
import { useQueryParam } from "@hooks/QueryParam"; import { useQueryParam } from "@hooks/QueryParam";
import { useRateLimit } from "@hooks/RateLimit";
import { import {
DuoDevicePostRequest, DuoDevicePostRequest,
completeDuoDeviceSelectionProcess, completeDuoDeviceSelectionProcess,
@@ -23,6 +25,7 @@ import {
getPreferredDuoDevice, getPreferredDuoDevice,
initiateDuoDeviceSelectionProcess, initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification"; } from "@services/PushNotification";
import { RateLimitedError } from "@services/RateLimit";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import DeviceSelectionContainer, { import DeviceSelectionContainer, {
SelectableDevice, SelectableDevice,
@@ -68,15 +71,11 @@ const PushNotificationMethod = function (props: Props) {
const { onSignInError, onSignInSuccess } = props; const { onSignInError, onSignInSuccess } = props;
const stateRef = useRef<null | State>(null); 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); const timeoutSuccessRef = useRef<null | ReturnType<typeof setTimeout>>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
if (timeoutSuccessRef.current !== null) { if (timeoutSuccessRef.current !== null) {
clearTimeout(timeoutSuccessRef.current); clearTimeout(timeoutSuccessRef.current);
timeoutSuccessRef.current = null; timeoutSuccessRef.current = null;
@@ -120,20 +119,13 @@ const PushNotificationMethod = function (props: Props) {
const handleRateLimited = useCallback( const handleRateLimited = useCallback(
(retryAfter: number) => { (retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
setState(State.RateLimited); setState(State.RateLimited);
onSignInError(new Error(translate("You have made too many requests"))); onSignInError(new Error(translate("You have made too many requests")));
timeoutRateLimitRef.current = setTimeout(() => { triggerRateLimit(retryAfter);
setState(State.Failure);
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
}, },
[onSignInError, translate], [onSignInError, translate, triggerRateLimit],
); );
const handleFetchDuoDevices = useCallback(async () => { const handleFetchDuoDevices = useCallback(async () => {
@@ -186,24 +178,17 @@ const PushNotificationMethod = function (props: Props) {
getSignal(), getSignal(),
); );
if (!res) { 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); handleSuccess(undefined);
return; return;
} }
switch (res.data.result) { switch (res.result) {
case "auth": case "auth":
setDevices(processDevices(res.data.devices)); setDevices(processDevices(res.devices));
setState(State.Selection); setState(State.Selection);
break; break;
case "enroll": case "enroll":
onSignInError(new Error(translate("No compatible device found"))); 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); setState(State.Enroll);
break; break;
case "deny": case "deny":
@@ -211,10 +196,14 @@ const PushNotificationMethod = function (props: Props) {
setState(State.Failure); setState(State.Failure);
break; break;
default: default:
handleSuccess(res.data.redirect); handleSuccess(res.redirect);
} }
} catch (err) { } catch (err) {
if (axios.isCancel(err)) return; if (axios.isCancel(err)) return;
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
return;
}
console.error(err); console.error(err);
onSignInError(new Error(translate("There was an issue completing sign in process"))); onSignInError(new Error(translate("There was an issue completing sign in process")));
setState(State.Failure); setState(State.Failure);
@@ -336,6 +325,7 @@ const PushNotificationMethod = function (props: Props) {
onRegisterClick={() => window.open(enrollUrl, "_blank", "noopener,noreferrer")} onRegisterClick={() => window.open(enrollUrl, "_blank", "noopener,noreferrer")}
> >
<div className="flex w-full justify-center">{icon}</div> <div className="flex w-full justify-center">{icon}</div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
<div className={state === State.Failure ? "" : "hidden"}> <div className={state === State.Failure ? "" : "hidden"}>
<Button variant={"ghost"} color={"secondary"} onClick={handleSignIn}> <Button variant={"ghost"} color={"secondary"} onClick={handleSignIn}>
{translate("Retry")} {translate("Retry")}
@@ -4,6 +4,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RateLimitedError } from "@services/RateLimit";
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1"; import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
const mockNavigate = vi.fn(); const mockNavigate = vi.fn();
@@ -71,7 +72,7 @@ it("shows error when username is empty", async () => {
it("navigates to index on successful reset", async () => { it("navigates to index on successful reset", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword"); const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: false, retryAfter: 0 }); vi.mocked(initiateResetPasswordProcess).mockResolvedValue(undefined);
render(<ResetPasswordStep1 />); render(<ResetPasswordStep1 />);
@@ -89,7 +90,7 @@ it("navigates to index on successful reset", async () => {
it("shows rate limited error", async () => { it("shows rate limited error", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword"); const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 }); vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
render(<ResetPasswordStep1 />); render(<ResetPasswordStep1 />);
@@ -131,7 +132,7 @@ describe("rate limiting", () => {
it("re-enables the reset button after the retry window", async () => { it("re-enables the reset button after the retry window", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { initiateResetPasswordProcess } = await import("@services/ResetPassword"); const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 }); vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
render(<ResetPasswordStep1 />); render(<ResetPasswordStep1 />);
@@ -154,7 +155,7 @@ describe("rate limiting", () => {
it("replaces an in flight rate limit timer", async () => { it("replaces an in flight rate limit timer", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { initiateResetPasswordProcess } = await import("@services/ResetPassword"); const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 }); vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
render(<ResetPasswordStep1 />); render(<ResetPasswordStep1 />);
@@ -196,7 +197,7 @@ describe("rate limiting", () => {
it("clears the rate limit timer on unmount", async () => { it("clears the rate limit timer on unmount", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { initiateResetPasswordProcess } = await import("@services/ResetPassword"); 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 />); const { unmount } = render(<ResetPasswordStep1 />);
@@ -219,7 +220,7 @@ describe("rate limiting", () => {
describe("keyboard handling", () => { describe("keyboard handling", () => {
it("submits on Enter", async () => { it("submits on Enter", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword"); const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: false, retryAfter: 0 }); vi.mocked(initiateResetPasswordProcess).mockResolvedValue(undefined);
render(<ResetPasswordStep1 />); render(<ResetPasswordStep1 />);
@@ -243,20 +244,6 @@ describe("keyboard handling", () => {
}); });
describe("responses", () => { 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 () => { it("disables the form while loading", async () => {
const { initiateResetPasswordProcess } = await import("@services/ResetPassword"); const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
let resolve: (value: unknown) => void = () => {}; let resolve: (value: unknown) => void = () => {};
@@ -270,7 +257,7 @@ describe("responses", () => {
await waitFor(() => expect(screen.getByLabelText("Username")).toBeDisabled()); await waitFor(() => expect(screen.getByLabelText("Username")).toBeDisabled());
expect(document.getElementById("cancel-button")).toBeDisabled(); expect(document.getElementById("cancel-button")).toBeDisabled();
resolve({ limited: false, retryAfter: 0 }); resolve(undefined);
await waitFor(() => expect(screen.getByLabelText("Username")).not.toBeDisabled()); await waitFor(() => expect(screen.getByLabelText("Username")).not.toBeDisabled());
}); });
}); });
@@ -2,19 +2,22 @@
// //
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import ComponentWithTooltip from "@components/ComponentWithTooltip"; import ComponentWithTooltip from "@components/ComponentWithTooltip";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { Button } from "@components/UI/Button"; import { Button } from "@components/UI/Button";
import { Input } from "@components/UI/Input"; import { Input } from "@components/UI/Input";
import { Label } from "@components/UI/Label"; import { Label } from "@components/UI/Label";
import { Spinner } from "@components/UI/Spinner"; import { Spinner } from "@components/UI/Spinner";
import { IndexRoute } from "@constants/Routes"; import { IndexRoute } from "@constants/Routes";
import { useNotifications } from "@contexts/NotificationsContext"; import { useNotifications } from "@contexts/NotificationsContext";
import { useRateLimit } from "@hooks/RateLimit";
import MinimalLayout from "@layouts/MinimalLayout"; import MinimalLayout from "@layouts/MinimalLayout";
import { RateLimitedError } from "@services/RateLimit";
import { initiateResetPasswordProcess } from "@services/ResetPassword"; import { initiateResetPasswordProcess } from "@services/ResetPassword";
const ResetPasswordStep1 = function () { const ResetPasswordStep1 = function () {
@@ -22,38 +25,19 @@ const ResetPasswordStep1 = function () {
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [rateLimited, setRateLimited] = useState(false); const { limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const { createErrorNotification, createInfoNotification } = useNotifications(); const { createErrorNotification, createInfoNotification } = useNotifications();
const navigate = useNavigate(); const navigate = useNavigate();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
useEffect(() => {
return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
};
}, []);
const handleRateLimited = useCallback( const handleRateLimited = useCallback(
(retryAfter: number) => { (retryAfter: number) => {
if (timeoutRateLimitRef.current) { triggerRateLimit(retryAfter);
clearTimeout(timeoutRateLimitRef.current);
}
setRateLimited(true);
createErrorNotification(translate("You have made too many requests")); 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 () => { const doInitiateResetPasswordProcess = async () => {
@@ -68,17 +52,15 @@ const ResetPasswordStep1 = function () {
} }
try { try {
const response = await initiateResetPasswordProcess(username); await initiateResetPasswordProcess(username);
if (response?.limited === false) { createInfoNotification(translate("An email has been sent to your address to complete the process"));
createInfoNotification(translate("An email has been sent to your address to complete the process")); navigate(IndexRoute);
navigate(IndexRoute); } catch (err) {
} else if (response?.limited) { if (err instanceof RateLimitedError) {
handleRateLimited(response.retryAfter); handleRateLimited(err.retryAfter);
} else { } else {
createErrorNotification(translate("There was an issue initiating the password reset process")); 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); setLoading(false);
}; };
@@ -141,6 +123,7 @@ const ResetPasswordStep1 = function () {
</Button> </Button>
</div> </div>
</div> </div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div> </div>
</div> </div>
</MinimalLayout> </MinimalLayout>
@@ -7,6 +7,7 @@ import { MemoryRouter } from "react-router";
import { PasswordPolicyMode } from "@models/PasswordPolicy"; import { PasswordPolicyMode } from "@models/PasswordPolicy";
import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration"; import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration";
import { RateLimitedError } from "@services/RateLimit";
import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword"; import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword";
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2"; import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
@@ -102,7 +103,7 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
queryParam.token = "test-token"; queryParam.token = "test-token";
completeProcessMock.mockResolvedValue({} as any); completeProcessMock.mockResolvedValue(undefined);
getPolicyMock.mockResolvedValue(standardPolicy); getPolicyMock.mockResolvedValue(standardPolicy);
resetPasswordMock.mockResolvedValue(undefined as any); resetPasswordMock.mockResolvedValue(undefined as any);
}); });
@@ -192,7 +193,7 @@ describe("process initiation", () => {
}); });
it("notifies and keeps the form disabled when rate limited", async () => { 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(); renderStep();
@@ -203,6 +204,33 @@ describe("process initiation", () => {
expect(getPolicyMock).not.toHaveBeenCalled(); 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 () => { it("notifies and keeps the form disabled when the token expired", async () => {
completeProcessMock.mockRejectedValue(new Error("expired")); completeProcessMock.mockRejectedValue(new Error("expired"));
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import PasswordMeter from "@components/PasswordMeter"; import PasswordMeter from "@components/PasswordMeter";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { Button } from "@components/UI/Button"; import { Button } from "@components/UI/Button";
import { Input } from "@components/UI/Input"; import { Input } from "@components/UI/Input";
import { Label } from "@components/UI/Label"; import { Label } from "@components/UI/Label";
@@ -17,9 +18,11 @@ import { IdentityToken } from "@constants/SearchParams";
import { useNotifications } from "@contexts/NotificationsContext"; import { useNotifications } from "@contexts/NotificationsContext";
import { usePasswordVisibility } from "@hooks/PasswordVisibility"; import { usePasswordVisibility } from "@hooks/PasswordVisibility";
import { useQueryParam } from "@hooks/QueryParam"; import { useQueryParam } from "@hooks/QueryParam";
import { useRateLimit } from "@hooks/RateLimit";
import MinimalLayout from "@layouts/MinimalLayout"; import MinimalLayout from "@layouts/MinimalLayout";
import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy"; import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy";
import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration"; import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration";
import { RateLimitedError } from "@services/RateLimit";
import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword"; import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword";
const uninitiated = Symbol("uninitiated"); const uninitiated = Symbol("uninitiated");
@@ -52,20 +55,27 @@ const ResetPasswordStep2 = function () {
const processToken = useQueryParam(IdentityToken); const processToken = useQueryParam(IdentityToken);
const resetInitiatedRef = useRef<null | string | typeof uninitiated | undefined>(uninitiated); 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( const handleRateLimited = useCallback(
(_retryAfter: number) => { (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? triggerRateLimit(retryAfter);
createErrorNotification(translate("You have made too many requests"));
}, },
[createErrorNotification, translate], [createErrorNotification, translate, triggerRateLimit],
); );
useEffect(() => { useEffect(() => {
if (resetInitiatedRef.current === processToken) { if (resetInitiatedRef.current === processToken && attemptInitiatedRef.current === attempt) {
return; return;
} }
resetInitiatedRef.current = processToken; resetInitiatedRef.current = processToken;
attemptInitiatedRef.current = attempt;
const submitReset = async () => { const submitReset = async () => {
if (!processToken) { if (!processToken) {
@@ -75,17 +85,17 @@ const ResetPasswordStep2 = function () {
} }
try { try {
const response = await completeResetPasswordProcess(processToken); await completeResetPasswordProcess(processToken);
if (response?.limited) {
handleRateLimited(response.retryAfter);
return;
}
const policy = await getPasswordPolicyConfiguration(); const policy = await getPasswordPolicyConfiguration();
setPPolicy(policy); setPPolicy(policy);
setFormDisabled(false); setFormDisabled(false);
} catch (err) { } catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
return;
}
console.error(err); console.error(err);
createErrorNotification( createErrorNotification(
translate("There was an issue completing the process the verification token might have expired"), 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); submitReset().catch(console.error);
}, [processToken, createErrorNotification, translate, handleRateLimited]); }, [attempt, processToken, createErrorNotification, translate, handleRateLimited]);
const doResetPassword = async () => { const doResetPassword = async () => {
setPassword1(""); setPassword1("");
@@ -217,6 +227,7 @@ const ResetPasswordStep2 = function () {
</Button> </Button>
</div> </div>
</div> </div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div> </div>
</div> </div>
</MinimalLayout> </MinimalLayout>
@@ -4,10 +4,14 @@
import { act, render, screen } from "@testing-library/react"; import { act, render, screen } from "@testing-library/react";
import { RateLimitedError } from "@services/RateLimit";
import RevokeResetPasswordTokenView from "@views/Revoke/RevokeResetPasswordTokenView"; import RevokeResetPasswordTokenView from "@views/Revoke/RevokeResetPasswordTokenView";
vi.mock("react-i18next", () => ({ 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(); 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"); 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"); const { deleteResetPasswordToken } = await import("@services/ResetPassword");
vi.mocked(deleteResetPasswordToken).mockResolvedValueOnce({ ok: false, status: 429 }); vi.mocked(deleteResetPasswordToken).mockRejectedValueOnce(new RateLimitedError(30));
await act(async () => { await act(async () => {
render(<RevokeResetPasswordTokenView />); 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 () => { it("reports a successful revocation", async () => {
@@ -102,6 +106,17 @@ it("reports a failed revocation", async () => {
expect(mockCreateError).toHaveBeenCalledWith("Failed to revoke the Token"); 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 () => { it("navigates back to the index route after the delay", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -10,6 +10,7 @@ import { IndexRoute } from "@constants/Routes";
import { useNotifications } from "@contexts/NotificationsContext"; import { useNotifications } from "@contexts/NotificationsContext";
import { useToken } from "@hooks/Revoke"; import { useToken } from "@hooks/Revoke";
import { useRouterNavigate } from "@hooks/RouterNavigate"; import { useRouterNavigate } from "@hooks/RouterNavigate";
import { RateLimitedError } from "@services/RateLimit";
import { deleteResetPasswordToken } from "@services/ResetPassword"; import { deleteResetPasswordToken } from "@services/ResetPassword";
import LoadingPage from "@views/LoadingPage/LoadingPage"; import LoadingPage from "@views/LoadingPage/LoadingPage";
@@ -29,14 +30,25 @@ const RevokeResetPasswordTokenView = function () {
const handleRevoke = useCallback(async () => { const handleRevoke = useCallback(async () => {
if (!token) return; if (!token) return;
const { ok, status } = await deleteResetPasswordToken(token); try {
const { ok } = await deleteResetPasswordToken(token);
if (ok) { if (ok) {
createSuccessNotification(translate("Successfully revoked the Token")); createSuccessNotification(translate("Successfully revoked the Token"));
} else if (status === 429) { } else {
createErrorNotification(translate("You have made too many requests")); createErrorNotification(translate("Failed to revoke 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(); handleRedirect();
@@ -4,6 +4,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RateLimitedError } from "@services/RateLimit";
import { import {
deleteUserSessionElevation, deleteUserSessionElevation,
generateUserSessionElevation, generateUserSessionElevation,
@@ -16,7 +17,10 @@ const mocks = vi.hoisted(() => ({
})); }));
vi.mock("react-i18next", () => ({ 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", () => ({ vi.mock("@contexts/NotificationsContext", () => ({
@@ -86,8 +90,8 @@ function clickCancel() {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
generateMock.mockResolvedValue({ delete_id: "delete-id" } as any); generateMock.mockResolvedValue({ delete_id: "delete-id" });
verifyMock.mockResolvedValue(true as any); verifyMock.mockResolvedValue(true);
deleteMock.mockResolvedValue(undefined as any); deleteMock.mockResolvedValue(undefined as any);
}); });
@@ -162,6 +166,19 @@ describe("code generation failures", () => {
expect(handleClosed).toHaveBeenCalledWith(false); 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 () => { it("notifies and closes when generation returns nothing", async () => {
generateMock.mockResolvedValue(null as any); generateMock.mockResolvedValue(null as any);
@@ -239,7 +256,7 @@ describe("verification", () => {
}); });
it("notifies, clears and refocuses the field on a wrong code", async () => { it("notifies, clears and refocuses the field on a wrong code", async () => {
verifyMock.mockResolvedValue(false as any); verifyMock.mockResolvedValue(false);
renderDialog(); renderDialog();
await screen.findByText("Verify"); await screen.findByText("Verify");
@@ -257,6 +274,44 @@ describe("verification", () => {
expect(getCode()).toHaveFocus(); 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 () => { it("notifies, clears and re-enables the field when verification fails", async () => {
verifyMock.mockRejectedValue(new Error("Network Error")); verifyMock.mockRejectedValue(new Error("Network Error"));
@@ -277,7 +332,7 @@ describe("verification", () => {
}); });
it("clears the error once the user retypes", async () => { it("clears the error once the user retypes", async () => {
verifyMock.mockResolvedValue(false as any); verifyMock.mockResolvedValue(false);
renderDialog(); renderDialog();
await screen.findByText("Verify"); await screen.findByText("Verify");
@@ -7,6 +7,7 @@ import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, us
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import OneTimeCodeTextField from "@components/OneTimeCodeTextField"; import OneTimeCodeTextField from "@components/OneTimeCodeTextField";
import RateLimitCountdown from "@components/RateLimitCountdown";
import SuccessIcon from "@components/SuccessIcon"; import SuccessIcon from "@components/SuccessIcon";
import { Button } from "@components/UI/Button"; import { Button } from "@components/UI/Button";
import { import {
@@ -19,6 +20,8 @@ import {
} from "@components/UI/Dialog"; } from "@components/UI/Dialog";
import { Spinner } from "@components/UI/Spinner"; import { Spinner } from "@components/UI/Spinner";
import { useNotifications } from "@contexts/NotificationsContext"; import { useNotifications } from "@contexts/NotificationsContext";
import { useRateLimit } from "@hooks/RateLimit";
import { RateLimitedError } from "@services/RateLimit";
import { import {
UserSessionElevation, UserSessionElevation,
deleteUserSessionElevation, deleteUserSessionElevation,
@@ -46,6 +49,7 @@ const IdentityVerificationDialog = function (props: Props) {
const [codeDelete, setCodeDelete] = useState<string>(); const [codeDelete, setCodeDelete] = useState<string>();
const [codeError, setCodeError] = useState(false); const [codeError, setCodeError] = useState(false);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const { clear: clearRateLimit, limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
const codeRef = useRef<HTMLInputElement>(null); const codeRef = useRef<HTMLInputElement>(null);
const timeoutSuccessRef = useRef<null | ReturnType<typeof setTimeout>>(null); const timeoutSuccessRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const attemptRef = useRef(0); 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 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( const handleClose = useCallback(
(ok: boolean) => { (ok: boolean) => {
setCodeInput(""); setCodeInput("");
@@ -70,9 +82,10 @@ const IdentityVerificationDialog = function (props: Props) {
setSuccess(false); setSuccess(false);
setClosing(false); setClosing(false);
setReady(false); setReady(false);
clearRateLimit();
handleClosed(ok); handleClosed(ok);
}, },
[handleClosed], [clearRateLimit, handleClosed],
); );
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
@@ -114,25 +127,36 @@ const IdentityVerificationDialog = function (props: Props) {
codeRef.current?.focus(); codeRef.current?.focus();
}, [createErrorNotification, translate]); }, [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 () => { const handleSubmit = useCallback(async () => {
if (codeInput === "") return; if (codeInput === "" || rateLimited) return;
setLoading(true); setLoading(true);
const attempt = ++attemptRef.current; const attempt = ++attemptRef.current;
const success = await verifyUserSessionElevation(codeInput).catch((err) => { const result = await verifyUserSessionElevation(codeInput).catch((err: unknown) => err);
console.error(err);
return false;
});
if (attempt !== attemptRef.current) return; if (attempt !== attemptRef.current) return;
if (success) { if (result instanceof RateLimitedError) {
handleRateLimited(result.retryAfter);
} else if (result === true) {
handleSuccess(); handleSuccess();
} else { } else {
if (result instanceof Error) console.error(result);
handleFailure(); handleFailure();
} }
}, [codeInput, handleFailure, handleSuccess]); }, [codeInput, handleFailure, handleRateLimited, handleSuccess, rateLimited]);
const handleSubmitKeyDown = useCallback( const handleSubmitKeyDown = useCallback(
async (event: KeyboardEvent<HTMLDivElement>) => { async (event: KeyboardEvent<HTMLDivElement>) => {
@@ -163,8 +187,18 @@ const IdentityVerificationDialog = function (props: Props) {
setReady(true); setReady(true);
}) })
.catch((error) => { .catch((error) => {
console.error(error); if (error instanceof RateLimitedError) {
createErrorNotification(translate("Failed to generate the One-Time Code. Please try again later.")); 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); handleClose(false);
}); });
}, [closing, opening, elevation, ready, translate, handleClose, handleOpened, createErrorNotification]); }, [closing, opening, elevation, ready, translate, handleClose, handleOpened, createErrorNotification]);
@@ -206,11 +240,12 @@ const IdentityVerificationDialog = function (props: Props) {
value={codeInput} value={codeInput}
onChange={handleChange} onChange={handleChange}
error={codeError} error={codeError}
disabled={loading} disabled={loading || rateLimited}
inputRef={codeRef} inputRef={codeRef}
onKeyDown={handleSubmitKeyDown} onKeyDown={handleSubmitKeyDown}
/> />
</div> </div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</div> </div>
)} )}
{success ? null : ( {success ? null : (
@@ -228,7 +263,7 @@ const IdentityVerificationDialog = function (props: Props) {
id={"dialog-verify"} id={"dialog-verify"}
variant={"ghost"} variant={"ghost"}
color={"primary"} color={"primary"}
disabled={loading} disabled={loading || rateLimited}
onClick={handleSubmit} onClick={handleSubmit}
> >
{loading ? <Spinner size={20} /> : null} {loading ? <Spinner size={20} /> : null}
@@ -9,6 +9,7 @@ import {
completePushNotificationSignIn, completePushNotificationSignIn,
initiateDuoDeviceSelectionProcess, initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification"; } from "@services/PushNotification";
import { RateLimitedError } from "@services/RateLimit";
import SecondFactorMethodMobilePush from "@views/Settings/Common/SecondFactorMethodMobilePush"; import SecondFactorMethodMobilePush from "@views/Settings/Common/SecondFactorMethodMobilePush";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -53,13 +54,11 @@ const initiateSelectionMock = vi.mocked(initiateDuoDeviceSelectionProcess);
const completeSelectionMock = vi.mocked(completeDuoDeviceSelectionProcess); const completeSelectionMock = vi.mocked(completeDuoDeviceSelectionProcess);
const authResponse = { const authResponse = {
data: { devices: [
devices: [ { capabilities: ["push", "sms"], device: "device-a", display_name: "Phone" },
{ capabilities: ["push", "sms"], device: "device-a", display_name: "Phone" }, { capabilities: ["push"], device: "device-b", display_name: "Tablet" },
{ capabilities: ["push"], device: "device-b", display_name: "Tablet" }, ],
], result: "auth",
result: "auth",
},
}; };
function renderPush(onSecondFactorSuccess = vi.fn()) { function renderPush(onSecondFactorSuccess = vi.fn()) {
@@ -72,7 +71,7 @@ function renderPush(onSecondFactorSuccess = vi.fn()) {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
completeSignInMock.mockResolvedValue(null as any); completeSignInMock.mockRejectedValue(new Error("boom"));
initiateSelectionMock.mockResolvedValue({} as any); initiateSelectionMock.mockResolvedValue({} as any);
completeSelectionMock.mockResolvedValue(undefined as any); completeSelectionMock.mockResolvedValue(undefined as any);
}); });
@@ -103,7 +102,7 @@ describe("rendering", () => {
describe("push response handling", () => { describe("push response handling", () => {
it("reports success and hides the device link", async () => { 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(); const { onSecondFactorSuccess } = renderPush();
@@ -128,7 +127,7 @@ describe("push response handling", () => {
}); });
it("fails when the response requires enrollment", async () => { it("fails when the response requires enrollment", async () => {
completeSignInMock.mockResolvedValue({ data: { result: "enroll" } } as any); completeSignInMock.mockResolvedValue({ result: "enroll" } as any);
renderPush(); renderPush();
@@ -137,7 +136,7 @@ describe("push response handling", () => {
}); });
it("fails when the Duo policy denies the request", async () => { it("fails when the Duo policy denies the request", async () => {
completeSignInMock.mockResolvedValue({ data: { result: "deny" } } as any); completeSignInMock.mockResolvedValue({ result: "deny" } as any);
renderPush(); renderPush();
@@ -147,26 +146,27 @@ describe("push response handling", () => {
expect(screen.getByTestId("failure-icon")).toBeInTheDocument(); expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
}); });
it("fails when the response is missing", async () => { it("reports success when the response carries no data", async () => {
completeSignInMock.mockResolvedValue(null as any); completeSignInMock.mockResolvedValue(undefined);
renderPush(); const { onSecondFactorSuccess } = renderPush();
await waitFor(() => await waitFor(() => expect(onSecondFactorSuccess).toHaveBeenCalled());
expect(mocks.createErrorNotification).toHaveBeenCalledWith("There was an issue completing sign in process"), expect(mocks.createErrorNotification).not.toHaveBeenCalled();
); expect(screen.queryByTestId("failure-icon")).not.toBeInTheDocument();
expect(screen.getByTestId("failure-icon")).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); completeSignInMock.mockResolvedValue({} as any);
renderPush(); const { onSecondFactorSuccess } = renderPush();
await waitFor(() => await waitFor(() =>
expect(mocks.createErrorNotification).toHaveBeenCalledWith("There was an issue completing sign in process"), expect(mocks.createErrorNotification).toHaveBeenCalledWith("There was an issue completing sign in process"),
); );
expect(screen.getByTestId("failure-icon")).toBeInTheDocument(); expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
}); });
it("fails when the push request throws", async () => { 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 () => { it("reports rate limiting and recovers after the retry window", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 3 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(3));
renderPush(); renderPush();
@@ -198,20 +198,8 @@ describe("push response handling", () => {
expect(screen.getByTestId("failure-icon")).toBeInTheDocument(); 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 () => { it("retries the push from the failure state", async () => {
completeSignInMock.mockResolvedValue(null as any); completeSignInMock.mockRejectedValue(new Error("boom"));
renderPush(); renderPush();
@@ -314,6 +302,40 @@ describe("device selection", () => {
await waitFor(() => expect(completeSignInMock).toHaveBeenCalledTimes(2)); 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 () => { it("logs failures updating the preferred Duo device", async () => {
completeSignInMock.mockResolvedValue(authResponse as any); completeSignInMock.mockResolvedValue(authResponse as any);
completeSelectionMock.mockRejectedValue(new Error("boom")); completeSelectionMock.mockRejectedValue(new Error("boom"));
@@ -335,7 +357,7 @@ describe("device selection", () => {
describe("cleanup", () => { describe("cleanup", () => {
it("clears the rate limit timer on unmount", async () => { it("clears the rate limit timer on unmount", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 3 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(3));
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
@@ -345,7 +367,6 @@ describe("cleanup", () => {
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"), 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); const index = setTimeoutSpy.mock.calls.findIndex(([, timeout]) => timeout === 3000);
expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeGreaterThanOrEqual(0);
@@ -2,20 +2,24 @@
// //
// SPDX-License-Identifier: Apache-2.0 // 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 { useTranslation } from "react-i18next";
import FailureIcon from "@components/FailureIcon"; import FailureIcon from "@components/FailureIcon";
import PushNotificationIcon from "@components/PushNotificationIcon"; import PushNotificationIcon from "@components/PushNotificationIcon";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { Button } from "@components/UI/Button"; import { Button } from "@components/UI/Button";
import { useNotifications } from "@contexts/NotificationsContext"; import { useNotifications } from "@contexts/NotificationsContext";
import { useRateLimit } from "@hooks/RateLimit";
import { import {
DuoDevicePostRequest, DuoDevicePostRequest,
DuoSignInResponse,
completeDuoDeviceSelectionProcess, completeDuoDeviceSelectionProcess,
completePushNotificationSignIn, completePushNotificationSignIn,
initiateDuoDeviceSelectionProcess, initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification"; } from "@services/PushNotification";
import { RateLimitedError } from "@services/RateLimit";
import DeviceSelectionContainer, { import DeviceSelectionContainer, {
SelectableDevice, SelectableDevice,
SelectedDevice, SelectedDevice,
@@ -30,6 +34,7 @@ type Action =
| { type: "pushFailure" } | { type: "pushFailure" }
| { type: "pushSuccess" } | { type: "pushSuccess" }
| { type: "rateLimited" } | { type: "rateLimited" }
| { type: "rateLimitExpired" }
| { type: "selectDevices"; devices: SelectableDevice[] } | { type: "selectDevices"; devices: SelectableDevice[] }
| { type: "setDevices"; devices: SelectableDevice[] } | { type: "setDevices"; devices: SelectableDevice[] }
| { type: "setStatus"; status: ComponentState["status"] } | { type: "setStatus"; status: ComponentState["status"] }
@@ -56,6 +61,8 @@ function reducer(state: ComponentState, action: Action): ComponentState {
return { ...state, devices: action.devices, status: "selecting" }; return { ...state, devices: action.devices, status: "selecting" };
case "rateLimited": case "rateLimited":
return { ...state, status: "rate_limited" }; return { ...state, status: "rate_limited" };
case "rateLimitExpired":
return state.status === "rate_limited" ? { ...state, status: "failure" } : state;
default: default:
return state; return state;
} }
@@ -72,73 +79,55 @@ const SecondFactorMethodMobilePush = function (props: Props) {
const { createErrorNotification } = useNotifications(); const { createErrorNotification } = useNotifications();
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null); const {
clear: clearRateLimit,
useEffect(() => { secondsRemaining,
return () => { trigger: triggerRateLimit,
if (timeoutRateLimitRef.current !== null) { } = useRateLimit(() => dispatch({ type: "rateLimitExpired" }));
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
};
}, []);
const handleRateLimited = useCallback( const handleRateLimited = useCallback(
(retryAfter: number) => { (retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
dispatch({ type: "rateLimited" }); dispatch({ type: "rateLimited" });
createErrorNotification(translate("You have made too many requests")); createErrorNotification(translate("You have made too many requests"));
timeoutRateLimitRef.current = setTimeout(() => { triggerRateLimit(retryAfter);
dispatch({ type: "pushFailure" });
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
}, },
[createErrorNotification, translate], [createErrorNotification, translate, triggerRateLimit],
); );
const handlePushResponse = useCallback( const handlePushResponse = useCallback(
(res: any) => { (res: DuoSignInResponse | undefined) => {
if (res) { if (res !== undefined && typeof res.result !== "string") {
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 {
createErrorNotification(translate("There was an issue completing sign in process")); createErrorNotification(translate("There was an issue completing sign in process"));
dispatch({ type: "pushFailure" }); 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 () => { const handleSelectDevice = useCallback(async () => {
@@ -177,23 +166,29 @@ const SecondFactorMethodMobilePush = function (props: Props) {
const res = await completePushNotificationSignIn(); const res = await completePushNotificationSignIn();
handlePushResponse(res); handlePushResponse(res);
} catch (err) { } catch (err) {
if (err instanceof RateLimitedError) {
handleRateLimited(err.retryAfter);
return;
}
console.error(err); console.error(err);
createErrorNotification(translate("There was an issue completing sign in process")); createErrorNotification(translate("There was an issue completing sign in process"));
dispatch({ type: "pushFailure" }); dispatch({ type: "pushFailure" });
} }
}, [handlePushResponse, createErrorNotification, translate]); }, [handlePushResponse, handleRateLimited, createErrorNotification, translate]);
const updateDuoDevice = useCallback( const updateDuoDevice = useCallback(
async function (device: DuoDevicePostRequest) { async function (device: DuoDevicePostRequest) {
try { try {
await completeDuoDeviceSelectionProcess(device); await completeDuoDeviceSelectionProcess(device);
clearRateLimit();
dispatch({ type: "startPush" }); dispatch({ type: "startPush" });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
console.error(new Error(translate("There was an issue updating preferred Duo device"))); console.error(new Error(translate("There was an issue updating preferred Duo device")));
} }
}, },
[translate], [clearRateLimit, translate],
); );
const handleDuoDeviceSelected = useCallback( const handleDuoDeviceSelected = useCallback(
@@ -232,6 +227,7 @@ const SecondFactorMethodMobilePush = function (props: Props) {
<Fragment> <Fragment>
<div className="h-[120px]"> <div className="h-[120px]">
<div className="inline-block size-17.5">{icon}</div> <div className="inline-block size-17.5">{icon}</div>
<RateLimitCountdown secondsRemaining={secondsRemaining} />
<div className={state.status === "failure" ? "" : "hidden"}> <div className={state.status === "failure" ? "" : "hidden"}>
<Button variant={"ghost"} color={"secondary"} onClick={() => dispatch({ type: "startPush" })}> <Button variant={"ghost"} color={"secondary"} onClick={() => dispatch({ type: "startPush" })}>
{translate("Retry")} {translate("Retry")}
@@ -5,6 +5,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { completeTOTPSignIn } from "@services/OneTimePassword"; import { completeTOTPSignIn } from "@services/OneTimePassword";
import { RateLimitedError } from "@services/RateLimit";
import SecondFactorMethodOneTimePassword from "@views/Settings/Common/SecondFactorMethodOneTimePassword"; import SecondFactorMethodOneTimePassword from "@views/Settings/Common/SecondFactorMethodOneTimePassword";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -66,7 +67,7 @@ beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
mocks.config = { digits: 6, period: 30 }; mocks.config = { digits: 6, period: 30 };
mocks.configError = null; mocks.configError = null;
completeSignInMock.mockResolvedValue({} as any); completeSignInMock.mockResolvedValue({ redirect: "https://example.com" });
}); });
afterEach(() => { afterEach(() => {
@@ -133,34 +134,33 @@ describe("sign in", () => {
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-passcode", "")); await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-passcode", ""));
}); });
it("notifies and fails when the code is rejected", async () => { it("succeeds when the response carries no data", async () => {
completeSignInMock.mockResolvedValue(null as any); completeSignInMock.mockResolvedValue(undefined);
const { onSecondFactorSuccess } = renderMethod(); const { onSecondFactorSuccess } = renderMethod();
fireEvent.click(screen.getByTestId("dial-full")); fireEvent.click(screen.getByTestId("dial-full"));
await waitFor(() => await waitFor(() => expect(onSecondFactorSuccess).toHaveBeenCalled());
expect(mocks.createErrorNotification).toHaveBeenCalledWith("The One-Time Password might be wrong"), expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "2");
);
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3"));
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
}); });
it("fails when the request throws", async () => { it("fails when the request throws", async () => {
completeSignInMock.mockRejectedValue(new Error("boom")); completeSignInMock.mockRejectedValue(new Error("boom"));
renderMethod(); const { onSecondFactorSuccess } = renderMethod();
fireEvent.click(screen.getByTestId("dial-full")); fireEvent.click(screen.getByTestId("dial-full"));
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3")); await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3"));
expect(console.error).toHaveBeenCalled(); expect(console.error).toHaveBeenCalled();
expect(mocks.createErrorNotification).not.toHaveBeenCalled();
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
}); });
it("reports rate limiting and recovers after the retry window", async () => { it("reports rate limiting and recovers after the retry window", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(4));
renderMethod(); renderMethod();
@@ -180,7 +180,7 @@ describe("sign in", () => {
it("ignores new submissions while rate limited", async () => { it("ignores new submissions while rate limited", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(4));
renderMethod(); renderMethod();
@@ -205,7 +205,7 @@ describe("sign in", () => {
expect(completeSignInMock).toHaveBeenCalledTimes(1); expect(completeSignInMock).toHaveBeenCalledTimes(1);
resolve({}); resolve(undefined);
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "2")); 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 () => { it("clears the rate limit timer on unmount", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any); completeSignInMock.mockRejectedValue(new RateLimitedError(4));
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
@@ -231,7 +231,6 @@ describe("sign in", () => {
fireEvent.click(screen.getByTestId("dial-full")); fireEvent.click(screen.getByTestId("dial-full"));
await vi.waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "4")); 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); const index = setTimeoutSpy.mock.calls.findIndex(([, timeout]) => timeout === 4000);
expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeGreaterThanOrEqual(0);
@@ -2,13 +2,16 @@
// //
// SPDX-License-Identifier: Apache-2.0 // 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 { useTranslation } from "react-i18next";
import RateLimitCountdown from "@components/RateLimitCountdown";
import { useNotifications } from "@contexts/NotificationsContext"; import { useNotifications } from "@contexts/NotificationsContext";
import { useRateLimit } from "@hooks/RateLimit";
import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration"; import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration";
import { completeTOTPSignIn } from "@services/OneTimePassword"; import { completeTOTPSignIn } from "@services/OneTimePassword";
import { RateLimitedError } from "@services/RateLimit";
import LoadingPage from "@views/LoadingPage/LoadingPage"; import LoadingPage from "@views/LoadingPage/LoadingPage";
import OTPDial, { State } from "@views/LoginPortal/SecondFactor/OTPDial"; import OTPDial, { State } from "@views/LoginPortal/SecondFactor/OTPDial";
@@ -49,16 +52,9 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
const [config, fetchConfig, , fetchConfigError] = useUserInfoTOTPConfiguration(); const [config, fetchConfig, , fetchConfigError] = useUserInfoTOTPConfiguration();
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null); const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() =>
dispatch({ status: State.Idle, type: "setStatus" }),
useEffect(() => { );
return () => {
if (timeoutRateLimitRef.current !== null) {
clearTimeout(timeoutRateLimitRef.current);
timeoutRateLimitRef.current = null;
}
};
}, []);
useEffect(() => { useEffect(() => {
if (fetchConfigError) { if (fetchConfigError) {
@@ -73,20 +69,13 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
const handleRateLimited = useCallback( const handleRateLimited = useCallback(
(retryAfter: number) => { (retryAfter: number) => {
if (timeoutRateLimitRef.current) {
clearTimeout(timeoutRateLimitRef.current);
}
dispatch({ status: State.RateLimited, type: "setStatus" }); dispatch({ status: State.RateLimited, type: "setStatus" });
createErrorNotification(translate("You have made too many requests", { ns: "portal" })); createErrorNotification(translate("You have made too many requests", { ns: "portal" }));
timeoutRateLimitRef.current = setTimeout(() => { triggerRateLimit(retryAfter);
dispatch({ status: State.Idle, type: "setStatus" });
timeoutRateLimitRef.current = null;
}, retryAfter * 1000);
}, },
[createErrorNotification, translate], [createErrorNotification, translate, triggerRateLimit],
); );
const handleSignIn = useCallback( const handleSignIn = useCallback(
@@ -102,27 +91,22 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
try { try {
dispatch({ status: State.InProgress, type: "setStatus" }); dispatch({ status: State.InProgress, type: "setStatus" });
const res = await completeTOTPSignIn(passcodeStr); await completeTOTPSignIn(passcodeStr);
if (res) { dispatch({ status: State.Success, type: "setStatus" });
if (res.limited) { onSecondFactorSuccess();
handleRateLimited(res.retryAfter); } catch (err) {
} else { if (err instanceof RateLimitedError) {
dispatch({ status: State.Success, type: "setStatus" }); handleRateLimited(err.retryAfter);
onSecondFactorSuccess();
}
} else { } else {
createErrorNotification(translate("The One-Time Password might be wrong", { ns: "portal" })); console.error(err);
dispatch({ status: State.Failure, type: "setStatus" }); dispatch({ status: State.Failure, type: "setStatus" });
} }
} catch (err) {
console.error(err);
dispatch({ status: State.Failure, type: "setStatus" });
} }
dispatch({ passcode: "", type: "setPasscode" }); dispatch({ passcode: "", type: "setPasscode" });
}, },
[config, handleRateLimited, createErrorNotification, translate, onSecondFactorSuccess], [config, handleRateLimited, onSecondFactorSuccess],
); );
const handlePasscodeChange = useCallback( const handlePasscodeChange = useCallback(
@@ -153,6 +137,7 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
) : ( ) : (
<LoadingPage /> <LoadingPage />
)} )}
<RateLimitCountdown secondsRemaining={secondsRemaining} />
</Fragment> </Fragment>
); );
}; };