Files
pocket-id/backend/internal/devicelogin/module.go
Alessandro (Ale) Segala 600ca3f31f feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When it is
empty or set to "embedded" (the default) nothing changes: Pocket ID starts
the runtime inside its own process, backed by its own database. Any other
value is the address, or a comma-separated list of addresses, of a
standalone Francis runtime; Pocket ID then connects to it as a remote actor
host and starts no embedded runtime.

Connecting to a standalone runtime also needs FRANCIS_HOST_PSK, the host
bootstrap pre-shared key the runtime is configured with, and optionally
FRANCIS_CA, the PEM-encoded cluster CA to pin before the first connection.
Without a pinned CA Francis trusts the certificate it is served on first
use, and warns about it.

The actor host is now held as the topology-agnostic francis host.Host
interface, since the concrete type depends on the configuration. The
commands that reach the actor data through Pocket ID's own database
(export, import, and one-time-access-token) fail with an explicit error
when a standalone runtime owns that data instead, rather than silently
operating on the wrong store.
2026-08-31 05:26:59 +00:00

77 lines
2.5 KiB
Go

package devicelogin
import (
"context"
"fmt"
"time"
"github.com/gin-gonic/gin"
francishost "github.com/italypaleale/francis/host"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"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 IPLocationResolver interface {
GetLocationByIP(ctx context.Context, ipAddress string) (country string, city string, err error)
}
type Dependencies struct {
DB *gorm.DB
Actors francishost.Host
BaseURL string
Signer TokenService
Reauth ReauthenticationTokenConsumer
AuditLog AuditLogger
IPLocator IPLocationResolver
AppConfig appconfig.AppConfigResolver
}
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, deps.IPLocator)
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, httpserver.Handle(m.handler.createRequest))
apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, httpserver.Handle(m.handler.exchangeRequest))
apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, httpserver.Handle(m.handler.inspectRequest))
apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, httpserver.Handle(m.handler.decideRequest))
}