mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-19 03:16:28 +00:00
feat: add qr code alternative sign in method (#1594)
Co-authored-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
co-authored by
ItalyPaleAle
parent
968f97fa61
commit
e1fd1d320f
+1
-1
@@ -24,7 +24,7 @@ require (
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/italypaleale/francis v0.1.0-beta.15
|
||||
github.com/italypaleale/francis v0.1.0-beta.16
|
||||
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86
|
||||
github.com/italypaleale/go-sql-utils v0.2.4
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
|
||||
+2
-2
@@ -263,8 +263,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/italypaleale/francis v0.1.0-beta.15 h1:yVFJCcD1pP91rIesAb06Gp1K1tPj0ruh6hha5YEhtRw=
|
||||
github.com/italypaleale/francis v0.1.0-beta.15/go.mod h1:KKwS+57OBD/MoHBVfbbMelA2vUx65fPiG9fhdWWapFc=
|
||||
github.com/italypaleale/francis v0.1.0-beta.16 h1:bkt+8iA2f/hi14vve0cYaUMvVhHBIoD/cLVlUkHR69c=
|
||||
github.com/italypaleale/francis v0.1.0-beta.16/go.mod h1:KKwS+57OBD/MoHBVfbbMelA2vUx65fPiG9fhdWWapFc=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86 h1:719T7W8hLVjelch856Sern60QAPMn0fIE87i91YVcfw=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86/go.mod h1:0Sy3bN3qnSy2kgcJ05A2CsP6os5wmLC9lPraFr+0jGk=
|
||||
github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c=
|
||||
|
||||
@@ -157,6 +157,12 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
|
||||
)
|
||||
svc.deviceLoginModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginCreate),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginExchange),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginVerification),
|
||||
)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailModule, svc.ldapService)
|
||||
|
||||
@@ -6,12 +6,11 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/devicelogin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/email"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type services struct {
|
||||
@@ -41,6 +41,7 @@ type services struct {
|
||||
appLockService *service.AppLockService
|
||||
|
||||
apiKeyModule *apikey.Module
|
||||
deviceLoginModule *devicelogin.Module
|
||||
oidcModule *oidc.Module
|
||||
webauthnModule *webauthn.Module
|
||||
userSignUpModule *usersignup.Module
|
||||
@@ -98,6 +99,18 @@ func initServices(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create WebAuthn module: %w", err)
|
||||
}
|
||||
svc.deviceLoginModule, err = devicelogin.New(devicelogin.Dependencies{
|
||||
DB: db,
|
||||
BaseURL: common.EnvConfig.AppURL,
|
||||
Actors: actors,
|
||||
Signer: svc.jwtService,
|
||||
Reauth: svc.webauthnModule,
|
||||
AuditLog: svc.auditLogService,
|
||||
AppConfig: svc.appConfigService,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create device login module: %w", err)
|
||||
}
|
||||
|
||||
svc.scimService = service.NewScimService(db, scheduler, httpClient)
|
||||
|
||||
|
||||
@@ -188,6 +188,20 @@ type OneTimeAccessDisabledError struct{}
|
||||
func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" }
|
||||
func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type DeviceLoginRequestInvalidOrExpiredError struct{}
|
||||
|
||||
func (e DeviceLoginRequestInvalidOrExpiredError) Error() string {
|
||||
return "Device login request is invalid or expired"
|
||||
}
|
||||
func (e DeviceLoginRequestInvalidOrExpiredError) HttpStatusCode() int {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
|
||||
type DeviceLoginDeniedError struct{}
|
||||
|
||||
func (e DeviceLoginDeniedError) Error() string { return "Device login request was denied" }
|
||||
func (e DeviceLoginDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type InvalidAPIKeyError struct{}
|
||||
|
||||
func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" }
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
requestActorType = "device-login-request"
|
||||
requestActorMethodCreate = "create"
|
||||
requestActorMethodInspect = "inspect"
|
||||
requestActorMethodPoll = "poll"
|
||||
requestActorMethodDecide = "decide"
|
||||
requestActorMethodConsume = "consume"
|
||||
)
|
||||
|
||||
type requestActorResultCode string
|
||||
|
||||
const (
|
||||
requestActorResultNone requestActorResultCode = ""
|
||||
requestActorResultCollision requestActorResultCode = "collision"
|
||||
requestActorResultInvalid requestActorResultCode = "invalid"
|
||||
requestActorResultDenied requestActorResultCode = "denied"
|
||||
)
|
||||
|
||||
type requestActorState struct {
|
||||
Code string
|
||||
DeviceTokenHash string
|
||||
Status RequestStatus
|
||||
ExpiresAt time.Time
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
UserID string
|
||||
}
|
||||
|
||||
type requestActorResult struct {
|
||||
Code requestActorResultCode
|
||||
Status RequestStatus
|
||||
UserCode string
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
ExpiresAt time.Time
|
||||
UserID string
|
||||
}
|
||||
|
||||
type requestActorCreateInput struct {
|
||||
Code string
|
||||
DeviceTokenHash string
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type requestActorPollInput struct {
|
||||
DeviceTokenHash string
|
||||
}
|
||||
|
||||
type requestActorDecisionInput struct {
|
||||
Decision string
|
||||
UserID string
|
||||
}
|
||||
|
||||
type requestActorConsumeInput struct {
|
||||
DeviceTokenHash string
|
||||
}
|
||||
|
||||
type requestActor struct {
|
||||
client actor.Client[requestActorState]
|
||||
}
|
||||
|
||||
func newRequestActor(actorID string, actorService *actor.Service) actor.Actor {
|
||||
return &requestActor{
|
||||
client: actor.NewActorClient[requestActorState](requestActorType, actorID, actorService),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *requestActor) Invoke(ctx context.Context, method string, data actor.Envelope) (any, error) {
|
||||
switch method {
|
||||
case requestActorMethodCreate:
|
||||
var input requestActorCreateInput
|
||||
err := decodeActorInput(data, &input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.create(ctx, input)
|
||||
case requestActorMethodDecide:
|
||||
var input requestActorDecisionInput
|
||||
err := decodeActorInput(data, &input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.decide(ctx, input)
|
||||
case requestActorMethodConsume:
|
||||
var input requestActorConsumeInput
|
||||
err := decodeActorInput(data, &input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.consume(ctx, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported device login actor method %q", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *requestActor) Peek(ctx context.Context, method string, data actor.Envelope) (any, error) {
|
||||
switch method {
|
||||
case requestActorMethodInspect:
|
||||
return a.inspect(ctx)
|
||||
case requestActorMethodPoll:
|
||||
var input requestActorPollInput
|
||||
if err := decodeActorInput(data, &input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.poll(ctx, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported device login actor peek method %q", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *requestActor) create(ctx context.Context, input requestActorCreateInput) (requestActorResult, error) {
|
||||
// Reject invalid initialization before touching durable state
|
||||
if input.Code == "" || input.DeviceTokenHash == "" {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
|
||||
// Preserve an existing live request when the short user code collides
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return requestActorResult{}, fmt.Errorf("failed to load device login actor state: %w", err)
|
||||
}
|
||||
if state.Code != "" {
|
||||
if state.ExpiresAt.After(time.Now()) {
|
||||
return requestActorResult{Code: requestActorResultCollision}, nil
|
||||
}
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Round(time.Second).Add(RequestDuration)
|
||||
|
||||
// Persist the request until its public expiry
|
||||
state = requestActorState{
|
||||
Code: input.Code,
|
||||
DeviceTokenHash: input.DeviceTokenHash,
|
||||
Status: RequestStatusPending,
|
||||
ExpiresAt: expiresAt,
|
||||
IPAddress: input.IPAddress,
|
||||
UserAgent: input.UserAgent,
|
||||
}
|
||||
if err = a.persistState(ctx, state); err != nil {
|
||||
return requestActorResult{}, err
|
||||
}
|
||||
|
||||
return requestActorResult{Status: state.Status, ExpiresAt: state.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
func (a *requestActor) inspect(ctx context.Context) (requestActorResult, error) {
|
||||
// Only pending live requests may reveal requester metadata or be decided
|
||||
state, valid, err := a.liveState(ctx)
|
||||
if err != nil {
|
||||
return requestActorResult{}, err
|
||||
}
|
||||
if !valid || state.Status != RequestStatusPending {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
|
||||
return requestActorResult{
|
||||
Status: state.Status,
|
||||
UserCode: state.Code,
|
||||
IPAddress: state.IPAddress,
|
||||
UserAgent: state.UserAgent,
|
||||
ExpiresAt: state.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *requestActor) poll(ctx context.Context, input requestActorPollInput) (requestActorResult, error) {
|
||||
// Validate the request lifetime and the high-entropy device binding on every poll
|
||||
state, err := a.loadState(ctx)
|
||||
if err != nil {
|
||||
return requestActorResult{}, err
|
||||
}
|
||||
if state.Code == "" || !utils.ConstantTimeStringEqual(state.DeviceTokenHash, input.DeviceTokenHash) {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
|
||||
result := requestActorResult{Status: state.Status, ExpiresAt: state.ExpiresAt}
|
||||
switch state.Status {
|
||||
case RequestStatusPending:
|
||||
if !state.ExpiresAt.After(time.Now()) {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
return result, nil
|
||||
case RequestStatusApproved:
|
||||
if state.UserID == "" || !state.ExpiresAt.After(time.Now()) {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
result.UserID = state.UserID
|
||||
return result, nil
|
||||
case RequestStatusDenied:
|
||||
if !state.ExpiresAt.After(time.Now()) {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
result.Code = requestActorResultDenied
|
||||
return result, nil
|
||||
default:
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *requestActor) decide(ctx context.Context, input requestActorDecisionInput) (requestActorResult, error) {
|
||||
// Serialize every decision against exchange and require the request to still be pending
|
||||
state, valid, err := a.liveState(ctx)
|
||||
if err != nil {
|
||||
return requestActorResult{}, err
|
||||
}
|
||||
if !valid || state.Status != RequestStatusPending {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
|
||||
switch input.Decision {
|
||||
case "deny":
|
||||
state.Status = RequestStatusDenied
|
||||
case "approve":
|
||||
state.Status = RequestStatusApproved
|
||||
state.UserID = input.UserID
|
||||
default:
|
||||
return requestActorResult{}, fmt.Errorf("unsupported device login decision %q", input.Decision)
|
||||
}
|
||||
|
||||
// Preserve the original expiry when committing the terminal decision
|
||||
if err = a.persistState(ctx, state); err != nil {
|
||||
return requestActorResult{}, err
|
||||
}
|
||||
|
||||
return requestActorResult{Status: state.Status, ExpiresAt: state.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
func (a *requestActor) consume(ctx context.Context, input requestActorConsumeInput) (requestActorResult, error) {
|
||||
// Serialize consume attempts and validate the device binding inside the actor turn
|
||||
state, err := a.loadState(ctx)
|
||||
if err != nil {
|
||||
return requestActorResult{}, err
|
||||
}
|
||||
if state.Code == "" || !utils.ConstantTimeStringEqual(state.DeviceTokenHash, input.DeviceTokenHash) || !state.ExpiresAt.After(time.Now()) {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
switch state.Status {
|
||||
case RequestStatusPending:
|
||||
return requestActorResult{Status: state.Status, ExpiresAt: state.ExpiresAt}, nil
|
||||
case RequestStatusDenied:
|
||||
return requestActorResult{Code: requestActorResultDenied, Status: state.Status, ExpiresAt: state.ExpiresAt}, nil
|
||||
case RequestStatusApproved:
|
||||
if state.UserID == "" {
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
// Delete the approved request before returning so only one exchange can continue
|
||||
if err = a.client.DeleteState(ctx); err != nil {
|
||||
return requestActorResult{}, fmt.Errorf("failed to delete consumed device login actor state: %w", err)
|
||||
}
|
||||
return requestActorResult{
|
||||
Status: state.Status,
|
||||
ExpiresAt: state.ExpiresAt,
|
||||
}, nil
|
||||
default:
|
||||
return requestActorResult{Code: requestActorResultInvalid}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *requestActor) liveState(ctx context.Context) (requestActorState, bool, error) {
|
||||
state, err := a.loadState(ctx)
|
||||
if err != nil {
|
||||
return requestActorState{}, false, err
|
||||
}
|
||||
if state.Code == "" {
|
||||
return state, false, nil
|
||||
}
|
||||
return state, state.ExpiresAt.After(time.Now()), nil
|
||||
}
|
||||
|
||||
func (a *requestActor) loadState(ctx context.Context) (requestActorState, error) {
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return requestActorState{}, fmt.Errorf("failed to load device login actor state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (a *requestActor) persistState(ctx context.Context, state requestActorState) error {
|
||||
ttl := time.Until(state.ExpiresAt)
|
||||
if ttl <= 0 {
|
||||
return errors.New("cannot persist expired device login actor state")
|
||||
}
|
||||
|
||||
err := a.client.SetState(ctx, state, &actor.SetStateOpts{
|
||||
TTL: ttl,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to persist device login actor state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeActorInput(data actor.Envelope, target any) error {
|
||||
if data == nil {
|
||||
return errors.New("device login actor input is missing")
|
||||
}
|
||||
|
||||
err := data.Decode(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode device login actor input: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
baseURL string
|
||||
appConfig AppConfigProvider
|
||||
}
|
||||
|
||||
func newHandler(service *Service, baseURL string, appConfig AppConfigProvider) *handler {
|
||||
return &handler{
|
||||
service: service,
|
||||
baseURL: baseURL,
|
||||
appConfig: appConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// createRequest godoc
|
||||
// @Summary Create device login request
|
||||
// @Description Create a short-lived request that can be approved from another authenticated device
|
||||
// @Tags Device Login
|
||||
// @Produce json
|
||||
// @Success 201 {object} requestCreateDto "Created device login request"
|
||||
// @Router /api/device-login/requests [post]
|
||||
func (h *handler) createRequest(c *gin.Context) {
|
||||
request, deviceToken, err := h.service.Create(c.Request.Context(), c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
verificationURI := h.baseURL + "/device"
|
||||
verificationURIComplete := verificationURI + "?user_code=" + url.QueryEscape(request.Code)
|
||||
cookie.AddDeviceLoginTokenCookie(c, request.ID, deviceToken)
|
||||
c.JSON(http.StatusCreated, requestCreateDto{
|
||||
ID: request.ID,
|
||||
UserCode: request.Code,
|
||||
VerificationURI: verificationURI,
|
||||
VerificationURIComplete: verificationURIComplete,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
Interval: PollingInterval,
|
||||
})
|
||||
}
|
||||
|
||||
// exchangeRequest godoc
|
||||
// @Summary Exchange device login request
|
||||
// @Description Wait for a device login decision and create a browser session after it has been approved
|
||||
// @Tags Device Login
|
||||
// @Produce json
|
||||
// @Param id path string true "Device login request ID"
|
||||
// @Success 200 {object} dto.UserDto "Approved request exchanged for a user session"
|
||||
// @Success 202 "Authorization pending"
|
||||
// @Router /api/device-login/requests/{id}/exchange [post]
|
||||
func (h *handler) exchangeRequest(c *gin.Context) {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
requestID := c.Param("id")
|
||||
deviceToken, _ := c.Cookie(cookie.DeviceLoginTokenCookieName)
|
||||
sessionDuration := dbConfig.SessionDuration.AsDurationMinutes()
|
||||
user, accessToken, status, err := h.service.Exchange(c.Request.Context(), requestID, deviceToken, c.ClientIP(), c.Request.UserAgent(), sessionDuration)
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
// Context canceled = the client stopped the request
|
||||
// Nothing to do here
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
if status == RequestStatusPending {
|
||||
// Request is pending, so respond with a 202
|
||||
c.Status(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(sessionDuration.Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||
c.JSON(http.StatusOK, dto.UserDto(user))
|
||||
}
|
||||
|
||||
// inspectRequest godoc
|
||||
// @Summary Inspect device login request
|
||||
// @Description Retrieve the requesting device details for an authenticated user before approval or denial
|
||||
// @Tags Device Login
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body verificationDto true "Device login code"
|
||||
// @Success 200 {object} verificationInfoDto "Device login request details"
|
||||
// @Router /api/device-login/verification [post]
|
||||
func (h *handler) inspectRequest(c *gin.Context) {
|
||||
var input verificationDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := h.service.Inspect(c.Request.Context(), input.Code)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, verificationInfoDto(info))
|
||||
}
|
||||
|
||||
// decideRequest godoc
|
||||
// @Summary Decide device login request
|
||||
// @Description Approve or deny a device login request; approval requires fresh passkey reauthentication
|
||||
// @Tags Device Login
|
||||
// @Accept json
|
||||
// @Param decision body decisionDto true "Device login decision"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/device-login/verification/decision [post]
|
||||
func (h *handler) decideRequest(c *gin.Context) {
|
||||
var input decisionDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
|
||||
err = h.service.Decide(c.Request.Context(), input.Code, input.Decision, c.GetString("userID"), reauthenticationToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type RequestStatus string
|
||||
|
||||
const (
|
||||
RequestStatusPending RequestStatus = "pending"
|
||||
RequestStatusApproved RequestStatus = "approved"
|
||||
RequestStatusDenied RequestStatus = "denied"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
ID string
|
||||
Code string
|
||||
Status RequestStatus
|
||||
ExpiresAt datatype.DateTime
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
type TokenService interface {
|
||||
GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error)
|
||||
}
|
||||
|
||||
type ReauthenticationTokenConsumer interface {
|
||||
ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error)
|
||||
}
|
||||
|
||||
type AuditLogger interface {
|
||||
Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
|
||||
DeviceStringFromUserAgent(userAgent string) string
|
||||
}
|
||||
|
||||
type AppConfigProvider interface {
|
||||
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
BaseURL string
|
||||
|
||||
Signer TokenService
|
||||
Reauth ReauthenticationTokenConsumer
|
||||
AuditLog AuditLogger
|
||||
AppConfig AppConfigProvider
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(deps Dependencies) (*Module, error) {
|
||||
service := NewService(deps.Actors.Service(), deps.DB, deps.Signer, deps.Reauth, deps.AuditLog)
|
||||
module := &Module{
|
||||
service: service,
|
||||
handler: newHandler(service, deps.BaseURL, deps.AppConfig),
|
||||
}
|
||||
|
||||
// Register the durable request actor before the host starts
|
||||
err := deps.Actors.RegisterActor(
|
||||
requestActorType,
|
||||
newRequestActor,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register device login actor: %w", err)
|
||||
}
|
||||
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the public exchange and authenticated verification endpoints
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, browserAuth, createRateLimit, exchangeRateLimit, verificationRateLimit gin.HandlerFunc) {
|
||||
apiGroup.POST("/device-login/requests", createRateLimit, m.handler.createRequest)
|
||||
apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, m.handler.exchangeRequest)
|
||||
apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, m.handler.inspectRequest)
|
||||
apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, m.handler.decideRequest)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
RequestDuration = 15 * time.Minute
|
||||
PollingInterval = 3
|
||||
longPollingDuration = 25 * time.Second
|
||||
actorPollingInterval = 2 * time.Second
|
||||
codePrefix = "P"
|
||||
codeRandomLength = 7
|
||||
reauthenticationMaxAge = time.Minute
|
||||
// authenticationMethodOneTimePassword identifies the login-code-equivalent AMR used on the waiting device
|
||||
authenticationMethodOneTimePassword = "otp"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
actService *actor.Service
|
||||
db *gorm.DB
|
||||
signer TokenService
|
||||
reauth ReauthenticationTokenConsumer
|
||||
auditLog AuditLogger
|
||||
}
|
||||
|
||||
type VerificationInfo struct {
|
||||
UserCode string
|
||||
Device string
|
||||
IPAddress string
|
||||
ExpiresAt datatype.DateTime
|
||||
}
|
||||
|
||||
func NewService(actService *actor.Service, db *gorm.DB, signer TokenService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger) *Service {
|
||||
return &Service{
|
||||
actService: actService,
|
||||
db: db,
|
||||
signer: signer,
|
||||
reauth: reauth,
|
||||
auditLog: auditLog,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, ipAddress, userAgent string) (Request, string, error) {
|
||||
// Bind the public request to a separate high-entropy secret that never enters the QR code
|
||||
deviceToken, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return Request{}, "", err
|
||||
}
|
||||
deviceTokenHash := utils.CreateSha256Hash(deviceToken)
|
||||
|
||||
// Retry code generation because of the small but non-zero chance of a live actor collision
|
||||
for range 3 {
|
||||
code, codeErr := newUserCode()
|
||||
if codeErr != nil {
|
||||
return Request{}, "", codeErr
|
||||
}
|
||||
|
||||
result, err := s.invoke(ctx, code, requestActorMethodCreate, requestActorCreateInput{
|
||||
Code: code,
|
||||
DeviceTokenHash: deviceTokenHash,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
if err != nil {
|
||||
return Request{}, "", err
|
||||
}
|
||||
if result.Code == requestActorResultCollision {
|
||||
continue
|
||||
}
|
||||
err = actorResultError(result.Code)
|
||||
if err != nil {
|
||||
return Request{}, "", err
|
||||
}
|
||||
|
||||
return Request{
|
||||
ID: code,
|
||||
Code: code,
|
||||
Status: result.Status,
|
||||
ExpiresAt: datatype.DateTime(result.ExpiresAt),
|
||||
}, deviceToken, nil
|
||||
}
|
||||
|
||||
return Request{}, "", errors.New("failed to generate a unique device login code")
|
||||
}
|
||||
|
||||
func (s *Service) Inspect(ctx context.Context, code string) (VerificationInfo, error) {
|
||||
actorID := normalizeUserCode(code)
|
||||
|
||||
// Read the pending actor state without taking an exclusive actor turn
|
||||
result, err := s.peek(ctx, actorID, requestActorMethodInspect, nil)
|
||||
if err != nil {
|
||||
return VerificationInfo{}, err
|
||||
}
|
||||
|
||||
err = actorResultError(result.Code)
|
||||
if err != nil {
|
||||
return VerificationInfo{}, err
|
||||
}
|
||||
|
||||
return VerificationInfo{
|
||||
UserCode: result.UserCode,
|
||||
Device: s.auditLog.DeviceStringFromUserAgent(result.UserAgent),
|
||||
IPAddress: result.IPAddress,
|
||||
ExpiresAt: datatype.DateTime(result.ExpiresAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenticationToken string) error {
|
||||
actorID := normalizeUserCode(code)
|
||||
|
||||
// Consume the fresh passkey proof outside the actor before approving the request
|
||||
if decision == "approve" {
|
||||
if err := s.consumeReauthenticationProof(ctx, reauthenticationToken, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Let the actor serialize the decision with every competing exchange
|
||||
result, err := s.invoke(ctx, actorID, requestActorMethodDecide, requestActorDecisionInput{
|
||||
Decision: decision,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return actorResultError(result.Code)
|
||||
}
|
||||
|
||||
func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string, sessionDuration time.Duration) (dto.UserDto, string, RequestStatus, error) {
|
||||
if requestID == "" || deviceToken == "" || sessionDuration <= 0 {
|
||||
return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
deviceTokenHash := utils.CreateSha256Hash(deviceToken)
|
||||
timeout := time.NewTimer(longPollingDuration)
|
||||
defer timeout.Stop()
|
||||
ticker := time.NewTicker(actorPollingInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
// Poll the actor's activation cache so the long-lived HTTP request does not repeatedly query the database
|
||||
result, err := s.peek(ctx, requestID, requestActorMethodPoll, requestActorPollInput{DeviceTokenHash: deviceTokenHash})
|
||||
if err != nil {
|
||||
return dto.UserDto{}, "", "", err
|
||||
}
|
||||
|
||||
err = actorResultError(result.Code)
|
||||
if err != nil {
|
||||
return dto.UserDto{}, "", result.Status, err
|
||||
}
|
||||
|
||||
switch result.Status {
|
||||
case RequestStatusApproved:
|
||||
// Validate the approved user before consuming so lookup failures leave the request untouched
|
||||
user, userDTO, err := s.loadExchangeUser(ctx, result.UserID)
|
||||
if err != nil {
|
||||
return dto.UserDto{}, "", result.Status, err
|
||||
}
|
||||
|
||||
// Consume inside the actor so only one concurrent exchange can mint a token
|
||||
consume, err := s.invoke(ctx, requestID, requestActorMethodConsume, requestActorConsumeInput{
|
||||
DeviceTokenHash: deviceTokenHash,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.UserDto{}, "", "", err
|
||||
}
|
||||
|
||||
err = actorResultError(consume.Code)
|
||||
if err != nil {
|
||||
return dto.UserDto{}, "", consume.Status, err
|
||||
}
|
||||
|
||||
// Mint the session with login-code semantics because the waiting device did not perform WebAuthn
|
||||
accessToken, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword, sessionDuration)
|
||||
if err != nil {
|
||||
return dto.UserDto{}, "", consume.Status, err
|
||||
}
|
||||
|
||||
// Record the successful remote sign-in after the request has been consumed
|
||||
_, created := s.auditLog.Create(ctx, model.AuditLogEventRemoteSignIn, ipAddress, userAgent, user.ID, model.AuditLogData{}, s.db)
|
||||
if !created {
|
||||
return dto.UserDto{}, "", consume.Status, errors.New("failed to create device login audit log")
|
||||
}
|
||||
|
||||
return userDTO, accessToken, consume.Status, nil
|
||||
case RequestStatusPending:
|
||||
// no-op
|
||||
case RequestStatusDenied:
|
||||
return dto.UserDto{}, "", result.Status, &common.DeviceLoginDeniedError{}
|
||||
default:
|
||||
return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// no-op
|
||||
case <-timeout.C:
|
||||
return dto.UserDto{}, "", RequestStatusPending, nil
|
||||
case <-ctx.Done():
|
||||
return dto.UserDto{}, "", "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userID string) error {
|
||||
if token == "" {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
|
||||
tx := s.db.WithContext(ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, tx, token, userID)
|
||||
if err != nil {
|
||||
var reauthenticationRequiredError *common.ReauthenticationRequiredError
|
||||
if errors.As(err, &reauthenticationRequiredError) {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if time.Since(reauthenticatedAt) > reauthenticationMaxAge {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("error committing database transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) loadExchangeUser(ctx context.Context, userID string) (model.User, dto.UserDto, error) {
|
||||
var user model.User
|
||||
err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return model.User{}, dto.UserDto{}, &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
case err != nil:
|
||||
return model.User{}, dto.UserDto{}, err
|
||||
case user.Disabled:
|
||||
return model.User{}, dto.UserDto{}, &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
var userDTO dto.UserDto
|
||||
if err = dto.MapStruct(user, &userDTO); err != nil {
|
||||
return model.User{}, dto.UserDto{}, fmt.Errorf("failed to map exchanged device login user: %w", err)
|
||||
}
|
||||
|
||||
return user, userDTO, nil
|
||||
}
|
||||
|
||||
func normalizeUserCode(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
return utils.NormalizeUnambiguousString(code)
|
||||
}
|
||||
|
||||
func (s *Service) invoke(ctx context.Context, actorID, method string, input any) (requestActorResult, error) {
|
||||
envelope, err := s.actService.Invoke(ctx, requestActorType, actorID, method, input)
|
||||
if err != nil {
|
||||
return requestActorResult{}, err
|
||||
}
|
||||
|
||||
return decodeActorResult(envelope)
|
||||
}
|
||||
|
||||
func (s *Service) peek(ctx context.Context, actorID, method string, input any) (requestActorResult, error) {
|
||||
envelope, err := s.actService.Peek(ctx, requestActorType, actorID, method, input)
|
||||
if err != nil {
|
||||
return requestActorResult{}, err
|
||||
}
|
||||
|
||||
return decodeActorResult(envelope)
|
||||
}
|
||||
|
||||
func decodeActorResult(envelope actor.Envelope) (requestActorResult, error) {
|
||||
if envelope == nil {
|
||||
return requestActorResult{}, errors.New("device login actor returned an empty response")
|
||||
}
|
||||
|
||||
var result requestActorResult
|
||||
err := envelope.Decode(&result)
|
||||
if err != nil {
|
||||
return requestActorResult{}, fmt.Errorf("failed to decode device login actor response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func actorResultError(code requestActorResultCode) error {
|
||||
switch code {
|
||||
case requestActorResultNone:
|
||||
return nil
|
||||
case requestActorResultCollision:
|
||||
return errors.New("unexpected live device login actor collision")
|
||||
case requestActorResultInvalid:
|
||||
return &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
case requestActorResultDenied:
|
||||
return &common.DeviceLoginDeniedError{}
|
||||
default:
|
||||
return fmt.Errorf("unsupported device login actor result %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func newUserCode() (string, error) {
|
||||
randomCode, err := utils.GenerateRandomUppercaseUnambiguousString(codeRandomLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return codePrefix + randomCode, nil
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
//go:build exclude_frontend && unit
|
||||
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testSessionDuration = time.Hour
|
||||
)
|
||||
|
||||
type fakeReauthenticationTokenConsumer struct {
|
||||
expectedValue string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) {
|
||||
if token != f.expectedValue {
|
||||
return time.Time{}, &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
if !f.createdAt.IsZero() {
|
||||
return f.createdAt, nil
|
||||
}
|
||||
return time.Now(), nil
|
||||
}
|
||||
|
||||
type fakeTokenService struct {
|
||||
mu sync.Mutex
|
||||
userID string
|
||||
authenticationMethod string
|
||||
sessionDuration time.Duration
|
||||
generated int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTokenService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.userID = user.ID
|
||||
f.authenticationMethod = authenticationMethod
|
||||
f.sessionDuration = sessionDuration
|
||||
f.generated++
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return "device-login-access-token", nil
|
||||
}
|
||||
|
||||
func (f *fakeTokenService) generatedToken() (string, string, time.Duration, int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.userID, f.authenticationMethod, f.sessionDuration, f.generated
|
||||
}
|
||||
|
||||
type auditEntry struct {
|
||||
event model.AuditLogEvent
|
||||
ipAddress string
|
||||
userAgent string
|
||||
userID string
|
||||
}
|
||||
|
||||
type fakeAuditLogger struct {
|
||||
mu sync.Mutex
|
||||
entries []auditEntry
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.entries = append(f.entries, auditEntry{event: event, ipAddress: ipAddress, userAgent: userAgent, userID: userID})
|
||||
return model.AuditLog{}, true
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) DeviceStringFromUserAgent(userAgent string) string {
|
||||
return "Parsed " + userAgent
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) lastEntry() auditEntry {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.entries[len(f.entries)-1]
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) entryCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.entries)
|
||||
}
|
||||
|
||||
type serviceFixture struct {
|
||||
service *Service
|
||||
actors *actor.Service
|
||||
signer *fakeTokenService
|
||||
auditLog *fakeAuditLogger
|
||||
reauth *fakeReauthenticationTokenConsumer
|
||||
}
|
||||
|
||||
func TestRequestLifecycle(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "device-login-user"},
|
||||
Username: "device-login-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "192.0.2.10", "Mozilla/5.0 Chrome/125.0.0.0")
|
||||
require.NoError(t, err)
|
||||
require.Regexp(t, `^P[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]{7}$`, request.Code)
|
||||
require.Equal(t, request.Code, request.ID)
|
||||
require.Equal(t, RequestStatusPending, request.Status)
|
||||
|
||||
state := getRequestActorState(t, fixture.actors, request.ID)
|
||||
require.Equal(t, utils.CreateSha256Hash(deviceToken), state.DeviceTokenHash)
|
||||
require.NotEqual(t, deviceToken, state.DeviceTokenHash)
|
||||
require.Equal(t, RequestStatusPending, state.Status)
|
||||
|
||||
info, err := fixture.service.Inspect(t.Context(), strings.ToLower(request.Code))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, request.Code, info.UserCode)
|
||||
require.Equal(t, "192.0.2.10", info.IPAddress)
|
||||
require.Equal(t, "Parsed Mozilla/5.0 Chrome/125.0.0.0", info.Device)
|
||||
|
||||
err = fixture.service.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof")
|
||||
require.NoError(t, err)
|
||||
|
||||
exchangedUser, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent", testSessionDuration)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RequestStatusApproved, status)
|
||||
require.Equal(t, user.ID, exchangedUser.ID)
|
||||
require.Equal(t, "device-login-access-token", accessToken)
|
||||
|
||||
signedUserID, authenticationMethod, sessionDuration, generated := fixture.signer.generatedToken()
|
||||
require.Equal(t, user.ID, signedUserID)
|
||||
require.Equal(t, authenticationMethodOneTimePassword, authenticationMethod)
|
||||
require.Equal(t, testSessionDuration, sessionDuration)
|
||||
require.Equal(t, 1, generated)
|
||||
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
|
||||
|
||||
entry := fixture.auditLog.lastEntry()
|
||||
require.Equal(t, model.AuditLogEventRemoteSignIn, entry.event)
|
||||
require.Equal(t, "198.51.100.20", entry.ipAddress)
|
||||
require.Equal(t, "target-agent", entry.userAgent)
|
||||
require.Equal(t, user.ID, entry.userID)
|
||||
|
||||
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
assertInvalidRequestError(t, err)
|
||||
|
||||
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", testSessionDuration)
|
||||
assertInvalidRequestError(t, err)
|
||||
_, _, _, generated = fixture.signer.generatedToken()
|
||||
require.Equal(t, 1, generated)
|
||||
require.Equal(t, 1, fixture.auditLog.entryCount())
|
||||
}
|
||||
|
||||
func TestPendingAndDeniedRequests(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := fixture.service.peek(t.Context(), request.ID, requestActorMethodPoll, requestActorPollInput{
|
||||
DeviceTokenHash: utils.CreateSha256Hash(deviceToken),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RequestStatusPending, result.Status)
|
||||
|
||||
err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
var deniedError *common.DeviceLoginDeniedError
|
||||
require.ErrorAs(t, err, &deniedError)
|
||||
require.Equal(t, RequestStatusDenied, status)
|
||||
require.Empty(t, user.ID)
|
||||
require.Empty(t, accessToken)
|
||||
}
|
||||
|
||||
func TestPendingExchangeObservesDecisionDuringLongPoll(t *testing.T) {
|
||||
db := testutils.NewConcurrentDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
type exchangeOutcome struct {
|
||||
status RequestStatus
|
||||
err error
|
||||
}
|
||||
result := make(chan exchangeOutcome, 1)
|
||||
go func() {
|
||||
_, _, status, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
result <- exchangeOutcome{status: status, err: exchangeErr}
|
||||
}()
|
||||
|
||||
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
|
||||
|
||||
select {
|
||||
case outcome := <-result:
|
||||
var deniedError *common.DeviceLoginDeniedError
|
||||
require.ErrorAs(t, outcome.err, &deniedError)
|
||||
require.Equal(t, RequestStatusDenied, outcome.status)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("exchange did not observe the actor decision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsInvalidAndExpiredRequestsWhileActorIsActive(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
unknownRequestID := strings.Repeat("a", 64)
|
||||
_, _, _, err = fixture.service.Exchange(t.Context(), unknownRequestID, "device-token", "", "", testSessionDuration)
|
||||
assertInvalidRequestError(t, err)
|
||||
_, err = fixture.service.Inspect(t.Context(), unknownRequestID)
|
||||
assertInvalidRequestError(t, err)
|
||||
err = fixture.service.Decide(t.Context(), unknownRequestID, "deny", "device-login-user", "")
|
||||
assertInvalidRequestError(t, err)
|
||||
|
||||
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", testSessionDuration)
|
||||
assertInvalidRequestError(t, err)
|
||||
|
||||
state := getRequestActorState(t, fixture.actors, request.ID)
|
||||
state.ExpiresAt = time.Now().Add(-time.Second)
|
||||
require.NoError(t, fixture.actors.Halt(requestActorType, request.ID))
|
||||
require.NoError(t, fixture.actors.SetState(t.Context(), requestActorType, request.ID, state, nil))
|
||||
_, err = fixture.service.Inspect(t.Context(), request.Code)
|
||||
assertInvalidRequestError(t, err)
|
||||
err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
|
||||
assertInvalidRequestError(t, err)
|
||||
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
assertInvalidRequestError(t, err)
|
||||
}
|
||||
|
||||
func TestRejectsDisabledUserAtExchange(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "disabled-device-login-user"},
|
||||
Username: "disabled-device-login-user",
|
||||
Disabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
||||
|
||||
_, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
var disabledError *common.UserDisabledError
|
||||
require.ErrorAs(t, err, &disabledError)
|
||||
require.Empty(t, accessToken)
|
||||
require.Equal(t, RequestStatusApproved, getRequestActorState(t, fixture.actors, request.ID).Status)
|
||||
}
|
||||
|
||||
func TestFailedTokenGenerationConsumesApprovedRequest(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
fixture.signer.err = errors.New("token generation failed")
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "token-failure-device-login-user"},
|
||||
Username: "token-failure-device-login-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
||||
|
||||
_, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
require.EqualError(t, err, "token generation failed")
|
||||
require.Empty(t, accessToken)
|
||||
require.Equal(t, RequestStatusApproved, status)
|
||||
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
|
||||
require.Equal(t, 0, fixture.auditLog.entryCount())
|
||||
|
||||
_, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
assertInvalidRequestError(t, err)
|
||||
}
|
||||
|
||||
func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
request, _, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "")
|
||||
var reauthenticationError *common.ReauthenticationRequiredError
|
||||
require.ErrorAs(t, err, &reauthenticationError)
|
||||
|
||||
fixture.reauth.expectedValue = "stale-proof"
|
||||
fixture.reauth.createdAt = time.Now().Add(-2 * time.Minute)
|
||||
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof")
|
||||
require.ErrorAs(t, err, &reauthenticationError)
|
||||
require.Equal(t, RequestStatusPending, getRequestActorState(t, fixture.actors, request.ID).Status)
|
||||
}
|
||||
|
||||
func TestNormalizeUserCodeAliases(t *testing.T) {
|
||||
require.Equal(t, "P100-110", normalizeUserCode(" piO0-i1o "))
|
||||
}
|
||||
|
||||
func TestConcurrentExchangeAllowsOnlyOneSuccess(t *testing.T) {
|
||||
db := testutils.NewConcurrentDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "single-use-device-login-user"},
|
||||
Username: "single-use-device-login-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
type exchangeResult struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
results := make(chan exchangeResult, 2)
|
||||
for range 2 {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
_, token, _, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
results <- exchangeResult{token: token, err: exchangeErr}
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
close(results)
|
||||
|
||||
var successfulTokens []string
|
||||
var invalidExchanges int
|
||||
for result := range results {
|
||||
if result.err == nil {
|
||||
successfulTokens = append(successfulTokens, result.token)
|
||||
continue
|
||||
}
|
||||
var invalidRequestError *common.DeviceLoginRequestInvalidOrExpiredError
|
||||
require.ErrorAs(t, result.err, &invalidRequestError)
|
||||
invalidExchanges++
|
||||
}
|
||||
require.Equal(t, []string{"device-login-access-token"}, successfulTokens)
|
||||
require.Equal(t, 1, invalidExchanges)
|
||||
require.Equal(t, 1, fixture.auditLog.entryCount())
|
||||
_, _, _, generated := fixture.signer.generatedToken()
|
||||
require.Equal(t, 1, generated)
|
||||
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
|
||||
}
|
||||
|
||||
func TestCreateCollisionPreservesOriginalActorState(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "192.0.2.1", "original-agent")
|
||||
require.NoError(t, err)
|
||||
original := getRequestActorState(t, fixture.actors, request.ID)
|
||||
|
||||
result, err := fixture.service.invoke(t.Context(), request.ID, requestActorMethodCreate, requestActorCreateInput{
|
||||
Code: request.Code,
|
||||
DeviceTokenHash: utils.CreateSha256Hash("different-token"),
|
||||
IPAddress: "198.51.100.1",
|
||||
UserAgent: "replacement-agent",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, requestActorResultCollision, result.Code)
|
||||
require.Equal(t, original, getRequestActorState(t, fixture.actors, request.ID))
|
||||
require.NotEqual(t, deviceToken, original.DeviceTokenHash)
|
||||
}
|
||||
|
||||
func TestRequestStateSurvivesActorHostRestart(t *testing.T) {
|
||||
db := testutils.NewConcurrentDatabaseForTest(t)
|
||||
deps := persistentTestDependencies(db)
|
||||
|
||||
firstModule, stopFirst := startPersistentDeviceLoginHost(t, db, deps)
|
||||
request, deviceToken, err := firstModule.service.Create(t.Context(), "192.0.2.1", "persistent-agent")
|
||||
require.NoError(t, err)
|
||||
stopFirst()
|
||||
|
||||
secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps)
|
||||
defer stopSecond()
|
||||
info, err := secondModule.service.Inspect(t.Context(), request.Code)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "persistent-agent", strings.TrimPrefix(info.Device, "Parsed "))
|
||||
require.NoError(t, secondModule.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
|
||||
_, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
var deniedError *common.DeviceLoginDeniedError
|
||||
require.ErrorAs(t, err, &deniedError)
|
||||
require.Equal(t, RequestStatusDenied, status)
|
||||
}
|
||||
|
||||
func TestCompletedExchangeIsInvalidAfterActorHostRestart(t *testing.T) {
|
||||
db := testutils.NewConcurrentDatabaseForTest(t)
|
||||
deps := persistentTestDependencies(db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "completed-exchange-user"},
|
||||
Username: "completed-exchange-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
firstModule, stopFirst := startPersistentDeviceLoginHost(t, db, deps)
|
||||
request, deviceToken, err := firstModule.service.Create(t.Context(), "", "persistent-agent")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, firstModule.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
||||
_, _, firstStatus, err := firstModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RequestStatusApproved, firstStatus)
|
||||
stopFirst()
|
||||
|
||||
secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps)
|
||||
defer stopSecond()
|
||||
_, _, _, err = secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
assertInvalidRequestError(t, err)
|
||||
|
||||
_, _, _, generated := deps.Signer.(*fakeTokenService).generatedToken()
|
||||
require.Equal(t, 1, generated)
|
||||
require.Equal(t, 1, deps.AuditLog.(*fakeAuditLogger).entryCount())
|
||||
}
|
||||
|
||||
func TestPendingExchangeStopsWhenRequestIsCanceled(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fixture := newServiceFixture(t, db)
|
||||
|
||||
request, deviceToken, err := fixture.service.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
started := make(chan struct{})
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
close(started)
|
||||
_, _, _, exchangeErr := fixture.service.Exchange(ctx, request.ID, deviceToken, "", "", testSessionDuration)
|
||||
result <- exchangeErr
|
||||
}()
|
||||
|
||||
<-started
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case exchangeErr := <-result:
|
||||
require.ErrorIs(t, exchangeErr, context.Canceled)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("canceled exchange did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func newServiceFixture(t *testing.T, db *gorm.DB) serviceFixture {
|
||||
t.Helper()
|
||||
signer := &fakeTokenService{}
|
||||
auditLog := &fakeAuditLogger{}
|
||||
reauth := &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"}
|
||||
var module *Module
|
||||
host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
|
||||
var err error
|
||||
module, err = New(Dependencies{
|
||||
DB: db,
|
||||
Actors: host,
|
||||
Signer: signer,
|
||||
AuditLog: auditLog,
|
||||
Reauth: reauth,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
return serviceFixture{
|
||||
service: module.service,
|
||||
actors: host.Service(),
|
||||
signer: signer,
|
||||
auditLog: auditLog,
|
||||
reauth: reauth,
|
||||
}
|
||||
}
|
||||
|
||||
func getRequestActorState(t *testing.T, actors *actor.Service, actorID string) requestActorState {
|
||||
t.Helper()
|
||||
var state requestActorState
|
||||
require.NoError(t, actors.GetState(t.Context(), requestActorType, actorID, &state))
|
||||
return state
|
||||
}
|
||||
|
||||
func requireRequestActorStateDeleted(t *testing.T, actors *actor.Service, actorID string) {
|
||||
t.Helper()
|
||||
var state requestActorState
|
||||
require.ErrorIs(t, actors.GetState(t.Context(), requestActorType, actorID, &state), actor.ErrStateNotFound)
|
||||
}
|
||||
|
||||
func assertInvalidRequestError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var invalidError *common.DeviceLoginRequestInvalidOrExpiredError
|
||||
require.ErrorAs(t, err, &invalidError)
|
||||
}
|
||||
|
||||
func persistentTestDependencies(db *gorm.DB) Dependencies {
|
||||
return Dependencies{
|
||||
DB: db,
|
||||
Signer: &fakeTokenService{},
|
||||
AuditLog: &fakeAuditLogger{},
|
||||
Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"},
|
||||
}
|
||||
}
|
||||
|
||||
func startPersistentDeviceLoginHost(t *testing.T, db *gorm.DB, deps Dependencies) (*Module, func()) {
|
||||
t.Helper()
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
_, err = sqlDB.Exec("PRAGMA foreign_keys = ON")
|
||||
require.NoError(t, err)
|
||||
|
||||
host, err := local.NewHost(
|
||||
local.WithAddress(freeLoopbackAddress(t)),
|
||||
local.WithRuntimePSKs([]byte("pocket-id-device-login-test-host-psk")),
|
||||
local.WithSQLiteProvider(local.SQLiteProviderOptions{DB: sqlDB}),
|
||||
local.WithShutdownGracePeriod(time.Second),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
deps.Actors = host
|
||||
module, err := New(deps)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- host.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-host.Ready():
|
||||
case runErr := <-errCh:
|
||||
t.Fatalf("persistent actor host stopped before becoming ready: %v", runErr)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("timed out waiting for persistent actor host")
|
||||
}
|
||||
|
||||
var stopOnce sync.Once
|
||||
stop := func() {
|
||||
stopOnce.Do(func() {
|
||||
cancel()
|
||||
runErr := <-errCh
|
||||
if runErr != nil && !errors.Is(runErr, context.Canceled) {
|
||||
require.NoError(t, runErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
return module, stop
|
||||
}
|
||||
|
||||
func freeLoopbackAddress(t *testing.T) string {
|
||||
t.Helper()
|
||||
var listenConfig net.ListenConfig
|
||||
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
address := listener.Addr().String()
|
||||
require.NoError(t, listener.Close())
|
||||
return address
|
||||
}
|
||||
@@ -19,15 +19,18 @@ import (
|
||||
// Rate-limit policy names
|
||||
// Each constant names a limiter registered on the actor host and is the value passed to Add to select that limiter
|
||||
const (
|
||||
RateLimitAPI = "api"
|
||||
RateLimitSignup = "signup"
|
||||
RateLimitWebauthnLogin = "webauthn-login"
|
||||
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
|
||||
RateLimitOneTimeAccessToken = "one-time-access-token"
|
||||
RateLimitOneTimeAccessEmail = "one-time-access-email"
|
||||
RateLimitSendEmailVerification = "send-email-verification"
|
||||
RateLimitVerifyEmail = "verify-email"
|
||||
RateLimitInternal = "internal"
|
||||
RateLimitAPI = "api"
|
||||
RateLimitSignup = "signup"
|
||||
RateLimitWebauthnLogin = "webauthn-login"
|
||||
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
|
||||
RateLimitOneTimeAccessToken = "one-time-access-token"
|
||||
RateLimitOneTimeAccessEmail = "one-time-access-email"
|
||||
RateLimitDeviceLoginCreate = "device-login-create"
|
||||
RateLimitDeviceLoginExchange = "device-login-exchange"
|
||||
RateLimitDeviceLoginVerification = "device-login-verification"
|
||||
RateLimitSendEmailVerification = "send-email-verification"
|
||||
RateLimitVerifyEmail = "verify-email"
|
||||
RateLimitInternal = "internal"
|
||||
)
|
||||
|
||||
// RateLimitPolicy is the configuration for a single rate-limit actor
|
||||
@@ -53,6 +56,9 @@ func RateLimitPolicies() []RateLimitPolicy {
|
||||
{Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitOneTimeAccessEmail, Rate: 2, Per: 10 * time.Minute, Burst: 5},
|
||||
{Name: RateLimitDeviceLoginCreate, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitDeviceLoginExchange, Rate: 1, Per: 2 * time.Second, Burst: 10},
|
||||
{Name: RateLimitDeviceLoginVerification, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitSendEmailVerification, Rate: 2, Per: 10 * time.Minute, Burst: 1},
|
||||
{Name: RateLimitVerifyEmail, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitInternal, Rate: 20, Per: time.Second, Burst: 20},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -219,6 +221,9 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID
|
||||
}
|
||||
|
||||
func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) {
|
||||
userCode = strings.ToUpper(strings.TrimSpace(userCode))
|
||||
userCode = utils.NormalizeUnambiguousString(userCode)
|
||||
|
||||
userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -61,6 +61,14 @@ func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t
|
||||
require.Equal(t, 1, reauth.calls)
|
||||
}
|
||||
|
||||
func TestDeviceServiceCreatesUserCodeWithOAuthPrefix(t *testing.T) {
|
||||
service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, "test-client", "test-user", false, nil)
|
||||
|
||||
require.Regexp(t, `^E[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]{7}$`, userCode)
|
||||
_, _, err := service.deviceRequestFromUserCode(t.Context(), strings.ToLower(userCode))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.T) {
|
||||
const (
|
||||
userID = "test-user"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -148,6 +148,8 @@ func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Durat
|
||||
}
|
||||
|
||||
func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
|
||||
token = utils.NormalizeUnambiguousString(token)
|
||||
|
||||
// Consume the token by invoking its actor: this atomically validates it and, if valid, deletes it.
|
||||
// It must happen outside of a DB transaction, since invoking an actor while a transaction is open would deadlock on SQLite.
|
||||
res, err := s.actorService.Invoke(ctx, TokenActorType, token, tokenMethodConsume, tokenConsumeRequest{
|
||||
|
||||
@@ -99,6 +99,28 @@ func TestExchangeTokenSuccess(t *testing.T) {
|
||||
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventOneTimeAccessTokenSignIn}, auditLog.events)
|
||||
}
|
||||
|
||||
func TestExchangeTokenAcceptsAmbiguousAliases(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc, host, _ := newServiceForTest(t, db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "alias-user"},
|
||||
Username: "alias-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
const token = "a10bc2"
|
||||
require.NoError(t, host.SetState(t.Context(), TokenActorType, token, TokenState{
|
||||
UserID: user.ID,
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
}, &actor.SetStateOpts{TTL: time.Minute}))
|
||||
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
exchangedUser, _, err := svc.ExchangeToken(t.Context(), dbConfig, "aIObc2", "", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, exchangedUser.ID)
|
||||
}
|
||||
|
||||
func TestExchangeTokenInvalidToken(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc, _, _ := newServiceForTest(t, db)
|
||||
|
||||
@@ -612,8 +612,8 @@ func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error {
|
||||
token string
|
||||
ttl time.Duration
|
||||
}{
|
||||
{token: "HPe6k6uiDRRVuAQV", ttl: time.Hour},
|
||||
{token: "one-time-token", ttl: time.Hour},
|
||||
{token: "HPe6k6u1DRRVuAQV", ttl: time.Hour},
|
||||
{token: "0ne-t1me-t0ken", ttl: time.Hour},
|
||||
}
|
||||
|
||||
for _, t := range tokens {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// CreateSha256Hash creates the SHA256 hash of a string, returning a hex-encoded string.
|
||||
func CreateSha256Hash(input string) string {
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ConstantTimeStringEqual compares two strings in constant time to prevent timing attacks.
|
||||
func ConstantTimeStringEqual(left, right string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
||||
}
|
||||
|
||||
@@ -19,10 +19,21 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
|
||||
|
||||
// GenerateRandomUnambiguousString generates a random string of the given length using unambiguous characters
|
||||
func GenerateRandomUnambiguousString(length int) (string, error) {
|
||||
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ0123456789"
|
||||
return GenerateRandomString(length, charset)
|
||||
}
|
||||
|
||||
// GenerateRandomUppercaseUnambiguousString generates a random uppercase string of the given length using unambiguous characters
|
||||
func GenerateRandomUppercaseUnambiguousString(length int) (string, error) {
|
||||
const charset = "ABCDEFGHJKMNPQRSTUVWXYZ0123456789"
|
||||
return GenerateRandomString(length, charset)
|
||||
}
|
||||
|
||||
// NormalizeUnambiguousString converts commonly confused letters to the canonical digits used by generated codes
|
||||
func NormalizeUnambiguousString(value string) string {
|
||||
return strings.NewReplacer("I", "1", "i", "1", "O", "0", "o", "0").Replace(value)
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a random string of the given length using the provided character set
|
||||
func GenerateRandomString(length int, charset string) (string, error) {
|
||||
|
||||
@@ -30,6 +41,10 @@ func GenerateRandomString(length int, charset string) (string, error) {
|
||||
return "", errors.New("length must be a positive integer")
|
||||
}
|
||||
|
||||
if len(charset) == 0 {
|
||||
return "", errors.New("character set must not be empty")
|
||||
}
|
||||
|
||||
// The algorithm below is adapted from https://stackoverflow.com/a/35615565
|
||||
const (
|
||||
letterIdxBits = 6 // 6 bits to represent a letter index
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestGenerateRandomUnambiguousString(t *testing.T) {
|
||||
t.Errorf("Expected length %d, got %d", length, len(str))
|
||||
}
|
||||
|
||||
matched, err := regexp.MatchString(`^[abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789]+$`, str)
|
||||
matched, err := regexp.MatchString(`^[abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ0123456789]+$`, str)
|
||||
if err != nil {
|
||||
t.Errorf("Regex match failed: %v", err)
|
||||
}
|
||||
@@ -86,6 +86,26 @@ func TestGenerateRandomUnambiguousString(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateRandomUppercaseUnambiguousString(t *testing.T) {
|
||||
const length = 10
|
||||
str, err := GenerateRandomUppercaseUnambiguousString(length)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(str) != length {
|
||||
t.Errorf("Expected length %d, got %d", length, len(str))
|
||||
}
|
||||
if !regexp.MustCompile(`^[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]+`).MatchString(str) {
|
||||
t.Errorf("String contains lowercase or ambiguous characters: %s", str)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUnambiguousString(t *testing.T) {
|
||||
if normalized := NormalizeUnambiguousString("iIoO-abc"); normalized != "1100-abc" {
|
||||
t.Errorf("Expected ambiguous letters to be converted, got %s", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomUnambiguousStringCharacterIndependence(t *testing.T) {
|
||||
const (
|
||||
sampleCount = 100_000
|
||||
@@ -112,7 +132,6 @@ func TestGenerateRandomUnambiguousStringCharacterIndependence(t *testing.T) {
|
||||
t.Errorf("first and last character collision rate = %.4f, want at most %.4f", collisionRate, maxAcceptableCollisionRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomString(t *testing.T) {
|
||||
t.Run("valid length returns characters from charset", func(t *testing.T) {
|
||||
const length = 20
|
||||
@@ -146,6 +165,14 @@ func TestGenerateRandomString(t *testing.T) {
|
||||
t.Error("Expected error for negative length, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty charset returns error", func(t *testing.T) {
|
||||
_, err := GenerateRandomString(10, "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty charset, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestCapitalizeFirstLetter(t *testing.T) {
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
DeviceLoginDecision,
|
||||
DeviceLoginExchangeResult,
|
||||
DeviceLoginRequest,
|
||||
DeviceLoginVerificationInfo
|
||||
} from '$lib/types/device-login.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
export default class DeviceLoginService extends APIService {
|
||||
createRequest = async (signal?: AbortSignal) => {
|
||||
const response = await this.api.post('/device-login/requests', undefined, { signal });
|
||||
return response.data as DeviceLoginRequest;
|
||||
};
|
||||
|
||||
exchangeRequest = async (
|
||||
requestId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<DeviceLoginExchangeResult> => {
|
||||
const response = await this.api.post(
|
||||
`/device-login/requests/${requestId}/exchange`,
|
||||
undefined,
|
||||
{ signal }
|
||||
);
|
||||
return response.status === 202 ? null : response.data;
|
||||
};
|
||||
|
||||
inspectRequest = async (code: string) => {
|
||||
const response = await this.api.post('/device-login/verification', { code });
|
||||
return response.data as DeviceLoginVerificationInfo;
|
||||
};
|
||||
|
||||
decideRequest = async (code: string, decision: DeviceLoginDecision) => {
|
||||
await this.api.post('/device-login/verification/decision', { code, decision });
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
import ScopeList from '$lib/components/scope-list.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as InputOTP from '$lib/components/ui/input-otp';
|
||||
import { Spinner } from '$lib/components/ui/spinner';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import DeviceLoginService from '$lib/services/device-login-service';
|
||||
import OIDCService from '$lib/services/oidc-service';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { DeviceLoginVerificationInfo } from '$lib/types/device-login.type';
|
||||
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
@@ -21,17 +24,26 @@
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const deviceLoginService = new DeviceLoginService();
|
||||
const oidcService = new OIDCService();
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
let userCode = $state(data.code || '');
|
||||
let isLoading = $state(false);
|
||||
let deviceInfo: OidcDeviceCodeInfo | undefined = $state();
|
||||
let deviceLoginInfo: DeviceLoginVerificationInfo | undefined = $state();
|
||||
let success = $state(false);
|
||||
let deviceLoginOutcome: 'approved' | 'denied' | undefined = $state();
|
||||
let deviceLoginDecision: 'approve' | 'deny' | undefined = $state();
|
||||
let errorMessage: string | null = $state(null);
|
||||
let authorizationRequired = $state(false);
|
||||
let reauthenticationRequired = $state(false);
|
||||
let reauthenticated = $state(false);
|
||||
let normalizedUserCode = $derived(
|
||||
userCode.trim().toUpperCase().replaceAll('I', '1').replaceAll('O', '0')
|
||||
);
|
||||
let codeComplete = $derived(normalizedUserCode.length === 8);
|
||||
let completed = $derived(success || deviceLoginOutcome !== undefined);
|
||||
|
||||
onMount(() => {
|
||||
if (data.code && $userStore) {
|
||||
@@ -40,28 +52,29 @@
|
||||
});
|
||||
|
||||
async function authorize() {
|
||||
if (!data.code && !codeComplete) return;
|
||||
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
// Get access token if not signed in
|
||||
if (!$userStore) {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
const user = await webauthnService.finishLogin(authResponse);
|
||||
await userStore.setUser(user);
|
||||
await authenticateUserIfNeeded();
|
||||
|
||||
let isDeviceLoginCode = normalizedUserCode.startsWith('P');
|
||||
if (isDeviceLoginCode) {
|
||||
deviceLoginInfo = await deviceLoginService.inspectRequest(normalizedUserCode);
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await oidcService.getDeviceCodeInfo(userCode);
|
||||
const info = await oidcService.getDeviceCodeInfo(normalizedUserCode);
|
||||
deviceInfo = info;
|
||||
|
||||
if (info.authorizationRequired && !authorizationRequired) {
|
||||
authorizationRequired = true;
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.reauthenticationRequired && !reauthenticationRequired && !authorizationRequired) {
|
||||
reauthenticationRequired = true;
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,16 +83,42 @@
|
||||
reauthenticated = true;
|
||||
}
|
||||
|
||||
await oidcService.verifyDeviceCode(userCode);
|
||||
|
||||
await oidcService.verifyDeviceCode(normalizedUserCode);
|
||||
success = true;
|
||||
} catch (e) {
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
} catch (error) {
|
||||
errorMessage = getWebauthnErrorMessage(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateUserIfNeeded() {
|
||||
if ($userStore) return;
|
||||
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
const user = await webauthnService.finishLogin(authResponse);
|
||||
await userStore.setUser(user);
|
||||
}
|
||||
|
||||
async function decideDeviceLogin(decision: 'approve' | 'deny') {
|
||||
isLoading = true;
|
||||
deviceLoginDecision = decision;
|
||||
errorMessage = null;
|
||||
try {
|
||||
if (decision === 'approve') {
|
||||
await reauthenticate();
|
||||
}
|
||||
await deviceLoginService.decideRequest(normalizedUserCode, decision);
|
||||
deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied';
|
||||
} catch (error) {
|
||||
errorMessage = getWebauthnErrorMessage(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
deviceLoginDecision = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function reauthenticate() {
|
||||
try {
|
||||
await webauthnService.reauthenticate();
|
||||
@@ -89,6 +128,16 @@
|
||||
await webauthnService.reauthenticate(authResponse);
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
errorMessage = null;
|
||||
userCode = '';
|
||||
if (!deviceLoginInfo) {
|
||||
deviceInfo = undefined;
|
||||
authorizationRequired = false;
|
||||
reauthenticationRequired = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -100,16 +149,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 +205,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 +223,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}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<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;
|
||||
let requestController: AbortController | undefined;
|
||||
|
||||
onMount(() => {
|
||||
void startRequest();
|
||||
|
||||
return () => {
|
||||
stopRequest();
|
||||
};
|
||||
});
|
||||
|
||||
async function startRequest() {
|
||||
stopRequest();
|
||||
const controller = new AbortController();
|
||||
requestController = controller;
|
||||
request = undefined;
|
||||
errorMessage = null;
|
||||
isStarting = true;
|
||||
|
||||
try {
|
||||
request = await deviceLoginService.createRequest(controller.signal);
|
||||
if (requestController !== controller) return;
|
||||
schedulePoll(controller);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
errorMessage = getAxiosErrorMessage(error);
|
||||
} finally {
|
||||
if (requestController === controller) {
|
||||
isStarting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll(controller: AbortController) {
|
||||
if (!request || requestController !== controller) return;
|
||||
pollTimer = setTimeout(() => void exchangeRequest(controller), request.interval * 1000);
|
||||
}
|
||||
|
||||
async function exchangeRequest(controller: AbortController) {
|
||||
if (!request || requestController !== controller) return;
|
||||
|
||||
try {
|
||||
const user = await deviceLoginService.exchangeRequest(request.id, controller.signal);
|
||||
if (requestController !== controller) return;
|
||||
if (!user) {
|
||||
schedulePoll(controller);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
await userStore.setUser(user);
|
||||
if (requestController !== controller) return;
|
||||
await goto(data.redirect);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
clearTimers();
|
||||
errorMessage = getAxiosErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
function stopRequest() {
|
||||
requestController?.abort();
|
||||
requestController = undefined;
|
||||
clearTimers();
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
return {
|
||||
redirect: url.searchParams.get('redirect') || '/settings'
|
||||
};
|
||||
};
|
||||
+2
-2
@@ -121,8 +121,8 @@ export const userGroups = {
|
||||
};
|
||||
|
||||
export const oneTimeAccessTokens = [
|
||||
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
|
||||
{ token: 'YCGDtftvsvYWiXd0', expired: true }
|
||||
{ token: 'HPe6k6u1DRRVuAQV', expired: false },
|
||||
{ token: 'YCGDtftvsvYW1Xd0', expired: true }
|
||||
];
|
||||
|
||||
export const emailVerificationTokens = [
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test, type Browser } from '@playwright/test';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import passkeyUtil from '../utils/passkey.util';
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await cleanupBackend();
|
||||
});
|
||||
|
||||
test('approves the QR link after requester review and fresh reauthentication', async ({
|
||||
browser,
|
||||
page
|
||||
}) => {
|
||||
const waiting = await openWaitingDevice(browser, '/settings/apps');
|
||||
const expectedLoginCodeText = `${waiting.request.userCode.substring(0, 4)} - ${waiting.request.userCode.substring(4, 8)}`;
|
||||
try {
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
await expect(waiting.page.getByTestId('device-login-code')).toHaveText(expectedLoginCodeText);
|
||||
await expect(waiting.page.getByLabel('Device login QR code')).toBeVisible();
|
||||
|
||||
await page.goto(waiting.request.verificationUriComplete);
|
||||
|
||||
await expect(page.getByText(expectedLoginCodeText)).toBeVisible();
|
||||
await expect(page.getByText('Chrome', { exact: false })).toBeVisible();
|
||||
const ipAddress = page.locator('dt', { hasText: 'IP Address' }).locator('..').locator('dd');
|
||||
await expect(ipAddress).not.toHaveText('Unknown');
|
||||
|
||||
const decisionWithoutReauthentication = await page.request.post(
|
||||
'/api/device-login/verification/decision',
|
||||
{
|
||||
data: { code: waiting.request.userCode, decision: 'approve' }
|
||||
}
|
||||
);
|
||||
expect(decisionWithoutReauthentication.status()).toBe(401);
|
||||
|
||||
const reauthenticationRequest = page.waitForRequest('/api/webauthn/reauthenticate');
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
await reauthenticationRequest;
|
||||
await expect(page.getByText('The requesting device has been signed in.')).toBeVisible();
|
||||
await waiting.page.waitForURL('/settings/apps');
|
||||
} finally {
|
||||
await waiting.context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('authenticates a signed-out primary device before manual-code approval', async ({
|
||||
browser
|
||||
}) => {
|
||||
const waiting = await openWaitingDevice(browser, '/settings/account');
|
||||
const primaryContext = await browser.newContext({
|
||||
baseURL: test.info().project.use.baseURL,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const primaryPage = await primaryContext.newPage();
|
||||
|
||||
try {
|
||||
await (await passkeyUtil.init(primaryPage)).addPasskey();
|
||||
await primaryPage.goto('/device');
|
||||
await primaryPage.getByRole('textbox', { name: 'Code' }).fill(waiting.request.userCode);
|
||||
await primaryPage.getByRole('button', { name: 'Authorize' }).click();
|
||||
|
||||
await primaryPage.getByRole('button', { name: 'Approve' }).click();
|
||||
await expect(primaryPage.getByText('The requesting device has been signed in.')).toBeVisible();
|
||||
await waiting.page.waitForURL('/settings/account');
|
||||
} finally {
|
||||
await primaryContext.close();
|
||||
await waiting.context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('denies a pending request without passkey reauthentication', async ({ browser, page }) => {
|
||||
const waiting = await openWaitingDevice(browser);
|
||||
|
||||
try {
|
||||
let reauthenticationCalled = false;
|
||||
await page.route('/api/webauthn/reauthenticate', async (route) => {
|
||||
reauthenticationCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto(waiting.request.verificationUriComplete);
|
||||
await page.getByRole('button', { name: 'Deny' }).click();
|
||||
|
||||
await expect(page.getByText('The sign-in request was denied.')).toBeVisible();
|
||||
await expect(waiting.page.getByText('Device login request was denied')).toBeVisible();
|
||||
expect(reauthenticationCalled).toBe(false);
|
||||
} finally {
|
||||
await waiting.context.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openWaitingDevice(browser: Browser, redirect = '/settings') {
|
||||
const context = await browser.newContext({
|
||||
baseURL: test.info().project.use.baseURL,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const createResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
response.url().endsWith('/api/device-login/requests')
|
||||
);
|
||||
|
||||
await page.goto(`/login/alternative?redirect=${encodeURIComponent(redirect)}`);
|
||||
await expect(page.getByText('Sign in with another device', { exact: true })).toBeVisible();
|
||||
await page.getByText('Sign in with another device', { exact: true }).click();
|
||||
|
||||
const response = await createResponse;
|
||||
expect(response.status()).toBe(201);
|
||||
const request = await response.json();
|
||||
expect(request.userCode).toMatch(/^P[ABCDEFGHJKMNPQRSTUVWXYZ0123456789]{7}$/);
|
||||
return {
|
||||
context,
|
||||
page,
|
||||
request
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user