Compare commits

...

3 Commits

Author SHA1 Message Date
Elias Schneider
5713d351aa Merge branch 'main' into feat/qr-code-login 2026-07-17 10:54:22 +02:00
Elias Schneider
f35402547f Apply suggestion from @coderabbitai[bot]
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-17 10:53:29 +02:00
Elias Schneider
c079bc4b6c feat: add qr code alternative sign in method 2026-07-16 23:06:38 +02:00
37 changed files with 1519 additions and 66 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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" }

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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},

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -0,0 +1 @@
DROP TABLE device_login_requests;

View File

@@ -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);

View File

@@ -0,0 +1 @@
DROP TABLE device_login_requests;

View File

@@ -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;

View File

@@ -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:"
}

View File

@@ -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)}`;
}
});
</script>
{#if backgroundImageExists === undefined}

View File

@@ -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);
});
}
});
</script>

View File

@@ -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<DeviceLoginExchangeResult> => {
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 });
};
}

View File

@@ -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;

View File

@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages';
export const eventTypes: Record<string, string> = {
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(),

View File

@@ -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;
}
}
</script>
<svelte:head>
@@ -100,16 +146,52 @@
{#if deviceInfo?.client}
<ClientProviderImages client={deviceInfo.client} {success} error={!!errorMessage} />
{:else}
<LoginLogoErrorSuccessIndicator {success} error={!!errorMessage} />
<LoginLogoErrorSuccessIndicator
success={success || deviceLoginOutcome === 'approved'}
error={!!errorMessage}
/>
{/if}
</div>
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
<h1 class="font-gloock mt-5 text-4xl font-bold">
{m.authorize_device()}
</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2">
{errorMessage}. {m.please_try_again()}
</p>
{:else if deviceLoginOutcome === 'approved'}
<p class="text-muted-foreground mt-2">{m.the_requesting_device_has_been_signed_in()}</p>
{:else if deviceLoginOutcome === 'denied'}
<p class="text-muted-foreground mt-2">{m.the_sign_in_request_was_denied()}</p>
{:else if success}
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
{:else if deviceLoginInfo}
<p class="text-muted-foreground mt-2">{m.review_the_request_before_approving_it()}</p>
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 text-start">
<Card.Content>
<dl class="flex flex-col gap-4 text-sm">
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.code()}</dt>
<dd class="font-medium">
{deviceLoginInfo.userCode.substring(0, 4)} - {deviceLoginInfo.userCode.substring(
4,
8
)}
</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.device()}</dt>
<dd class="text-right font-medium">{deviceLoginInfo.device}</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.ip_address()}</dt>
<dd class="font-medium">{deviceLoginInfo.ipAddress || m.unknown()}</dd>
</div>
</dl>
</Card.Content>
</Card.Root>
</div>
{:else if reauthenticationRequired && deviceInfo?.client}
<p class="text-muted-foreground mt-2">
<FormattedMessage
@@ -120,16 +202,16 @@
/>
</p>
{:else if authorizationRequired}
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 gap-2">
<Card.Header>
<p class="text-muted-foreground text-start">
<Card.Description class="text-start">
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
client: deviceInfo!.client.name
})}
/>
</p>
</Card.Description>
</Card.Header>
<Card.Content data-testid="scopes">
<ScopeList scopes={deviceInfo!.scope || []} scopeInfo={deviceInfo!.scopeInfo || []} />
@@ -138,19 +220,63 @@
</div>
{:else}
<p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p>
<form id="device-code-form" onsubmit={preventDefault(authorize)} class="w-full max-w-[450px]">
<Input id="user-code" class="mt-7" placeholder={m.code()} bind:value={userCode} type="text" />
<form
id="device-code-form"
onsubmit={preventDefault(authorize)}
class="mt-7 flex w-full max-w-112.5 justify-center"
>
<InputOTP.Root
maxlength={8}
aria-label={m.code()}
bind:value={userCode}
onValueChange={(value) => (userCode = value.toUpperCase())}
pasteTransformer={(value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()}
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 4) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(4) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
</form>
{/if}
{#if !success}
<div class="mt-10 flex w-full max-w-[450px] gap-2">
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
{#if !errorMessage}
<Button form="device-code-form" class="flex-1" onclick={authorize} {isLoading}
>{m.authorize()}</Button
{#if !completed}
<div class="mt-10 flex w-full max-w-112.5 gap-2">
{#if errorMessage}
<Button class="flex-1" variant="secondary" href="/">{m.cancel()}</Button>
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
{:else if deviceLoginInfo}
<Button
class="flex-1"
variant="secondary"
disabled={isLoading}
onclick={() => decideDeviceLogin('deny')}
>
{#if deviceLoginDecision === 'deny'}<Spinner data-icon="inline-start" />{/if}
{m.deny()}
</Button>
<Button class="flex-1" {isLoading} onclick={() => decideDeviceLogin('approve')}>
{m.approve()}
</Button>
{:else}
<Button class="flex-1" onclick={() => (errorMessage = null)}>{m.try_again()}</Button>
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
<Button
form="device-code-form"
class="flex-1"
disabled={isLoading || !codeComplete}
onclick={authorize}
>
{#if isLoading}<Spinner data-icon="inline-start" />{/if}
{m.authorize()}
</Button>
{/if}
</div>
{/if}

View File

@@ -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'
}
];

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Separator } from '$lib/components/ui/separator';
import { Spinner } from '$lib/components/ui/spinner';
import { m } from '$lib/paraglide/messages';
import DeviceLoginService from '$lib/services/device-login-service';
import userStore from '$lib/stores/user-store';
import type { DeviceLoginRequest } from '$lib/types/device-login.type';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
let { data } = $props();
const deviceLoginService = new DeviceLoginService();
let request: DeviceLoginRequest | undefined = $state();
let errorMessage: string | null = $state(null);
let isStarting = $state(true);
let pollTimer: ReturnType<typeof setTimeout> | undefined;
onMount(() => {
startRequest();
return () => {
clearTimers();
};
});
async function startRequest() {
clearTimers();
request = undefined;
errorMessage = null;
isStarting = true;
try {
request = await deviceLoginService.createRequest();
schedulePoll();
} catch (error) {
errorMessage = getAxiosErrorMessage(error);
} finally {
isStarting = false;
}
}
function schedulePoll() {
if (!request) return;
pollTimer = setTimeout(exchangeRequest, request.interval * 1000);
}
async function exchangeRequest() {
if (!request) return;
try {
const user = await deviceLoginService.exchangeRequest(request.id);
if (!user) {
schedulePoll();
return;
}
clearTimers();
await userStore.setUser(user);
await goto(data.redirect);
} catch (error) {
clearTimers();
errorMessage = getAxiosErrorMessage(error);
}
}
function clearTimers() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = undefined;
}
</script>
<svelte:head>
<title>{m.sign_in_with_another_device()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!errorMessage} />
</div>
<h1 class="font-gloock mt-5 text-2xl font-bold sm:text-4xl">
{m.sign_in_with_another_device()}
</h1>
<p class="text-muted-foreground mt-2">
{errorMessage ? errorMessage : m.sign_in_with_another_device_description()}
</p>
{#if isStarting}
<div class="mt-10 flex items-center gap-2 text-sm">
<Spinner />
{m.creating_device_login_request()}
</div>
{:else if request && !errorMessage}
<Card.Root class="mt-8 w-full max-w-sm shrink-0">
<Card.Content class="flex flex-col items-center gap-5">
<Qrcode
value={request.verificationUriComplete}
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
aria-label={m.device_login_qr_code()}
class="h-[12dvh]"
/>
<div class="flex w-full items-center gap-3">
<Separator class="flex-1" />
<span class="text-muted-foreground text-xs">{m.or()}</span>
<Separator class="flex-1" />
</div>
<div>
<p class="text-muted-foreground text-sm mb-2">
{m.visit_and_enter({ url: request.verificationUri })}
</p>
<CopyToClipboard value={request.userCode}>
<p class="text-xl sm:text-2xl font-bold tracking-wider" data-testid="device-login-code">
{request.userCode.substring(0, 4)}
<span class="text-muted-foreground font-normal">-</span>
{request.userCode.substring(4, 8)}
</p>
</CopyToClipboard>
</div>
</Card.Content>
</Card.Root>
{/if}
<div class="flex mt-7 md:mt-15 gap-3 w-full max-w-112.5">
<Button class="flex-1" href={'/login/alternative' + page.url.search} variant="secondary"
>{m.go_back()}</Button
>
<Button class="flex-1" isLoading={!errorMessage} onclick={startRequest}>
{errorMessage ? m.try_again() : m.waiting_for_approval()}
</Button>
</div>
</SignInWrapper>

View File

@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
return {
redirect: url.searchParams.get('redirect') || '/settings'
};
};

View File

@@ -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
};
}