From c079bc4b6ce65e6c98b359d9821f33c4a878e825 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Thu, 16 Jul 2026 22:43:15 +0200 Subject: [PATCH] feat: add qr code alternative sign in method --- .../internal/bootstrap/router_bootstrap.go | 5 + .../internal/bootstrap/services_bootstrap.go | 20 +- backend/internal/common/errors.go | 14 + backend/internal/devicelogin/cleanup.go | 19 ++ backend/internal/devicelogin/cleanup_test.go | 46 +++ backend/internal/devicelogin/dto.go | 28 ++ backend/internal/devicelogin/handler.go | 134 +++++++++ backend/internal/devicelogin/models.go | 32 +++ backend/internal/devicelogin/module.go | 59 ++++ backend/internal/devicelogin/service.go | 242 ++++++++++++++++ backend/internal/devicelogin/service_test.go | 269 ++++++++++++++++++ backend/internal/job/db_cleanup_job.go | 14 + backend/internal/middleware/rate_limit.go | 22 +- backend/internal/model/audit_log.go | 1 + backend/internal/oidc/device_service.go | 3 + backend/internal/oidc/device_service_test.go | 8 + backend/internal/oidc/device_strategy.go | 32 +++ backend/internal/oidc/provider.go | 5 +- backend/internal/utils/cookie/add_cookie.go | 5 + backend/internal/utils/cookie/cookie_names.go | 2 + backend/internal/utils/string_util.go | 10 + backend/internal/utils/string_util_test.go | 22 ++ ...60713120000_device_login_requests.down.sql | 1 + ...0260713120000_device_login_requests.up.sql | 14 + ...60713120000_device_login_requests.down.sql | 1 + ...0260713120000_device_login_requests.up.sql | 20 ++ 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 | 28 ++ frontend/src/lib/types/device-login.type.ts | 21 ++ .../src/lib/utils/audit-log-translator.ts | 1 + frontend/src/routes/device/+page.svelte | 182 ++++++++++-- .../src/routes/login/alternative/+page.svelte | 13 +- .../login/alternative/device/+page.svelte | 140 +++++++++ .../routes/login/alternative/device/+page.ts | 7 + tests/specs/device-login.spec.ts | 116 ++++++++ 37 files changed, 1519 insertions(+), 66 deletions(-) create mode 100644 backend/internal/devicelogin/cleanup.go create mode 100644 backend/internal/devicelogin/cleanup_test.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 backend/resources/migrations/postgres/20260713120000_device_login_requests.down.sql create mode 100644 backend/resources/migrations/postgres/20260713120000_device_login_requests.up.sql create mode 100644 backend/resources/migrations/sqlite/20260713120000_device_login_requests.down.sql create mode 100644 backend/resources/migrations/sqlite/20260713120000_device_login_requests.up.sql 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/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 2ea0e162..dbd6099d 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -157,6 +157,11 @@ 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.RateLimitDeviceLoginVerification), + ) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index d05edf06..3b2aff2e 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/pocket-id/pocket-id/backend/internal/apikey" + "github.com/pocket-id/pocket-id/backend/internal/devicelogin" "github.com/pocket-id/pocket-id/backend/internal/job" "gorm.io/gorm" @@ -36,11 +37,12 @@ type services struct { appLockService *service.AppLockService oneTimeAccessService *service.OneTimeAccessService - apiKeyModule *apikey.Module - oidcModule *oidc.Module - webauthnModule *webauthn.Module - userSignUpModule *usersignup.Module - apiModule *api.Module + apiKeyModule *apikey.Module + deviceLoginModule *devicelogin.Module + oidcModule *oidc.Module + webauthnModule *webauthn.Module + userSignUpModule *usersignup.Module + apiModule *api.Module } // Initializes all services @@ -79,6 +81,14 @@ func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClien if err != nil { return nil, fmt.Errorf("failed to create WebAuthn module: %w", err) } + svc.deviceLoginModule = devicelogin.New(devicelogin.Dependencies{ + DB: db, + BaseURL: common.EnvConfig.AppURL, + Signer: svc.jwtService, + Reauth: svc.webauthnModule, + AuditLog: svc.auditLogService, + AppConfig: svc.appConfigService, + }) svc.scimService = service.NewScimService(db, scheduler, httpClient) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 14536541..a412e103 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -181,6 +181,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/cleanup.go b/backend/internal/devicelogin/cleanup.go new file mode 100644 index 00000000..7f2a81ef --- /dev/null +++ b/backend/internal/devicelogin/cleanup.go @@ -0,0 +1,19 @@ +package devicelogin + +import ( + "context" + "time" + + "gorm.io/gorm" + + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +) + +// CleanupExpiredRequests deletes device login requests that have expired +// It returns the number of rows removed +func CleanupExpiredRequests(ctx context.Context, db *gorm.DB) (int64, error) { + statement := db. + WithContext(ctx). + Delete(&Request{}, "expires_at < ?", datatype.DateTime(time.Now())) + return statement.RowsAffected, statement.Error +} diff --git a/backend/internal/devicelogin/cleanup_test.go b/backend/internal/devicelogin/cleanup_test.go new file mode 100644 index 00000000..cff729dc --- /dev/null +++ b/backend/internal/devicelogin/cleanup_test.go @@ -0,0 +1,46 @@ +//go:build unit + +package devicelogin + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +func TestCleanupExpiredRequests(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + requests := []Request{ + { + Base: model.Base{ID: "expired-device-login-request"}, + Code: "PAAAAAAA", + DeviceTokenHash: "expired-token-hash", + Status: RequestStatusPending, + ExpiresAt: datatype.DateTime(time.Now().Add(-time.Minute)), + UserAgent: "expired-agent", + }, + { + Base: model.Base{ID: "active-device-login-request"}, + Code: "PBBBBBBB", + DeviceTokenHash: "active-token-hash", + Status: RequestStatusPending, + ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)), + UserAgent: "active-agent", + }, + } + require.NoError(t, db.Create(&requests).Error) + + deleted, err := CleanupExpiredRequests(t.Context(), db) + require.NoError(t, err) + require.Equal(t, int64(1), deleted) + + var remaining []Request + require.NoError(t, db.Order("id").Find(&remaining).Error) + require.Len(t, remaining, 1) + require.Equal(t, "active-device-login-request", remaining[0].ID) +} 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..15496500 --- /dev/null +++ b/backend/internal/devicelogin/handler.go @@ -0,0 +1,134 @@ +package devicelogin + +import ( + "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 Poll a device login request 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) { + requestID := c.Param("id") + deviceToken, _ := c.Cookie(cookie.DeviceLoginTokenCookieName) + user, accessToken, status, err := h.service.Exchange(c.Request.Context(), requestID, deviceToken, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + _ = c.Error(err) + return + } + if status == RequestStatusPending { + c.Status(http.StatusAccepted) + return + } + + var userDto dto.UserDto + if err = dto.MapStruct(user, &userDto); err != nil { + _ = c.Error(err) + return + } + + maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + cookie.AddAccessTokenCookie(c, maxAge, accessToken) + c.JSON(http.StatusOK, userDto) +} + +// 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 + if err := c.ShouldBindJSON(&input); 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 + if err := c.ShouldBindJSON(&input); err != nil { + _ = c.Error(err) + return + } + + reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) + if err := h.service.Decide(c.Request.Context(), input.Code, input.Decision, c.GetString("userID"), reauthenticationToken); 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..701dd97f --- /dev/null +++ b/backend/internal/devicelogin/models.go @@ -0,0 +1,32 @@ +package devicelogin + +import ( + "github.com/pocket-id/pocket-id/backend/internal/model" + 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 { + model.Base + + Code string + DeviceTokenHash string + Status RequestStatus + ExpiresAt datatype.DateTime + IpAddress string + UserAgent string + + UserID *string + User model.User +} + +func (Request) TableName() string { + return "device_login_requests" +} diff --git a/backend/internal/devicelogin/module.go b/backend/internal/devicelogin/module.go new file mode 100644 index 00000000..73cee560 --- /dev/null +++ b/backend/internal/devicelogin/module.go @@ -0,0 +1,59 @@ +package devicelogin + +import ( + "context" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +type TokenService interface { + GenerateAccessToken(user model.User, authenticationMethod string) (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 { + GetDbConfig() *model.AppConfig +} + +type Dependencies struct { + DB *gorm.DB + BaseURL string + + Signer TokenService + Reauth ReauthenticationTokenConsumer + AuditLog AuditLogger + AppConfig AppConfigProvider +} + +type Module struct { + service *Service + handler *handler +} + +func New(deps Dependencies) *Module { + service := newService(deps) + return &Module{ + service: service, + handler: newHandler(service, deps.BaseURL, deps.AppConfig), + } +} + +// RegisterRoutes mounts the public exchange and authenticated verification endpoints +func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, browserAuth, createRateLimit, verificationRateLimit gin.HandlerFunc) { + apiGroup.POST("/device-login/requests", createRateLimit, m.handler.createRequest) + apiGroup.POST("/device-login/requests/:id/exchange", 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..8caa9ee2 --- /dev/null +++ b/backend/internal/devicelogin/service.go @@ -0,0 +1,242 @@ +package devicelogin + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "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 + codePrefix = "P" + codeRandomLength = 7 + reauthenticationMaxAge = time.Minute + // authenticationMethodOneTimePassword identifies the login-code-equivalent AMR used on the waiting device + authenticationMethodOneTimePassword = "otp" +) + +type Service struct { + db *gorm.DB + signer TokenService + auditLog AuditLogger + reauth ReauthenticationTokenConsumer +} + +type VerificationInfo struct { + UserCode string + Device string + IPAddress string + ExpiresAt datatype.DateTime +} + +func newService(deps Dependencies) *Service { + return &Service{ + db: deps.DB, + signer: deps.Signer, + auditLog: deps.AuditLog, + reauth: deps.Reauth, + } +} + +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 + } + + now := time.Now().Round(time.Second) + request := Request{ + DeviceTokenHash: utils.CreateSha256Hash(deviceToken), + Status: RequestStatusPending, + ExpiresAt: datatype.DateTime(now.Add(RequestDuration)), + UserAgent: userAgent, + IpAddress: ipAddress, + } + + // Retry code generation because of the small but non-zero chance of a collision with an existing code + for range 3 { + request.Code, err = newUserCode() + if err != nil { + return Request{}, "", err + } + + err = s.db.WithContext(ctx).Create(&request).Error + if err == nil { + return request, deviceToken, nil + } + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return Request{}, "", err + } + } + + return Request{}, "", errors.New("failed to generate a unique device login code") +} + +func (s *Service) Inspect(ctx context.Context, code string) (VerificationInfo, error) { + code = strings.ToUpper(strings.TrimSpace(code)) + + // Return requester metadata only while the request can still be decided + var request Request + err := s.db. + WithContext(ctx). + Where("code = ? AND status = ? AND expires_at > ?", code, RequestStatusPending, datatype.DateTime(time.Now())). + First(&request). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return VerificationInfo{}, &common.DeviceLoginRequestInvalidOrExpiredError{} + } + return VerificationInfo{}, err + } + + return VerificationInfo{ + UserCode: request.Code, + Device: s.auditLog.DeviceStringFromUserAgent(request.UserAgent), + IPAddress: request.IpAddress, + ExpiresAt: request.ExpiresAt, + }, nil +} + +func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenticationToken string) error { + code = strings.ToUpper(strings.TrimSpace(code)) + + tx := s.db.Begin() + defer tx.Rollback() + + var request Request + err := tx. + WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("code = ? AND status = ? AND expires_at > ?", code, RequestStatusPending, datatype.DateTime(time.Now())). + First(&request). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.DeviceLoginRequestInvalidOrExpiredError{} + } + return err + } + + switch decision { + case "approve": + // Consume the fresh passkey proof in the same transaction as the approval + if reauthenticationToken == "" { + return &common.ReauthenticationRequiredError{} + } + reauthenticatedAt, consumeErr := s.reauth.ConsumeReauthenticationToken(ctx, tx, reauthenticationToken, userID) + if consumeErr != nil { + return consumeErr + } + if time.Since(reauthenticatedAt) > reauthenticationMaxAge { + return &common.ReauthenticationRequiredError{} + } + request.Status = RequestStatusApproved + request.UserID = &userID + case "deny": + request.Status = RequestStatusDenied + default: + return fmt.Errorf("unsupported device login decision %q", decision) + } + + result := tx. + WithContext(ctx). + Model(&Request{}). + Where("id = ? AND status = ? AND expires_at > ?", request.ID, RequestStatusPending, datatype.DateTime(time.Now())). + Updates(map[string]any{"status": request.Status, "user_id": request.UserID}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return &common.DeviceLoginRequestInvalidOrExpiredError{} + } + + return tx.Commit().Error +} + +func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string) (model.User, string, RequestStatus, error) { + if deviceToken == "" { + return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + } + + tx := s.db.Begin() + defer tx.Rollback() + + var request Request + err := tx. + WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Preload("User"). + Where("id = ? AND device_token_hash = ? AND expires_at > ?", requestID, utils.CreateSha256Hash(deviceToken), datatype.DateTime(time.Now())). + First(&request). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + } + return model.User{}, "", "", err + } + + switch request.Status { + case RequestStatusPending: + // Leave pending requests untouched so the waiting device can continue polling + return model.User{}, "", request.Status, nil + case RequestStatusDenied: + return model.User{}, "", request.Status, &common.DeviceLoginDeniedError{} + case RequestStatusApproved: + default: + return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + } + + if request.UserID == nil || request.User.ID == "" { + return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + } + if request.User.Disabled { + return model.User{}, "", "", &common.UserDisabledError{} + } + + // Mint the session with login-code semantics because the waiting device did not perform WebAuthn + accessToken, err := s.signer.GenerateAccessToken(request.User, authenticationMethodOneTimePassword) + if err != nil { + return model.User{}, "", "", err + } + + result := tx. + WithContext(ctx). + Where("id = ? AND status = ?", request.ID, RequestStatusApproved). + Delete(&Request{}) + if result.Error != nil { + return model.User{}, "", "", result.Error + } + if result.RowsAffected != 1 { + return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + } + + s.auditLog.Create(ctx, model.AuditLogEventRemoteSignIn, ipAddress, userAgent, request.User.ID, model.AuditLogData{}, tx) + + err = tx.Commit().Error + if err != nil { + return model.User{}, "", "", err + } + + return request.User, accessToken, request.Status, nil +} + +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..b2a7d7d5 --- /dev/null +++ b/backend/internal/devicelogin/service_test.go @@ -0,0 +1,269 @@ +package devicelogin + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "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" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +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 +} + +func (f *fakeTokenService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.userID = user.ID + f.authenticationMethod = authenticationMethod + return "device-login-access-token", nil +} + +func (f *fakeTokenService) generatedToken() (string, string) { + f.mu.Lock() + defer f.mu.Unlock() + return f.userID, f.authenticationMethod +} + +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 TestRequestLifecycle(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + deviceLoginService, signer, auditLog := newServiceForTest(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 := deviceLoginService.Create(t.Context(), "192.0.2.10", "Mozilla/5.0 Chrome/125.0.0.0") + require.NoError(t, err) + require.Regexp(t, `^P[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{7}$`, request.Code) + require.Equal(t, utils.CreateSha256Hash(deviceToken), request.DeviceTokenHash) + require.NotEqual(t, deviceToken, request.DeviceTokenHash) + require.Equal(t, RequestStatusPending, request.Status) + + info, err := deviceLoginService.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 = deviceLoginService.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof") + require.NoError(t, err) + + exchangedUser, accessToken, status, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent") + 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 := signer.generatedToken() + require.Equal(t, user.ID, signedUserID) + require.Equal(t, authenticationMethodOneTimePassword, authenticationMethod) + + var requestCount int64 + require.NoError(t, db.Model(&Request{}).Where("id = ?", request.ID).Count(&requestCount).Error) + require.Zero(t, requestCount) + + entry := 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) +} + +func TestPendingAndDeniedRequests(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + deviceLoginService, _, _ := newServiceForTest(db) + + request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + + user, accessToken, status, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "") + require.NoError(t, err) + require.Equal(t, RequestStatusPending, status) + require.Empty(t, user.ID) + require.Empty(t, accessToken) + + err = deviceLoginService.Decide(t.Context(), request.Code, "deny", "device-login-user", "") + require.NoError(t, err) + + user, accessToken, status, err = deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "") + var deniedError *common.DeviceLoginDeniedError + require.ErrorAs(t, err, &deniedError) + require.Equal(t, RequestStatusDenied, status) + require.Empty(t, user.ID) + require.Empty(t, accessToken) +} + +func TestRejectsInvalidAndExpiredRequests(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + deviceLoginService, _, _ := newServiceForTest(db) + + request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + + _, _, _, err = deviceLoginService.Exchange(t.Context(), request.ID, "wrong-token", "", "") + var invalidError *common.DeviceLoginRequestInvalidOrExpiredError + require.ErrorAs(t, err, &invalidError) + + require.NoError(t, db.Model(&request).Update("expires_at", datatype.DateTime(time.Now().Add(-time.Minute))).Error) + _, err = deviceLoginService.Inspect(t.Context(), request.Code) + require.ErrorAs(t, err, &invalidError) + err = deviceLoginService.Decide(t.Context(), request.Code, "deny", "device-login-user", "") + require.ErrorAs(t, err, &invalidError) + _, _, _, err = deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "") + require.ErrorAs(t, err, &invalidError) + +} + +func TestRejectsDisabledUserAtExchange(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + deviceLoginService, _, _ := newServiceForTest(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 := deviceLoginService.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + require.NoError(t, deviceLoginService.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof")) + + _, accessToken, _, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "") + var disabledError *common.UserDisabledError + require.ErrorAs(t, err, &disabledError) + require.Empty(t, accessToken) + + var remainingRequest Request + require.NoError(t, db.First(&remainingRequest, "id = ?", request.ID).Error) +} + +func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + deviceLoginService, _, _ := newServiceForTest(db) + + request, _, err := deviceLoginService.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + + err = deviceLoginService.Decide(t.Context(), request.Code, "approve", "device-login-user", "") + var reauthenticationError *common.ReauthenticationRequiredError + require.ErrorAs(t, err, &reauthenticationError) + + deviceLoginService.reauth = &fakeReauthenticationTokenConsumer{ + expectedValue: "stale-proof", + createdAt: time.Now().Add(-2 * time.Minute), + } + err = deviceLoginService.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof") + require.ErrorAs(t, err, &reauthenticationError) + + var persistedRequest Request + require.NoError(t, db.First(&persistedRequest, "id = ?", request.ID).Error) + require.Equal(t, RequestStatusPending, persistedRequest.Status) +} + +func TestExchangeIsSingleUse(t *testing.T) { + db := testutils.NewConcurrentDatabaseForTest(t) + deviceLoginService, _, _ := newServiceForTest(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 := deviceLoginService.Create(t.Context(), "", "requesting-agent") + require.NoError(t, err) + require.NoError(t, deviceLoginService.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof")) + + var waitGroup sync.WaitGroup + results := make(chan error, 2) + for range 2 { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + _, _, _, exchangeErr := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "") + results <- exchangeErr + }() + } + waitGroup.Wait() + close(results) + + var successes int + for result := range results { + if result == nil { + successes++ + } + } + require.Equal(t, 1, successes) +} + +func newServiceForTest(db *gorm.DB) (*Service, *fakeTokenService, *fakeAuditLogger) { + signer := &fakeTokenService{} + auditLog := &fakeAuditLogger{} + deviceLoginService := newService(Dependencies{ + DB: db, + Signer: signer, + AuditLog: auditLog, + Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"}, + }) + return deviceLoginService, signer, auditLog +} diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 24bbc4d3..54d0cca5 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -11,6 +11,7 @@ import ( "gorm.io/gorm" "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/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/oidc" @@ -35,6 +36,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro return errors.Join( s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearDeviceLoginRequests", jobDefWithJitter(24*time.Hour), jobs.clearDeviceLoginRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), @@ -75,6 +77,18 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error { return nil } +// clearDeviceLoginRequests deletes device login requests that have expired +func (j *DbCleanupJobs) clearDeviceLoginRequests(ctx context.Context) error { + count, err := devicelogin.CleanupExpiredRequests(ctx, j.db) + if err != nil { + return fmt.Errorf("failed to clean expired device login requests: %w", err) + } + + slog.InfoContext(ctx, "Cleaned expired device login requests", slog.Int64("count", count)) + + return nil +} + // clearSignupTokens deletes signup tokens that have expired func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error { count, err := usersignup.CleanupExpiredSignupTokens(ctx, j.db) diff --git a/backend/internal/middleware/rate_limit.go b/backend/internal/middleware/rate_limit.go index dbfa49ad..78006ede 100644 --- a/backend/internal/middleware/rate_limit.go +++ b/backend/internal/middleware/rate_limit.go @@ -19,15 +19,17 @@ 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" + 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 +55,8 @@ 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: 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..6ae2cd8b 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" @@ -219,6 +220,8 @@ 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)) + 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..62ada051 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[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{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/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/string_util.go b/backend/internal/utils/string_util.go index 13281903..0583a9ec 100644 --- a/backend/internal/utils/string_util.go +++ b/backend/internal/utils/string_util.go @@ -23,6 +23,12 @@ func GenerateRandomUnambiguousString(length int) (string, error) { 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 = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + return GenerateRandomString(length, charset) +} + // GenerateRandomString generates a random string of the given length using the provided character set func GenerateRandomString(length int, charset string) (string, error) { @@ -30,6 +36,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 38251d5d..90c41186 100644 --- a/backend/internal/utils/string_util_test.go +++ b/backend/internal/utils/string_util_test.go @@ -86,6 +86,20 @@ 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(`^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]+$`).MatchString(str) { + t.Errorf("String contains lowercase or ambiguous characters: %s", str) + } +} + func TestGenerateRandomString(t *testing.T) { t.Run("valid length returns characters from charset", func(t *testing.T) { const length = 20 @@ -119,6 +133,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/backend/resources/migrations/postgres/20260713120000_device_login_requests.down.sql b/backend/resources/migrations/postgres/20260713120000_device_login_requests.down.sql new file mode 100644 index 00000000..386065a7 --- /dev/null +++ b/backend/resources/migrations/postgres/20260713120000_device_login_requests.down.sql @@ -0,0 +1 @@ +DROP TABLE device_login_requests; diff --git a/backend/resources/migrations/postgres/20260713120000_device_login_requests.up.sql b/backend/resources/migrations/postgres/20260713120000_device_login_requests.up.sql new file mode 100644 index 00000000..06743828 --- /dev/null +++ b/backend/resources/migrations/postgres/20260713120000_device_login_requests.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE device_login_requests +( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + code TEXT NOT NULL UNIQUE, + device_token_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied')), + expires_at TIMESTAMPTZ NOT NULL, + ip_address INET NOT NULL, + user_agent TEXT NOT NULL, + user_id UUID REFERENCES users ON DELETE CASCADE +); + +CREATE INDEX idx_device_login_requests_expires_at ON device_login_requests (expires_at); diff --git a/backend/resources/migrations/sqlite/20260713120000_device_login_requests.down.sql b/backend/resources/migrations/sqlite/20260713120000_device_login_requests.down.sql new file mode 100644 index 00000000..386065a7 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260713120000_device_login_requests.down.sql @@ -0,0 +1 @@ +DROP TABLE device_login_requests; diff --git a/backend/resources/migrations/sqlite/20260713120000_device_login_requests.up.sql b/backend/resources/migrations/sqlite/20260713120000_device_login_requests.up.sql new file mode 100644 index 00000000..052d1170 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260713120000_device_login_requests.up.sql @@ -0,0 +1,20 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +CREATE TABLE device_login_requests +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL, + code TEXT NOT NULL UNIQUE, + device_token_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied')), + expires_at DATETIME NOT NULL, + ip_address TEXT NOT NULL, + user_agent TEXT NOT NULL, + user_id TEXT REFERENCES users ON DELETE CASCADE +); + +CREATE INDEX idx_device_login_requests_expires_at ON device_login_requests (expires_at); + +COMMIT; +PRAGMA foreign_keys=ON; 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..5a8aabca 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' + : `/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..c51ce0ad --- /dev/null +++ b/frontend/src/lib/services/device-login-service.ts @@ -0,0 +1,28 @@ +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 () => { + const response = await this.api.post('/device-login/requests'); + return response.data as DeviceLoginRequest; + }; + + exchangeRequest = async (requestId: string): Promise => { + const response = await this.api.post(`/device-login/requests/${requestId}/exchange`); + 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..d285e340 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,24 @@ 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()); + let codeComplete = $derived(normalizedUserCode.length === 8); + let completed = $derived(success || deviceLoginOutcome !== undefined); onMount(() => { if (data.code && $userStore) { @@ -40,28 +50,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 +81,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 +126,15 @@ await webauthnService.reauthenticate(authResponse); } } + + function retry() { + errorMessage = null; + if (!deviceLoginInfo) { + deviceInfo = undefined; + authorizationRequired = false; + reauthenticationRequired = false; + } + } @@ -100,16 +146,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 +220,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..f0034a10 --- /dev/null +++ b/frontend/src/routes/login/alternative/device/+page.svelte @@ -0,0 +1,140 @@ + + + + {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/specs/device-login.spec.ts b/tests/specs/device-login.spec.ts new file mode 100644 index 00000000..79a052b4 --- /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[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{7}$/); + return { + context, + page, + request + }; +}