From 8f59eb6b8af2a46293a0fd17e4fdce118d2d864b Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Thu, 26 Mar 2026 23:56:17 +0100 Subject: [PATCH] Distinguish expired magic links from invalid tokens When a magic link token expires, the user now sees a specific error message ("This magic link has expired. Please request a new one.") instead of the generic "Failed to connect" error. This adds ErrExpiredToken to the IAM error types, checks for statelesstoken.ErrExpiredToken in both GetMagicLinkEmail and OpenSessionWithMagicLink, and handles it in the trust resolver and frontend. Signed-off-by: Bryan Frimin --- apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx | 9 ++++++++- pkg/iam/auth_service.go | 10 ++++++++++ pkg/iam/errors.go | 10 ++++++++++ pkg/server/api/trust/v1/v1_resolver.go | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx b/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx index 09b94452d..c6faaaf59 100644 --- a/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx +++ b/apps/trust/src/pages/auth/VerifyMagicLinkPage.tsx @@ -46,9 +46,16 @@ export default function VerifyMagicLinkPagePageMutation() { return; } } + + const hasExpiredToken = errors.some( + (err) => err.message === "token has expired", + ); + toast({ title: __("Error"), - description: formatError(__("Failed to connect"), errors), + description: hasExpiredToken + ? __("This magic link has expired. Please request a new one.") + : formatError(__("Failed to connect"), errors), variant: "error", }); return; diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index 81c3002c0..da841833c 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -634,6 +634,11 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques func (s AuthService) GetMagicLinkEmail(ctx context.Context, tokenString string) (mail.Addr, error) { payload, err := statelesstoken.ValidateToken[MagicLinkData](s.tokenSecret, TokenTypeMagicLink, tokenString) if err != nil { + var errExpired *statelesstoken.ErrExpiredToken + if errors.As(err, &errExpired) { + return mail.Nil, NewExpiredTokenError() + } + return mail.Nil, NewInvalidTokenError() } @@ -649,6 +654,11 @@ func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString s payload, err := statelesstoken.ValidateToken[MagicLinkData](s.tokenSecret, TokenTypeMagicLink, tokenString) if err != nil { + var errExpired *statelesstoken.ErrExpiredToken + if errors.As(err, &errExpired) { + return nil, nil, nil, NewExpiredTokenError() + } + return nil, nil, nil, NewInvalidTokenError() } diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 4f28f2fb0..a797e9eee 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -31,6 +31,16 @@ func (e ErrInvalidToken) Error() string { return e.message } +type ErrExpiredToken struct{ message string } + +func NewExpiredTokenError() error { + return &ErrExpiredToken{"token has expired"} +} + +func (e ErrExpiredToken) Error() string { + return e.message +} + type ErrInvitationAlreadyAccepted struct{ InvitationID gid.GID } func NewInvitationAlreadyAcceptedError(invitationID gid.GID) error { diff --git a/pkg/server/api/trust/v1/v1_resolver.go b/pkg/server/api/trust/v1/v1_resolver.go index 3c7bb81b1..254c89448 100644 --- a/pkg/server/api/trust/v1/v1_resolver.go +++ b/pkg/server/api/trust/v1/v1_resolver.go @@ -225,6 +225,11 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri email, err := r.iam.AuthService.GetMagicLinkEmail(ctx, input.Token) if err != nil { + var errExpiredToken *iam.ErrExpiredToken + if errors.As(err, &errExpiredToken) { + return nil, gqlutils.Invalid(ctx, err) + } + var errInvalidToken *iam.ErrInvalidToken if errors.As(err, &errInvalidToken) { return nil, gqlutils.Invalid(ctx, err) @@ -241,6 +246,11 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri var err error identity, session, continueURL, err = r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token) if err != nil { + var errExpiredToken *iam.ErrExpiredToken + if errors.As(err, &errExpiredToken) { + return nil, gqlutils.Invalid(ctx, err) + } + var errInvalidToken *iam.ErrInvalidToken if errors.As(err, &errInvalidToken) { return nil, gqlutils.Invalid(ctx, err) @@ -258,6 +268,11 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri var err error identity, session, continueURL, err = r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token) if err != nil { + var errExpiredToken *iam.ErrExpiredToken + if errors.As(err, &errExpiredToken) { + return nil, gqlutils.Invalid(ctx, err) + } + var errInvalidToken *iam.ErrInvalidToken if errors.As(err, &errInvalidToken) { return nil, gqlutils.Invalid(ctx, err)