package webauthn import ( "context" "errors" "fmt" "time" "github.com/gin-gonic/gin" "github.com/italypaleale/francis/host/local" "github.com/lestrrat-go/jwx/v3/jwt" "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) VerifyAccessToken(tokenString string) (jwt.Token, error) GetAuthenticationMethod(token jwt.Token) (string, error) } type AuditLogger interface { Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, emailLoginNotificationEnabled bool) model.AuditLog } type Dependencies struct { DB *gorm.DB Actors *local.Host AppURL string Signer TokenService AuditLog AuditLogger AppConfig appconfig.AppConfigResolver // CleanupDisabled skips registering the cron jobs that delete expired rows from the database, for example in tests CleanupDisabled bool } type Module struct { service *Service handler *handler } func New(deps Dependencies) (*Module, error) { service, err := newService(deps) if err != nil { return nil, err } // Register the cleanup jobs for expired WebAuthn rows if !deps.CleanupDisabled { if deps.Actors == nil { return nil, errors.New("actor host is required for the WebAuthn cleanup cron jobs") } jobs, err := newCleanupJobs(deps.DB) if err != nil { return nil, err } for _, cj := range jobs { err = deps.Actors.RegisterBuiltInActor(cj) if err != nil { return nil, fmt.Errorf("error registering WebAuthn cleanup cron actor %q: %w", cj.ActorType(), err) } } } return &Module{ service: service, handler: newHandler(service, deps.AppConfig), }, nil } // RegisterRoutes mounts the WebAuthn registration, login and reauthentication endpoints func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, browserAuth, loginRateLimit, reauthRateLimit gin.HandlerFunc) { apiGroup.GET("/webauthn/register/start", browserAuth, httpserver.Handle(m.handler.beginRegistration)) apiGroup.POST("/webauthn/register/finish", browserAuth, httpserver.Handle(m.handler.verifyRegistration)) apiGroup.GET("/webauthn/login/start", httpserver.Handle(m.handler.beginLogin)) apiGroup.POST("/webauthn/login/finish", loginRateLimit, httpserver.Handle(m.handler.verifyLogin)) apiGroup.POST("/webauthn/logout", userAuth, httpserver.Handle(m.handler.logout)) apiGroup.POST("/webauthn/reauthenticate", browserAuth, reauthRateLimit, httpserver.Handle(m.handler.reauthenticate)) apiGroup.GET("/webauthn/credentials", userAuth, httpserver.Handle(m.handler.listCredentials)) apiGroup.PATCH("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.updateCredential)) apiGroup.DELETE("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.deleteCredential)) } // ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface func (m *Module) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) { return m.service.ConsumeReauthenticationToken(ctx, tx, token, userID) } // ListCredentials returns the passkeys registered for the given user // It is consumed by the user controller for the admin "manage passkeys" view func (m *Module) ListCredentials(ctx context.Context, userID string) ([]model.WebauthnCredential, error) { return m.service.ListCredentials(ctx, userID) } // DeleteCredential removes a passkey, optionally on behalf of an admin acting for another user // It is consumed by the user controller for the admin "manage passkeys" view func (m *Module) DeleteCredential(ctx context.Context, userID, credentialID, ipAddress, userAgent, actorUserID string) error { return m.service.DeleteCredential(ctx, userID, credentialID, ipAddress, userAgent, actorUserID) }