├── Public
├── .gitkeep
├── favicon.ico
├── mstile-70x70.png
├── favicon-16x16.png
├── favicon-32x32.png
├── mstile-144x144.png
├── mstile-150x150.png
├── mstile-310x150.png
├── mstile-310x310.png
├── apple-touch-icon.png
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── browserconfig.xml
├── scripts
│ ├── utils.js
│ ├── createAccount.js
│ ├── signIn.js
│ └── base64.js
├── site.webmanifest
├── images
│ ├── github-mark.svg
│ ├── person.badge.key.fill.svg
│ └── vapor-logo.svg
├── safari-pinned-tab.svg
└── styles
│ └── tailwind.css
├── Sources
├── App
│ ├── Controllers
│ │ └── .gitkeep
│ ├── Models
│ │ ├── API
│ │ │ ├── MakeCredentialResponse.swift
│ │ │ └── StartAuthenticateResponse.swift
│ │ ├── User.swift
│ │ └── WebAuthnCredential.swift
│ ├── Migrations
│ │ ├── CreateUser.swift
│ │ └── CreateWebAuthnCredential.swift
│ ├── Jobs
│ │ └── DeleteUsers.swift
│ ├── Services
│ │ └── Application+WebAuthn.swift
│ ├── configure.swift
│ └── routes.swift
└── Run
│ └── main.swift
├── .dockerignore
├── Procfile
├── images
├── demo.png
└── passkeys.webp
├── .env.example
├── .gitignore
├── tailwind.config.js
├── .github
└── workflows
│ ├── test.yml
│ └── deploy.yml
├── Tests
└── AppTests
│ └── AppTests.swift
├── fly.toml
├── Resources
├── Utils
│ └── styles.css
└── Views
│ ├── private.leaf
│ ├── base.leaf
│ └── index.leaf
├── .vscode
└── launch.json
├── LICENSE
├── README.md
├── docker-compose.yml
├── Package.swift
├── Dockerfile
└── Package.resolved
/Public/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Sources/App/Controllers/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .build/
2 | .swiftpm/
3 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: Run serve --env production --hostname 0.0.0.0 --port $PORT
2 |
--------------------------------------------------------------------------------
/images/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/images/demo.png
--------------------------------------------------------------------------------
/Public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/favicon.ico
--------------------------------------------------------------------------------
/images/passkeys.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/images/passkeys.webp
--------------------------------------------------------------------------------
/Public/mstile-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/mstile-70x70.png
--------------------------------------------------------------------------------
/Public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/favicon-16x16.png
--------------------------------------------------------------------------------
/Public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/favicon-32x32.png
--------------------------------------------------------------------------------
/Public/mstile-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/mstile-144x144.png
--------------------------------------------------------------------------------
/Public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/mstile-150x150.png
--------------------------------------------------------------------------------
/Public/mstile-310x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/mstile-310x150.png
--------------------------------------------------------------------------------
/Public/mstile-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/mstile-310x310.png
--------------------------------------------------------------------------------
/Public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/apple-touch-icon.png
--------------------------------------------------------------------------------
/Public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/Public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brokenhandsio/Vapor-PasskeyDemo/HEAD/Public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | SQLITE_DATABASE_PATH=db.sqlite
2 | RP_ID=localhost
3 | RP_ORIGIN=http://localhost:8080
4 | RP_DISPLAY_NAME=Vapor Passkey Demo
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | Packages
2 | .build
3 | xcuserdata
4 | *.xcodeproj
5 | DerivedData/
6 | .DS_Store
7 | *.sqlite
8 | .swiftpm
9 | .env
10 | tailwindcss
11 |
--------------------------------------------------------------------------------
/Sources/App/Models/API/MakeCredentialResponse.swift:
--------------------------------------------------------------------------------
1 | import Vapor
2 |
3 | struct MakeCredentialResponse: Content {
4 | let userID: String
5 | let challenge: String
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/App/Models/API/StartAuthenticateResponse.swift:
--------------------------------------------------------------------------------
1 | import Vapor
2 |
3 | struct StartAuthenticateResponse: Content {
4 | let challenge: String
5 | let credentials: [WebAuthnCredential]
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/Run/main.swift:
--------------------------------------------------------------------------------
1 | import App
2 | import Vapor
3 |
4 | var env = try Environment.detect()
5 | try LoggingSystem.bootstrap(from: &env)
6 | let app = Application(env)
7 | defer { app.shutdown() }
8 | try configure(app)
9 | try app.run()
10 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./Resources/**/*.leaf",
5 | ],
6 | theme: {
7 | extend: {},
8 | },
9 | plugins: [
10 | require('@tailwindcss/forms')
11 | ],
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/Public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #2b5797
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 |
3 | on: [push]
4 |
5 | jobs:
6 | test:
7 | name: Run Tests
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v3
11 | - name: Build
12 | run: swift build
13 | - name: Run tests
14 | run: swift test
--------------------------------------------------------------------------------
/Public/scripts/utils.js:
--------------------------------------------------------------------------------
1 | function bufferEncode(value) {
2 | return base64js.fromByteArray(value)
3 | .replace(/\+/g, "-")
4 | .replace(/\//g, "_")
5 | .replace(/=/g, "");
6 | }
7 |
8 | function bufferDecode(value) {
9 | return Uint8Array.from(atob(value.replace(/_/g, '/').replace(/-/g, '+')), c => c.charCodeAt(0));
10 | }
11 |
--------------------------------------------------------------------------------
/Tests/AppTests/AppTests.swift:
--------------------------------------------------------------------------------
1 | @testable import App
2 | import XCTVapor
3 |
4 | final class AppTests: XCTestCase {
5 | func testIndex() throws {
6 | let app = Application(.testing)
7 | defer { app.shutdown() }
8 | try configure(app)
9 |
10 | try app.test(.GET, "", afterResponse: { res in
11 | XCTAssertEqual(res.status, .ok)
12 | })
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to Fly.io
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 |
7 | jobs:
8 | deploy:
9 | name: Build and deploy
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: superfly/flyctl-actions/setup-flyctl@master
14 | - run: flyctl deploy --local-only
15 | env:
16 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for vapor-passkey-demo on 2023-06-05T10:35:12+02:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = "vapor-passkey-demo"
7 | primary_region = "lhr"
8 |
9 | [mounts]
10 | source = "sqlite_data"
11 | destination = "/data"
12 |
13 | [http_service]
14 | internal_port = 8080
15 | force_https = true
16 | auto_stop_machines = true
17 | auto_start_machines = true
18 |
--------------------------------------------------------------------------------
/Public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/App/Migrations/CreateUser.swift:
--------------------------------------------------------------------------------
1 | import Fluent
2 |
3 | struct CreateUser: AsyncMigration {
4 | func prepare(on database: Database) async throws {
5 | try await database.schema("users")
6 | .id()
7 | .field("username", .string, .required)
8 | .field("created_at", .datetime, .required)
9 | .unique(on: "username")
10 | .create()
11 | }
12 |
13 | func revert(on database: Database) async throws {
14 | try await database.schema("users").delete()
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/App/Jobs/DeleteUsers.swift:
--------------------------------------------------------------------------------
1 | import Vapor
2 | import Queues
3 | import Fluent
4 |
5 | struct DeleteUsersJob: AsyncScheduledJob {
6 | func run(context: QueueContext) async throws {
7 | let dateTwoHoursAgo = Date().addingTimeInterval(-3600 * 2)
8 | let users = try await User.query(on: context.application.db)
9 | .filter(\.$createdAt < dateTwoHoursAgo)
10 | .all()
11 | try await users.delete(on: context.application.db)
12 | context.logger.info("Deleted \(users.count) users")
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/App/Services/Application+WebAuthn.swift:
--------------------------------------------------------------------------------
1 | import Vapor
2 | import WebAuthn
3 |
4 | extension Application {
5 | struct WebAuthnKey: StorageKey {
6 | typealias Value = WebAuthnManager
7 | }
8 |
9 | var webAuthn: WebAuthnManager {
10 | get {
11 | guard let webAuthn = storage[WebAuthnKey.self] else {
12 | fatalError("WebAuthn is not configured. Use app.webAuthn = ...")
13 | }
14 | return webAuthn
15 | }
16 | set {
17 | storage[WebAuthnKey.self] = newValue
18 | }
19 | }
20 | }
21 |
22 | extension Request {
23 | var webAuthn: WebAuthnManager { application.webAuthn }
24 | }
--------------------------------------------------------------------------------
/Resources/Utils/styles.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer components {
6 | .btn-primary {
7 | @apply flex gap-2 w-full items-center justify-center rounded-lg bg-fuchsia-700 px-6 py-3 font-semibold leading-6 text-white shadow-sm hover:bg-fuchsia-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-800;
8 | }
9 |
10 | .btn-soft {
11 | @apply flex gap-2 w-full items-center justify-center rounded-lg bg-slate-100 px-6 py-3 font-medium leading-6 text-gray-700 shadow-sm hover:bg-slate-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-200;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/App/Migrations/CreateWebAuthnCredential.swift:
--------------------------------------------------------------------------------
1 | import Fluent
2 |
3 | struct CreateWebAuthnCredential: AsyncMigration {
4 | func prepare(on database: Database) async throws {
5 | try await database.schema("webauth_credentals")
6 | .field("id", .string, .identifier(auto: false))
7 | .field("public_key", .string, .required)
8 | .field("current_sign_count", .uint32, .required)
9 | .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
10 | .unique(on: "id")
11 | .create()
12 | }
13 |
14 | func revert(on database: Database) async throws {
15 | try await database.schema("webauth_credentals").delete()
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "configurations": [
3 | {
4 | "type": "lldb",
5 | "request": "launch",
6 | "name": "Debug Run",
7 | "program": "${workspaceFolder:Vapor-PasskeyDemo}/.build/debug/Run",
8 | "args": ["serve", "--hostname", "localhost"],
9 | "cwd": "${workspaceFolder:Vapor-PasskeyDemo}",
10 | "preLaunchTask": "swift: Build Debug Run"
11 | },
12 | {
13 | "type": "lldb",
14 | "request": "launch",
15 | "name": "Release Run",
16 | "program": "${workspaceFolder:Vapor-PasskeyDemo}/.build/release/Run",
17 | "args": ["serve", "--hostname", "localhost"],
18 | "cwd": "${workspaceFolder:Vapor-PasskeyDemo}",
19 | "preLaunchTask": "swift: Build Release Run"
20 | }
21 | ]
22 | }
--------------------------------------------------------------------------------
/Sources/App/Models/User.swift:
--------------------------------------------------------------------------------
1 | import Fluent
2 | import Vapor
3 | import WebAuthn
4 |
5 | final class User: Model, Content {
6 | static let schema: String = "users"
7 |
8 | @ID
9 | var id: UUID?
10 |
11 | @Field(key: "username")
12 | var username: String
13 |
14 | @Timestamp(key: "created_at", on: .create)
15 | var createdAt: Date?
16 |
17 | @Children(for: \.$user)
18 | var credentials: [WebAuthnCredential]
19 |
20 | init() {}
21 |
22 | init(id: UUID? = nil, username: String) {
23 | self.id = id
24 | self.username = username
25 | }
26 | }
27 |
28 | extension User {
29 | var webAuthnUser: PublicKeyCredentialUserEntity {
30 | PublicKeyCredentialUserEntity(id: [UInt8](id!.uuidString.utf8), name: username, displayName: username)
31 | }
32 | }
33 |
34 | extension User: ModelSessionAuthenticatable {}
35 |
--------------------------------------------------------------------------------
/Public/images/github-mark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Tim Condon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Vapor Passkey Demo
4 |
5 | Proof of concept app for trying to integrate passkeys and WebAuthn into Vapor
6 |
7 | 
8 |
9 | ## Usage
10 |
11 | Clone the project, then in Terminal run
12 |
13 | ```bash
14 | swift run
15 | ```
16 |
17 | In your browser go to http://localhost:8080 and follow the steps!
18 |
19 | ## Development
20 |
21 | If you want to make CSS changes you'll need to download the Tailwind CSS executable and place it in the root of the
22 | project:
23 |
24 | ```bash
25 | # Example for macOS arm64
26 | curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64
27 | chmod +x tailwindcss-macos-arm64
28 | mv tailwindcss-macos-arm64 tailwindcss
29 | ```
30 |
31 | Then run the following to generate Tailwind CSS classes and watch for changes:
32 |
33 | ```bash
34 | ./tailwindcss -i Resources/Utils/styles.css -o Public/styles/tailwind.css --watch
35 | ```
36 |
37 | > Do not edit `Public/styles/tailwind.css` manually as it will be overwritten by the above command!
38 |
--------------------------------------------------------------------------------
/Public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
9 |
10 |
12 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/Resources/Views/private.leaf:
--------------------------------------------------------------------------------
1 | #extend("base"):
2 | #export("body"):
3 |
4 |
5 |
Private Area
6 |
7 |
8 |
9 |
10 |
Hello, #(username)!
11 |
12 |
13 | You successfully entered the private area with a passkey. Try logging out and logging in again with the same
14 | passkey.
15 |
16 |
17 |
This account will be deleted ~2h after creation.
18 |
19 |
20 |
21 |
24 |
25 |
26 | #endexport
27 | #endextend
--------------------------------------------------------------------------------
/Sources/App/Models/WebAuthnCredential.swift:
--------------------------------------------------------------------------------
1 | import Fluent
2 | import Vapor
3 | import WebAuthn
4 |
5 | final class WebAuthnCredential: Model, Content {
6 | static let schema = "webauth_credentals"
7 |
8 | @ID(custom: "id", generatedBy: .user)
9 | var id: String?
10 |
11 | @Field(key: "public_key")
12 | var publicKey: String
13 |
14 | @Field(key: "current_sign_count")
15 | var currentSignCount: UInt32
16 |
17 | @Parent(key: "user_id")
18 | var user: User
19 |
20 | // TODO
21 | // Add signature count
22 | // Add attenstation
23 | // authenticatorMetadata?
24 | // lastAccessTime?
25 | // creationDate?
26 |
27 | init() {}
28 |
29 | init(id: String, publicKey: String, currentSignCount: UInt32, userID: UUID) {
30 | self.id = id
31 | self.publicKey = publicKey
32 | self.currentSignCount = currentSignCount
33 | self.$user.id = userID
34 | }
35 |
36 | convenience init(from credential: Credential, userID: UUID) {
37 | self.init(
38 | id: credential.id,
39 | publicKey: credential.publicKey.base64URLEncodedString().asString(),
40 | currentSignCount: credential.signCount,
41 | userID: userID
42 | )
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # Docker Compose file for Vapor
2 | #
3 | # Install Docker on your system to run and test
4 | # your Vapor app in a production-like environment.
5 | #
6 | # Note: This file is intended for testing and does not
7 | # implement best practices for a production deployment.
8 | #
9 | # Learn more: https://docs.docker.com/compose/reference/
10 | #
11 | # Build images: docker-compose build
12 | # Start app: docker-compose up app
13 | # Stop all: docker-compose down
14 | #
15 | version: '3.7'
16 |
17 | x-shared_environment: &shared_environment
18 | LOG_LEVEL: ${LOG_LEVEL:-debug}
19 |
20 | services:
21 | app:
22 | image: passkey-demo:latest
23 | build:
24 | context: .
25 | environment:
26 | <<: *shared_environment
27 | ports:
28 | - '8080:8080'
29 | # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user.
30 | command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
31 | migrate:
32 | image: passkey-demo:latest
33 | build:
34 | context: .
35 | environment:
36 | <<: *shared_environment
37 | command: ["migrate", "--yes"]
38 | deploy:
39 | replicas: 0
40 | revert:
41 | image: passkey-demo:latest
42 | build:
43 | context: .
44 | environment:
45 | <<: *shared_environment
46 | command: ["migrate", "--revert", "--yes"]
47 | deploy:
48 | replicas: 0
49 |
--------------------------------------------------------------------------------
/Sources/App/configure.swift:
--------------------------------------------------------------------------------
1 | import Fluent
2 | import FluentSQLiteDriver
3 | import Leaf
4 | import Vapor
5 | import WebAuthn
6 | import QueuesFluentDriver
7 |
8 | // configures your application
9 | public func configure(_ app: Application) throws {
10 | // uncomment to serve files from /Public folder
11 | app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
12 |
13 | app.middleware.use(app.sessions.middleware)
14 | app.sessions.use(.memory)
15 |
16 | if app.environment == .testing {
17 | app.databases.use(.sqlite(.file(Environment.get("SQLITE_DATABASE_PATH") ?? "db.sqlite")), as: .sqlite)
18 | } else {
19 | app.databases.use(.sqlite(.memory), as: .sqlite)
20 | }
21 |
22 | app.migrations.add(JobMetadataMigrate())
23 | app.migrations.add(CreateUser())
24 | app.migrations.add(CreateWebAuthnCredential())
25 |
26 | app.queues.use(.fluent())
27 | try app.queues.startInProcessJobs(on: .default)
28 |
29 | app.queues.schedule(DeleteUsersJob()).hourly().at(0)
30 | try app.queues.startScheduledJobs()
31 |
32 | app.views.use(.leaf)
33 | app.webAuthn = WebAuthnManager(
34 | config: WebAuthnManager.Config(
35 | relyingPartyID: Environment.get("RP_ID") ?? "localhost",
36 | relyingPartyName: Environment.get("RP_DISPLAY_NAME") ?? "Vapor Passkey Demo",
37 | relyingPartyOrigin: Environment.get("RP_ORIGIN") ?? "http://localhost:8080"
38 | )
39 | )
40 |
41 | // register routes
42 | try routes(app)
43 |
44 | try app.autoMigrate().wait()
45 | }
46 |
--------------------------------------------------------------------------------
/Public/images/person.badge.key.fill.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/Resources/Views/base.leaf:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #(title) · Passkey Demo
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | #import("body")
25 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.8
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "PasskeyDemo",
6 | platforms: [
7 | .macOS(.v12)
8 | ],
9 | dependencies: [
10 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
11 | .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
12 | .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"),
13 | .package(url: "https://github.com/m-barthelemy/vapor-queues-fluent-driver.git", from: "3.0.0-beta1"),
14 | .package(url: "https://github.com/vapor/leaf.git", from: "4.0.0"),
15 | .package(url: "https://github.com/swift-server/webauthn-swift.git", from: "1.0.0-alpha")
16 | ],
17 | targets: [
18 | .target(
19 | name: "App",
20 | dependencies: [
21 | .product(name: "Fluent", package: "fluent"),
22 | .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
23 | .product(name: "Leaf", package: "leaf"),
24 | .product(name: "Vapor", package: "vapor"),
25 | .product(name: "WebAuthn", package: "webauthn-swift"),
26 | .product(name: "QueuesFluentDriver", package: "vapor-queues-fluent-driver")
27 | ],
28 | swiftSettings: [
29 | // Enable better optimizations when building in Release configuration. Despite the use of
30 | // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release
31 | // builds. See for details.
32 | .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
33 | ]
34 | ),
35 | .executableTarget(name: "Run", dependencies: [.target(name: "App")]),
36 | .testTarget(name: "AppTests", dependencies: [
37 | .target(name: "App"),
38 | .product(name: "XCTVapor", package: "vapor"),
39 | ])
40 | ]
41 | )
42 |
--------------------------------------------------------------------------------
/Resources/Views/index.leaf:
--------------------------------------------------------------------------------
1 | #extend("base"):
2 | #export("body"):
3 |
4 |
5 |
6 |
Vapor Passkey Demo
7 |
8 |
9 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Don't have an account yet?
29 |
30 |
31 |
32 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | #endexport
58 | #endextend
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # ================================
2 | # Build image
3 | # ================================
4 | FROM swift:5.8-focal as build
5 |
6 | # Install OS updates and, if needed, sqlite3
7 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
8 | && apt-get -q update \
9 | && apt-get -q dist-upgrade -y \
10 | && apt-get install -y libsqlite3-dev \
11 | && rm -rf /var/lib/apt/lists/*
12 |
13 | # Set up a build area
14 | WORKDIR /build
15 |
16 | # First just resolve dependencies.
17 | # This creates a cached layer that can be reused
18 | # as long as your Package.swift/Package.resolved
19 | # files do not change.
20 | COPY ./Package.* ./
21 | RUN swift package resolve
22 |
23 | # Copy entire repo into container
24 | COPY . .
25 |
26 | # Build everything, with optimizations
27 | RUN swift build -c release --static-swift-stdlib
28 |
29 | # Switch to the staging area
30 | WORKDIR /staging
31 |
32 | # Copy main executable to staging area
33 | RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./
34 |
35 | # Copy resources bundled by SPM to staging area
36 | RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
37 |
38 | # Copy any resources from the public directory and views directory if the directories exist
39 | # Ensure that by default, neither the directory nor any of its contents are writable.
40 | RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
41 | RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true
42 |
43 | # ================================
44 | # Run image
45 | # ================================
46 | FROM ubuntu:focal
47 |
48 | # Make sure all system packages are up to date.
49 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true && \
50 | apt-get -q update && apt-get -q dist-upgrade -y && apt-get -q install -y ca-certificates tzdata sqlite3 && \
51 | rm -r /var/lib/apt/lists/*
52 |
53 | # Create a vapor user and group with /app as its home directory
54 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor
55 |
56 | # Switch to the new home directory
57 | WORKDIR /app
58 |
59 | # Copy built executable and any staged resources from builder
60 | COPY --from=build --chown=vapor:vapor /staging /app
61 |
62 | # Ensure all further commands run as the vapor user
63 | USER vapor:vapor
64 |
65 | # Let Docker bind to port 8080
66 | EXPOSE 8080
67 |
68 | # Start the Vapor service when the image is run, default to listening on 8080 in production environment
69 | ENTRYPOINT ["./Run"]
70 | CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
71 |
--------------------------------------------------------------------------------
/Public/scripts/createAccount.js:
--------------------------------------------------------------------------------
1 | const createAccountForm = document.getElementById("createAccountForm");
2 | const createAccountError = document.getElementById("createAccountError");
3 |
4 | createAccountForm.addEventListener("submit", async function(event) {
5 | event.preventDefault();
6 | hideCreateAccountError();
7 |
8 | const username = document.getElementById("username").value;
9 |
10 | try {
11 | const credentialCreationOptions = await fetchCredentialCreationOptions(username);
12 | const registrationCredential = await navigator.credentials.create({ publicKey: credentialCreationOptions });
13 | await registerNewCredential(registrationCredential);
14 | } catch (error) {
15 | console.log(error);
16 | return;
17 | }
18 | location.href = "/private";
19 | });
20 |
21 |
22 | async function fetchCredentialCreationOptions(username) {
23 | const makeCredentialsResponse = await fetch('/signup?username=' + username);
24 | if (makeCredentialsResponse.status == 409) {
25 | showCreateAccountError("Username is already taken");
26 | throw new Error("Username is already taken");
27 | } else if (!makeCredentialsResponse.status == 200) {
28 | showCreateAccountError("Something went wrong (" + makeCredentialsResponse.status + ")");
29 | throw new Error("Signup request failed");
30 | }
31 |
32 | let credentialCreationOptions = await makeCredentialsResponse.json();
33 | credentialCreationOptions.challenge = bufferDecode(credentialCreationOptions.challenge);
34 | credentialCreationOptions.user.id = bufferDecode(credentialCreationOptions.user.id);
35 |
36 | return credentialCreationOptions;
37 | }
38 |
39 | async function registerNewCredential(newCredential) {
40 | // Move data into Arrays incase it is super long
41 | const attestationObject = new Uint8Array(newCredential.response.attestationObject);
42 | const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
43 | const rawId = new Uint8Array(newCredential.rawId);
44 |
45 | const registerResponse = await fetch('/makeCredential', {
46 | method: 'POST',
47 | headers: {
48 | 'Content-Type': 'application/json'
49 | },
50 | body: JSON.stringify({
51 | id: newCredential.id,
52 | rawId: bufferEncode(rawId),
53 | type: newCredential.type,
54 | response: {
55 | attestationObject: bufferEncode(attestationObject),
56 | clientDataJSON: bufferEncode(clientDataJSON),
57 | },
58 | })
59 | });
60 |
61 | if (registerResponse.status != 200) {
62 | showCreateAccountError("Something went wrong (" + makeCredentialsResponse.status + ")");
63 | throw new Error("makeCredential request failed");
64 | }
65 | }
66 |
67 | function showCreateAccountError(message) {
68 | createAccountError.innerHTML = message;
69 | createAccountError.classList.remove("hidden");
70 | }
71 |
72 | function hideCreateAccountError() {
73 | createAccountError.classList.add("hidden");
74 | }
--------------------------------------------------------------------------------
/Public/images/vapor-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/Public/scripts/signIn.js:
--------------------------------------------------------------------------------
1 | const signInForm = document.getElementById("signInForm");
2 | const authenticateError = document.getElementById("authenticateError");
3 |
4 | signInForm.addEventListener("submit", async function (event) {
5 | event.preventDefault();
6 |
7 | try {
8 | const publicKeyCredentialRequestOptions = await fetchCredentialRequestOptions();
9 | const credential = await navigator.credentials.get({
10 | publicKey: publicKeyCredentialRequestOptions,
11 | });
12 |
13 | await signIn(credential);
14 | } catch (error) {
15 | console.log(error);
16 | return;
17 | }
18 |
19 | location.href = "/private";
20 | });
21 |
22 | async function fetchCredentialRequestOptions() {
23 | const authenticateResponse = await fetch("/authenticate");
24 | const publicKeyCredentialRequestOptions = await authenticateResponse.json();
25 |
26 | if (publicKeyCredentialRequestOptions.allowCredentials) {
27 | publicKeyCredentialRequestOptions.allowCredentials = publicKeyCredentialRequestOptions.allowCredentials.map(
28 | (allowedCredential) => {
29 | return {
30 | id: bufferDecode(allowedCredential.id),
31 | type: allowedCredential.type,
32 | transports: allowedCredential.transports,
33 | };
34 | }
35 | );
36 | }
37 |
38 | publicKeyCredentialRequestOptions.challenge = bufferDecode(publicKeyCredentialRequestOptions.challenge);
39 |
40 | return publicKeyCredentialRequestOptions;
41 | }
42 |
43 | async function signIn(credential) {
44 | // Move data into Arrays incase it is super long
45 | let authData = new Uint8Array(credential.response.authenticatorData);
46 | let clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
47 | let rawId = new Uint8Array(credential.rawId);
48 | let sig = new Uint8Array(credential.response.signature);
49 | let userHandle = new Uint8Array(credential.response.userHandle);
50 |
51 | const authenticateResponse = await fetch("/authenticate", {
52 | method: "POST",
53 | headers: {
54 | "Content-Type": "application/json",
55 | },
56 | body: JSON.stringify({
57 | id: credential.id,
58 | rawId: bufferEncode(rawId),
59 | type: credential.type,
60 | response: {
61 | authenticatorData: bufferEncode(authData),
62 | clientDataJSON: bufferEncode(clientDataJSON),
63 | signature: bufferEncode(sig),
64 | userHandle: bufferEncode(userHandle),
65 | },
66 | }),
67 | });
68 |
69 | if (authenticateResponse.status == 401) {
70 | showAuthenticateError("Unauthorized");
71 | throw new Error("Unauthorized");
72 | } else if (!authenticateResponse.status == 200) {
73 | showAuthenticateError("Something went wrong (" + authenticateResponse.status + ")");
74 | throw new Error("Authentication request failed");
75 | }
76 | }
77 |
78 | function showAuthenticateError(message) {
79 | authenticateError.innerHTML = message;
80 | authenticateError.classList.remove("hidden");
81 | }
82 |
83 | function hideAuthenticateError() {
84 | authenticateError.classList.add("hidden");
85 | }
86 |
--------------------------------------------------------------------------------
/Public/scripts/base64.js:
--------------------------------------------------------------------------------
1 | var lookup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
2 |
3 | ;(function (exports) {
4 | 'use strict'
5 |
6 | var Arr = (typeof Uint8Array !== 'undefined')
7 | ? Uint8Array
8 | : Array
9 |
10 | var PLUS = '+'.charCodeAt(0)
11 | var SLASH = '/'.charCodeAt(0)
12 | var NUMBER = '0'.charCodeAt(0)
13 | var LOWER = 'a'.charCodeAt(0)
14 | var UPPER = 'A'.charCodeAt(0)
15 | var PLUS_URL_SAFE = '-'.charCodeAt(0)
16 | var SLASH_URL_SAFE = '_'.charCodeAt(0)
17 |
18 | function decode (elt) {
19 | var code = elt.charCodeAt(0)
20 | if (code === PLUS || code === PLUS_URL_SAFE) return 62 // '+'
21 | if (code === SLASH || code === SLASH_URL_SAFE) return 63 // '/'
22 | if (code < NUMBER) return -1 // no match
23 | if (code < NUMBER + 10) return code - NUMBER + 26 + 26
24 | if (code < UPPER + 26) return code - UPPER
25 | if (code < LOWER + 26) return code - LOWER + 26
26 | }
27 |
28 | function b64ToByteArray (b64) {
29 | var i, j, l, tmp, placeHolders, arr
30 |
31 | if (b64.length % 4 > 0) {
32 | throw new Error('Invalid string. Length must be a multiple of 4')
33 | }
34 |
35 | // the number of equal signs (place holders)
36 | // if there are two placeholders, than the two characters before it
37 | // represent one byte
38 | // if there is only one, then the three characters before it represent 2 bytes
39 | // this is just a cheap hack to not do indexOf twice
40 | var len = b64.length
41 | placeHolders = b64.charAt(len - 2) === '=' ? 2 : b64.charAt(len - 1) === '=' ? 1 : 0
42 |
43 | // base64 is 4/3 + up to two characters of the original data
44 | arr = new Arr(b64.length * 3 / 4 - placeHolders)
45 |
46 | // if there are placeholders, only get up to the last complete 4 chars
47 | l = placeHolders > 0 ? b64.length - 4 : b64.length
48 |
49 | var L = 0
50 |
51 | function push (v) {
52 | arr[L++] = v
53 | }
54 |
55 | for (i = 0, j = 0; i < l; i += 4, j += 3) {
56 | tmp = (decode(b64.charAt(i)) << 18) | (decode(b64.charAt(i + 1)) << 12) | (decode(b64.charAt(i + 2)) << 6) | decode(b64.charAt(i + 3))
57 | push((tmp & 0xFF0000) >> 16)
58 | push((tmp & 0xFF00) >> 8)
59 | push(tmp & 0xFF)
60 | }
61 |
62 | if (placeHolders === 2) {
63 | tmp = (decode(b64.charAt(i)) << 2) | (decode(b64.charAt(i + 1)) >> 4)
64 | push(tmp & 0xFF)
65 | } else if (placeHolders === 1) {
66 | tmp = (decode(b64.charAt(i)) << 10) | (decode(b64.charAt(i + 1)) << 4) | (decode(b64.charAt(i + 2)) >> 2)
67 | push((tmp >> 8) & 0xFF)
68 | push(tmp & 0xFF)
69 | }
70 |
71 | return arr
72 | }
73 |
74 | function uint8ToBase64 (uint8) {
75 | var i
76 | var extraBytes = uint8.length % 3 // if we have 1 byte left, pad 2 bytes
77 | var output = ''
78 | var temp, length
79 |
80 | function encode (num) {
81 | return lookup.charAt(num)
82 | }
83 |
84 | function tripletToBase64 (num) {
85 | return encode(num >> 18 & 0x3F) + encode(num >> 12 & 0x3F) + encode(num >> 6 & 0x3F) + encode(num & 0x3F)
86 | }
87 |
88 | // go through the array every three bytes, we'll deal with trailing stuff later
89 | for (i = 0, length = uint8.length - extraBytes; i < length; i += 3) {
90 | temp = (uint8[i] << 16) + (uint8[i + 1] << 8) + (uint8[i + 2])
91 | output += tripletToBase64(temp)
92 | }
93 |
94 | // pad the end with zeros, but make sure to not forget the extra bytes
95 | switch (extraBytes) {
96 | case 1:
97 | temp = uint8[uint8.length - 1]
98 | output += encode(temp >> 2)
99 | output += encode((temp << 4) & 0x3F)
100 | output += '=='
101 | break
102 | case 2:
103 | temp = (uint8[uint8.length - 2] << 8) + (uint8[uint8.length - 1])
104 | output += encode(temp >> 10)
105 | output += encode((temp >> 4) & 0x3F)
106 | output += encode((temp << 2) & 0x3F)
107 | output += '='
108 | break
109 | default:
110 | break
111 | }
112 |
113 | return output
114 | }
115 |
116 | exports.toByteArray = b64ToByteArray
117 | exports.fromByteArray = uint8ToBase64
118 | }(typeof exports === 'undefined' ? (this.base64js = {}) : exports))
--------------------------------------------------------------------------------
/Sources/App/routes.swift:
--------------------------------------------------------------------------------
1 | import Fluent
2 | import Vapor
3 | import WebAuthn
4 |
5 | func routes(_ app: Application) throws {
6 | app.get { req in
7 | return req.view.render("index", ["title": "Log In"])
8 | }
9 |
10 | app.get(".well-known", "apple-app-site-association") { req -> Response in
11 | let appIdentifier = "YWLW23LT6G.io.brokenhands.demos.auth.Shiny"
12 | let responseString =
13 | """
14 | {
15 | "applinks": {
16 | "details": [
17 | {
18 | "appIDs": [
19 | "\(appIdentifier)"
20 | ],
21 | "components": [
22 | ]
23 | }
24 | ]
25 | },
26 | "webcredentials": {
27 | "apps": [
28 | "\(appIdentifier)"
29 | ]
30 | }
31 | }
32 | """
33 | let response = try await responseString.encodeResponse(for: req)
34 | response.headers.contentType = HTTPMediaType(type: "application", subType: "json")
35 | return response
36 | }
37 |
38 | let authSessionRoutes = app.grouped(User.sessionAuthenticator())
39 |
40 | let protected = authSessionRoutes.grouped(User.redirectMiddleware(path: "/"))
41 |
42 | protected.get("private") { req -> View in
43 | let user = try req.auth.require(User.self)
44 | return try await req.view.render("private", ["username": user.username, "title": "Private Area"])
45 | }
46 |
47 | protected.post("logout") { req -> Response in
48 | req.session.destroy()
49 | req.auth.logout(User.self)
50 | return req.redirect(to: "/")
51 | }
52 |
53 | authSessionRoutes.get("signup", use: { req -> Response in
54 | let username = try req.query.get(String.self, at: "username")
55 | guard try await User.query(on: req.db).filter(\.$username == username).first() == nil else {
56 | throw Abort(.conflict, reason: "Username already taken.")
57 | }
58 | let user = User(username: username)
59 | try await user.create(on: req.db)
60 | req.auth.login(user)
61 | return req.redirect(to: "makeCredential")
62 | })
63 |
64 | // step 1 for registration
65 | authSessionRoutes.get("makeCredential") { (req: Request) -> PublicKeyCredentialCreationOptions in
66 | // In order to create a credential we need to know who the user is
67 | let user = try req.auth.require(User.self)
68 |
69 | // We can then create the options for the client to create a new credential
70 | let options = req.webAuthn.beginRegistration(user: user.webAuthnUser)
71 |
72 | // We need to temporarily store the challenge somewhere safe
73 | req.session.data["registrationChallenge"] = Data(options.challenge).base64EncodedString()
74 |
75 | // Return the options to the client
76 | return options
77 | }
78 |
79 | authSessionRoutes.delete("makeCredential") { req -> HTTPStatus in
80 | let user = try req.auth.require(User.self)
81 | try await user.delete(on: req.db)
82 | return .noContent
83 | }
84 |
85 | // step 2 for registration
86 | authSessionRoutes.post("makeCredential") { req -> HTTPStatus in
87 | // Obtain the user we're registering a credential for
88 | let user = try req.auth.require(User.self)
89 |
90 | // Obtain the challenge we stored on the server for this session
91 | guard let challengeEncoded = req.session.data["registrationChallenge"],
92 | let challenge = Data(base64Encoded: challengeEncoded) else {
93 | throw Abort(.badRequest, reason: "Missing registration session ID")
94 | }
95 |
96 | // Delete the challenge from the server to prevent attackers from reusing it
97 | req.session.data["registrationChallenge"] = nil
98 |
99 | // Verify the credential the client sent us
100 | let credential = try await req.webAuthn.finishRegistration(
101 | challenge: [UInt8](challenge),
102 | credentialCreationData: req.content.decode(RegistrationCredential.self),
103 | confirmCredentialIDNotRegisteredYet: { credentialID in
104 | let existingCredential = try await WebAuthnCredential.query(on: req.db)
105 | .filter(\.$id == credentialID)
106 | .first()
107 | return existingCredential == nil
108 | }
109 | )
110 |
111 | // If the credential was verified, save it to the database
112 | try await WebAuthnCredential(from: credential, userID: user.requireID()).save(on: req.db)
113 |
114 | return .ok
115 | }
116 |
117 | // step 1 for authentication
118 | authSessionRoutes.get("authenticate") { req -> PublicKeyCredentialRequestOptions in
119 | let options = try req.webAuthn.beginAuthentication()
120 |
121 | req.session.data["authChallenge"] = Data(options.challenge).base64EncodedString()
122 |
123 | return options
124 | }
125 |
126 | // step 2 for authentication
127 | authSessionRoutes.post("authenticate") { req -> HTTPStatus in
128 | // Obtain the challenge we stored on the server for this session
129 | guard let challengeEncoded = req.session.data["authChallenge"],
130 | let challenge = Data(base64Encoded: challengeEncoded) else {
131 | throw Abort(.badRequest, reason: "Missing auth session ID")
132 | }
133 |
134 | // Delete the challenge from the server to prevent attackers from reusing it
135 | req.session.data["authChallenge"] = nil
136 |
137 | // Decode the credential the client sent us
138 | let authenticationCredential = try req.content.decode(AuthenticationCredential.self)
139 |
140 | // find the credential the stranger claims to possess
141 | guard let credential = try await WebAuthnCredential.query(on: req.db)
142 | .filter(\.$id == authenticationCredential.id.urlDecoded.asString())
143 | .with(\.$user)
144 | .first() else {
145 | throw Abort(.unauthorized)
146 | }
147 |
148 | // if we found a credential, use the stored public key to verify the challenge
149 | let verifiedAuthentication = try req.webAuthn.finishAuthentication(
150 | credential: authenticationCredential,
151 | expectedChallenge: [UInt8](challenge),
152 | credentialPublicKey: [UInt8](URLEncodedBase64(credential.publicKey).urlDecoded.decoded!),
153 | credentialCurrentSignCount: credential.currentSignCount
154 | )
155 |
156 | // if we successfully verified the user, update the sign count
157 | credential.currentSignCount = verifiedAuthentication.newSignCount
158 | try await credential.save(on: req.db)
159 |
160 | // finally authenticate the user
161 | req.auth.login(credential.user)
162 | return .ok
163 | }
164 | }
165 |
166 | extension PublicKeyCredentialCreationOptions: AsyncResponseEncodable {
167 | public func encodeResponse(for request: Request) async throws -> Response {
168 | var headers = HTTPHeaders()
169 | headers.contentType = .json
170 | return try Response(status: .ok, headers: headers, body: .init(data: JSONEncoder().encode(self)))
171 | }
172 | }
173 |
174 | extension PublicKeyCredentialRequestOptions: AsyncResponseEncodable {
175 | public func encodeResponse(for request: Request) async throws -> Response {
176 | var headers = HTTPHeaders()
177 | headers.contentType = .json
178 | return try Response(status: .ok, headers: headers, body: .init(data: JSONEncoder().encode(self)))
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "async-http-client",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/swift-server/async-http-client.git",
7 | "state" : {
8 | "revision" : "78db67e5bf4a8543075787f228e8920097319281",
9 | "version" : "1.18.0"
10 | }
11 | },
12 | {
13 | "identity" : "async-kit",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/vapor/async-kit.git",
16 | "state" : {
17 | "revision" : "a61da00d404ec91d12766f1b9aac7d90777b484d",
18 | "version" : "1.17.0"
19 | }
20 | },
21 | {
22 | "identity" : "console-kit",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/vapor/console-kit.git",
25 | "state" : {
26 | "revision" : "447f1046fb4e9df40973fe426ecb24a6f0e8d3b4",
27 | "version" : "4.6.0"
28 | }
29 | },
30 | {
31 | "identity" : "fluent",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/vapor/fluent.git",
34 | "state" : {
35 | "revision" : "4b4d8bf15a06fd60137e9c543e5503c4b842654e",
36 | "version" : "4.8.0"
37 | }
38 | },
39 | {
40 | "identity" : "fluent-kit",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/vapor/fluent-kit.git",
43 | "state" : {
44 | "revision" : "2d7dce5cb04156eecdb17e7349f4eac4206e8a17",
45 | "version" : "1.42.4"
46 | }
47 | },
48 | {
49 | "identity" : "fluent-sqlite-driver",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/vapor/fluent-sqlite-driver.git",
52 | "state" : {
53 | "revision" : "33e920bd53c9a3a77f82733aaf26a82495afddd4",
54 | "version" : "4.4.0"
55 | }
56 | },
57 | {
58 | "identity" : "leaf",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/vapor/leaf.git",
61 | "state" : {
62 | "revision" : "6fe0e843c6599f5189e45c7b08739ebc5c410c3b",
63 | "version" : "4.2.4"
64 | }
65 | },
66 | {
67 | "identity" : "leaf-kit",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/vapor/leaf-kit.git",
70 | "state" : {
71 | "revision" : "13f2fc4c8479113cd23876d9a434ef4573e368bb",
72 | "version" : "1.10.2"
73 | }
74 | },
75 | {
76 | "identity" : "multipart-kit",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/vapor/multipart-kit.git",
79 | "state" : {
80 | "revision" : "1adfd69df2da08f7931d4281b257475e32c96734",
81 | "version" : "4.5.4"
82 | }
83 | },
84 | {
85 | "identity" : "queues",
86 | "kind" : "remoteSourceControl",
87 | "location" : "https://github.com/vapor/queues.git",
88 | "state" : {
89 | "revision" : "f1adaf4c925eb7074a4acf363172c1fa6ec888c8",
90 | "version" : "1.12.1"
91 | }
92 | },
93 | {
94 | "identity" : "routing-kit",
95 | "kind" : "remoteSourceControl",
96 | "location" : "https://github.com/vapor/routing-kit.git",
97 | "state" : {
98 | "revision" : "611bc45c5dfb1f54b84d99b89d1f72191fb6b71b",
99 | "version" : "4.7.2"
100 | }
101 | },
102 | {
103 | "identity" : "sql-kit",
104 | "kind" : "remoteSourceControl",
105 | "location" : "https://github.com/vapor/sql-kit.git",
106 | "state" : {
107 | "revision" : "5026e7c0f2e464ea1af9f5948701aa8922ab14eb",
108 | "version" : "3.27.0"
109 | }
110 | },
111 | {
112 | "identity" : "sqlite-kit",
113 | "kind" : "remoteSourceControl",
114 | "location" : "https://github.com/vapor/sqlite-kit.git",
115 | "state" : {
116 | "revision" : "f66ddded9a330454856f5ba272cccc9e0c995b17",
117 | "version" : "4.3.0"
118 | }
119 | },
120 | {
121 | "identity" : "sqlite-nio",
122 | "kind" : "remoteSourceControl",
123 | "location" : "https://github.com/vapor/sqlite-nio.git",
124 | "state" : {
125 | "revision" : "2b7bcf3d2e4d2f68d52a66d12d2057867cee383a",
126 | "version" : "1.5.2"
127 | }
128 | },
129 | {
130 | "identity" : "swift-algorithms",
131 | "kind" : "remoteSourceControl",
132 | "location" : "https://github.com/apple/swift-algorithms.git",
133 | "state" : {
134 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
135 | "version" : "1.0.0"
136 | }
137 | },
138 | {
139 | "identity" : "swift-asn1",
140 | "kind" : "remoteSourceControl",
141 | "location" : "https://github.com/apple/swift-asn1.git",
142 | "state" : {
143 | "revision" : "a53d9f676cbc84ccc1643cf559a470ee5732ebb6",
144 | "version" : "0.8.0"
145 | }
146 | },
147 | {
148 | "identity" : "swift-atomics",
149 | "kind" : "remoteSourceControl",
150 | "location" : "https://github.com/apple/swift-atomics.git",
151 | "state" : {
152 | "revision" : "6c89474e62719ddcc1e9614989fff2f68208fe10",
153 | "version" : "1.1.0"
154 | }
155 | },
156 | {
157 | "identity" : "swift-backtrace",
158 | "kind" : "remoteSourceControl",
159 | "location" : "https://github.com/swift-server/swift-backtrace.git",
160 | "state" : {
161 | "revision" : "f25620d5d05e2f1ba27154b40cafea2b67566956",
162 | "version" : "1.3.3"
163 | }
164 | },
165 | {
166 | "identity" : "swift-certificates",
167 | "kind" : "remoteSourceControl",
168 | "location" : "https://github.com/apple/swift-certificates.git",
169 | "state" : {
170 | "revision" : "9099e78aad1693ce5a2e5b108d8e1337fbff433b",
171 | "version" : "0.6.0"
172 | }
173 | },
174 | {
175 | "identity" : "swift-collections",
176 | "kind" : "remoteSourceControl",
177 | "location" : "https://github.com/apple/swift-collections.git",
178 | "state" : {
179 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
180 | "version" : "1.0.4"
181 | }
182 | },
183 | {
184 | "identity" : "swift-crypto",
185 | "kind" : "remoteSourceControl",
186 | "location" : "https://github.com/apple/swift-crypto.git",
187 | "state" : {
188 | "revision" : "33a20e650c33f6d72d822d558333f2085effa3dc",
189 | "version" : "2.5.0"
190 | }
191 | },
192 | {
193 | "identity" : "swift-log",
194 | "kind" : "remoteSourceControl",
195 | "location" : "https://github.com/apple/swift-log.git",
196 | "state" : {
197 | "revision" : "32e8d724467f8fe623624570367e3d50c5638e46",
198 | "version" : "1.5.2"
199 | }
200 | },
201 | {
202 | "identity" : "swift-metrics",
203 | "kind" : "remoteSourceControl",
204 | "location" : "https://github.com/apple/swift-metrics.git",
205 | "state" : {
206 | "revision" : "34025104068262db0cc998ace178975c5ff4f36b",
207 | "version" : "2.4.0"
208 | }
209 | },
210 | {
211 | "identity" : "swift-nio",
212 | "kind" : "remoteSourceControl",
213 | "location" : "https://github.com/apple/swift-nio.git",
214 | "state" : {
215 | "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf",
216 | "version" : "2.54.0"
217 | }
218 | },
219 | {
220 | "identity" : "swift-nio-extras",
221 | "kind" : "remoteSourceControl",
222 | "location" : "https://github.com/apple/swift-nio-extras.git",
223 | "state" : {
224 | "revision" : "0e0d0aab665ff1a0659ce75ac003081f2b1c8997",
225 | "version" : "1.19.0"
226 | }
227 | },
228 | {
229 | "identity" : "swift-nio-http2",
230 | "kind" : "remoteSourceControl",
231 | "location" : "https://github.com/apple/swift-nio-http2.git",
232 | "state" : {
233 | "revision" : "a8ccf13fa62775277a5d56844878c828bbb3be1a",
234 | "version" : "1.27.0"
235 | }
236 | },
237 | {
238 | "identity" : "swift-nio-ssl",
239 | "kind" : "remoteSourceControl",
240 | "location" : "https://github.com/apple/swift-nio-ssl.git",
241 | "state" : {
242 | "revision" : "e866a626e105042a6a72a870c88b4c531ba05f83",
243 | "version" : "2.24.0"
244 | }
245 | },
246 | {
247 | "identity" : "swift-nio-transport-services",
248 | "kind" : "remoteSourceControl",
249 | "location" : "https://github.com/apple/swift-nio-transport-services.git",
250 | "state" : {
251 | "revision" : "41f4098903878418537020075a4d8a6e20a0b182",
252 | "version" : "1.17.0"
253 | }
254 | },
255 | {
256 | "identity" : "swift-numerics",
257 | "kind" : "remoteSourceControl",
258 | "location" : "https://github.com/apple/swift-numerics",
259 | "state" : {
260 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
261 | "version" : "1.0.2"
262 | }
263 | },
264 | {
265 | "identity" : "swiftcbor",
266 | "kind" : "remoteSourceControl",
267 | "location" : "https://github.com/unrelentingtech/SwiftCBOR.git",
268 | "state" : {
269 | "revision" : "edc01765cf6b3685bb622bb09242ef5964fb991b",
270 | "version" : "0.4.6"
271 | }
272 | },
273 | {
274 | "identity" : "vapor",
275 | "kind" : "remoteSourceControl",
276 | "location" : "https://github.com/vapor/vapor.git",
277 | "state" : {
278 | "revision" : "9a340de4995e5a9dade4ff4c51cd2e6ae30c12d6",
279 | "version" : "4.77.0"
280 | }
281 | },
282 | {
283 | "identity" : "vapor-queues-fluent-driver",
284 | "kind" : "remoteSourceControl",
285 | "location" : "https://github.com/m-barthelemy/vapor-queues-fluent-driver.git",
286 | "state" : {
287 | "revision" : "e2ce6775850bdbe277cb2e5792d05eff42434f52",
288 | "version" : "3.0.0-beta1"
289 | }
290 | },
291 | {
292 | "identity" : "webauthn-swift",
293 | "kind" : "remoteSourceControl",
294 | "location" : "https://github.com/swift-server/webauthn-swift.git",
295 | "state" : {
296 | "revision" : "0e97beb4c868725707f0239cf9b664423f226ee9",
297 | "version" : "1.0.0-alpha.1"
298 | }
299 | },
300 | {
301 | "identity" : "websocket-kit",
302 | "kind" : "remoteSourceControl",
303 | "location" : "https://github.com/vapor/websocket-kit.git",
304 | "state" : {
305 | "revision" : "53fe0639a98903858d0196b699720decb42aee7b",
306 | "version" : "2.14.0"
307 | }
308 | }
309 | ],
310 | "version" : 2
311 | }
312 |
--------------------------------------------------------------------------------
/Public/styles/tailwind.css:
--------------------------------------------------------------------------------
1 | /*
2 | ! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com
3 | */
4 |
5 | /*
6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
8 | */
9 |
10 | *,
11 | ::before,
12 | ::after {
13 | box-sizing: border-box;
14 | /* 1 */
15 | border-width: 0;
16 | /* 2 */
17 | border-style: solid;
18 | /* 2 */
19 | border-color: #e5e7eb;
20 | /* 2 */
21 | }
22 |
23 | ::before,
24 | ::after {
25 | --tw-content: '';
26 | }
27 |
28 | /*
29 | 1. Use a consistent sensible line-height in all browsers.
30 | 2. Prevent adjustments of font size after orientation changes in iOS.
31 | 3. Use a more readable tab size.
32 | 4. Use the user's configured `sans` font-family by default.
33 | 5. Use the user's configured `sans` font-feature-settings by default.
34 | 6. Use the user's configured `sans` font-variation-settings by default.
35 | */
36 |
37 | html {
38 | line-height: 1.5;
39 | /* 1 */
40 | -webkit-text-size-adjust: 100%;
41 | /* 2 */
42 | -moz-tab-size: 4;
43 | /* 3 */
44 | -o-tab-size: 4;
45 | tab-size: 4;
46 | /* 3 */
47 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
48 | /* 4 */
49 | font-feature-settings: normal;
50 | /* 5 */
51 | font-variation-settings: normal;
52 | /* 6 */
53 | }
54 |
55 | /*
56 | 1. Remove the margin in all browsers.
57 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
58 | */
59 |
60 | body {
61 | margin: 0;
62 | /* 1 */
63 | line-height: inherit;
64 | /* 2 */
65 | }
66 |
67 | /*
68 | 1. Add the correct height in Firefox.
69 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
70 | 3. Ensure horizontal rules are visible by default.
71 | */
72 |
73 | hr {
74 | height: 0;
75 | /* 1 */
76 | color: inherit;
77 | /* 2 */
78 | border-top-width: 1px;
79 | /* 3 */
80 | }
81 |
82 | /*
83 | Add the correct text decoration in Chrome, Edge, and Safari.
84 | */
85 |
86 | abbr:where([title]) {
87 | -webkit-text-decoration: underline dotted;
88 | text-decoration: underline dotted;
89 | }
90 |
91 | /*
92 | Remove the default font size and weight for headings.
93 | */
94 |
95 | h1,
96 | h2,
97 | h3,
98 | h4,
99 | h5,
100 | h6 {
101 | font-size: inherit;
102 | font-weight: inherit;
103 | }
104 |
105 | /*
106 | Reset links to optimize for opt-in styling instead of opt-out.
107 | */
108 |
109 | a {
110 | color: inherit;
111 | text-decoration: inherit;
112 | }
113 |
114 | /*
115 | Add the correct font weight in Edge and Safari.
116 | */
117 |
118 | b,
119 | strong {
120 | font-weight: bolder;
121 | }
122 |
123 | /*
124 | 1. Use the user's configured `mono` font family by default.
125 | 2. Correct the odd `em` font sizing in all browsers.
126 | */
127 |
128 | code,
129 | kbd,
130 | samp,
131 | pre {
132 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
133 | /* 1 */
134 | font-size: 1em;
135 | /* 2 */
136 | }
137 |
138 | /*
139 | Add the correct font size in all browsers.
140 | */
141 |
142 | small {
143 | font-size: 80%;
144 | }
145 |
146 | /*
147 | Prevent `sub` and `sup` elements from affecting the line height in all browsers.
148 | */
149 |
150 | sub,
151 | sup {
152 | font-size: 75%;
153 | line-height: 0;
154 | position: relative;
155 | vertical-align: baseline;
156 | }
157 |
158 | sub {
159 | bottom: -0.25em;
160 | }
161 |
162 | sup {
163 | top: -0.5em;
164 | }
165 |
166 | /*
167 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
168 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
169 | 3. Remove gaps between table borders by default.
170 | */
171 |
172 | table {
173 | text-indent: 0;
174 | /* 1 */
175 | border-color: inherit;
176 | /* 2 */
177 | border-collapse: collapse;
178 | /* 3 */
179 | }
180 |
181 | /*
182 | 1. Change the font styles in all browsers.
183 | 2. Remove the margin in Firefox and Safari.
184 | 3. Remove default padding in all browsers.
185 | */
186 |
187 | button,
188 | input,
189 | optgroup,
190 | select,
191 | textarea {
192 | font-family: inherit;
193 | /* 1 */
194 | font-size: 100%;
195 | /* 1 */
196 | font-weight: inherit;
197 | /* 1 */
198 | line-height: inherit;
199 | /* 1 */
200 | color: inherit;
201 | /* 1 */
202 | margin: 0;
203 | /* 2 */
204 | padding: 0;
205 | /* 3 */
206 | }
207 |
208 | /*
209 | Remove the inheritance of text transform in Edge and Firefox.
210 | */
211 |
212 | button,
213 | select {
214 | text-transform: none;
215 | }
216 |
217 | /*
218 | 1. Correct the inability to style clickable types in iOS and Safari.
219 | 2. Remove default button styles.
220 | */
221 |
222 | button,
223 | [type='button'],
224 | [type='reset'],
225 | [type='submit'] {
226 | -webkit-appearance: button;
227 | /* 1 */
228 | background-color: transparent;
229 | /* 2 */
230 | background-image: none;
231 | /* 2 */
232 | }
233 |
234 | /*
235 | Use the modern Firefox focus style for all focusable elements.
236 | */
237 |
238 | :-moz-focusring {
239 | outline: auto;
240 | }
241 |
242 | /*
243 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
244 | */
245 |
246 | :-moz-ui-invalid {
247 | box-shadow: none;
248 | }
249 |
250 | /*
251 | Add the correct vertical alignment in Chrome and Firefox.
252 | */
253 |
254 | progress {
255 | vertical-align: baseline;
256 | }
257 |
258 | /*
259 | Correct the cursor style of increment and decrement buttons in Safari.
260 | */
261 |
262 | ::-webkit-inner-spin-button,
263 | ::-webkit-outer-spin-button {
264 | height: auto;
265 | }
266 |
267 | /*
268 | 1. Correct the odd appearance in Chrome and Safari.
269 | 2. Correct the outline style in Safari.
270 | */
271 |
272 | [type='search'] {
273 | -webkit-appearance: textfield;
274 | /* 1 */
275 | outline-offset: -2px;
276 | /* 2 */
277 | }
278 |
279 | /*
280 | Remove the inner padding in Chrome and Safari on macOS.
281 | */
282 |
283 | ::-webkit-search-decoration {
284 | -webkit-appearance: none;
285 | }
286 |
287 | /*
288 | 1. Correct the inability to style clickable types in iOS and Safari.
289 | 2. Change font properties to `inherit` in Safari.
290 | */
291 |
292 | ::-webkit-file-upload-button {
293 | -webkit-appearance: button;
294 | /* 1 */
295 | font: inherit;
296 | /* 2 */
297 | }
298 |
299 | /*
300 | Add the correct display in Chrome and Safari.
301 | */
302 |
303 | summary {
304 | display: list-item;
305 | }
306 |
307 | /*
308 | Removes the default spacing and border for appropriate elements.
309 | */
310 |
311 | blockquote,
312 | dl,
313 | dd,
314 | h1,
315 | h2,
316 | h3,
317 | h4,
318 | h5,
319 | h6,
320 | hr,
321 | figure,
322 | p,
323 | pre {
324 | margin: 0;
325 | }
326 |
327 | fieldset {
328 | margin: 0;
329 | padding: 0;
330 | }
331 |
332 | legend {
333 | padding: 0;
334 | }
335 |
336 | ol,
337 | ul,
338 | menu {
339 | list-style: none;
340 | margin: 0;
341 | padding: 0;
342 | }
343 |
344 | /*
345 | Prevent resizing textareas horizontally by default.
346 | */
347 |
348 | textarea {
349 | resize: vertical;
350 | }
351 |
352 | /*
353 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
354 | 2. Set the default placeholder color to the user's configured gray 400 color.
355 | */
356 |
357 | input::-moz-placeholder, textarea::-moz-placeholder {
358 | opacity: 1;
359 | /* 1 */
360 | color: #9ca3af;
361 | /* 2 */
362 | }
363 |
364 | input::placeholder,
365 | textarea::placeholder {
366 | opacity: 1;
367 | /* 1 */
368 | color: #9ca3af;
369 | /* 2 */
370 | }
371 |
372 | /*
373 | Set the default cursor for buttons.
374 | */
375 |
376 | button,
377 | [role="button"] {
378 | cursor: pointer;
379 | }
380 |
381 | /*
382 | Make sure disabled buttons don't get the pointer cursor.
383 | */
384 |
385 | :disabled {
386 | cursor: default;
387 | }
388 |
389 | /*
390 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
391 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
392 | This can trigger a poorly considered lint error in some tools but is included by design.
393 | */
394 |
395 | img,
396 | svg,
397 | video,
398 | canvas,
399 | audio,
400 | iframe,
401 | embed,
402 | object {
403 | display: block;
404 | /* 1 */
405 | vertical-align: middle;
406 | /* 2 */
407 | }
408 |
409 | /*
410 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
411 | */
412 |
413 | img,
414 | video {
415 | max-width: 100%;
416 | height: auto;
417 | }
418 |
419 | /* Make elements with the HTML hidden attribute stay hidden by default */
420 |
421 | [hidden] {
422 | display: none;
423 | }
424 |
425 | [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
426 | -webkit-appearance: none;
427 | -moz-appearance: none;
428 | appearance: none;
429 | background-color: #fff;
430 | border-color: #6b7280;
431 | border-width: 1px;
432 | border-radius: 0px;
433 | padding-top: 0.5rem;
434 | padding-right: 0.75rem;
435 | padding-bottom: 0.5rem;
436 | padding-left: 0.75rem;
437 | font-size: 1rem;
438 | line-height: 1.5rem;
439 | --tw-shadow: 0 0 #0000;
440 | }
441 |
442 | [type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
443 | outline: 2px solid transparent;
444 | outline-offset: 2px;
445 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
446 | --tw-ring-offset-width: 0px;
447 | --tw-ring-offset-color: #fff;
448 | --tw-ring-color: #2563eb;
449 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
450 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
451 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
452 | border-color: #2563eb;
453 | }
454 |
455 | input::-moz-placeholder, textarea::-moz-placeholder {
456 | color: #6b7280;
457 | opacity: 1;
458 | }
459 |
460 | input::placeholder,textarea::placeholder {
461 | color: #6b7280;
462 | opacity: 1;
463 | }
464 |
465 | ::-webkit-datetime-edit-fields-wrapper {
466 | padding: 0;
467 | }
468 |
469 | ::-webkit-date-and-time-value {
470 | min-height: 1.5em;
471 | }
472 |
473 | ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
474 | padding-top: 0;
475 | padding-bottom: 0;
476 | }
477 |
478 | select {
479 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
480 | background-position: right 0.5rem center;
481 | background-repeat: no-repeat;
482 | background-size: 1.5em 1.5em;
483 | padding-right: 2.5rem;
484 | -webkit-print-color-adjust: exact;
485 | print-color-adjust: exact;
486 | }
487 |
488 | [multiple] {
489 | background-image: initial;
490 | background-position: initial;
491 | background-repeat: unset;
492 | background-size: initial;
493 | padding-right: 0.75rem;
494 | -webkit-print-color-adjust: unset;
495 | print-color-adjust: unset;
496 | }
497 |
498 | [type='checkbox'],[type='radio'] {
499 | -webkit-appearance: none;
500 | -moz-appearance: none;
501 | appearance: none;
502 | padding: 0;
503 | -webkit-print-color-adjust: exact;
504 | print-color-adjust: exact;
505 | display: inline-block;
506 | vertical-align: middle;
507 | background-origin: border-box;
508 | -webkit-user-select: none;
509 | -moz-user-select: none;
510 | user-select: none;
511 | flex-shrink: 0;
512 | height: 1rem;
513 | width: 1rem;
514 | color: #2563eb;
515 | background-color: #fff;
516 | border-color: #6b7280;
517 | border-width: 1px;
518 | --tw-shadow: 0 0 #0000;
519 | }
520 |
521 | [type='checkbox'] {
522 | border-radius: 0px;
523 | }
524 |
525 | [type='radio'] {
526 | border-radius: 100%;
527 | }
528 |
529 | [type='checkbox']:focus,[type='radio']:focus {
530 | outline: 2px solid transparent;
531 | outline-offset: 2px;
532 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
533 | --tw-ring-offset-width: 2px;
534 | --tw-ring-offset-color: #fff;
535 | --tw-ring-color: #2563eb;
536 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
537 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
538 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
539 | }
540 |
541 | [type='checkbox']:checked,[type='radio']:checked {
542 | border-color: transparent;
543 | background-color: currentColor;
544 | background-size: 100% 100%;
545 | background-position: center;
546 | background-repeat: no-repeat;
547 | }
548 |
549 | [type='checkbox']:checked {
550 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
551 | }
552 |
553 | [type='radio']:checked {
554 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
555 | }
556 |
557 | [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
558 | border-color: transparent;
559 | background-color: currentColor;
560 | }
561 |
562 | [type='checkbox']:indeterminate {
563 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");
564 | border-color: transparent;
565 | background-color: currentColor;
566 | background-size: 100% 100%;
567 | background-position: center;
568 | background-repeat: no-repeat;
569 | }
570 |
571 | [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
572 | border-color: transparent;
573 | background-color: currentColor;
574 | }
575 |
576 | [type='file'] {
577 | background: unset;
578 | border-color: inherit;
579 | border-width: 0;
580 | border-radius: 0;
581 | padding: 0;
582 | font-size: unset;
583 | line-height: inherit;
584 | }
585 |
586 | [type='file']:focus {
587 | outline: 1px solid ButtonText;
588 | outline: 1px auto -webkit-focus-ring-color;
589 | }
590 |
591 | *, ::before, ::after {
592 | --tw-border-spacing-x: 0;
593 | --tw-border-spacing-y: 0;
594 | --tw-translate-x: 0;
595 | --tw-translate-y: 0;
596 | --tw-rotate: 0;
597 | --tw-skew-x: 0;
598 | --tw-skew-y: 0;
599 | --tw-scale-x: 1;
600 | --tw-scale-y: 1;
601 | --tw-pan-x: ;
602 | --tw-pan-y: ;
603 | --tw-pinch-zoom: ;
604 | --tw-scroll-snap-strictness: proximity;
605 | --tw-gradient-from-position: ;
606 | --tw-gradient-via-position: ;
607 | --tw-gradient-to-position: ;
608 | --tw-ordinal: ;
609 | --tw-slashed-zero: ;
610 | --tw-numeric-figure: ;
611 | --tw-numeric-spacing: ;
612 | --tw-numeric-fraction: ;
613 | --tw-ring-inset: ;
614 | --tw-ring-offset-width: 0px;
615 | --tw-ring-offset-color: #fff;
616 | --tw-ring-color: rgb(59 130 246 / 0.5);
617 | --tw-ring-offset-shadow: 0 0 #0000;
618 | --tw-ring-shadow: 0 0 #0000;
619 | --tw-shadow: 0 0 #0000;
620 | --tw-shadow-colored: 0 0 #0000;
621 | --tw-blur: ;
622 | --tw-brightness: ;
623 | --tw-contrast: ;
624 | --tw-grayscale: ;
625 | --tw-hue-rotate: ;
626 | --tw-invert: ;
627 | --tw-saturate: ;
628 | --tw-sepia: ;
629 | --tw-drop-shadow: ;
630 | --tw-backdrop-blur: ;
631 | --tw-backdrop-brightness: ;
632 | --tw-backdrop-contrast: ;
633 | --tw-backdrop-grayscale: ;
634 | --tw-backdrop-hue-rotate: ;
635 | --tw-backdrop-invert: ;
636 | --tw-backdrop-opacity: ;
637 | --tw-backdrop-saturate: ;
638 | --tw-backdrop-sepia: ;
639 | }
640 |
641 | ::backdrop {
642 | --tw-border-spacing-x: 0;
643 | --tw-border-spacing-y: 0;
644 | --tw-translate-x: 0;
645 | --tw-translate-y: 0;
646 | --tw-rotate: 0;
647 | --tw-skew-x: 0;
648 | --tw-skew-y: 0;
649 | --tw-scale-x: 1;
650 | --tw-scale-y: 1;
651 | --tw-pan-x: ;
652 | --tw-pan-y: ;
653 | --tw-pinch-zoom: ;
654 | --tw-scroll-snap-strictness: proximity;
655 | --tw-gradient-from-position: ;
656 | --tw-gradient-via-position: ;
657 | --tw-gradient-to-position: ;
658 | --tw-ordinal: ;
659 | --tw-slashed-zero: ;
660 | --tw-numeric-figure: ;
661 | --tw-numeric-spacing: ;
662 | --tw-numeric-fraction: ;
663 | --tw-ring-inset: ;
664 | --tw-ring-offset-width: 0px;
665 | --tw-ring-offset-color: #fff;
666 | --tw-ring-color: rgb(59 130 246 / 0.5);
667 | --tw-ring-offset-shadow: 0 0 #0000;
668 | --tw-ring-shadow: 0 0 #0000;
669 | --tw-shadow: 0 0 #0000;
670 | --tw-shadow-colored: 0 0 #0000;
671 | --tw-blur: ;
672 | --tw-brightness: ;
673 | --tw-contrast: ;
674 | --tw-grayscale: ;
675 | --tw-hue-rotate: ;
676 | --tw-invert: ;
677 | --tw-saturate: ;
678 | --tw-sepia: ;
679 | --tw-drop-shadow: ;
680 | --tw-backdrop-blur: ;
681 | --tw-backdrop-brightness: ;
682 | --tw-backdrop-contrast: ;
683 | --tw-backdrop-grayscale: ;
684 | --tw-backdrop-hue-rotate: ;
685 | --tw-backdrop-invert: ;
686 | --tw-backdrop-opacity: ;
687 | --tw-backdrop-saturate: ;
688 | --tw-backdrop-sepia: ;
689 | }
690 |
691 | .btn-primary {
692 | display: flex;
693 | width: 100%;
694 | align-items: center;
695 | justify-content: center;
696 | gap: 0.5rem;
697 | border-radius: 0.5rem;
698 | --tw-bg-opacity: 1;
699 | background-color: rgb(162 28 175 / var(--tw-bg-opacity));
700 | padding-left: 1.5rem;
701 | padding-right: 1.5rem;
702 | padding-top: 0.75rem;
703 | padding-bottom: 0.75rem;
704 | font-weight: 600;
705 | line-height: 1.5rem;
706 | --tw-text-opacity: 1;
707 | color: rgb(255 255 255 / var(--tw-text-opacity));
708 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
709 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
710 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
711 | }
712 |
713 | .btn-primary:hover {
714 | --tw-bg-opacity: 1;
715 | background-color: rgb(192 38 211 / var(--tw-bg-opacity));
716 | }
717 |
718 | .btn-primary:focus-visible {
719 | outline-style: solid;
720 | outline-width: 2px;
721 | outline-offset: 2px;
722 | outline-color: #86198f;
723 | }
724 |
725 | .btn-soft {
726 | display: flex;
727 | width: 100%;
728 | align-items: center;
729 | justify-content: center;
730 | gap: 0.5rem;
731 | border-radius: 0.5rem;
732 | --tw-bg-opacity: 1;
733 | background-color: rgb(241 245 249 / var(--tw-bg-opacity));
734 | padding-left: 1.5rem;
735 | padding-right: 1.5rem;
736 | padding-top: 0.75rem;
737 | padding-bottom: 0.75rem;
738 | font-weight: 500;
739 | line-height: 1.5rem;
740 | --tw-text-opacity: 1;
741 | color: rgb(55 65 81 / var(--tw-text-opacity));
742 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
743 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
744 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
745 | }
746 |
747 | .btn-soft:hover {
748 | --tw-bg-opacity: 1;
749 | background-color: rgb(226 232 240 / var(--tw-bg-opacity));
750 | }
751 |
752 | .btn-soft:focus-visible {
753 | outline-style: solid;
754 | outline-width: 2px;
755 | outline-offset: 2px;
756 | outline-color: #e2e8f0;
757 | }
758 |
759 | .absolute {
760 | position: absolute;
761 | }
762 |
763 | .left-1\/2 {
764 | left: 50%;
765 | }
766 |
767 | .top-1\/2 {
768 | top: 50%;
769 | }
770 |
771 | .-z-10 {
772 | z-index: -10;
773 | }
774 |
775 | .mx-auto {
776 | margin-left: auto;
777 | margin-right: auto;
778 | }
779 |
780 | .mb-3 {
781 | margin-bottom: 0.75rem;
782 | }
783 |
784 | .mt-10 {
785 | margin-top: 2.5rem;
786 | }
787 |
788 | .mt-2 {
789 | margin-top: 0.5rem;
790 | }
791 |
792 | .block {
793 | display: block;
794 | }
795 |
796 | .flex {
797 | display: flex;
798 | }
799 |
800 | .hidden {
801 | display: none;
802 | }
803 |
804 | .h-1\/2 {
805 | height: 50%;
806 | }
807 |
808 | .h-20 {
809 | height: 5rem;
810 | }
811 |
812 | .h-\[40px\] {
813 | height: 40px;
814 | }
815 |
816 | .h-full {
817 | height: 100%;
818 | }
819 |
820 | .h-\[30px\] {
821 | height: 30px;
822 | }
823 |
824 | .h-\[20px\] {
825 | height: 20px;
826 | }
827 |
828 | .h-\[25px\] {
829 | height: 25px;
830 | }
831 |
832 | .min-h-full {
833 | min-height: 100%;
834 | }
835 |
836 | .w-2\/3 {
837 | width: 66.666667%;
838 | }
839 |
840 | .w-auto {
841 | width: auto;
842 | }
843 |
844 | .w-full {
845 | width: 100%;
846 | }
847 |
848 | .-translate-x-1\/2 {
849 | --tw-translate-x: -50%;
850 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
851 | }
852 |
853 | .-translate-y-1\/2 {
854 | --tw-translate-y: -50%;
855 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
856 | }
857 |
858 | .flex-col {
859 | flex-direction: column;
860 | }
861 |
862 | .items-center {
863 | align-items: center;
864 | }
865 |
866 | .justify-center {
867 | justify-content: center;
868 | }
869 |
870 | .justify-around {
871 | justify-content: space-around;
872 | }
873 |
874 | .gap-10 {
875 | gap: 2.5rem;
876 | }
877 |
878 | .gap-2 {
879 | gap: 0.5rem;
880 | }
881 |
882 | .gap-5 {
883 | gap: 1.25rem;
884 | }
885 |
886 | .gap-6 {
887 | gap: 1.5rem;
888 | }
889 |
890 | .space-y-6 > :not([hidden]) ~ :not([hidden]) {
891 | --tw-space-y-reverse: 0;
892 | margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
893 | margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
894 | }
895 |
896 | .rounded {
897 | border-radius: 0.25rem;
898 | }
899 |
900 | .rounded-lg {
901 | border-radius: 0.5rem;
902 | }
903 |
904 | .border-0 {
905 | border-width: 0px;
906 | }
907 |
908 | .border-gray-400 {
909 | --tw-border-opacity: 1;
910 | border-color: rgb(156 163 175 / var(--tw-border-opacity));
911 | }
912 |
913 | .bg-white {
914 | --tw-bg-opacity: 1;
915 | background-color: rgb(255 255 255 / var(--tw-bg-opacity));
916 | }
917 |
918 | .bg-gradient-to-tr {
919 | background-image: linear-gradient(to top right, var(--tw-gradient-stops));
920 | }
921 |
922 | .from-cyan-300 {
923 | --tw-gradient-from: #67e8f9 var(--tw-gradient-from-position);
924 | --tw-gradient-to: rgb(103 232 249 / 0) var(--tw-gradient-to-position);
925 | --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
926 | }
927 |
928 | .to-fuchsia-300 {
929 | --tw-gradient-to: #f0abfc var(--tw-gradient-to-position);
930 | }
931 |
932 | .p-2 {
933 | padding: 0.5rem;
934 | }
935 |
936 | .px-6 {
937 | padding-left: 1.5rem;
938 | padding-right: 1.5rem;
939 | }
940 |
941 | .py-12 {
942 | padding-top: 3rem;
943 | padding-bottom: 3rem;
944 | }
945 |
946 | .py-3 {
947 | padding-top: 0.75rem;
948 | padding-bottom: 0.75rem;
949 | }
950 |
951 | .text-center {
952 | text-align: center;
953 | }
954 |
955 | .text-2xl {
956 | font-size: 1.5rem;
957 | line-height: 2rem;
958 | }
959 |
960 | .text-sm {
961 | font-size: 0.875rem;
962 | line-height: 1.25rem;
963 | }
964 |
965 | .text-xl {
966 | font-size: 1.25rem;
967 | line-height: 1.75rem;
968 | }
969 |
970 | .font-bold {
971 | font-weight: 700;
972 | }
973 |
974 | .font-medium {
975 | font-weight: 500;
976 | }
977 |
978 | .leading-6 {
979 | line-height: 1.5rem;
980 | }
981 |
982 | .leading-9 {
983 | line-height: 2.25rem;
984 | }
985 |
986 | .tracking-tight {
987 | letter-spacing: -0.025em;
988 | }
989 |
990 | .text-gray-600 {
991 | --tw-text-opacity: 1;
992 | color: rgb(75 85 99 / var(--tw-text-opacity));
993 | }
994 |
995 | .text-gray-800 {
996 | --tw-text-opacity: 1;
997 | color: rgb(31 41 55 / var(--tw-text-opacity));
998 | }
999 |
1000 | .text-gray-900 {
1001 | --tw-text-opacity: 1;
1002 | color: rgb(17 24 39 / var(--tw-text-opacity));
1003 | }
1004 |
1005 | .text-red-600 {
1006 | --tw-text-opacity: 1;
1007 | color: rgb(220 38 38 / var(--tw-text-opacity));
1008 | }
1009 |
1010 | .opacity-40 {
1011 | opacity: 0.4;
1012 | }
1013 |
1014 | .shadow-sm {
1015 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
1016 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
1017 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1018 | }
1019 |
1020 | .ring-1 {
1021 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
1022 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
1023 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
1024 | }
1025 |
1026 | .ring-inset {
1027 | --tw-ring-inset: inset;
1028 | }
1029 |
1030 | .ring-gray-300 {
1031 | --tw-ring-opacity: 1;
1032 | --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
1033 | }
1034 |
1035 | .blur-3xl {
1036 | --tw-blur: blur(64px);
1037 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
1038 | }
1039 |
1040 | .placeholder\:text-gray-400::-moz-placeholder {
1041 | --tw-text-opacity: 1;
1042 | color: rgb(156 163 175 / var(--tw-text-opacity));
1043 | }
1044 |
1045 | .placeholder\:text-gray-400::placeholder {
1046 | --tw-text-opacity: 1;
1047 | color: rgb(156 163 175 / var(--tw-text-opacity));
1048 | }
1049 |
1050 | .focus\:ring-2:focus {
1051 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
1052 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
1053 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
1054 | }
1055 |
1056 | .focus\:ring-inset:focus {
1057 | --tw-ring-inset: inset;
1058 | }
1059 |
1060 | .focus\:ring-fuchsia-800:focus {
1061 | --tw-ring-opacity: 1;
1062 | --tw-ring-color: rgb(134 25 143 / var(--tw-ring-opacity));
1063 | }
1064 |
1065 | @media (min-width: 640px) {
1066 | .sm\:mx-auto {
1067 | margin-left: auto;
1068 | margin-right: auto;
1069 | }
1070 |
1071 | .sm\:w-full {
1072 | width: 100%;
1073 | }
1074 |
1075 | .sm\:max-w-md {
1076 | max-width: 28rem;
1077 | }
1078 |
1079 | .sm\:max-w-sm {
1080 | max-width: 24rem;
1081 | }
1082 | }
1083 |
1084 | @media (min-width: 1024px) {
1085 | .lg\:px-8 {
1086 | padding-left: 2rem;
1087 | padding-right: 2rem;
1088 | }
1089 | }
1090 |
--------------------------------------------------------------------------------