From a487c7855056b126b7b109f30ba7452c4539b04e Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Tue, 18 Aug 2026 21:31:22 +0200 Subject: [PATCH] feat: reduce one time access code length for better UX --- backend/internal/onetimeaccess/handler.go | 4 ++-- backend/internal/onetimeaccess/service.go | 12 ++++++++---- backend/internal/onetimeaccess/service_test.go | 18 ++++++++++++++++++ backend/internal/service/e2etest_service.go | 2 +- .../routes/login/alternative/code/+page.svelte | 10 +++++++--- tests/data.ts | 4 ++-- 6 files changed, 38 insertions(+), 12 deletions(-) diff --git a/backend/internal/onetimeaccess/handler.go b/backend/internal/onetimeaccess/handler.go index d26bf2c1..efc48e4d 100644 --- a/backend/internal/onetimeaccess/handler.go +++ b/backend/internal/onetimeaccess/handler.go @@ -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() } diff --git a/backend/internal/onetimeaccess/service.go b/backend/internal/onetimeaccess/service.go index 06510433..4779e031 100644 --- a/backend/internal/onetimeaccess/service.go +++ b/backend/internal/onetimeaccess/service.go @@ -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) diff --git a/backend/internal/onetimeaccess/service_test.go b/backend/internal/onetimeaccess/service_test.go index dde0d20d..bc184800 100644 --- a/backend/internal/onetimeaccess/service_test.go +++ b/backend/internal/onetimeaccess/service_test.go @@ -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) diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 8230b69b..5201faf4 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -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}, } diff --git a/frontend/src/routes/login/alternative/code/+page.svelte b/frontend/src/routes/login/alternative/code/+page.svelte index f099b514..c7e7941e 100644 --- a/frontend/src/routes/login/alternative/code/+page.svelte +++ b/frontend/src/routes/login/alternative/code/+page.svelte @@ -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} - + {#snippet children({ cells })} {#each cells as cell (cell)} diff --git a/tests/data.ts b/tests/data.ts index 92e9c8f2..c384714e 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -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 = [