Fluent database storage implementation for Passage authentication framework.
This package provides persistent storage for all Passage authentication data using Vapor's Fluent ORM, including users, refresh tokens, verification codes, password reset codes, and passkey (WebAuthn) credentials and challenges.
Note: This package cannot be used standalone. It requires Passage and a Fluent database driver (PostgreSQL, MySQL, SQLite, etc.) to function.
Add the package to your Package.swift:
dependencies: [
.package(url: "https://github.com/rozd/passage-fluent.git", from: "0.0.1"),
]Then add PassageFluent to your target dependencies:
.target(
name: "App",
dependencies: [
.product(name: "PassageFluent", package: "passage-fluent"),
]
)Configure DatabaseStore with your Vapor application and database:
import Passage
import PassageFluent
let store = DatabaseStore(app: app, db: app.db)Then pass it to Passage during configuration:
app.passage.configure(
services: .init(
store: store,
// ... other services
),
configuration: .init(/* ... */)
)Note:
DatabaseStoreautomatically registers all required migrations. Runapp.autoMigrate()or use Vapor's migration commands to apply them.
The following tables are automatically created:
| Table | Description |
|---|---|
users |
Core user entity with password hash |
identifiers |
Polymorphic user identifiers (email, phone, username, federated) |
refresh_tokens |
JWT refresh tokens with rotation chain tracking |
email_verification_codes |
Email verification codes with expiration |
phone_verification_codes |
Phone verification codes with expiration |
email_password_reset_codes |
Email-based password reset codes |
phone_password_reset_codes |
Phone-based password reset codes |
exchange_tokens |
OAuth exchange tokens for federated login |
passkey_credentials |
WebAuthn credential records (credential ID, COSE public key, sign count, transports, backup state) |
passkey_challenges |
One-shot passkey ceremony challenges, stored as SHA-256 hashes with TTL and consumption tracking |
- Create users with email, phone, username, or federated identity
- Support for multiple identifiers per user
- Email and phone verification status tracking
- Password hash storage with secure updates
- Refresh token storage with secure hashing
- Token rotation with family chain tracking
- Automatic revocation of compromised token families
- Per-user token revocation for logout
- Time-limited verification codes
- Failed attempt tracking for rate limiting
- Automatic code invalidation after use
- Separate flows for email and phone
- Full W3C credential record persistence: credential ID, COSE public key, sign count, transports, backup-eligibility, AAGUID, attestation format
- Transparent SHA-256 hashing of challenge bytes — plain-text challenges never touch the database
- One-shot challenge consumption with expiry for all three ceremony entry points: discoverable authentication (
createPasskeyChallenge(from:)), authenticated user adding a passkey (createPasskeyChallenge(for: User, from:)), and guest registration where the user does not yet exist (createPasskeyChallenge(for: Identifier, from:)) cleanupExpiredPasskeyChallenges(before:)for periodic GC of abandoned ceremonies- Cascade-delete on user removal wipes the user's credentials and user-bound challenges
DatabaseStore conforms to the optional Passage.PasskeyCredentialStore and Passage.PasskeyChallengeStore sub-stores out of the box — passkey flows in Passage light up as soon as a PasskeyService is registered. See vapor-community/passage for the service-side configuration.
By default, DatabaseStore creates its own users and identifiers tables (island mode). If your app already has a user table, use overlay mode to reuse it.
Note: Overlay mode supports all identifier kinds — email, phone, username, and federated — exactly like island mode. The
identifierstable (S1) remains the source of truth for lookups. The example below is intentionally narrow: it models an existing table with aNOT NULL emailcolumn, which is whystoredEmailis non-optional andpassageMakeUserthrows for non-email identifiers. Those are constraints of the example schema, not of overlay mode — a model with nullable columns can accept every identifier kind (the defaultpassageMakeUseralready does).
import Passage
import PassageFluent
final class AppUser: Model, PassageUserModel, ModelSessionAuthenticatable, @unchecked Sendable {
static let schema = "app_users"
@ID(custom: "id", generatedBy: .database)
var id: Int?
// Stored with a distinct name: a non-optional `String` cannot witness
// Passage.User's `var email: String? { get }` requirement directly.
@Field(key: "email")
var storedEmail: String
@Field(key: "email_verified")
var emailVerified: Bool
@OptionalField(key: "password_hash")
var passwordHash: String?
// ... other fields
// MARK: - PassageUserModel Conformance
/// Called when creating a new user (e.g., during registration).
/// Override to populate required columns from the identifier.
static func passageMakeUser(identifier: Identifier, passwordHash: String?) throws -> AppUser {
// This table's NOT NULL email column can't store other kinds —
// a schema with nullable columns can accept phone/username/federated too.
guard identifier.kind == .email else {
throw PassageError.unexpected(message: "Only email identifiers supported")
}
let user = AppUser()
user.storedEmail = identifier.value
user.emailVerified = false
user.passwordHash = passwordHash
return user
}
/// Override if you track verification state in your own columns.
/// Called after the `IdentifierModel` row is marked verified.
static func passageDidMarkIdentifierVerified(
_ kind: Identifier.Kind,
for user: AppUser,
on db: any Database
) async throws {
if kind == .email {
user.emailVerified = true
try await user.save(on: db)
}
}
}
// MARK: - Passage.User Conformance
extension AppUser: User {
public var email: String? { storedEmail.isEmpty ? nil : storedEmail }
public var phone: String? { nil }
public var username: String? { nil }
public var isAnonymous: Bool { storedEmail.isEmpty }
public var isEmailVerified: Bool { emailVerified }
public var isPhoneVerified: Bool { false }
}Conformance Requirements:
final class— required byPassageUserModel'sSelf-returning requirements (and Fluent convention)@unchecked Sendable— required for Vapor concurrencySessionAuthenticatable— easiest viaModelSessionAuthenticatablemixin- Single-column
@ID(UUID, Int, String, or customCodabletype) — no@CompositeID - Implement
Passage.Usercomputed properties (email, phone, username, isEmailVerified, etc.)
The FK ordering requirement: PassageFluent's dependent tables reference your user table, so your user migration must run before PassageFluent migrations.
app.migrations.add(CreateAppUser()) // Your user tableInject your user model type; PassageFluent creates the identifiers table and all token/code tables:
let store = DatabaseStore(
app: app,
db: app.db,
userModelType: AppUser.self
)Provide both the model type AND a factory for a custom Passage.UserStore implementation that operates on your user table. PassageFluent skips creating the identifiers table.
The factory receives the Database the store must use. DatabaseStore.transaction calls it again with the transaction's connection so user writes commit or roll back together with tokens, codes and credentials — bind your store to the database it is handed, never to a captured app.db:
struct AppUserStore: Passage.UserStore {
typealias ConcreateUser = AppUser
let db: any Database
var userType: AppUser.Type { AppUser.self }
func find(byIdentifier identifier: Identifier) async throws -> (any User)? {
// Query your app_users table directly. This demo store only resolves
// emails — implement whichever kinds your schema can answer.
guard identifier.kind == .email else { return nil }
return try await AppUser.query(on: db)
.filter(\.$storedEmail == identifier.value)
.first()
}
// ... implement other required methods
}
let store = DatabaseStore(
app: app,
db: app.db,
userModelType: AppUser.self,
userStore: { AppUserStore(db: $0) }
)Support any Fluent ID type — UUID (default), Int, String, or custom:
// Int ID example (supported out-of-box)
@ID(custom: "id", generatedBy: .database)
var id: Int?
// String ID example (supported out-of-box)
@ID
var id: String?
// Custom type (implement passageParseUserID and passageUserIDDataType)
@ID
var id: MyCustomID?Control which migrations are registered via the registerMigrations: parameter:
// Register Passage migrations (default = true)
let store = DatabaseStore(
app: app,
db: app.db,
userModelType: AppUser.self,
registerMigrations: true
)
// Manual control (advanced)
app.migrations.add(
contentsOf: DatabaseStore.migrations(
for: AppUser.self,
includeIdentifiers: true, // S1 mode
isIslandMode: false
)
)If you don't override PassageUserModel hooks, derive Passage.User computed properties from the identifiers relationship:
extension AppUser: User {
@Children(for: \.$user)
var identifiers: [IdentifierModel<AppUser>]
public var email: String? {
identifiers.first { $0.type == "email" }?.value
}
public var isEmailVerified: Bool {
identifiers.first { $0.type == "email" }?.verified == true
}
}Pass any Fluent database to DatabaseStore:
// Use a specific database (e.g., for multi-database setups)
let store = DatabaseStore(app: app, db: app.db(.auth))
// Or use the default database
let store = DatabaseStore(app: app, db: app.db)DatabaseStore is tested against SQLite and designed for SQL drivers. Two behaviours depend on the driver being an SQLDatabase:
- Indexes on the token tables are created with raw SQL and are skipped on non-SQL drivers.
- Refresh-token concurrency limits (
configuration.refreshToken.concurrency = .single/.limit(n)) are enforced by taking aSELECT … FOR UPDATElock on the user row so that overlapping logins are serialised. PostgreSQL and MySQL honour the lock; SQLite has no row locks but permits a single writer, so it is safe there too. On non-SQL drivers (e.g. MongoDB) Fluent exposes no locking primitive, so the limit is best-effort: two logins that overlap can leave one more session active than configured. If you need a strict limit on such a backend, provide a customPassage.TokenStorethat serialises per-user issuance with a driver-native lock.
- Swift 6.2+
- macOS 13+ / Linux
- Vapor 4.119+
- Fluent 4.13+
This project is licensed under the MIT License - see the LICENSE file for details.