├── 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 |

Passkeys Logo

2 | 3 | # Vapor Passkey Demo 4 | 5 | Proof of concept app for trying to integrate passkeys and WebAuthn into Vapor 6 | 7 | ![Screenshot of app](/images/demo.png) 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 |
22 | 23 |
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 |
26 | 27 | GitHub logo 28 |

Vapor Passkey Demo

29 |
30 | 31 | GitHub logo 32 |

Swift WebAuthn

33 |
34 |
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 | Vapor logo 6 |

Vapor Passkey Demo

7 |
8 | 9 |
10 |
11 |
12 |
13 | 14 | 18 |
19 |
20 |
21 |
22 | 23 | 24 |
25 |
26 | 27 |

28 | Don't have an account yet? 29 |

30 | 31 | 32 |
33 |
34 |
35 | 36 |
37 | 38 |
39 |
40 | 41 |
42 |

Note: Your demo account will be deleted after 2 hours.

43 | 44 | 47 |
48 |
49 |
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 | --------------------------------------------------------------------------------