From e1fd1d320ffa3eff51a395680c74f6eb5f2ac9e3 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Tue, 28 Jul 2026 01:30:13 +0200 Subject: [PATCH] feat: add qr code alternative sign in method (#1594) Co-authored-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com> --- backend/go.mod | 2 +- backend/go.sum | 4 +- .../internal/bootstrap/router_bootstrap.go | 6 + .../internal/bootstrap/services_bootstrap.go | 17 +- backend/internal/common/errors.go | 14 + backend/internal/devicelogin/actor.go | 316 ++++++++++ backend/internal/devicelogin/dto.go | 28 + backend/internal/devicelogin/handler.go | 145 +++++ backend/internal/devicelogin/models.go | 20 + backend/internal/devicelogin/module.go | 74 +++ backend/internal/devicelogin/service.go | 326 ++++++++++ backend/internal/devicelogin/service_test.go | 584 ++++++++++++++++++ backend/internal/middleware/rate_limit.go | 24 +- backend/internal/model/audit_log.go | 1 + backend/internal/oidc/device_service.go | 5 + backend/internal/oidc/device_service_test.go | 8 + backend/internal/oidc/device_strategy.go | 32 + backend/internal/oidc/provider.go | 5 +- backend/internal/onetimeaccess/service.go | 2 + .../internal/onetimeaccess/service_test.go | 22 + backend/internal/service/e2etest_service.go | 4 +- backend/internal/utils/cookie/add_cookie.go | 5 + backend/internal/utils/cookie/cookie_names.go | 2 + backend/internal/utils/hash_util.go | 7 + backend/internal/utils/string_util.go | 17 +- backend/internal/utils/string_util_test.go | 31 +- frontend/messages/en.json | 17 +- .../src/lib/components/login-wrapper.svelte | 21 +- .../src/lib/components/qrcode/qrcode.svelte | 11 +- .../src/lib/services/device-login-service.ts | 35 ++ frontend/src/lib/types/device-login.type.ts | 21 + .../src/lib/utils/audit-log-translator.ts | 1 + frontend/src/routes/device/+page.svelte | 185 +++++- .../src/routes/login/alternative/+page.svelte | 13 +- .../login/alternative/device/+page.svelte | 156 +++++ .../routes/login/alternative/device/+page.ts | 7 + tests/data.ts | 4 +- tests/specs/device-login.spec.ts | 116 ++++ 38 files changed, 2215 insertions(+), 73 deletions(-) create mode 100644 backend/internal/devicelogin/actor.go create mode 100644 backend/internal/devicelogin/dto.go create mode 100644 backend/internal/devicelogin/handler.go create mode 100644 backend/internal/devicelogin/models.go create mode 100644 backend/internal/devicelogin/module.go create mode 100644 backend/internal/devicelogin/service.go create mode 100644 backend/internal/devicelogin/service_test.go create mode 100644 backend/internal/oidc/device_strategy.go create mode 100644 frontend/src/lib/services/device-login-service.ts create mode 100644 frontend/src/lib/types/device-login.type.ts create mode 100644 frontend/src/routes/login/alternative/device/+page.svelte create mode 100644 frontend/src/routes/login/alternative/device/+page.ts create mode 100644 tests/specs/device-login.spec.ts diff --git a/backend/go.mod b/backend/go.mod index 1422dc5a..9768ac89 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,7 +24,7 @@ require ( github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 - github.com/italypaleale/francis v0.1.0-beta.15 + github.com/italypaleale/francis v0.1.0-beta.16 github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86 github.com/italypaleale/go-sql-utils v0.2.4 github.com/jackc/pgx/v5 v5.10.0 diff --git a/backend/go.sum b/backend/go.sum index 184240bb..5ab2f3ae 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -263,8 +263,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/italypaleale/francis v0.1.0-beta.15 h1:yVFJCcD1pP91rIesAb06Gp1K1tPj0ruh6hha5YEhtRw= -github.com/italypaleale/francis v0.1.0-beta.15/go.mod h1:KKwS+57OBD/MoHBVfbbMelA2vUx65fPiG9fhdWWapFc= +github.com/italypaleale/francis v0.1.0-beta.16 h1:bkt+8iA2f/hi14vve0cYaUMvVhHBIoD/cLVlUkHR69c= +github.com/italypaleale/francis v0.1.0-beta.16/go.mod h1:KKwS+57OBD/MoHBVfbbMelA2vUx65fPiG9fhdWWapFc= github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86 h1:719T7W8hLVjelch856Sern60QAPMn0fIE87i91YVcfw= github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86/go.mod h1:0Sy3bN3qnSy2kgcJ05A2CsP6os5wmLC9lPraFr+0jGk= github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c= diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 62c75dec..dadb8671 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -157,6 +157,12 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin), rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate), ) + svc.deviceLoginModule.RegisterRoutes(apiGroup, + authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), + rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginCreate), + rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginExchange), + rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginVerification), + ) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) controller.NewUserController(apiGroup, authMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailModule, svc.ldapService) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 966a3911..1974de78 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -6,12 +6,11 @@ import ( "net/http" "github.com/italypaleale/francis/host/local" - "gorm.io/gorm" - "github.com/pocket-id/pocket-id/backend/internal/api" "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/devicelogin" "github.com/pocket-id/pocket-id/backend/internal/email" "github.com/pocket-id/pocket-id/backend/internal/emailverification" "github.com/pocket-id/pocket-id/backend/internal/job" @@ -21,6 +20,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/usersignup" "github.com/pocket-id/pocket-id/backend/internal/webauthn" + "gorm.io/gorm" ) type services struct { @@ -41,6 +41,7 @@ type services struct { appLockService *service.AppLockService apiKeyModule *apikey.Module + deviceLoginModule *devicelogin.Module oidcModule *oidc.Module webauthnModule *webauthn.Module userSignUpModule *usersignup.Module @@ -98,6 +99,18 @@ func initServices( if err != nil { return nil, fmt.Errorf("failed to create WebAuthn module: %w", err) } + svc.deviceLoginModule, err = devicelogin.New(devicelogin.Dependencies{ + DB: db, + BaseURL: common.EnvConfig.AppURL, + Actors: actors, + Signer: svc.jwtService, + Reauth: svc.webauthnModule, + AuditLog: svc.auditLogService, + AppConfig: svc.appConfigService, + }) + if err != nil { + return nil, fmt.Errorf("failed to create device login module: %w", err) + } svc.scimService = service.NewScimService(db, scheduler, httpClient) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 1d6a03c6..9a026431 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -188,6 +188,20 @@ type OneTimeAccessDisabledError struct{} func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" } func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest } +type DeviceLoginRequestInvalidOrExpiredError struct{} + +func (e DeviceLoginRequestInvalidOrExpiredError) Error() string { + return "Device login request is invalid or expired" +} +func (e DeviceLoginRequestInvalidOrExpiredError) HttpStatusCode() int { + return http.StatusUnauthorized +} + +type DeviceLoginDeniedError struct{} + +func (e DeviceLoginDeniedError) Error() string { return "Device login request was denied" } +func (e DeviceLoginDeniedError) HttpStatusCode() int { return http.StatusForbidden } + type InvalidAPIKeyError struct{} func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" } diff --git a/backend/internal/devicelogin/actor.go b/backend/internal/devicelogin/actor.go new file mode 100644 index 00000000..93dc7be8 --- /dev/null +++ b/backend/internal/devicelogin/actor.go @@ -0,0 +1,316 @@ +package devicelogin + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/italypaleale/francis/actor" + + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +const ( + requestActorType = "device-login-request" + requestActorMethodCreate = "create" + requestActorMethodInspect = "inspect" + requestActorMethodPoll = "poll" + requestActorMethodDecide = "decide" + requestActorMethodConsume = "consume" +) + +type requestActorResultCode string + +const ( + requestActorResultNone requestActorResultCode = "" + requestActorResultCollision requestActorResultCode = "collision" + requestActorResultInvalid requestActorResultCode = "invalid" + requestActorResultDenied requestActorResultCode = "denied" +) + +type requestActorState struct { + Code string + DeviceTokenHash string + Status RequestStatus + ExpiresAt time.Time + IPAddress string + UserAgent string + UserID string +} + +type requestActorResult struct { + Code requestActorResultCode + Status RequestStatus + UserCode string + IPAddress string + UserAgent string + ExpiresAt time.Time + UserID string +} + +type requestActorCreateInput struct { + Code string + DeviceTokenHash string + IPAddress string + UserAgent string +} + +type requestActorPollInput struct { + DeviceTokenHash string +} + +type requestActorDecisionInput struct { + Decision string + UserID string +} + +type requestActorConsumeInput struct { + DeviceTokenHash string +} + +type requestActor struct { + client actor.Client[requestActorState] +} + +func newRequestActor(actorID string, actorService *actor.Service) actor.Actor { + return &requestActor{ + client: actor.NewActorClient[requestActorState](requestActorType, actorID, actorService), + } +} + +func (a *requestActor) Invoke(ctx context.Context, method string, data actor.Envelope) (any, error) { + switch method { + case requestActorMethodCreate: + var input requestActorCreateInput + err := decodeActorInput(data, &input) + if err != nil { + return nil, err + } + return a.create(ctx, input) + case requestActorMethodDecide: + var input requestActorDecisionInput + err := decodeActorInput(data, &input) + if err != nil { + return nil, err + } + return a.decide(ctx, input) + case requestActorMethodConsume: + var input requestActorConsumeInput + err := decodeActorInput(data, &input) + if err != nil { + return nil, err + } + return a.consume(ctx, input) + default: + return nil, fmt.Errorf("unsupported device login actor method %q", method) + } +} + +func (a *requestActor) Peek(ctx context.Context, method string, data actor.Envelope) (any, error) { + switch method { + case requestActorMethodInspect: + return a.inspect(ctx) + case requestActorMethodPoll: + var input requestActorPollInput + if err := decodeActorInput(data, &input); err != nil { + return nil, err + } + return a.poll(ctx, input) + default: + return nil, fmt.Errorf("unsupported device login actor peek method %q", method) + } +} + +func (a *requestActor) create(ctx context.Context, input requestActorCreateInput) (requestActorResult, error) { + // Reject invalid initialization before touching durable state + if input.Code == "" || input.DeviceTokenHash == "" { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + + // Preserve an existing live request when the short user code collides + state, err := a.client.GetState(ctx) + if err != nil { + return requestActorResult{}, fmt.Errorf("failed to load device login actor state: %w", err) + } + if state.Code != "" { + if state.ExpiresAt.After(time.Now()) { + return requestActorResult{Code: requestActorResultCollision}, nil + } + } + + expiresAt := time.Now().Round(time.Second).Add(RequestDuration) + + // Persist the request until its public expiry + state = requestActorState{ + Code: input.Code, + DeviceTokenHash: input.DeviceTokenHash, + Status: RequestStatusPending, + ExpiresAt: expiresAt, + IPAddress: input.IPAddress, + UserAgent: input.UserAgent, + } + if err = a.persistState(ctx, state); err != nil { + return requestActorResult{}, err + } + + return requestActorResult{Status: state.Status, ExpiresAt: state.ExpiresAt}, nil +} + +func (a *requestActor) inspect(ctx context.Context) (requestActorResult, error) { + // Only pending live requests may reveal requester metadata or be decided + state, valid, err := a.liveState(ctx) + if err != nil { + return requestActorResult{}, err + } + if !valid || state.Status != RequestStatusPending { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + + return requestActorResult{ + Status: state.Status, + UserCode: state.Code, + IPAddress: state.IPAddress, + UserAgent: state.UserAgent, + ExpiresAt: state.ExpiresAt, + }, nil +} + +func (a *requestActor) poll(ctx context.Context, input requestActorPollInput) (requestActorResult, error) { + // Validate the request lifetime and the high-entropy device binding on every poll + state, err := a.loadState(ctx) + if err != nil { + return requestActorResult{}, err + } + if state.Code == "" || !utils.ConstantTimeStringEqual(state.DeviceTokenHash, input.DeviceTokenHash) { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + + result := requestActorResult{Status: state.Status, ExpiresAt: state.ExpiresAt} + switch state.Status { + case RequestStatusPending: + if !state.ExpiresAt.After(time.Now()) { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + return result, nil + case RequestStatusApproved: + if state.UserID == "" || !state.ExpiresAt.After(time.Now()) { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + result.UserID = state.UserID + return result, nil + case RequestStatusDenied: + if !state.ExpiresAt.After(time.Now()) { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + result.Code = requestActorResultDenied + return result, nil + default: + return requestActorResult{Code: requestActorResultInvalid}, nil + } +} + +func (a *requestActor) decide(ctx context.Context, input requestActorDecisionInput) (requestActorResult, error) { + // Serialize every decision against exchange and require the request to still be pending + state, valid, err := a.liveState(ctx) + if err != nil { + return requestActorResult{}, err + } + if !valid || state.Status != RequestStatusPending { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + + switch input.Decision { + case "deny": + state.Status = RequestStatusDenied + case "approve": + state.Status = RequestStatusApproved + state.UserID = input.UserID + default: + return requestActorResult{}, fmt.Errorf("unsupported device login decision %q", input.Decision) + } + + // Preserve the original expiry when committing the terminal decision + if err = a.persistState(ctx, state); err != nil { + return requestActorResult{}, err + } + + return requestActorResult{Status: state.Status, ExpiresAt: state.ExpiresAt}, nil +} + +func (a *requestActor) consume(ctx context.Context, input requestActorConsumeInput) (requestActorResult, error) { + // Serialize consume attempts and validate the device binding inside the actor turn + state, err := a.loadState(ctx) + if err != nil { + return requestActorResult{}, err + } + if state.Code == "" || !utils.ConstantTimeStringEqual(state.DeviceTokenHash, input.DeviceTokenHash) || !state.ExpiresAt.After(time.Now()) { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + switch state.Status { + case RequestStatusPending: + return requestActorResult{Status: state.Status, ExpiresAt: state.ExpiresAt}, nil + case RequestStatusDenied: + return requestActorResult{Code: requestActorResultDenied, Status: state.Status, ExpiresAt: state.ExpiresAt}, nil + case RequestStatusApproved: + if state.UserID == "" { + return requestActorResult{Code: requestActorResultInvalid}, nil + } + // Delete the approved request before returning so only one exchange can continue + if err = a.client.DeleteState(ctx); err != nil { + return requestActorResult{}, fmt.Errorf("failed to delete consumed device login actor state: %w", err) + } + return requestActorResult{ + Status: state.Status, + ExpiresAt: state.ExpiresAt, + }, nil + default: + return requestActorResult{Code: requestActorResultInvalid}, nil + } +} + +func (a *requestActor) liveState(ctx context.Context) (requestActorState, bool, error) { + state, err := a.loadState(ctx) + if err != nil { + return requestActorState{}, false, err + } + if state.Code == "" { + return state, false, nil + } + return state, state.ExpiresAt.After(time.Now()), nil +} + +func (a *requestActor) loadState(ctx context.Context) (requestActorState, error) { + state, err := a.client.GetState(ctx) + if err != nil { + return requestActorState{}, fmt.Errorf("failed to load device login actor state: %w", err) + } + return state, nil +} + +func (a *requestActor) persistState(ctx context.Context, state requestActorState) error { + ttl := time.Until(state.ExpiresAt) + if ttl <= 0 { + return errors.New("cannot persist expired device login actor state") + } + + err := a.client.SetState(ctx, state, &actor.SetStateOpts{ + TTL: ttl, + }) + if err != nil { + return fmt.Errorf("failed to persist device login actor state: %w", err) + } + return nil +} + +func decodeActorInput(data actor.Envelope, target any) error { + if data == nil { + return errors.New("device login actor input is missing") + } + + err := data.Decode(target) + if err != nil { + return fmt.Errorf("failed to decode device login actor input: %w", err) + } + return nil +} diff --git a/backend/internal/devicelogin/dto.go b/backend/internal/devicelogin/dto.go new file mode 100644 index 00000000..d5b4f43c --- /dev/null +++ b/backend/internal/devicelogin/dto.go @@ -0,0 +1,28 @@ +package devicelogin + +import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + +type requestCreateDto struct { + ID string `json:"id"` + UserCode string `json:"userCode"` + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + ExpiresAt datatype.DateTime `json:"expiresAt"` + Interval int `json:"interval"` +} + +type verificationDto struct { + Code string `json:"code" binding:"required"` +} + +type decisionDto struct { + Code string `json:"code" binding:"required"` + Decision string `json:"decision" binding:"required,oneof=approve deny"` +} + +type verificationInfoDto struct { + UserCode string `json:"userCode"` + Device string `json:"device"` + IPAddress string `json:"ipAddress"` + ExpiresAt datatype.DateTime `json:"expiresAt"` +} diff --git a/backend/internal/devicelogin/handler.go b/backend/internal/devicelogin/handler.go new file mode 100644 index 00000000..fd52c70b --- /dev/null +++ b/backend/internal/devicelogin/handler.go @@ -0,0 +1,145 @@ +package devicelogin + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" +) + +type handler struct { + service *Service + baseURL string + appConfig AppConfigProvider +} + +func newHandler(service *Service, baseURL string, appConfig AppConfigProvider) *handler { + return &handler{ + service: service, + baseURL: baseURL, + appConfig: appConfig, + } +} + +// createRequest godoc +// @Summary Create device login request +// @Description Create a short-lived request that can be approved from another authenticated device +// @Tags Device Login +// @Produce json +// @Success 201 {object} requestCreateDto "Created device login request" +// @Router /api/device-login/requests [post] +func (h *handler) createRequest(c *gin.Context) { + request, deviceToken, err := h.service.Create(c.Request.Context(), c.ClientIP(), c.Request.UserAgent()) + if err != nil { + _ = c.Error(err) + return + } + + verificationURI := h.baseURL + "/device" + verificationURIComplete := verificationURI + "?user_code=" + url.QueryEscape(request.Code) + cookie.AddDeviceLoginTokenCookie(c, request.ID, deviceToken) + c.JSON(http.StatusCreated, requestCreateDto{ + ID: request.ID, + UserCode: request.Code, + VerificationURI: verificationURI, + VerificationURIComplete: verificationURIComplete, + ExpiresAt: request.ExpiresAt, + Interval: PollingInterval, + }) +} + +// exchangeRequest godoc +// @Summary Exchange device login request +// @Description Wait for a device login decision and create a browser session after it has been approved +// @Tags Device Login +// @Produce json +// @Param id path string true "Device login request ID" +// @Success 200 {object} dto.UserDto "Approved request exchanged for a user session" +// @Success 202 "Authorization pending" +// @Router /api/device-login/requests/{id}/exchange [post] +func (h *handler) exchangeRequest(c *gin.Context) { + dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + + requestID := c.Param("id") + deviceToken, _ := c.Cookie(cookie.DeviceLoginTokenCookieName) + sessionDuration := dbConfig.SessionDuration.AsDurationMinutes() + user, accessToken, status, err := h.service.Exchange(c.Request.Context(), requestID, deviceToken, c.ClientIP(), c.Request.UserAgent(), sessionDuration) + if err != nil { + if c.Request.Context().Err() != nil { + // Context canceled = the client stopped the request + // Nothing to do here + return + } + _ = c.Error(err) + return + } + if status == RequestStatusPending { + // Request is pending, so respond with a 202 + c.Status(http.StatusAccepted) + return + } + + maxAge := int(sessionDuration.Seconds()) + cookie.AddAccessTokenCookie(c, maxAge, accessToken) + c.JSON(http.StatusOK, dto.UserDto(user)) +} + +// inspectRequest godoc +// @Summary Inspect device login request +// @Description Retrieve the requesting device details for an authenticated user before approval or denial +// @Tags Device Login +// @Accept json +// @Produce json +// @Param request body verificationDto true "Device login code" +// @Success 200 {object} verificationInfoDto "Device login request details" +// @Router /api/device-login/verification [post] +func (h *handler) inspectRequest(c *gin.Context) { + var input verificationDto + err := c.ShouldBindJSON(&input) + if err != nil { + _ = c.Error(err) + return + } + + info, err := h.service.Inspect(c.Request.Context(), input.Code) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, verificationInfoDto(info)) +} + +// decideRequest godoc +// @Summary Decide device login request +// @Description Approve or deny a device login request; approval requires fresh passkey reauthentication +// @Tags Device Login +// @Accept json +// @Param decision body decisionDto true "Device login decision" +// @Success 204 "No Content" +// @Router /api/device-login/verification/decision [post] +func (h *handler) decideRequest(c *gin.Context) { + var input decisionDto + err := c.ShouldBindJSON(&input) + if err != nil { + _ = c.Error(err) + return + } + + reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) + err = h.service.Decide(c.Request.Context(), input.Code, input.Decision, c.GetString("userID"), reauthenticationToken) + if err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/devicelogin/models.go b/backend/internal/devicelogin/models.go new file mode 100644 index 00000000..71646538 --- /dev/null +++ b/backend/internal/devicelogin/models.go @@ -0,0 +1,20 @@ +package devicelogin + +import ( + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +) + +type RequestStatus string + +const ( + RequestStatusPending RequestStatus = "pending" + RequestStatusApproved RequestStatus = "approved" + RequestStatusDenied RequestStatus = "denied" +) + +type Request struct { + ID string + Code string + Status RequestStatus + ExpiresAt datatype.DateTime +} diff --git a/backend/internal/devicelogin/module.go b/backend/internal/devicelogin/module.go new file mode 100644 index 00000000..38ba9444 --- /dev/null +++ b/backend/internal/devicelogin/module.go @@ -0,0 +1,74 @@ +package devicelogin + +import ( + "context" + "fmt" + "time" + + "github.com/gin-gonic/gin" + "github.com/italypaleale/francis/host/local" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +type TokenService interface { + GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) +} + +type ReauthenticationTokenConsumer interface { + ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) +} + +type AuditLogger interface { + Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) + DeviceStringFromUserAgent(userAgent string) string +} + +type AppConfigProvider interface { + GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error) +} + +type Dependencies struct { + DB *gorm.DB + Actors *local.Host + BaseURL string + + Signer TokenService + Reauth ReauthenticationTokenConsumer + AuditLog AuditLogger + AppConfig AppConfigProvider +} + +type Module struct { + service *Service + handler *handler +} + +func New(deps Dependencies) (*Module, error) { + service := NewService(deps.Actors.Service(), deps.DB, deps.Signer, deps.Reauth, deps.AuditLog) + module := &Module{ + service: service, + handler: newHandler(service, deps.BaseURL, deps.AppConfig), + } + + // Register the durable request actor before the host starts + err := deps.Actors.RegisterActor( + requestActorType, + newRequestActor, + ) + if err != nil { + return nil, fmt.Errorf("failed to register device login actor: %w", err) + } + + return module, nil +} + +// RegisterRoutes mounts the public exchange and authenticated verification endpoints +func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, browserAuth, createRateLimit, exchangeRateLimit, verificationRateLimit gin.HandlerFunc) { + apiGroup.POST("/device-login/requests", createRateLimit, m.handler.createRequest) + apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, m.handler.exchangeRequest) + apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, m.handler.inspectRequest) + apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, m.handler.decideRequest) +} diff --git a/backend/internal/devicelogin/service.go b/backend/internal/devicelogin/service.go new file mode 100644 index 00000000..fbe16cce --- /dev/null +++ b/backend/internal/devicelogin/service.go @@ -0,0 +1,326 @@ +package devicelogin + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/italypaleale/francis/actor" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +const ( + RequestDuration = 15 * time.Minute + PollingInterval = 3 + longPollingDuration = 25 * time.Second + actorPollingInterval = 2 * time.Second + codePrefix = "P" + codeRandomLength = 7 + reauthenticationMaxAge = time.Minute + // authenticationMethodOneTimePassword identifies the login-code-equivalent AMR used on the waiting device + authenticationMethodOneTimePassword = "otp" +) + +type Service struct { + actService *actor.Service + db *gorm.DB + signer TokenService + reauth ReauthenticationTokenConsumer + auditLog AuditLogger +} + +type VerificationInfo struct { + UserCode string + Device string + IPAddress string + ExpiresAt datatype.DateTime +} + +func NewService(actService *actor.Service, db *gorm.DB, signer TokenService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger) *Service { + return &Service{ + actService: actService, + db: db, + signer: signer, + reauth: reauth, + auditLog: auditLog, + } +} + +func (s *Service) Create(ctx context.Context, ipAddress, userAgent string) (Request, string, error) { + // Bind the public request to a separate high-entropy secret that never enters the QR code + deviceToken, err := utils.GenerateRandomAlphanumericString(32) + if err != nil { + return Request{}, "", err + } + deviceTokenHash := utils.CreateSha256Hash(deviceToken) + + // Retry code generation because of the small but non-zero chance of a live actor collision + for range 3 { + code, codeErr := newUserCode() + if codeErr != nil { + return Request{}, "", codeErr + } + + result, err := s.invoke(ctx, code, requestActorMethodCreate, requestActorCreateInput{ + Code: code, + DeviceTokenHash: deviceTokenHash, + IPAddress: ipAddress, + UserAgent: userAgent, + }) + if err != nil { + return Request{}, "", err + } + if result.Code == requestActorResultCollision { + continue + } + err = actorResultError(result.Code) + if err != nil { + return Request{}, "", err + } + + return Request{ + ID: code, + Code: code, + Status: result.Status, + ExpiresAt: datatype.DateTime(result.ExpiresAt), + }, deviceToken, nil + } + + return Request{}, "", errors.New("failed to generate a unique device login code") +} + +func (s *Service) Inspect(ctx context.Context, code string) (VerificationInfo, error) { + actorID := normalizeUserCode(code) + + // Read the pending actor state without taking an exclusive actor turn + result, err := s.peek(ctx, actorID, requestActorMethodInspect, nil) + if err != nil { + return VerificationInfo{}, err + } + + err = actorResultError(result.Code) + if err != nil { + return VerificationInfo{}, err + } + + return VerificationInfo{ + UserCode: result.UserCode, + Device: s.auditLog.DeviceStringFromUserAgent(result.UserAgent), + IPAddress: result.IPAddress, + ExpiresAt: datatype.DateTime(result.ExpiresAt), + }, nil +} + +func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenticationToken string) error { + actorID := normalizeUserCode(code) + + // Consume the fresh passkey proof outside the actor before approving the request + if decision == "approve" { + if err := s.consumeReauthenticationProof(ctx, reauthenticationToken, userID); err != nil { + return err + } + } + + // Let the actor serialize the decision with every competing exchange + result, err := s.invoke(ctx, actorID, requestActorMethodDecide, requestActorDecisionInput{ + Decision: decision, + UserID: userID, + }) + if err != nil { + return err + } + return actorResultError(result.Code) +} + +func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string, sessionDuration time.Duration) (dto.UserDto, string, RequestStatus, error) { + if requestID == "" || deviceToken == "" || sessionDuration <= 0 { + return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + } + + deviceTokenHash := utils.CreateSha256Hash(deviceToken) + timeout := time.NewTimer(longPollingDuration) + defer timeout.Stop() + ticker := time.NewTicker(actorPollingInterval) + defer ticker.Stop() + + for { + // Poll the actor's activation cache so the long-lived HTTP request does not repeatedly query the database + result, err := s.peek(ctx, requestID, requestActorMethodPoll, requestActorPollInput{DeviceTokenHash: deviceTokenHash}) + if err != nil { + return dto.UserDto{}, "", "", err + } + + err = actorResultError(result.Code) + if err != nil { + return dto.UserDto{}, "", result.Status, err + } + + switch result.Status { + case RequestStatusApproved: + // Validate the approved user before consuming so lookup failures leave the request untouched + user, userDTO, err := s.loadExchangeUser(ctx, result.UserID) + if err != nil { + return dto.UserDto{}, "", result.Status, err + } + + // Consume inside the actor so only one concurrent exchange can mint a token + consume, err := s.invoke(ctx, requestID, requestActorMethodConsume, requestActorConsumeInput{ + DeviceTokenHash: deviceTokenHash, + }) + if err != nil { + return dto.UserDto{}, "", "", err + } + + err = actorResultError(consume.Code) + if err != nil { + return dto.UserDto{}, "", consume.Status, err + } + + // Mint the session with login-code semantics because the waiting device did not perform WebAuthn + accessToken, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword, sessionDuration) + if err != nil { + return dto.UserDto{}, "", consume.Status, err + } + + // Record the successful remote sign-in after the request has been consumed + _, created := s.auditLog.Create(ctx, model.AuditLogEventRemoteSignIn, ipAddress, userAgent, user.ID, model.AuditLogData{}, s.db) + if !created { + return dto.UserDto{}, "", consume.Status, errors.New("failed to create device login audit log") + } + + return userDTO, accessToken, consume.Status, nil + case RequestStatusPending: + // no-op + case RequestStatusDenied: + return dto.UserDto{}, "", result.Status, &common.DeviceLoginDeniedError{} + default: + return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + } + + select { + case <-ticker.C: + // no-op + case <-timeout.C: + return dto.UserDto{}, "", RequestStatusPending, nil + case <-ctx.Done(): + return dto.UserDto{}, "", "", ctx.Err() + } + } +} + +func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userID string) error { + if token == "" { + return &common.ReauthenticationRequiredError{} + } + + tx := s.db.WithContext(ctx).Begin() + if tx.Error != nil { + return tx.Error + } + defer tx.Rollback() + + reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, tx, token, userID) + if err != nil { + var reauthenticationRequiredError *common.ReauthenticationRequiredError + if errors.As(err, &reauthenticationRequiredError) { + return &common.ReauthenticationRequiredError{} + } + return err + } + if time.Since(reauthenticatedAt) > reauthenticationMaxAge { + return &common.ReauthenticationRequiredError{} + } + + if err = tx.Commit().Error; err != nil { + return fmt.Errorf("error committing database transaction: %w", err) + } + return nil +} + +func (s *Service) loadExchangeUser(ctx context.Context, userID string) (model.User, dto.UserDto, error) { + var user model.User + err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + return model.User{}, dto.UserDto{}, &common.DeviceLoginRequestInvalidOrExpiredError{} + case err != nil: + return model.User{}, dto.UserDto{}, err + case user.Disabled: + return model.User{}, dto.UserDto{}, &common.UserDisabledError{} + } + + var userDTO dto.UserDto + if err = dto.MapStruct(user, &userDTO); err != nil { + return model.User{}, dto.UserDto{}, fmt.Errorf("failed to map exchanged device login user: %w", err) + } + + return user, userDTO, nil +} + +func normalizeUserCode(code string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + return utils.NormalizeUnambiguousString(code) +} + +func (s *Service) invoke(ctx context.Context, actorID, method string, input any) (requestActorResult, error) { + envelope, err := s.actService.Invoke(ctx, requestActorType, actorID, method, input) + if err != nil { + return requestActorResult{}, err + } + + return decodeActorResult(envelope) +} + +func (s *Service) peek(ctx context.Context, actorID, method string, input any) (requestActorResult, error) { + envelope, err := s.actService.Peek(ctx, requestActorType, actorID, method, input) + if err != nil { + return requestActorResult{}, err + } + + return decodeActorResult(envelope) +} + +func decodeActorResult(envelope actor.Envelope) (requestActorResult, error) { + if envelope == nil { + return requestActorResult{}, errors.New("device login actor returned an empty response") + } + + var result requestActorResult + err := envelope.Decode(&result) + if err != nil { + return requestActorResult{}, fmt.Errorf("failed to decode device login actor response: %w", err) + } + return result, nil +} + +func actorResultError(code requestActorResultCode) error { + switch code { + case requestActorResultNone: + return nil + case requestActorResultCollision: + return errors.New("unexpected live device login actor collision") + case requestActorResultInvalid: + return &common.DeviceLoginRequestInvalidOrExpiredError{} + case requestActorResultDenied: + return &common.DeviceLoginDeniedError{} + default: + return fmt.Errorf("unsupported device login actor result %q", code) + } +} + +func newUserCode() (string, error) { + randomCode, err := utils.GenerateRandomUppercaseUnambiguousString(codeRandomLength) + if err != nil { + return "", err + } + + return codePrefix + randomCode, nil +} diff --git a/backend/internal/devicelogin/service_test.go b/backend/internal/devicelogin/service_test.go new file mode 100644 index 00000000..5ab349f1 --- /dev/null +++ b/backend/internal/devicelogin/service_test.go @@ -0,0 +1,584 @@ +//go:build exclude_frontend && unit + +package devicelogin + +import ( + "context" + "errors" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/italypaleale/francis/actor" + "github.com/italypaleale/francis/host/local" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/internal/utils" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +const ( + testSessionDuration = time.Hour +) + +type fakeReauthenticationTokenConsumer struct { + expectedValue string + createdAt time.Time +} + +func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) { + if token != f.expectedValue { + return time.Time{}, &common.ReauthenticationRequiredError{} + } + if !f.createdAt.IsZero() { + return f.createdAt, nil + } + return time.Now(), nil +} + +type fakeTokenService struct { + mu sync.Mutex + userID string + authenticationMethod string + sessionDuration time.Duration + generated int + err error +} + +func (f *fakeTokenService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.userID = user.ID + f.authenticationMethod = authenticationMethod + f.sessionDuration = sessionDuration + f.generated++ + if f.err != nil { + return "", f.err + } + return "device-login-access-token", nil +} + +func (f *fakeTokenService) generatedToken() (string, string, time.Duration, int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.userID, f.authenticationMethod, f.sessionDuration, f.generated +} + +type auditEntry struct { + event model.AuditLogEvent + ipAddress string + userAgent string + userID string +} + +type fakeAuditLogger struct { + mu sync.Mutex + entries []auditEntry +} + +func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) { + f.mu.Lock() + defer f.mu.Unlock() + f.entries = append(f.entries, auditEntry{event: event, ipAddress: ipAddress, userAgent: userAgent, userID: userID}) + return model.AuditLog{}, true +} + +func (f *fakeAuditLogger) DeviceStringFromUserAgent(userAgent string) string { + return "Parsed " + userAgent +} + +func (f *fakeAuditLogger) lastEntry() auditEntry { + f.mu.Lock() + defer f.mu.Unlock() + return f.entries[len(f.entries)-1] +} + +func (f *fakeAuditLogger) entryCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.entries) +} + +type serviceFixture struct { + service *Service + actors *actor.Service + signer *fakeTokenService + auditLog *fakeAuditLogger + reauth *fakeReauthenticationTokenConsumer +} + +func TestRequestLifecycle(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + user := model.User{ + Base: model.Base{ID: "device-login-user"}, + Username: "device-login-user", + } + require.NoError(t, db.Create(&user).Error) + + request, deviceToken, err := fixture.service.Create(t.Context(), "192.0.2.10", "Mozilla/5.0 Chrome/125.0.0.0") + require.NoError(t, err) + require.Regexp(t, `^P[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]{7}$`, request.Code) + require.Equal(t, request.Code, request.ID) + require.Equal(t, RequestStatusPending, request.Status) + + state := getRequestActorState(t, fixture.actors, request.ID) + require.Equal(t, utils.CreateSha256Hash(deviceToken), state.DeviceTokenHash) + require.NotEqual(t, deviceToken, state.DeviceTokenHash) + require.Equal(t, RequestStatusPending, state.Status) + + info, err := fixture.service.Inspect(t.Context(), strings.ToLower(request.Code)) + require.NoError(t, err) + require.Equal(t, request.Code, info.UserCode) + require.Equal(t, "192.0.2.10", info.IPAddress) + require.Equal(t, "Parsed Mozilla/5.0 Chrome/125.0.0.0", info.Device) + + err = fixture.service.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof") + require.NoError(t, err) + + exchangedUser, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent", testSessionDuration) + require.NoError(t, err) + require.Equal(t, RequestStatusApproved, status) + require.Equal(t, user.ID, exchangedUser.ID) + require.Equal(t, "device-login-access-token", accessToken) + + signedUserID, authenticationMethod, sessionDuration, generated := fixture.signer.generatedToken() + require.Equal(t, user.ID, signedUserID) + require.Equal(t, authenticationMethodOneTimePassword, authenticationMethod) + require.Equal(t, testSessionDuration, sessionDuration) + require.Equal(t, 1, generated) + requireRequestActorStateDeleted(t, fixture.actors, request.ID) + + entry := fixture.auditLog.lastEntry() + require.Equal(t, model.AuditLogEventRemoteSignIn, entry.event) + require.Equal(t, "198.51.100.20", entry.ipAddress) + require.Equal(t, "target-agent", entry.userAgent) + require.Equal(t, user.ID, entry.userID) + + _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + assertInvalidRequestError(t, err) + + _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", testSessionDuration) + assertInvalidRequestError(t, err) + _, _, _, generated = fixture.signer.generatedToken() + require.Equal(t, 1, generated) + require.Equal(t, 1, fixture.auditLog.entryCount()) +} + +func TestPendingAndDeniedRequests(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + + result, err := fixture.service.peek(t.Context(), request.ID, requestActorMethodPoll, requestActorPollInput{ + DeviceTokenHash: utils.CreateSha256Hash(deviceToken), + }) + require.NoError(t, err) + require.Equal(t, RequestStatusPending, result.Status) + + err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "") + require.NoError(t, err) + + user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + var deniedError *common.DeviceLoginDeniedError + require.ErrorAs(t, err, &deniedError) + require.Equal(t, RequestStatusDenied, status) + require.Empty(t, user.ID) + require.Empty(t, accessToken) +} + +func TestPendingExchangeObservesDecisionDuringLongPoll(t *testing.T) { + db := testutils.NewConcurrentDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + + type exchangeOutcome struct { + status RequestStatus + err error + } + result := make(chan exchangeOutcome, 1) + go func() { + _, _, status, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + result <- exchangeOutcome{status: status, err: exchangeErr} + }() + + require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")) + + select { + case outcome := <-result: + var deniedError *common.DeviceLoginDeniedError + require.ErrorAs(t, outcome.err, &deniedError) + require.Equal(t, RequestStatusDenied, outcome.status) + case <-time.After(2 * time.Second): + t.Fatal("exchange did not observe the actor decision") + } +} + +func TestRejectsInvalidAndExpiredRequestsWhileActorIsActive(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + + unknownRequestID := strings.Repeat("a", 64) + _, _, _, err = fixture.service.Exchange(t.Context(), unknownRequestID, "device-token", "", "", testSessionDuration) + assertInvalidRequestError(t, err) + _, err = fixture.service.Inspect(t.Context(), unknownRequestID) + assertInvalidRequestError(t, err) + err = fixture.service.Decide(t.Context(), unknownRequestID, "deny", "device-login-user", "") + assertInvalidRequestError(t, err) + + _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", testSessionDuration) + assertInvalidRequestError(t, err) + + state := getRequestActorState(t, fixture.actors, request.ID) + state.ExpiresAt = time.Now().Add(-time.Second) + require.NoError(t, fixture.actors.Halt(requestActorType, request.ID)) + require.NoError(t, fixture.actors.SetState(t.Context(), requestActorType, request.ID, state, nil)) + _, err = fixture.service.Inspect(t.Context(), request.Code) + assertInvalidRequestError(t, err) + err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "") + assertInvalidRequestError(t, err) + _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + assertInvalidRequestError(t, err) +} + +func TestRejectsDisabledUserAtExchange(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + user := model.User{ + Base: model.Base{ID: "disabled-device-login-user"}, + Username: "disabled-device-login-user", + Disabled: true, + } + require.NoError(t, db.Create(&user).Error) + + request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof")) + + _, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + var disabledError *common.UserDisabledError + require.ErrorAs(t, err, &disabledError) + require.Empty(t, accessToken) + require.Equal(t, RequestStatusApproved, getRequestActorState(t, fixture.actors, request.ID).Status) +} + +func TestFailedTokenGenerationConsumesApprovedRequest(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fixture := newServiceFixture(t, db) + fixture.signer.err = errors.New("token generation failed") + + user := model.User{ + Base: model.Base{ID: "token-failure-device-login-user"}, + Username: "token-failure-device-login-user", + } + require.NoError(t, db.Create(&user).Error) + + request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof")) + + _, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + require.EqualError(t, err, "token generation failed") + require.Empty(t, accessToken) + require.Equal(t, RequestStatusApproved, status) + requireRequestActorStateDeleted(t, fixture.actors, request.ID) + require.Equal(t, 0, fixture.auditLog.entryCount()) + + _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + assertInvalidRequestError(t, err) +} + +func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + request, _, err := fixture.service.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + + err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "") + var reauthenticationError *common.ReauthenticationRequiredError + require.ErrorAs(t, err, &reauthenticationError) + + fixture.reauth.expectedValue = "stale-proof" + fixture.reauth.createdAt = time.Now().Add(-2 * time.Minute) + err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof") + require.ErrorAs(t, err, &reauthenticationError) + require.Equal(t, RequestStatusPending, getRequestActorState(t, fixture.actors, request.ID).Status) +} + +func TestNormalizeUserCodeAliases(t *testing.T) { + require.Equal(t, "P100-110", normalizeUserCode(" piO0-i1o ")) +} + +func TestConcurrentExchangeAllowsOnlyOneSuccess(t *testing.T) { + db := testutils.NewConcurrentDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + user := model.User{ + Base: model.Base{ID: "single-use-device-login-user"}, + Username: "single-use-device-login-user", + } + require.NoError(t, db.Create(&user).Error) + + request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof")) + + var waitGroup sync.WaitGroup + type exchangeResult struct { + token string + err error + } + results := make(chan exchangeResult, 2) + for range 2 { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + _, token, _, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + results <- exchangeResult{token: token, err: exchangeErr} + }() + } + waitGroup.Wait() + close(results) + + var successfulTokens []string + var invalidExchanges int + for result := range results { + if result.err == nil { + successfulTokens = append(successfulTokens, result.token) + continue + } + var invalidRequestError *common.DeviceLoginRequestInvalidOrExpiredError + require.ErrorAs(t, result.err, &invalidRequestError) + invalidExchanges++ + } + require.Equal(t, []string{"device-login-access-token"}, successfulTokens) + require.Equal(t, 1, invalidExchanges) + require.Equal(t, 1, fixture.auditLog.entryCount()) + _, _, _, generated := fixture.signer.generatedToken() + require.Equal(t, 1, generated) + requireRequestActorStateDeleted(t, fixture.actors, request.ID) +} + +func TestCreateCollisionPreservesOriginalActorState(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + request, deviceToken, err := fixture.service.Create(t.Context(), "192.0.2.1", "original-agent") + require.NoError(t, err) + original := getRequestActorState(t, fixture.actors, request.ID) + + result, err := fixture.service.invoke(t.Context(), request.ID, requestActorMethodCreate, requestActorCreateInput{ + Code: request.Code, + DeviceTokenHash: utils.CreateSha256Hash("different-token"), + IPAddress: "198.51.100.1", + UserAgent: "replacement-agent", + }) + require.NoError(t, err) + require.Equal(t, requestActorResultCollision, result.Code) + require.Equal(t, original, getRequestActorState(t, fixture.actors, request.ID)) + require.NotEqual(t, deviceToken, original.DeviceTokenHash) +} + +func TestRequestStateSurvivesActorHostRestart(t *testing.T) { + db := testutils.NewConcurrentDatabaseForTest(t) + deps := persistentTestDependencies(db) + + firstModule, stopFirst := startPersistentDeviceLoginHost(t, db, deps) + request, deviceToken, err := firstModule.service.Create(t.Context(), "192.0.2.1", "persistent-agent") + require.NoError(t, err) + stopFirst() + + secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps) + defer stopSecond() + info, err := secondModule.service.Inspect(t.Context(), request.Code) + require.NoError(t, err) + require.Equal(t, "persistent-agent", strings.TrimPrefix(info.Device, "Parsed ")) + require.NoError(t, secondModule.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")) + _, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + var deniedError *common.DeviceLoginDeniedError + require.ErrorAs(t, err, &deniedError) + require.Equal(t, RequestStatusDenied, status) +} + +func TestCompletedExchangeIsInvalidAfterActorHostRestart(t *testing.T) { + db := testutils.NewConcurrentDatabaseForTest(t) + deps := persistentTestDependencies(db) + + user := model.User{ + Base: model.Base{ID: "completed-exchange-user"}, + Username: "completed-exchange-user", + } + require.NoError(t, db.Create(&user).Error) + + firstModule, stopFirst := startPersistentDeviceLoginHost(t, db, deps) + request, deviceToken, err := firstModule.service.Create(t.Context(), "", "persistent-agent") + require.NoError(t, err) + require.NoError(t, firstModule.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof")) + _, _, firstStatus, err := firstModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + require.NoError(t, err) + require.Equal(t, RequestStatusApproved, firstStatus) + stopFirst() + + secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps) + defer stopSecond() + _, _, _, err = secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) + assertInvalidRequestError(t, err) + + _, _, _, generated := deps.Signer.(*fakeTokenService).generatedToken() + require.Equal(t, 1, generated) + require.Equal(t, 1, deps.AuditLog.(*fakeAuditLogger).entryCount()) +} + +func TestPendingExchangeStopsWhenRequestIsCanceled(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fixture := newServiceFixture(t, db) + + request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + started := make(chan struct{}) + result := make(chan error, 1) + go func() { + close(started) + _, _, _, exchangeErr := fixture.service.Exchange(ctx, request.ID, deviceToken, "", "", testSessionDuration) + result <- exchangeErr + }() + + <-started + cancel() + + select { + case exchangeErr := <-result: + require.ErrorIs(t, exchangeErr, context.Canceled) + case <-time.After(time.Second): + t.Fatal("canceled exchange did not stop") + } +} + +func newServiceFixture(t *testing.T, db *gorm.DB) serviceFixture { + t.Helper() + signer := &fakeTokenService{} + auditLog := &fakeAuditLogger{} + reauth := &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"} + var module *Module + host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) { + var err error + module, err = New(Dependencies{ + DB: db, + Actors: host, + Signer: signer, + AuditLog: auditLog, + Reauth: reauth, + }) + require.NoError(t, err) + }) + + return serviceFixture{ + service: module.service, + actors: host.Service(), + signer: signer, + auditLog: auditLog, + reauth: reauth, + } +} + +func getRequestActorState(t *testing.T, actors *actor.Service, actorID string) requestActorState { + t.Helper() + var state requestActorState + require.NoError(t, actors.GetState(t.Context(), requestActorType, actorID, &state)) + return state +} + +func requireRequestActorStateDeleted(t *testing.T, actors *actor.Service, actorID string) { + t.Helper() + var state requestActorState + require.ErrorIs(t, actors.GetState(t.Context(), requestActorType, actorID, &state), actor.ErrStateNotFound) +} + +func assertInvalidRequestError(t *testing.T, err error) { + t.Helper() + var invalidError *common.DeviceLoginRequestInvalidOrExpiredError + require.ErrorAs(t, err, &invalidError) +} + +func persistentTestDependencies(db *gorm.DB) Dependencies { + return Dependencies{ + DB: db, + Signer: &fakeTokenService{}, + AuditLog: &fakeAuditLogger{}, + Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"}, + } +} + +func startPersistentDeviceLoginHost(t *testing.T, db *gorm.DB, deps Dependencies) (*Module, func()) { + t.Helper() + sqlDB, err := db.DB() + require.NoError(t, err) + _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") + require.NoError(t, err) + + host, err := local.NewHost( + local.WithAddress(freeLoopbackAddress(t)), + local.WithRuntimePSKs([]byte("pocket-id-device-login-test-host-psk")), + local.WithSQLiteProvider(local.SQLiteProviderOptions{DB: sqlDB}), + local.WithShutdownGracePeriod(time.Second), + ) + require.NoError(t, err) + + deps.Actors = host + module, err := New(deps) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + errCh <- host.Run(ctx) + }() + + select { + case <-host.Ready(): + case runErr := <-errCh: + t.Fatalf("persistent actor host stopped before becoming ready: %v", runErr) + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for persistent actor host") + } + + var stopOnce sync.Once + stop := func() { + stopOnce.Do(func() { + cancel() + runErr := <-errCh + if runErr != nil && !errors.Is(runErr, context.Canceled) { + require.NoError(t, runErr) + } + }) + } + t.Cleanup(stop) + return module, stop +} + +func freeLoopbackAddress(t *testing.T) string { + t.Helper() + var listenConfig net.ListenConfig + listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + address := listener.Addr().String() + require.NoError(t, listener.Close()) + return address +} diff --git a/backend/internal/middleware/rate_limit.go b/backend/internal/middleware/rate_limit.go index dbfa49ad..7530ff15 100644 --- a/backend/internal/middleware/rate_limit.go +++ b/backend/internal/middleware/rate_limit.go @@ -19,15 +19,18 @@ import ( // Rate-limit policy names // Each constant names a limiter registered on the actor host and is the value passed to Add to select that limiter const ( - RateLimitAPI = "api" - RateLimitSignup = "signup" - RateLimitWebauthnLogin = "webauthn-login" - RateLimitWebauthnReauthenticate = "webauthn-reauthenticate" - RateLimitOneTimeAccessToken = "one-time-access-token" - RateLimitOneTimeAccessEmail = "one-time-access-email" - RateLimitSendEmailVerification = "send-email-verification" - RateLimitVerifyEmail = "verify-email" - RateLimitInternal = "internal" + RateLimitAPI = "api" + RateLimitSignup = "signup" + RateLimitWebauthnLogin = "webauthn-login" + RateLimitWebauthnReauthenticate = "webauthn-reauthenticate" + RateLimitOneTimeAccessToken = "one-time-access-token" + RateLimitOneTimeAccessEmail = "one-time-access-email" + RateLimitDeviceLoginCreate = "device-login-create" + RateLimitDeviceLoginExchange = "device-login-exchange" + RateLimitDeviceLoginVerification = "device-login-verification" + RateLimitSendEmailVerification = "send-email-verification" + RateLimitVerifyEmail = "verify-email" + RateLimitInternal = "internal" ) // RateLimitPolicy is the configuration for a single rate-limit actor @@ -53,6 +56,9 @@ func RateLimitPolicies() []RateLimitPolicy { {Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5}, {Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5}, {Name: RateLimitOneTimeAccessEmail, Rate: 2, Per: 10 * time.Minute, Burst: 5}, + {Name: RateLimitDeviceLoginCreate, Rate: 1, Per: 10 * time.Second, Burst: 5}, + {Name: RateLimitDeviceLoginExchange, Rate: 1, Per: 2 * time.Second, Burst: 10}, + {Name: RateLimitDeviceLoginVerification, Rate: 1, Per: 10 * time.Second, Burst: 5}, {Name: RateLimitSendEmailVerification, Rate: 2, Per: 10 * time.Minute, Burst: 1}, {Name: RateLimitVerifyEmail, Rate: 1, Per: 10 * time.Second, Burst: 5}, {Name: RateLimitInternal, Rate: 20, Per: time.Second, Burst: 20}, diff --git a/backend/internal/model/audit_log.go b/backend/internal/model/audit_log.go index c7b0505b..4b9e5371 100644 --- a/backend/internal/model/audit_log.go +++ b/backend/internal/model/audit_log.go @@ -29,6 +29,7 @@ type AuditLogEvent string //nolint:recvcheck const ( AuditLogEventSignIn AuditLogEvent = "SIGN_IN" AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN" + AuditLogEventRemoteSignIn AuditLogEvent = "REMOTE_SIGN_IN" AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED" AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION" AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION" diff --git a/backend/internal/oidc/device_service.go b/backend/internal/oidc/device_service.go index ed702d2c..b530e17d 100644 --- a/backend/internal/oidc/device_service.go +++ b/backend/internal/oidc/device_service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "time" "github.com/ory/fosite" @@ -11,6 +12,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/internal/utils" "gorm.io/gorm" ) @@ -219,6 +221,9 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID } func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) { + userCode = strings.ToUpper(strings.TrimSpace(userCode)) + userCode = utils.NormalizeUnambiguousString(userCode) + userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode) if err != nil { return nil, "", err diff --git a/backend/internal/oidc/device_service_test.go b/backend/internal/oidc/device_service_test.go index f7c45479..d329d5a5 100644 --- a/backend/internal/oidc/device_service_test.go +++ b/backend/internal/oidc/device_service_test.go @@ -61,6 +61,14 @@ func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t require.Equal(t, 1, reauth.calls) } +func TestDeviceServiceCreatesUserCodeWithOAuthPrefix(t *testing.T) { + service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, "test-client", "test-user", false, nil) + + require.Regexp(t, `^E[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]{7}$`, userCode) + _, _, err := service.deviceRequestFromUserCode(t.Context(), strings.ToLower(userCode)) + require.NoError(t, err) +} + func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.T) { const ( userID = "test-user" diff --git a/backend/internal/oidc/device_strategy.go b/backend/internal/oidc/device_strategy.go new file mode 100644 index 00000000..be00463e --- /dev/null +++ b/backend/internal/oidc/device_strategy.go @@ -0,0 +1,32 @@ +package oidc + +import ( + "context" + + "github.com/ory/fosite/handler/rfc8628" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +const ( + oauthDeviceUserCodePrefix = "E" + oauthDeviceUserCodeRandomLength = 7 +) + +type deviceStrategy struct { + *rfc8628.DefaultDeviceStrategy +} + +func (s *deviceStrategy) GenerateUserCode(ctx context.Context) (string, string, error) { + userCode, err := utils.GenerateRandomUppercaseUnambiguousString(oauthDeviceUserCodeRandomLength) + if err != nil { + return "", "", err + } + + userCode = oauthDeviceUserCodePrefix + userCode + signature, err := s.UserCodeSignature(ctx, userCode) + if err != nil { + return "", "", err + } + + return userCode, signature, nil +} diff --git a/backend/internal/oidc/provider.go b/backend/internal/oidc/provider.go index 0604700d..671c1917 100644 --- a/backend/internal/oidc/provider.go +++ b/backend/internal/oidc/provider.go @@ -12,7 +12,6 @@ import ( "github.com/ory/fosite/compose" fositeoauth2 "github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/openid" - "github.com/ory/fosite/handler/rfc8628" "github.com/ory/fosite/token/jwt" "github.com/pocket-id/pocket-id/backend/internal/utils" "golang.org/x/crypto/hkdf" @@ -20,7 +19,7 @@ import ( type oidcProvider struct { fosite.OAuth2Provider - deviceStrategy *rfc8628.DefaultDeviceStrategy + deviceStrategy *deviceStrategy tokenStrategies } @@ -64,7 +63,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign } sig := newJWTSigner(keyGetter) coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig) - deviceStrategy := compose.NewDeviceStrategy(fositeConfig) + deviceStrategy := &deviceStrategy{DefaultDeviceStrategy: compose.NewDeviceStrategy(fositeConfig)} accessTokenStrategy := &fositeoauth2.DefaultJWTStrategy{ Signer: sig, HMACSHAStrategy: coreStrategy, diff --git a/backend/internal/onetimeaccess/service.go b/backend/internal/onetimeaccess/service.go index 86273cf9..8ac91060 100644 --- a/backend/internal/onetimeaccess/service.go +++ b/backend/internal/onetimeaccess/service.go @@ -148,6 +148,8 @@ func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Durat } func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) { + token = utils.NormalizeUnambiguousString(token) + // Consume the token by invoking its actor: this atomically validates it and, if valid, deletes it. // It must happen outside of a DB transaction, since invoking an actor while a transaction is open would deadlock on SQLite. res, err := s.actorService.Invoke(ctx, TokenActorType, token, tokenMethodConsume, tokenConsumeRequest{ diff --git a/backend/internal/onetimeaccess/service_test.go b/backend/internal/onetimeaccess/service_test.go index 6332c426..2ef6c7dd 100644 --- a/backend/internal/onetimeaccess/service_test.go +++ b/backend/internal/onetimeaccess/service_test.go @@ -99,6 +99,28 @@ func TestExchangeTokenSuccess(t *testing.T) { require.Equal(t, []model.AuditLogEvent{model.AuditLogEventOneTimeAccessTokenSignIn}, auditLog.events) } +func TestExchangeTokenAcceptsAmbiguousAliases(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + svc, host, _ := newServiceForTest(t, db) + + user := model.User{ + Base: model.Base{ID: "alias-user"}, + Username: "alias-user", + } + require.NoError(t, db.Create(&user).Error) + + const token = "a10bc2" + require.NoError(t, host.SetState(t.Context(), TokenActorType, token, TokenState{ + UserID: user.ID, + ExpiresAt: time.Now().Add(time.Minute), + }, &actor.SetStateOpts{TTL: time.Minute})) + + dbConfig := appconfig.NewTestConfig(nil) + exchangedUser, _, err := svc.ExchangeToken(t.Context(), dbConfig, "aIObc2", "", "", "") + require.NoError(t, err) + require.Equal(t, user.ID, exchangedUser.ID) +} + func TestExchangeTokenInvalidToken(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc, _, _ := newServiceForTest(t, db) diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 59dd22c3..7f0d6c7c 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -612,8 +612,8 @@ func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error { token string ttl time.Duration }{ - {token: "HPe6k6uiDRRVuAQV", ttl: time.Hour}, - {token: "one-time-token", ttl: time.Hour}, + {token: "HPe6k6u1DRRVuAQV", ttl: time.Hour}, + {token: "0ne-t1me-t0ken", ttl: time.Hour}, } for _, t := range tokens { diff --git a/backend/internal/utils/cookie/add_cookie.go b/backend/internal/utils/cookie/add_cookie.go index c2b8ab74..e865d15f 100644 --- a/backend/internal/utils/cookie/add_cookie.go +++ b/backend/internal/utils/cookie/add_cookie.go @@ -18,6 +18,11 @@ func AddDeviceTokenCookie(c *gin.Context, deviceToken string) { c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true) } +func AddDeviceLoginTokenCookie(c *gin.Context, requestID, deviceToken string) { + path := "/api/device-login/requests/" + requestID + "/exchange" + c.SetCookie(DeviceLoginTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), path, "", true, true) +} + func AddReauthenticationTokenCookie(c *gin.Context, reauthenticationToken string) { c.SetCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/", "", true, true) } diff --git a/backend/internal/utils/cookie/cookie_names.go b/backend/internal/utils/cookie/cookie_names.go index 9465701a..738dc4d8 100644 --- a/backend/internal/utils/cookie/cookie_names.go +++ b/backend/internal/utils/cookie/cookie_names.go @@ -9,6 +9,7 @@ import ( var AccessTokenCookieName = "__Host-access_token" var SessionIdCookieName = "__Host-session" var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec +var DeviceLoginTokenCookieName = "__Secure-device_login_token" //nolint:gosec var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" //nolint:gosec func init() { @@ -16,6 +17,7 @@ func init() { AccessTokenCookieName = "access_token" SessionIdCookieName = "session" DeviceTokenCookieName = "device_token" + DeviceLoginTokenCookieName = "device_login_token" ReauthenticationTokenCookieName = "reauthentication_token" } } diff --git a/backend/internal/utils/hash_util.go b/backend/internal/utils/hash_util.go index d80c41f1..1b94c227 100644 --- a/backend/internal/utils/hash_util.go +++ b/backend/internal/utils/hash_util.go @@ -2,10 +2,17 @@ package utils import ( "crypto/sha256" + "crypto/subtle" "encoding/hex" ) +// CreateSha256Hash creates the SHA256 hash of a string, returning a hex-encoded string. func CreateSha256Hash(input string) string { hash := sha256.Sum256([]byte(input)) return hex.EncodeToString(hash[:]) } + +// ConstantTimeStringEqual compares two strings in constant time to prevent timing attacks. +func ConstantTimeStringEqual(left, right string) bool { + return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 +} diff --git a/backend/internal/utils/string_util.go b/backend/internal/utils/string_util.go index 045ade98..1a5559c9 100644 --- a/backend/internal/utils/string_util.go +++ b/backend/internal/utils/string_util.go @@ -19,10 +19,21 @@ func GenerateRandomAlphanumericString(length int) (string, error) { // GenerateRandomUnambiguousString generates a random string of the given length using unambiguous characters func GenerateRandomUnambiguousString(length int) (string, error) { - const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" + const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ0123456789" return GenerateRandomString(length, charset) } +// GenerateRandomUppercaseUnambiguousString generates a random uppercase string of the given length using unambiguous characters +func GenerateRandomUppercaseUnambiguousString(length int) (string, error) { + const charset = "ABCDEFGHJKMNPQRSTUVWXYZ0123456789" + return GenerateRandomString(length, charset) +} + +// NormalizeUnambiguousString converts commonly confused letters to the canonical digits used by generated codes +func NormalizeUnambiguousString(value string) string { + return strings.NewReplacer("I", "1", "i", "1", "O", "0", "o", "0").Replace(value) +} + // GenerateRandomString generates a random string of the given length using the provided character set func GenerateRandomString(length int, charset string) (string, error) { @@ -30,6 +41,10 @@ func GenerateRandomString(length int, charset string) (string, error) { return "", errors.New("length must be a positive integer") } + if len(charset) == 0 { + return "", errors.New("character set must not be empty") + } + // The algorithm below is adapted from https://stackoverflow.com/a/35615565 const ( letterIdxBits = 6 // 6 bits to represent a letter index diff --git a/backend/internal/utils/string_util_test.go b/backend/internal/utils/string_util_test.go index f15baa36..e8742992 100644 --- a/backend/internal/utils/string_util_test.go +++ b/backend/internal/utils/string_util_test.go @@ -62,7 +62,7 @@ func TestGenerateRandomUnambiguousString(t *testing.T) { t.Errorf("Expected length %d, got %d", length, len(str)) } - matched, err := regexp.MatchString(`^[abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789]+$`, str) + matched, err := regexp.MatchString(`^[abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ0123456789]+$`, str) if err != nil { t.Errorf("Regex match failed: %v", err) } @@ -86,6 +86,26 @@ func TestGenerateRandomUnambiguousString(t *testing.T) { }) } +func TestGenerateRandomUppercaseUnambiguousString(t *testing.T) { + const length = 10 + str, err := GenerateRandomUppercaseUnambiguousString(length) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(str) != length { + t.Errorf("Expected length %d, got %d", length, len(str)) + } + if !regexp.MustCompile(`^[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]+`).MatchString(str) { + t.Errorf("String contains lowercase or ambiguous characters: %s", str) + } +} + +func TestNormalizeUnambiguousString(t *testing.T) { + if normalized := NormalizeUnambiguousString("iIoO-abc"); normalized != "1100-abc" { + t.Errorf("Expected ambiguous letters to be converted, got %s", normalized) + } +} + func TestGenerateRandomUnambiguousStringCharacterIndependence(t *testing.T) { const ( sampleCount = 100_000 @@ -112,7 +132,6 @@ func TestGenerateRandomUnambiguousStringCharacterIndependence(t *testing.T) { t.Errorf("first and last character collision rate = %.4f, want at most %.4f", collisionRate, maxAcceptableCollisionRate) } } - func TestGenerateRandomString(t *testing.T) { t.Run("valid length returns characters from charset", func(t *testing.T) { const length = 20 @@ -146,6 +165,14 @@ func TestGenerateRandomString(t *testing.T) { t.Error("Expected error for negative length, got nil") } }) + + t.Run("empty charset returns error", func(t *testing.T) { + _, err := GenerateRandomString(10, "") + if err == nil { + t.Error("Expected error for empty charset, got nil") + } + }) + } func TestCapitalizeFirstLetter(t *testing.T) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 92c7515e..9fa74252 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -564,5 +564,20 @@ "select_the_permissions_this_client_may_request": "Select the permissions this client may request on behalf of the signed-in user (user-delegated access) and for itself without a user via the client credentials grant (client access).", "i_have_a_longer_code": "I have a longer code", "pkce_supported_client_title": "This client supports PKCE", - "pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it." + "pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it.", + "sign_in_with_another_device": "Sign in with another device", + "sign_in_with_another_device_description": "Scan a QR code with a device that has your passkey.", + "creating_device_login_request": "Creating sign-in request...", + "device_login_qr_code": "Device login QR code", + "waiting_for_approval": "Waiting for approval", + "remote_sign_in": "Remote sign in", + "the_requesting_device_has_been_signed_in": "The requesting device has been signed in.", + "the_sign_in_request_was_denied": "The sign-in request was denied.", + "review_the_request_before_approving_it": "Review the requesting device before approving this sign in.", + "sign_in_request": "Sign-in request", + "only_approve_if_you_started_this_sign_in": "Only approve if you started this sign in on the requesting device.", + "deny": "Deny", + "approve": "Approve", + "or": "or", + "visit_and_enter": "Visit {url} and enter:" } diff --git a/frontend/src/lib/components/login-wrapper.svelte b/frontend/src/lib/components/login-wrapper.svelte index 41ddc39e..75a00863 100644 --- a/frontend/src/lib/components/login-wrapper.svelte +++ b/frontend/src/lib/components/login-wrapper.svelte @@ -38,24 +38,13 @@ }); const isDesktop = new MediaQuery('(min-width: 1024px)'); - let alternativeSignInButton = $state({ - href: '/login/alternative', + let alternativeSignInButton = $derived({ + href: + page.url.pathname === '/login' + ? `/login/alternative${page.url.search}` + : `/login/alternative?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`, label: m.alternative_sign_in_methods() }); - - appConfigStore.subscribe((config) => { - if (config.emailOneTimeAccessAsUnauthenticatedEnabled) { - alternativeSignInButton.href = '/login/alternative'; - alternativeSignInButton.label = m.alternative_sign_in_methods(); - } else { - alternativeSignInButton.href = '/login/alternative/code'; - alternativeSignInButton.label = m.sign_in_with_login_code(); - } - - if (page.url.pathname != '/login') { - alternativeSignInButton.href = `${alternativeSignInButton.href}?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`; - } - }); {#if backgroundImageExists === undefined} diff --git a/frontend/src/lib/components/qrcode/qrcode.svelte b/frontend/src/lib/components/qrcode/qrcode.svelte index 868525ad..2a08ab59 100644 --- a/frontend/src/lib/components/qrcode/qrcode.svelte +++ b/frontend/src/lib/components/qrcode/qrcode.svelte @@ -32,9 +32,14 @@ } }; - QRCode.toCanvas(canvasEl, value, options).catch((error: Error) => { - console.error('Error generating QR Code:', error); - }); + QRCode.toCanvas(canvasEl, value, options) + .then(() => { + canvasEl?.style.removeProperty('height'); + canvasEl?.style.removeProperty('width'); + }) + .catch((error: Error) => { + console.error('Error generating QR Code:', error); + }); } }); diff --git a/frontend/src/lib/services/device-login-service.ts b/frontend/src/lib/services/device-login-service.ts new file mode 100644 index 00000000..8ce73573 --- /dev/null +++ b/frontend/src/lib/services/device-login-service.ts @@ -0,0 +1,35 @@ +import type { + DeviceLoginDecision, + DeviceLoginExchangeResult, + DeviceLoginRequest, + DeviceLoginVerificationInfo +} from '$lib/types/device-login.type'; +import APIService from './api-service'; + +export default class DeviceLoginService extends APIService { + createRequest = async (signal?: AbortSignal) => { + const response = await this.api.post('/device-login/requests', undefined, { signal }); + return response.data as DeviceLoginRequest; + }; + + exchangeRequest = async ( + requestId: string, + signal?: AbortSignal + ): Promise => { + const response = await this.api.post( + `/device-login/requests/${requestId}/exchange`, + undefined, + { signal } + ); + return response.status === 202 ? null : response.data; + }; + + inspectRequest = async (code: string) => { + const response = await this.api.post('/device-login/verification', { code }); + return response.data as DeviceLoginVerificationInfo; + }; + + decideRequest = async (code: string, decision: DeviceLoginDecision) => { + await this.api.post('/device-login/verification/decision', { code, decision }); + }; +} diff --git a/frontend/src/lib/types/device-login.type.ts b/frontend/src/lib/types/device-login.type.ts new file mode 100644 index 00000000..8a33f1c4 --- /dev/null +++ b/frontend/src/lib/types/device-login.type.ts @@ -0,0 +1,21 @@ +import type { User } from './user.type'; + +export type DeviceLoginRequest = { + id: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresAt: string; + interval: number; +}; + +export type DeviceLoginVerificationInfo = { + userCode: string; + device: string; + ipAddress?: string; + expiresAt: string; +}; + +export type DeviceLoginDecision = 'approve' | 'deny'; + +export type DeviceLoginExchangeResult = User | null; diff --git a/frontend/src/lib/utils/audit-log-translator.ts b/frontend/src/lib/utils/audit-log-translator.ts index 754d192e..ed62f255 100644 --- a/frontend/src/lib/utils/audit-log-translator.ts +++ b/frontend/src/lib/utils/audit-log-translator.ts @@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages'; export const eventTypes: Record = { SIGN_IN: m.sign_in(), TOKEN_SIGN_IN: m.token_sign_in(), + REMOTE_SIGN_IN: m.remote_sign_in(), CLIENT_AUTHORIZATION: m.client_authorization(), NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(), ACCOUNT_CREATED: m.account_created(), diff --git a/frontend/src/routes/device/+page.svelte b/frontend/src/routes/device/+page.svelte index 0cdbba06..e24f40d4 100644 --- a/frontend/src/routes/device/+page.svelte +++ b/frontend/src/routes/device/+page.svelte @@ -4,12 +4,15 @@ import ScopeList from '$lib/components/scope-list.svelte'; import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; - import { Input } from '$lib/components/ui/input'; + import * as InputOTP from '$lib/components/ui/input-otp'; + import { Spinner } from '$lib/components/ui/spinner'; import { m } from '$lib/paraglide/messages'; + import DeviceLoginService from '$lib/services/device-login-service'; import OIDCService from '$lib/services/oidc-service'; import WebAuthnService from '$lib/services/webauthn-service'; import appConfigStore from '$lib/stores/application-configuration-store'; import userStore from '$lib/stores/user-store'; + import type { DeviceLoginVerificationInfo } from '$lib/types/device-login.type'; import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type'; import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { preventDefault } from '$lib/utils/event-util'; @@ -21,17 +24,26 @@ let { data } = $props(); + const deviceLoginService = new DeviceLoginService(); const oidcService = new OIDCService(); const webauthnService = new WebAuthnService(); let userCode = $state(data.code || ''); let isLoading = $state(false); let deviceInfo: OidcDeviceCodeInfo | undefined = $state(); + let deviceLoginInfo: DeviceLoginVerificationInfo | undefined = $state(); let success = $state(false); + let deviceLoginOutcome: 'approved' | 'denied' | undefined = $state(); + let deviceLoginDecision: 'approve' | 'deny' | undefined = $state(); let errorMessage: string | null = $state(null); let authorizationRequired = $state(false); let reauthenticationRequired = $state(false); let reauthenticated = $state(false); + let normalizedUserCode = $derived( + userCode.trim().toUpperCase().replaceAll('I', '1').replaceAll('O', '0') + ); + let codeComplete = $derived(normalizedUserCode.length === 8); + let completed = $derived(success || deviceLoginOutcome !== undefined); onMount(() => { if (data.code && $userStore) { @@ -40,28 +52,29 @@ }); async function authorize() { + if (!data.code && !codeComplete) return; + isLoading = true; + errorMessage = null; try { - // Get access token if not signed in - if (!$userStore) { - const loginOptions = await webauthnService.getLoginOptions(); - const authResponse = await startAuthentication({ optionsJSON: loginOptions }); - const user = await webauthnService.finishLogin(authResponse); - await userStore.setUser(user); + await authenticateUserIfNeeded(); + + let isDeviceLoginCode = normalizedUserCode.startsWith('P'); + if (isDeviceLoginCode) { + deviceLoginInfo = await deviceLoginService.inspectRequest(normalizedUserCode); + return; } - const info = await oidcService.getDeviceCodeInfo(userCode); + const info = await oidcService.getDeviceCodeInfo(normalizedUserCode); deviceInfo = info; if (info.authorizationRequired && !authorizationRequired) { authorizationRequired = true; - isLoading = false; return; } if (info.reauthenticationRequired && !reauthenticationRequired && !authorizationRequired) { reauthenticationRequired = true; - isLoading = false; return; } @@ -70,16 +83,42 @@ reauthenticated = true; } - await oidcService.verifyDeviceCode(userCode); - + await oidcService.verifyDeviceCode(normalizedUserCode); success = true; - } catch (e) { - errorMessage = getWebauthnErrorMessage(e); + } catch (error) { + errorMessage = getWebauthnErrorMessage(error); } finally { isLoading = false; } } + async function authenticateUserIfNeeded() { + if ($userStore) return; + + const loginOptions = await webauthnService.getLoginOptions(); + const authResponse = await startAuthentication({ optionsJSON: loginOptions }); + const user = await webauthnService.finishLogin(authResponse); + await userStore.setUser(user); + } + + async function decideDeviceLogin(decision: 'approve' | 'deny') { + isLoading = true; + deviceLoginDecision = decision; + errorMessage = null; + try { + if (decision === 'approve') { + await reauthenticate(); + } + await deviceLoginService.decideRequest(normalizedUserCode, decision); + deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied'; + } catch (error) { + errorMessage = getWebauthnErrorMessage(error); + } finally { + isLoading = false; + deviceLoginDecision = undefined; + } + } + async function reauthenticate() { try { await webauthnService.reauthenticate(); @@ -89,6 +128,16 @@ await webauthnService.reauthenticate(authResponse); } } + + function retry() { + errorMessage = null; + userCode = ''; + if (!deviceLoginInfo) { + deviceInfo = undefined; + authorizationRequired = false; + reauthenticationRequired = false; + } + } @@ -100,16 +149,52 @@ {#if deviceInfo?.client} {:else} - + {/if} -

{m.authorize_device()}

+

+ {m.authorize_device()} +

{#if errorMessage}

{errorMessage}. {m.please_try_again()}

+ {:else if deviceLoginOutcome === 'approved'} +

{m.the_requesting_device_has_been_signed_in()}

+ {:else if deviceLoginOutcome === 'denied'} +

{m.the_sign_in_request_was_denied()}

{:else if success}

{m.the_device_has_been_authorized()}

+ {:else if deviceLoginInfo} +

{m.review_the_request_before_approving_it()}

+
+ + +
+
+
{m.code()}
+
+ {deviceLoginInfo.userCode.substring(0, 4)} - {deviceLoginInfo.userCode.substring( + 4, + 8 + )} +
+
+
+
{m.device()}
+
{deviceLoginInfo.device}
+
+
+
{m.ip_address()}
+
{deviceLoginInfo.ipAddress || m.unknown()}
+
+
+
+
+
{:else if reauthenticationRequired && deviceInfo?.client}

{:else if authorizationRequired} -
+
-

+ -

+
@@ -138,19 +223,63 @@
{:else}

{m.enter_code_displayed_in_previous_step()}

-
- + + (userCode = value.toUpperCase())} + pasteTransformer={(value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()} + > + {#snippet children({ cells })} + + {#each cells.slice(0, 4) as cell} + + {/each} + + + + {#each cells.slice(4) as cell} + + {/each} + + {/snippet} +
{/if} - {#if !success} -
- - {#if !errorMessage} - + {#if errorMessage} + + + {:else if deviceLoginInfo} + + {:else} - + + {/if}
{/if} diff --git a/frontend/src/routes/login/alternative/+page.svelte b/frontend/src/routes/login/alternative/+page.svelte index def9ab89..d6858d6c 100644 --- a/frontend/src/routes/login/alternative/+page.svelte +++ b/frontend/src/routes/login/alternative/+page.svelte @@ -5,7 +5,12 @@ import * as Item from '$lib/components/ui/item/index.js'; import { m } from '$lib/paraglide/messages'; import appConfigStore from '$lib/stores/application-configuration-store'; - import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from '@lucide/svelte'; + import { + LucideChevronRight, + LucideMail, + LucideQrCode, + LucideRectangleEllipsis + } from '@lucide/svelte'; const methods = [ { @@ -13,6 +18,12 @@ title: m.login_code(), description: m.enter_a_login_code_to_sign_in(), href: '/login/alternative/code' + }, + { + icon: LucideQrCode, + title: m.sign_in_with_another_device(), + description: m.sign_in_with_another_device_description(), + href: '/login/alternative/device' } ]; diff --git a/frontend/src/routes/login/alternative/device/+page.svelte b/frontend/src/routes/login/alternative/device/+page.svelte new file mode 100644 index 00000000..535f56bd --- /dev/null +++ b/frontend/src/routes/login/alternative/device/+page.svelte @@ -0,0 +1,156 @@ + + + + {m.sign_in_with_another_device()} + + + +
+ +
+

+ {m.sign_in_with_another_device()} +

+

+ {errorMessage ? errorMessage : m.sign_in_with_another_device_description()} +

+ + {#if isStarting} +
+ + {m.creating_device_login_request()} +
+ {:else if request && !errorMessage} + + + +
+ + {m.or()} + +
+
+

+ {m.visit_and_enter({ url: request.verificationUri })} +

+ +

+ {request.userCode.substring(0, 4)} + - + {request.userCode.substring(4, 8)} +

+
+
+
+
+ {/if} +
+ + +
+
diff --git a/frontend/src/routes/login/alternative/device/+page.ts b/frontend/src/routes/login/alternative/device/+page.ts new file mode 100644 index 00000000..8ec2e0c0 --- /dev/null +++ b/frontend/src/routes/login/alternative/device/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ url }) => { + return { + redirect: url.searchParams.get('redirect') || '/settings' + }; +}; diff --git a/tests/data.ts b/tests/data.ts index 1371f438..f6e256ae 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -121,8 +121,8 @@ export const userGroups = { }; export const oneTimeAccessTokens = [ - { token: 'HPe6k6uiDRRVuAQV', expired: false }, - { token: 'YCGDtftvsvYWiXd0', expired: true } + { token: 'HPe6k6u1DRRVuAQV', expired: false }, + { token: 'YCGDtftvsvYW1Xd0', expired: true } ]; export const emailVerificationTokens = [ diff --git a/tests/specs/device-login.spec.ts b/tests/specs/device-login.spec.ts new file mode 100644 index 00000000..5a9a8c20 --- /dev/null +++ b/tests/specs/device-login.spec.ts @@ -0,0 +1,116 @@ +import { expect, test, type Browser } from '@playwright/test'; +import { cleanupBackend } from '../utils/cleanup.util'; +import passkeyUtil from '../utils/passkey.util'; + +test.beforeEach(async () => { + await cleanupBackend(); +}); + +test('approves the QR link after requester review and fresh reauthentication', async ({ + browser, + page +}) => { + const waiting = await openWaitingDevice(browser, '/settings/apps'); + const expectedLoginCodeText = `${waiting.request.userCode.substring(0, 4)} - ${waiting.request.userCode.substring(4, 8)}`; + try { + await (await passkeyUtil.init(page)).addPasskey(); + await expect(waiting.page.getByTestId('device-login-code')).toHaveText(expectedLoginCodeText); + await expect(waiting.page.getByLabel('Device login QR code')).toBeVisible(); + + await page.goto(waiting.request.verificationUriComplete); + + await expect(page.getByText(expectedLoginCodeText)).toBeVisible(); + await expect(page.getByText('Chrome', { exact: false })).toBeVisible(); + const ipAddress = page.locator('dt', { hasText: 'IP Address' }).locator('..').locator('dd'); + await expect(ipAddress).not.toHaveText('Unknown'); + + const decisionWithoutReauthentication = await page.request.post( + '/api/device-login/verification/decision', + { + data: { code: waiting.request.userCode, decision: 'approve' } + } + ); + expect(decisionWithoutReauthentication.status()).toBe(401); + + const reauthenticationRequest = page.waitForRequest('/api/webauthn/reauthenticate'); + await page.getByRole('button', { name: 'Approve' }).click(); + await reauthenticationRequest; + await expect(page.getByText('The requesting device has been signed in.')).toBeVisible(); + await waiting.page.waitForURL('/settings/apps'); + } finally { + await waiting.context.close(); + } +}); + +test('authenticates a signed-out primary device before manual-code approval', async ({ + browser +}) => { + const waiting = await openWaitingDevice(browser, '/settings/account'); + const primaryContext = await browser.newContext({ + baseURL: test.info().project.use.baseURL, + storageState: { cookies: [], origins: [] } + }); + const primaryPage = await primaryContext.newPage(); + + try { + await (await passkeyUtil.init(primaryPage)).addPasskey(); + await primaryPage.goto('/device'); + await primaryPage.getByRole('textbox', { name: 'Code' }).fill(waiting.request.userCode); + await primaryPage.getByRole('button', { name: 'Authorize' }).click(); + + await primaryPage.getByRole('button', { name: 'Approve' }).click(); + await expect(primaryPage.getByText('The requesting device has been signed in.')).toBeVisible(); + await waiting.page.waitForURL('/settings/account'); + } finally { + await primaryContext.close(); + await waiting.context.close(); + } +}); + +test('denies a pending request without passkey reauthentication', async ({ browser, page }) => { + const waiting = await openWaitingDevice(browser); + + try { + let reauthenticationCalled = false; + await page.route('/api/webauthn/reauthenticate', async (route) => { + reauthenticationCalled = true; + await route.continue(); + }); + + await page.goto(waiting.request.verificationUriComplete); + await page.getByRole('button', { name: 'Deny' }).click(); + + await expect(page.getByText('The sign-in request was denied.')).toBeVisible(); + await expect(waiting.page.getByText('Device login request was denied')).toBeVisible(); + expect(reauthenticationCalled).toBe(false); + } finally { + await waiting.context.close(); + } +}); + +async function openWaitingDevice(browser: Browser, redirect = '/settings') { + const context = await browser.newContext({ + baseURL: test.info().project.use.baseURL, + storageState: { cookies: [], origins: [] } + }); + const page = await context.newPage(); + const createResponse = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().endsWith('/api/device-login/requests') + ); + + await page.goto(`/login/alternative?redirect=${encodeURIComponent(redirect)}`); + await expect(page.getByText('Sign in with another device', { exact: true })).toBeVisible(); + await page.getByText('Sign in with another device', { exact: true }).click(); + + const response = await createResponse; + expect(response.status()).toBe(201); + const request = await response.json(); + expect(request.userCode).toMatch(/^P[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]{7}$/); + return { + context, + page, + request + }; +}