feat: reduce one time access code length for better UX

This commit is contained in:
Elias Schneider
2026-08-18 21:31:22 +02:00
parent 900346a1c3
commit a487c78550
6 changed files with 38 additions and 12 deletions
+2 -2
View File
@@ -141,8 +141,8 @@ func (h *handler) exchangeToken(c *gin.Context) error {
}
loginCode := c.Param("token")
// reject invalid length login codes
if len(loginCode) != 6 && len(loginCode) != 16 {
// Reject values that cannot match either supported login code format
if len(loginCode) != shortTokenLength && len(loginCode) != longTokenLength {
return apperror.TokenInvalidOrExpired()
}
+8 -4
View File
@@ -21,7 +21,11 @@ import (
// authenticationMethodOneTimePassword identifies one-time password/code authentication
// It must match the value emitted by the JWT service in the access token's "amr" claim
const authenticationMethodOneTimePassword = "otp"
const (
authenticationMethodOneTimePassword = "otp"
shortTokenLength = 6
longTokenLength = 12
)
// TokenStore is the minimal interface needed to persist a one-time access token in the actor state store.
// It's satisfied by both *actor.Service (used by the running application) and *local.Host (used by CLI commands, which don't run the full actor host).
@@ -263,10 +267,10 @@ func StoreToken(ctx context.Context, store TokenStore, userID string, ttl time.D
// generateToken generates the random token value (and optional device token) for a one-time access token.
func generateToken(ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
// Use the shorter format only for codes that expire within 15 minutes
tokenLength := longTokenLength
if ttl <= 15*time.Minute {
tokenLength = 6
tokenLength = shortTokenLength
}
token, err = utils.GenerateRandomUnambiguousString(tokenLength)
@@ -71,6 +71,24 @@ func newServiceForTest(t *testing.T, db *gorm.DB) (*Service, *local.Host, *fakeA
return svc, host, auditLog
}
func TestGenerateTokenLength(t *testing.T) {
for _, test := range []struct {
name string
ttl time.Duration
wantLength int
}{
{name: "short-lived", ttl: 15 * time.Minute, wantLength: shortTokenLength},
{name: "long-lived", ttl: time.Hour, wantLength: longTokenLength},
} {
t.Run(test.name, func(t *testing.T) {
token, _, err := generateToken(test.ttl, false)
require.NoError(t, err)
require.Len(t, token, test.wantLength)
})
}
}
func TestExchangeTokenSuccess(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, host, auditLog := newServiceForTest(t, db)
+1 -1
View File
@@ -657,7 +657,7 @@ func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error {
token string
ttl time.Duration
}{
{token: "HPe6k6u1DRRVuAQV", ttl: time.Hour},
{token: "HPe6k6u1DRRV", ttl: time.Hour},
{token: "0ne-t1me-t0ken", ttl: time.Hour},
}
@@ -18,12 +18,16 @@
let isLoading = $state(false);
let error: string | undefined = $state();
let backHref = $state('/login/alternative');
const shortCodeLength = 6;
const longCodeLength = 12;
let longCodeRequested = $state(
code.length > 6 || !$appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled
code.length > shortCodeLength || !$appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled
);
let showLongCodeOption = $state($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled);
let codeComplete = $derived(longCodeRequested ? code.length === 16 : code.length === 6);
let codeComplete = $derived(
longCodeRequested ? code.length === longCodeLength : code.length === shortCodeLength
);
const userService = new UserService();
@@ -94,7 +98,7 @@
type="text"
/>
{:else}
<InputOTP.Root maxlength={6} bind:value={code} autofocus>
<InputOTP.Root maxlength={shortCodeLength} bind:value={code} autofocus>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell (cell)}
+2 -2
View File
@@ -122,8 +122,8 @@ export const userGroups = {
};
export const oneTimeAccessTokens = [
{ token: 'HPe6k6u1DRRVuAQV', expired: false },
{ token: 'YCGDtftvsvYW1Xd0', expired: true }
{ token: 'HPe6k6u1DRRV', expired: false },
{ token: 'YCGDtftvsvYW', expired: true }
];
export const emailVerificationTokens = [