From 16262e8a5c77f926bffd34794d2691898be44caa Mon Sep 17 00:00:00 2001 From: Mauricio Cardozo Date: Mon, 10 Aug 2026 19:25:17 -0300 Subject: [PATCH 1/2] adds vapor authentication paths --- .../xcshareddata/swiftpm/Package.resolved | 18 + .../CocoaHeadsCore/Auth/AttestDTOs.swift | 41 +++ .../CocoaHeadsCore/Auth/AuthDTOs.swift | 93 +++++ .../xcshareddata/xcodecloud/manifest.json | 9 + .../xcschemes/CocoaHeadsBR Watch.xcscheme | 2 - backend/Package.resolved | 348 ++++++++++++++++++ backend/Package.swift | 12 + backend/README.md | 9 + .../backend/Auth/AccessTokenPayload.swift | 52 +++ .../backend/Auth/AccountDeletionService.swift | 48 +++ .../backend/Auth/AccountPurgeService.swift | 52 +++ .../Auth/AppAttest/AppAttestVerifier.swift | 251 +++++++++++++ .../Sources/backend/Auth/AppAttest/CBOR.swift | 149 ++++++++ .../Auth/AppAttest/ChallengeStore.swift | 56 +++ .../backend/Auth/AppleAuthService.swift | 169 +++++++++ .../backend/Auth/AuthConfiguration.swift | 102 +++++ .../backend/Auth/TokenEncryption.swift | 80 ++++ .../Sources/backend/Auth/TokenService.swift | 112 ++++++ .../Controllers/Auth/AttestController.swift | 88 +++++ .../Controllers/Auth/AuthController.swift | 116 ++++++ .../Controllers/Auth/MeController.swift | 43 +++ .../backend/Middleware/APIKeyMiddleware.swift | 33 ++ .../Middleware/AppAttestMiddleware.swift | 100 +++++ .../backend/Migrations/CreateAuthTables.swift | 67 ++++ .../Sources/backend/Models/AppAttestKey.swift | 64 ++++ .../Sources/backend/Models/RefreshToken.swift | 49 +++ backend/Sources/backend/Models/User.swift | 74 ++++ backend/Sources/backend/configure.swift | 42 +++ backend/Sources/backend/routes.swift | 15 +- backend/Tests/backendTests/AuthTests.swift | 240 ++++++++++++ .../AuthTokenAndMiddlewareTests.swift | 317 ++++++++++++++++ backend/docker-compose.yml | 16 +- backend/docs/user-auth-implementation.md | 126 +++++++ backend/docs/user-auth-spec.md | 278 ++++++++++++++ 34 files changed, 3267 insertions(+), 4 deletions(-) create mode 100644 CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/AttestDTOs.swift create mode 100644 CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/AuthDTOs.swift create mode 100644 NSBrazilConf.xcodeproj/xcshareddata/xcodecloud/manifest.json create mode 100644 backend/Package.resolved create mode 100644 backend/Sources/backend/Auth/AccessTokenPayload.swift create mode 100644 backend/Sources/backend/Auth/AccountDeletionService.swift create mode 100644 backend/Sources/backend/Auth/AccountPurgeService.swift create mode 100644 backend/Sources/backend/Auth/AppAttest/AppAttestVerifier.swift create mode 100644 backend/Sources/backend/Auth/AppAttest/CBOR.swift create mode 100644 backend/Sources/backend/Auth/AppAttest/ChallengeStore.swift create mode 100644 backend/Sources/backend/Auth/AppleAuthService.swift create mode 100644 backend/Sources/backend/Auth/AuthConfiguration.swift create mode 100644 backend/Sources/backend/Auth/TokenEncryption.swift create mode 100644 backend/Sources/backend/Auth/TokenService.swift create mode 100644 backend/Sources/backend/Controllers/Auth/AttestController.swift create mode 100644 backend/Sources/backend/Controllers/Auth/AuthController.swift create mode 100644 backend/Sources/backend/Controllers/Auth/MeController.swift create mode 100644 backend/Sources/backend/Middleware/APIKeyMiddleware.swift create mode 100644 backend/Sources/backend/Middleware/AppAttestMiddleware.swift create mode 100644 backend/Sources/backend/Migrations/CreateAuthTables.swift create mode 100644 backend/Sources/backend/Models/AppAttestKey.swift create mode 100644 backend/Sources/backend/Models/RefreshToken.swift create mode 100644 backend/Sources/backend/Models/User.swift create mode 100644 backend/Tests/backendTests/AuthTests.swift create mode 100644 backend/Tests/backendTests/AuthTokenAndMiddlewareTests.swift create mode 100644 backend/docs/user-auth-implementation.md create mode 100644 backend/docs/user-auth-spec.md diff --git a/CocoaHeads.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CocoaHeads.xcworkspace/xcshareddata/swiftpm/Package.resolved index ce14122..61f8d36 100644 --- a/CocoaHeads.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CocoaHeads.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -54,6 +54,24 @@ "version" : "2.11.0" } }, + { + "identity" : "jwt", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/jwt.git", + "state" : { + "revision" : "af1c59762d70d1065ddbc0d7902ea9b3dacd1a26", + "version" : "5.1.2" + } + }, + { + "identity" : "jwt-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/jwt-kit.git", + "state" : { + "revision" : "2033b3e661238dda3d30e36a2d40987499d987de", + "version" : "5.2.0" + } + }, { "identity" : "lrucache", "kind" : "remoteSourceControl", diff --git a/CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/AttestDTOs.swift b/CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/AttestDTOs.swift new file mode 100644 index 0000000..ad1e19a --- /dev/null +++ b/CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/AttestDTOs.swift @@ -0,0 +1,41 @@ +// +// AttestDTOs.swift +// +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Foundation + +/// Response of `POST /attest/challenge`. The challenge is single-use and +/// short-lived; the client embeds it in the attestation or assertion it +/// produces next. +public struct AttestChallengeResponse: Codable, Equatable, Sendable { + public init(challenge: String, expiresIn: Int) { + self.challenge = challenge + self.expiresIn = expiresIn + } + + /// Base64-encoded random challenge bytes. + public let challenge: String + /// Seconds until the challenge expires. + public let expiresIn: Int +} + +/// Body of `POST /attest/key` β€” registers an App Attest key with the server. +public struct AttestKeyRegistrationRequest: Codable, Equatable, Sendable { + public init(keyId: String, attestation: String, challenge: String) { + self.keyId = keyId + self.attestation = attestation + self.challenge = challenge + } + + /// The App Attest key identifier (base64) from `DCAppAttestService.generateKey`. + public let keyId: String + /// Base64-encoded CBOR attestation object from + /// `DCAppAttestService.attestKey(_:clientDataHash:)`, where `clientDataHash` + /// is SHA-256 of the raw challenge bytes. + public let attestation: String + /// The base64 challenge previously issued by `POST /attest/challenge`. + public let challenge: String +} diff --git a/CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/AuthDTOs.swift b/CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/AuthDTOs.swift new file mode 100644 index 0000000..6ac8180 --- /dev/null +++ b/CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/AuthDTOs.swift @@ -0,0 +1,93 @@ +// +// AuthDTOs.swift +// +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Foundation + +/// Body of `POST /auth/apple`. Sent by the iOS client after a successful +/// `ASAuthorizationController` run. +public struct AppleSignInRequest: Codable, Equatable, Sendable { + public init( + identityToken: String, + authorizationCode: String, + fullName: String? = nil, + email: String? = nil + ) { + self.identityToken = identityToken + self.authorizationCode = authorizationCode + self.fullName = fullName + self.email = email + } + + /// The Apple identity token (a JWT) from `ASAuthorizationAppleIDCredential`. + public let identityToken: String + /// The single-use authorization code, exchanged server-side for Apple tokens. + public let authorizationCode: String + /// Only present on the user's first authorization. + public let fullName: String? + /// Only present on the user's first authorization. May be a Hide-My-Email relay. + public let email: String? +} + +/// Token pair returned by `POST /auth/apple` and `POST /auth/refresh`. +public struct TokenResponse: Codable, Equatable, Sendable { + public init( + accessToken: String, + refreshToken: String, + expiresIn: Int, + user: UserDTO + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresIn = expiresIn + self.user = user + } + + /// Backend-signed JWT. Send as `Authorization: Bearer `. + public let accessToken: String + /// Opaque single-use refresh token. Store in the Keychain. + public let refreshToken: String + /// Access-token lifetime in seconds. + public let expiresIn: Int + public let user: UserDTO +} + +/// Body of `POST /auth/refresh` and `POST /auth/logout`. +public struct RefreshRequest: Codable, Equatable, Sendable { + public init(refreshToken: String) { + self.refreshToken = refreshToken + } + + public let refreshToken: String +} + +/// The current user, as returned by `GET /me`. +public struct UserDTO: Codable, Equatable, Sendable { + public init( + id: UUID, + email: String? = nil, + fullName: String? = nil, + role: UserRole + ) { + self.id = id + self.email = email + self.fullName = fullName + self.role = role + } + + public let id: UUID + public let email: String? + public let fullName: String? + public let role: UserRole +} + +/// Roles are foundation-only for now: they are embedded in access-token claims +/// but not yet enforced by any middleware. +public enum UserRole: String, Codable, Equatable, Sendable, CaseIterable { + case user + case organizer + case admin +} diff --git a/NSBrazilConf.xcodeproj/xcshareddata/xcodecloud/manifest.json b/NSBrazilConf.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 0000000..8b2ef9e --- /dev/null +++ b/NSBrazilConf.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "b56c993d-8301-4e37-9f79-98d769725e3f", + "targets" : [ + { + "id" : "D8075982-631D-4086-9418-269EA668F614", + "name" : "NSBrazil" + } + ] +} \ No newline at end of file diff --git a/NSBrazilConf.xcodeproj/xcshareddata/xcschemes/CocoaHeadsBR Watch.xcscheme b/NSBrazilConf.xcodeproj/xcshareddata/xcschemes/CocoaHeadsBR Watch.xcscheme index c99fafd..9055029 100644 --- a/NSBrazilConf.xcodeproj/xcshareddata/xcschemes/CocoaHeadsBR Watch.xcscheme +++ b/NSBrazilConf.xcodeproj/xcshareddata/xcschemes/CocoaHeadsBR Watch.xcscheme @@ -60,7 +60,6 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "B482AA862C5C721600F66F3B" BuildableName = "CocoaHeadsBR.app" - BlueprintName = "CocoaHeadsBR Watch" ReferencedContainer = "container:NSBrazilConf.xcodeproj"> @@ -77,7 +76,6 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "B482AA862C5C721600F66F3B" BuildableName = "CocoaHeadsBR.app" - BlueprintName = "CocoaHeadsBR Watch" ReferencedContainer = "container:NSBrazilConf.xcodeproj"> diff --git a/backend/Package.resolved b/backend/Package.resolved new file mode 100644 index 0000000..64531d3 --- /dev/null +++ b/backend/Package.resolved @@ -0,0 +1,348 @@ +{ + "originHash" : "03572c03a022bfc12306adb45f97e62cfbc9e38d8269781007372e8238d48fb0", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "9544287b9416c0bc71e58b9f3aead8dd14b16103", + "version" : "1.36.0" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9", + "version" : "1.22.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", + "version" : "4.16.0" + } + }, + { + "identity" : "fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent.git", + "state" : { + "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", + "version" : "4.13.0" + } + }, + { + "identity" : "fluent-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-kit.git", + "state" : { + "revision" : "ca609b2132bde05f9a2d7561e2864587c24fa7b9", + "version" : "1.57.0" + } + }, + { + "identity" : "fluent-postgres-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-postgres-driver.git", + "state" : { + "revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69", + "version" : "2.12.0" + } + }, + { + "identity" : "jwt", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/jwt.git", + "state" : { + "revision" : "af1c59762d70d1065ddbc0d7902ea9b3dacd1a26", + "version" : "5.1.2" + } + }, + { + "identity" : "jwt-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/jwt-kit.git", + "state" : { + "revision" : "2033b3e661238dda3d30e36a2d40987499d987de", + "version" : "5.2.0" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "postgres-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-kit.git", + "state" : { + "revision" : "218aaf6810db9e61398f887aaaf26424142bdb53", + "version" : "2.16.1" + } + }, + { + "identity" : "postgres-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-nio.git", + "state" : { + "revision" : "39ce38a93408937bcd27f521f3cdf88266136b80", + "version" : "1.33.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "3779cedb44b1f374f2cca261c6d28f206024a582", + "version" : "3.36.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "449dbbecd0f31e82b510ada227ca152caa8b5e98", + "version" : "1.19.4" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222", + "version" : "1.15.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "88a51340f59cf181ebde888bd1b749296b3ec029", + "version" : "1.34.3" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "45bdf670248be5f16ec0340e125dca285536f0fb", + "version" : "1.45.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "d930168b86f46ca51a4bc09c5ca45c1833db8067", + "version" : "2.37.2" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "704705c5c51156ede21172a38654d522ce487074", + "version" : "1.8.0" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "8d6ad267714cac3ae747cefdd21f7a6665006e1f", + "version" : "2.13.7" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "748ae8432a33e0965bbf0351fedd4e915e7f460c", + "version" : "4.122.0" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" + } + } + ], + "version" : 3 +} diff --git a/backend/Package.swift b/backend/Package.swift index cacf270..14455ec 100644 --- a/backend/Package.swift +++ b/backend/Package.swift @@ -16,6 +16,14 @@ let package = Package( // πŸ”΅ Non-blocking, event-driven networking for Swift. Used for custom executors .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.8.7"), + // πŸ” JSON Web Tokens β€” Apple identity-token verification + backend-signed access tokens. + .package(url: "https://github.com/vapor/jwt.git", from: "5.1.0"), + // πŸ“œ X.509 certificate parsing/validation for App Attest attestation chains. + .package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"), + // πŸ” Cryptography (SHA-256, P-256 signatures, AES-GCM) for tokens and App Attest. + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.8.0"), + // πŸ“„ ASN.1/DER parsing for the App Attest nonce certificate extension. + .package(url: "https://github.com/apple/swift-asn1.git", from: "1.3.0"), .package(path: "../CocoaHeadsCore") ], targets: [ @@ -28,6 +36,10 @@ let package = Package( .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "SwiftSoup", package: "SwiftSoup"), + .product(name: "JWT", package: "jwt"), + .product(name: "X509", package: "swift-certificates"), + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "SwiftASN1", package: "swift-asn1"), "CocoaHeadsCore" ], swiftSettings: swiftSettings diff --git a/backend/README.md b/backend/README.md index 5f9c89c..0b884e5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -19,6 +19,15 @@ To execute tests, use the following command: swift test ``` +## Authentication + +The backend has two security layers: app authentication (API key + Apple App +Attest) on every route, and user authentication (Sign in with Apple β†’ backend +JWTs) on user-scoped routes. See +[docs/user-auth-spec.md](docs/user-auth-spec.md) for the design and +[docs/user-auth-implementation.md](docs/user-auth-implementation.md) for the +endpoints, client protocol, and required environment variables. + ## Deployment **Note**: The production backend is hosted and managed by CocoaHeads Brasil on AWS infrastructure. Deployments are controlled by the CocoaHeads team and are triggered automatically upon merging to the main branch. diff --git a/backend/Sources/backend/Auth/AccessTokenPayload.swift b/backend/Sources/backend/Auth/AccessTokenPayload.swift new file mode 100644 index 0000000..ed197f5 --- /dev/null +++ b/backend/Sources/backend/Auth/AccessTokenPayload.swift @@ -0,0 +1,52 @@ +// +// AccessTokenPayload.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import CocoaHeadsCore +import JWT +import Vapor + +/// The backend-signed access token. Verified statelessly β€” no database hit on +/// the hot path. +struct AccessTokenPayload: JWTPayload, Authenticatable { + enum CodingKeys: String, CodingKey { + case subject = "sub" + case expiration = "exp" + case issuedAt = "iat" + case role + } + + /// The `User` id. + let subject: SubjectClaim + let expiration: ExpirationClaim + let issuedAt: IssuedAtClaim + let role: UserRole + + func verify(using algorithm: some JWTAlgorithm) async throws { + try expiration.verifyNotExpired() + } + + var userID: UUID { + get throws { + guard let id = UUID(uuidString: subject.value) else { + throw Abort(.unauthorized, reason: "Malformed access token subject.") + } + return id + } + } +} + +extension Request { + /// The authenticated user for a Bearer-protected route. Rejects tokens whose + /// account no longer exists or is soft-deleted. + func authenticatedUser() async throws -> User { + let payload = try auth.require(AccessTokenPayload.self) + guard let user = try await User.find(payload.userID, on: db) else { + throw Abort(.unauthorized, reason: "Account no longer exists.") + } + return user + } +} diff --git a/backend/Sources/backend/Auth/AccountDeletionService.swift b/backend/Sources/backend/Auth/AccountDeletionService.swift new file mode 100644 index 0000000..3137896 --- /dev/null +++ b/backend/Sources/backend/Auth/AccountDeletionService.swift @@ -0,0 +1,48 @@ +// +// AccountDeletionService.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Fluent +import Vapor + +/// Account deletion per spec Β§8: soft-delete with immediate PII scrub, token +/// and device-key revocation, and Apple grant revocation. Hard purge happens +/// later via `AccountPurgeService`. +struct AccountDeletionService: Sendable { + let appleAuth: any AppleAuthServiceProtocol + + func delete(_ user: User, on req: Request) async throws { + let userID = try user.requireID() + + // Revoke Apple's grant (server-to-server, required for Sign in with + // Apple). Best-effort: a transient Apple failure must not leave the user + // unable to delete their account. + if let encrypted = user.appleRefreshToken { + do { + let appleRefreshToken = try req.application.tokenEncryption.decrypt(encrypted) + try await appleAuth.revokeRefreshToken(appleRefreshToken, on: req) + } catch { + req.logger.error("Apple grant revocation failed for user \(userID): \(error)") + } + } + + // Scrub PII immediately β€” the tombstoned row keeps no personal data. + user.email = nil + user.fullName = nil + user.appleRefreshToken = nil + try await user.save(on: req.db) + + try await TokenService().revokeAll(for: userID, on: req.db) + try await AppAttestKey.query(on: req.db) + .filter(\.$user.$id == userID) + .set(\.$revoked, to: true) + .update() + + // Sets `deleted_at` (Fluent soft delete); hard purge follows after the + // configured grace period. + try await user.delete(on: req.db) + } +} diff --git a/backend/Sources/backend/Auth/AccountPurgeService.swift b/backend/Sources/backend/Auth/AccountPurgeService.swift new file mode 100644 index 0000000..d955991 --- /dev/null +++ b/backend/Sources/backend/Auth/AccountPurgeService.swift @@ -0,0 +1,52 @@ +// +// AccountPurgeService.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Fluent +import NIOConcurrencyHelpers +import Vapor + +/// Hard-purges soft-deleted accounts once their grace period +/// (`ACCOUNT_PURGE_GRACE_DAYS`) has elapsed: a sweep at boot, then every 12 +/// hours (Β§8). +final class AccountPurgeService: LifecycleHandler { + private let task: NIOLockedValueBox?> = .init(nil) + + static let sweepInterval: Duration = .seconds(12 * 60 * 60) + + func didBootAsync(_ application: Application) async throws { + task.withLockedValue { value in + value = Task { + while !Task.isCancelled { + await Self.purge(on: application) + try? await Task.sleep(for: Self.sweepInterval) + } + } + } + } + + func shutdownAsync(_ application: Application) async { + task.withLockedValue { $0?.cancel() } + } + + static func purge(on application: Application) async { + let graceDays = application.authConfiguration.accountPurgeGraceDays + let cutoff = Date().addingTimeInterval(-TimeInterval(graceDays) * 24 * 60 * 60) + do { + let expired = try await User.query(on: application.db) + .withDeleted() + .filter(\.$deletedAt < cutoff) + .all() + guard !expired.isEmpty else { return } + for user in expired { + try await user.delete(force: true, on: application.db) + } + application.logger.info("Purged \(expired.count) soft-deleted account(s).") + } catch { + application.logger.error("Account purge sweep failed: \(error)") + } + } +} diff --git a/backend/Sources/backend/Auth/AppAttest/AppAttestVerifier.swift b/backend/Sources/backend/Auth/AppAttest/AppAttestVerifier.swift new file mode 100644 index 0000000..310d9d9 --- /dev/null +++ b/backend/Sources/backend/Auth/AppAttest/AppAttestVerifier.swift @@ -0,0 +1,251 @@ +// +// AppAttestVerifier.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Crypto +import Foundation +import SwiftASN1 +import X509 + +/// Server-side verification of Apple App Attest attestation objects and +/// assertions, per +/// https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server +/// +/// There is no first-party Swift server library for this; the CBOR decoding +/// lives in `CBOR.swift` and the X.509 chain validation uses swift-certificates +/// against Apple's published App Attest root CA. +struct AppAttestVerifier: Sendable { + /// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem + static let appleAppAttestRootCA = """ + -----BEGIN CERTIFICATE----- + MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw + JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK + QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa + Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv + biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y + bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh + NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au + Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ + MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw + CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn + 53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV + oyFraWVIyd/dganmrduC1bmTBGwD + -----END CERTIFICATE----- + """ + + /// OID of the credential certificate extension carrying the expected nonce. + static let nonceExtensionOID: ASN1ObjectIdentifier = [1, 2, 840, 113_635, 100, 8, 2] + + enum VerificationError: Error, Equatable { + case malformedAttestation + case unexpectedFormat(String) + case certificateChainInvalid + case nonceMismatch + case keyIdMismatch + case rpIdMismatch + case invalidSignCount + case invalidAAGUID + case credentialIdMismatch + case malformedAssertion + case invalidSignature + case nonIncreasingSignCount + } + + struct AttestedKey: Sendable { + /// P-256 public key, X9.63 representation. + let publicKey: Data + let receipt: Data + let signCount: Int + } + + let environment: AuthConfiguration.AppAttestEnvironment + + /// Verifies an attestation object produced by + /// `DCAppAttestService.attestKey(_:clientDataHash:)`, where the client data + /// hash is SHA-256 of the raw challenge bytes. + func verifyAttestation( + _ attestation: Data, + keyId: Data, + challenge: Data, + appID: String + ) async throws -> AttestedKey { + let object = try CBOR.decode(attestation) + guard + let fmt = object["fmt"]?.textValue, + let attStmt = object["attStmt"], + let authData = object["authData"]?.bytesValue, + let x5c = attStmt["x5c"]?.arrayValue, + let receipt = attStmt["receipt"]?.bytesValue, + let credCertDER = x5c.first?.bytesValue + else { + throw VerificationError.malformedAttestation + } + guard fmt == "apple-appattest" else { + throw VerificationError.unexpectedFormat(fmt) + } + + // 1. Validate the certificate chain up to Apple's App Attest root CA. + let credCert = try Certificate(derEncoded: Array(credCertDER)) + let intermediates = try x5c.dropFirst().map { item -> Certificate in + guard let der = item.bytesValue else { throw VerificationError.malformedAttestation } + return try Certificate(derEncoded: Array(der)) + } + let root = try Certificate(pemEncoded: Self.appleAppAttestRootCA) + var verifier = Verifier(rootCertificates: CertificateStore([root])) { + RFC5280Policy() + } + let result = await verifier.validate( + leaf: credCert, + intermediates: CertificateStore(intermediates) + ) + guard case .validCertificate = result else { + throw VerificationError.certificateChainInvalid + } + + // 2/3. Recreate the nonce and compare against the credential certificate's + // 1.2.840.113635.100.8.2 extension. + let clientDataHash = Data(SHA256.hash(data: challenge)) + let nonce = Data(SHA256.hash(data: authData + clientDataHash)) + guard + let nonceExtension = credCert.extensions[oid: Self.nonceExtensionOID], + try Self.extractNonce(from: nonceExtension.value) == nonce + else { + throw VerificationError.nonceMismatch + } + + // 4. The key identifier must be the SHA-256 of the attested public key. + guard let publicKey = P256.Signing.PublicKey(credCert.publicKey) else { + throw VerificationError.malformedAttestation + } + guard Data(SHA256.hash(data: publicKey.x963Representation)) == keyId else { + throw VerificationError.keyIdMismatch + } + + // 5–9. Authenticator data checks: RP ID hash, initial counter, aaguid, + // credential id. + let parsed = try AuthenticatorData(authData) + guard parsed.rpIdHash == Data(SHA256.hash(data: Data(appID.utf8))) else { + throw VerificationError.rpIdMismatch + } + guard parsed.signCount == 0 else { + throw VerificationError.invalidSignCount + } + guard parsed.aaguid == Self.expectedAAGUID(for: environment) else { + throw VerificationError.invalidAAGUID + } + guard parsed.credentialId == keyId else { + throw VerificationError.credentialIdMismatch + } + + return AttestedKey( + publicKey: publicKey.x963Representation, + receipt: receipt, + signCount: Int(parsed.signCount) + ) + } + + /// Verifies a per-request assertion produced by + /// `DCAppAttestService.generateAssertion(_:clientDataHash:)`. + /// Returns the new sign count to persist. + func verifyAssertion( + _ assertion: Data, + publicKey x963: Data, + clientDataHash: Data, + appID: String, + storedSignCount: Int + ) throws -> Int { + let object = try CBOR.decode(assertion) + guard + let signature = object["signature"]?.bytesValue, + let authData = object["authenticatorData"]?.bytesValue + else { + throw VerificationError.malformedAssertion + } + + let publicKey = try P256.Signing.PublicKey(x963Representation: x963) + let nonce = Data(SHA256.hash(data: authData + clientDataHash)) + guard + let ecdsaSignature = try? P256.Signing.ECDSASignature(derRepresentation: signature), + publicKey.isValidSignature(ecdsaSignature, for: nonce) + else { + throw VerificationError.invalidSignature + } + + guard authData.count >= 37 else { + throw VerificationError.malformedAssertion + } + let rpIdHash = Data(authData.prefix(32)) + guard rpIdHash == Data(SHA256.hash(data: Data(appID.utf8))) else { + throw VerificationError.rpIdMismatch + } + + let signCount = authData.dropFirst(33).prefix(4).reduce(UInt32(0)) { $0 << 8 | UInt32($1) } + guard signCount > storedSignCount else { + throw VerificationError.nonIncreasingSignCount + } + return Int(signCount) + } + + static func expectedAAGUID(for environment: AuthConfiguration.AppAttestEnvironment) -> Data { + switch environment { + case .production: + // "appattest" padded with 7 zero bytes. + return Data("appattest".utf8) + Data(repeating: 0, count: 7) + case .development: + return Data("appattestdevelop".utf8) + } + } + + /// The nonce extension value is `SEQUENCE { [1] { OCTET STRING (32) } }`. + static func extractNonce(from extensionValue: ArraySlice) throws -> Data { + let root = try DER.parse(Array(extensionValue)) + guard case .constructed(let children) = root.content else { + throw VerificationError.nonceMismatch + } + for child in children { + switch child.content { + case .primitive(let bytes) where bytes.count == 32: + return Data(bytes) + case .constructed(let inner): + for node in inner { + if case .primitive(let bytes) = node.content, bytes.count == 32 { + return Data(bytes) + } + } + default: + continue + } + } + throw VerificationError.nonceMismatch + } +} + +/// WebAuthn-style authenticator data with attested credential data, as used by +/// App Attest attestation objects. +struct AuthenticatorData { + let rpIdHash: Data + let flags: UInt8 + let signCount: UInt32 + let aaguid: Data + let credentialId: Data + + init(_ data: Data) throws { + // rpIdHash(32) | flags(1) | signCount(4) | aaguid(16) | credIdLen(2) | credId + guard data.count >= 55 else { + throw AppAttestVerifier.VerificationError.malformedAttestation + } + let bytes = [UInt8](data) + rpIdHash = Data(bytes[0..<32]) + flags = bytes[32] + signCount = bytes[33..<37].reduce(UInt32(0)) { $0 << 8 | UInt32($1) } + aaguid = Data(bytes[37..<53]) + let credIdLength = Int(bytes[53]) << 8 | Int(bytes[54]) + guard bytes.count >= 55 + credIdLength else { + throw AppAttestVerifier.VerificationError.malformedAttestation + } + credentialId = Data(bytes[55..<(55 + credIdLength)]) + } +} diff --git a/backend/Sources/backend/Auth/AppAttest/CBOR.swift b/backend/Sources/backend/Auth/AppAttest/CBOR.swift new file mode 100644 index 0000000..ecce0b3 --- /dev/null +++ b/backend/Sources/backend/Auth/AppAttest/CBOR.swift @@ -0,0 +1,149 @@ +// +// CBOR.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Foundation + +/// Minimal CBOR (RFC 8949) decoder covering the subset used by App Attest +/// attestation and assertion objects: unsigned/negative integers, byte +/// strings, text strings, arrays, and maps with definite lengths. +enum CBOR: Equatable, Sendable { + case unsigned(UInt64) + case negative(Int64) + case bytes(Data) + case text(String) + case array([CBOR]) + case map([CBOR: CBOR]) + + enum DecodingError: Error { + case truncated + case unsupportedType(UInt8) + case indefiniteLengthUnsupported + case invalidUTF8 + case trailingBytes + } + + static func decode(_ data: Data) throws -> CBOR { + var reader = Reader(data: data) + let value = try reader.decodeItem() + return value + } + + subscript(key: String) -> CBOR? { + guard case .map(let map) = self else { return nil } + return map[.text(key)] + } + + var bytesValue: Data? { + guard case .bytes(let data) = self else { return nil } + return data + } + + var textValue: String? { + guard case .text(let string) = self else { return nil } + return string + } + + var arrayValue: [CBOR]? { + guard case .array(let items) = self else { return nil } + return items + } + + var unsignedValue: UInt64? { + guard case .unsigned(let value) = self else { return nil } + return value + } + + private struct Reader { + let data: Data + var index: Data.Index + + init(data: Data) { + self.data = data + self.index = data.startIndex + } + + mutating func decodeItem() throws -> CBOR { + let initial = try readByte() + let majorType = initial >> 5 + let additional = initial & 0x1F + + switch majorType { + case 0: + return .unsigned(try readLength(additional)) + case 1: + let value = try readLength(additional) + guard value <= UInt64(Int64.max) else { throw DecodingError.unsupportedType(initial) } + return .negative(-1 - Int64(value)) + case 2: + let length = try readLength(additional) + return .bytes(try readBytes(count: length)) + case 3: + let length = try readLength(additional) + guard let string = String(data: try readBytes(count: length), encoding: .utf8) else { + throw DecodingError.invalidUTF8 + } + return .text(string) + case 4: + let count = try readLength(additional) + var items: [CBOR] = [] + items.reserveCapacity(Int(count)) + for _ in 0.. UInt64 { + switch additional { + case 0...23: + return UInt64(additional) + case 24: + return UInt64(try readByte()) + case 25: + let bytes = try readBytes(count: 2) + return bytes.reduce(UInt64(0)) { $0 << 8 | UInt64($1) } + case 26: + let bytes = try readBytes(count: 4) + return bytes.reduce(UInt64(0)) { $0 << 8 | UInt64($1) } + case 27: + let bytes = try readBytes(count: 8) + return bytes.reduce(UInt64(0)) { $0 << 8 | UInt64($1) } + default: + throw DecodingError.indefiniteLengthUnsupported + } + } + + private mutating func readByte() throws -> UInt8 { + guard index < data.endIndex else { throw DecodingError.truncated } + defer { index = data.index(after: index) } + return data[index] + } + + private mutating func readBytes(count: UInt64) throws -> Data { + guard count <= UInt64(data.distance(from: index, to: data.endIndex)) else { + throw DecodingError.truncated + } + let end = data.index(index, offsetBy: Int(count)) + defer { index = end } + return Data(data[index.. Data { + prune() + let challenge = Data((0..<32).map { _ in UInt8.random(in: .min ... .max) }) + challenges[challenge.base64EncodedString()] = Date().addingTimeInterval(Self.challengeTTL) + return challenge + } + + /// Consumes a challenge. Returns `false` for unknown, expired, or already + /// used challenges. + func consume(_ challenge: Data) -> Bool { + prune() + return challenges.removeValue(forKey: challenge.base64EncodedString()).map { $0 > Date() } + ?? false + } + + private func prune() { + let now = Date() + challenges = challenges.filter { $0.value > now } + } +} + +extension Application { + private struct ChallengeStoreKey: StorageKey { + typealias Value = ChallengeStore + } + + var challengeStore: ChallengeStore { + if let existing = storage[ChallengeStoreKey.self] { + return existing + } + let store = ChallengeStore() + storage[ChallengeStoreKey.self] = store + return store + } +} diff --git a/backend/Sources/backend/Auth/AppleAuthService.swift b/backend/Sources/backend/Auth/AppleAuthService.swift new file mode 100644 index 0000000..ab2cc70 --- /dev/null +++ b/backend/Sources/backend/Auth/AppleAuthService.swift @@ -0,0 +1,169 @@ +// +// AppleAuthService.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import JWT +import Vapor + +/// Server-to-server client for Apple's Sign in with Apple endpoints: +/// exchanging the authorization code for Apple tokens at sign-in (Β§4) and +/// revoking the user's grant at account deletion (Β§8). +protocol AppleAuthServiceProtocol: Sendable { + /// Exchanges the single-use `authorizationCode` for Apple's token set. + /// Returns Apple's refresh token. + func exchangeAuthorizationCode(_ code: String, on req: Request) async throws -> String + + /// Revokes the user's Sign in with Apple grant using the stored Apple + /// refresh token. + func revokeRefreshToken(_ appleRefreshToken: String, on req: Request) async throws +} + +struct AppleAuthService: AppleAuthServiceProtocol { + static let tokenURL = URI(string: "https://appleid.apple.com/auth/token") + static let revokeURL = URI(string: "https://appleid.apple.com/auth/revoke") + + func exchangeAuthorizationCode(_ code: String, on req: Request) async throws -> String { + let config = req.authConfiguration + let clientSecret = try await makeClientSecret(config: config) + + let response = try await req.client.post(Self.tokenURL) { clientReq in + try clientReq.content.encode( + AppleTokenRequest( + clientId: config.appleBundleID, + clientSecret: clientSecret, + code: code, + grantType: "authorization_code" + ), + as: .urlEncodedForm + ) + } + + guard response.status == .ok else { + let body = response.body.map { String(buffer: $0) } ?? "" + req.logger.error("Apple token exchange failed: \(response.status) \(body)") + throw Abort(.unauthorized, reason: "Apple rejected the authorization code.") + } + + let tokens = try response.content.decode(AppleTokenResponse.self) + return tokens.refreshToken + } + + func revokeRefreshToken(_ appleRefreshToken: String, on req: Request) async throws { + let config = req.authConfiguration + let clientSecret = try await makeClientSecret(config: config) + + let response = try await req.client.post(Self.revokeURL) { clientReq in + try clientReq.content.encode( + AppleRevokeRequest( + clientId: config.appleBundleID, + clientSecret: clientSecret, + token: appleRefreshToken, + tokenTypeHint: "refresh_token" + ), + as: .urlEncodedForm + ) + } + + guard response.status == .ok else { + let body = response.body.map { String(buffer: $0) } ?? "" + req.logger.error("Apple token revocation failed: \(response.status) \(body)") + throw Abort(.internalServerError, reason: "Apple token revocation failed.") + } + } + + /// Builds the client-secret JWT Apple requires, signed with the Sign in with + /// Apple `.p8` key (ES256). + private func makeClientSecret(config: AuthConfiguration) async throws -> String { + guard + let teamID = config.appleTeamID, + let keyID = config.appleSignInKeyID, + let privateKeyPEM = config.appleSignInPrivateKey + else { + throw Abort( + .internalServerError, + reason: "Sign in with Apple service credentials are not configured." + ) + } + + let keys = JWTKeyCollection() + let kid = JWKIdentifier(string: keyID) + try await keys.add(ecdsa: ES256PrivateKey(pem: privateKeyPEM), kid: kid) + + let now = Date() + let payload = AppleClientSecretPayload( + issuer: IssuerClaim(value: teamID), + issuedAt: IssuedAtClaim(value: now), + expiration: ExpirationClaim(value: now.addingTimeInterval(300)), + audience: AudienceClaim(value: "https://appleid.apple.com"), + subject: SubjectClaim(value: config.appleBundleID) + ) + return try await keys.sign(payload, kid: kid) + } +} + +struct AppleClientSecretPayload: JWTPayload { + enum CodingKeys: String, CodingKey { + case issuer = "iss" + case issuedAt = "iat" + case expiration = "exp" + case audience = "aud" + case subject = "sub" + } + + let issuer: IssuerClaim + let issuedAt: IssuedAtClaim + let expiration: ExpirationClaim + let audience: AudienceClaim + let subject: SubjectClaim + + func verify(using algorithm: some JWTAlgorithm) async throws { + try expiration.verifyNotExpired() + } +} + +private struct AppleTokenRequest: Content { + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case clientSecret = "client_secret" + case code + case grantType = "grant_type" + } + + let clientId: String + let clientSecret: String + let code: String + let grantType: String +} + +private struct AppleTokenResponse: Content { + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case idToken = "id_token" + case expiresIn = "expires_in" + case tokenType = "token_type" + } + + let accessToken: String + let refreshToken: String + let idToken: String? + let expiresIn: Int? + let tokenType: String? +} + +private struct AppleRevokeRequest: Content { + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case clientSecret = "client_secret" + case token + case tokenTypeHint = "token_type_hint" + } + + let clientId: String + let clientSecret: String + let token: String + let tokenTypeHint: String +} diff --git a/backend/Sources/backend/Auth/AuthConfiguration.swift b/backend/Sources/backend/Auth/AuthConfiguration.swift new file mode 100644 index 0000000..4501f90 --- /dev/null +++ b/backend/Sources/backend/Auth/AuthConfiguration.swift @@ -0,0 +1,102 @@ +// +// AuthConfiguration.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Vapor + +/// Auth-related environment configuration, loaded once in `configure(_:)`. +/// +/// Environment variables (see also `DATABASE_*` and `FIRECRAWL_API_KEY`): +/// - `JWT_SIGNING_KEY`: ES256 private key PEM (recommended) or an HS256 secret. +/// - `APPLE_BUNDLE_ID`: expected `aud` of the Apple identity token / client id. +/// - `API_KEYS`: comma-separated static client keys for the coarse API-key gate. +/// - `ACCESS_TOKEN_TTL`: access-token lifetime in seconds (default 900). +/// - `REFRESH_TOKEN_TTL`: refresh-token lifetime in seconds (default 45 days). +/// - `ACCOUNT_PURGE_GRACE_DAYS`: hard-purge delay after soft-delete (default 30). +/// - `APPLE_TEAM_ID`, `APPLE_SIGNIN_KEY_ID`, `APPLE_SIGNIN_PRIVATE_KEY`: +/// Sign in with Apple service credentials (client-secret JWT for Apple's +/// token & revoke endpoints). +/// - `APP_ATTEST_TEAM_ID`: team id used to build the App Attest app id. +/// - `APP_ATTEST_ENVIRONMENT`: `production` (default) or `development` +/// (accepts the `appattestdevelop` aaguid). +/// - `APP_ATTEST_DISABLED`: set to `true` to skip assertion checks (local +/// development / simulator only β€” App Attest requires real hardware). +/// - `TOKEN_ENCRYPTION_KEY`: base64 32-byte AES-256 key for encrypting the +/// stored Apple refresh token. Falls back to a key derived from +/// `JWT_SIGNING_KEY` when unset. +struct AuthConfiguration: Sendable { + let appleBundleID: String + let apiKeys: Set + let accessTokenTTL: TimeInterval + let refreshTokenTTL: TimeInterval + let accountPurgeGraceDays: Int + let appleTeamID: String? + let appleSignInKeyID: String? + let appleSignInPrivateKey: String? + let appAttestTeamID: String? + let appAttestEnvironment: AppAttestEnvironment + let appAttestDisabled: Bool + + enum AppAttestEnvironment: String, Sendable { + case production + case development + } + + /// `.` β€” the App Attest app id. + var appAttestAppID: String? { + appAttestTeamID.map { "\($0).\(appleBundleID)" } + } + + /// Whether the Sign in with Apple `.p8` service credentials are configured + /// (required for the authorization-code exchange and grant revocation). + var hasAppleServiceCredentials: Bool { + appleTeamID != nil && appleSignInKeyID != nil && appleSignInPrivateKey != nil + } + + static func load(from environment: Environment) -> AuthConfiguration { + AuthConfiguration( + appleBundleID: Environment.get("APPLE_BUNDLE_ID") ?? "com.cocoaheadsbr.conf", + apiKeys: Set( + (Environment.get("API_KEYS") ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + ), + accessTokenTTL: Environment.get("ACCESS_TOKEN_TTL").flatMap(TimeInterval.init) ?? 900, + refreshTokenTTL: Environment.get("REFRESH_TOKEN_TTL").flatMap(TimeInterval.init) + ?? 45 * 24 * 60 * 60, + accountPurgeGraceDays: Environment.get("ACCOUNT_PURGE_GRACE_DAYS").flatMap(Int.init) ?? 30, + appleTeamID: Environment.get("APPLE_TEAM_ID"), + appleSignInKeyID: Environment.get("APPLE_SIGNIN_KEY_ID"), + appleSignInPrivateKey: Environment.get("APPLE_SIGNIN_PRIVATE_KEY"), + appAttestTeamID: Environment.get("APP_ATTEST_TEAM_ID") ?? Environment.get("APPLE_TEAM_ID"), + appAttestEnvironment: Environment.get("APP_ATTEST_ENVIRONMENT") + .flatMap(AppAttestEnvironment.init(rawValue:)) ?? .production, + appAttestDisabled: Environment.get("APP_ATTEST_DISABLED").map { $0 == "true" || $0 == "1" } + ?? false + ) + } +} + +extension Application { + private struct AuthConfigurationKey: StorageKey { + typealias Value = AuthConfiguration + } + + var authConfiguration: AuthConfiguration { + get { + guard let config = storage[AuthConfigurationKey.self] else { + fatalError("AuthConfiguration not set. Call configure(_:) first.") + } + return config + } + set { storage[AuthConfigurationKey.self] = newValue } + } +} + +extension Request { + var authConfiguration: AuthConfiguration { application.authConfiguration } +} diff --git a/backend/Sources/backend/Auth/TokenEncryption.swift b/backend/Sources/backend/Auth/TokenEncryption.swift new file mode 100644 index 0000000..49ce47d --- /dev/null +++ b/backend/Sources/backend/Auth/TokenEncryption.swift @@ -0,0 +1,80 @@ +// +// TokenEncryption.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Crypto +import Foundation +import Vapor + +/// AES-256-GCM encryption for secrets at rest (the stored Apple refresh token). +/// +/// The key comes from `TOKEN_ENCRYPTION_KEY` (base64, 32 bytes). When unset, a +/// key is derived from `JWT_SIGNING_KEY` so local development works without +/// extra configuration β€” production should set a dedicated key. +struct TokenEncryption: Sendable { + private let key: SymmetricKey + + init(key: SymmetricKey) { + self.key = key + } + + static func load(from environment: Environment) throws -> TokenEncryption { + if let base64 = Environment.get("TOKEN_ENCRYPTION_KEY") { + guard let data = Data(base64Encoded: base64), data.count == 32 else { + throw AuthError.invalidEncryptionKey + } + return TokenEncryption(key: SymmetricKey(data: data)) + } + guard let signingKey = Environment.get("JWT_SIGNING_KEY") else { + throw AuthError.missingSigningKey + } + let derived = SHA256.hash(data: Data("token-encryption:\(signingKey)".utf8)) + return TokenEncryption(key: SymmetricKey(data: Data(derived))) + } + + /// Returns base64(nonce + ciphertext + tag). + func encrypt(_ plaintext: String) throws -> String { + let sealed = try AES.GCM.seal(Data(plaintext.utf8), using: key) + guard let combined = sealed.combined else { + throw AuthError.encryptionFailed + } + return combined.base64EncodedString() + } + + func decrypt(_ base64: String) throws -> String { + guard let combined = Data(base64Encoded: base64) else { + throw AuthError.encryptionFailed + } + let box = try AES.GCM.SealedBox(combined: combined) + let plaintext = try AES.GCM.open(box, using: key) + guard let string = String(data: plaintext, encoding: .utf8) else { + throw AuthError.encryptionFailed + } + return string + } +} + +enum AuthError: Error { + case missingSigningKey + case invalidEncryptionKey + case encryptionFailed +} + +extension Application { + private struct TokenEncryptionKey: StorageKey { + typealias Value = TokenEncryption + } + + var tokenEncryption: TokenEncryption { + get { + guard let encryption = storage[TokenEncryptionKey.self] else { + fatalError("TokenEncryption not set. Call configure(_:) first.") + } + return encryption + } + set { storage[TokenEncryptionKey.self] = newValue } + } +} diff --git a/backend/Sources/backend/Auth/TokenService.swift b/backend/Sources/backend/Auth/TokenService.swift new file mode 100644 index 0000000..d8b7acb --- /dev/null +++ b/backend/Sources/backend/Auth/TokenService.swift @@ -0,0 +1,112 @@ +// +// TokenService.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import CocoaHeadsCore +import Crypto +import Fluent +import JWT +import Vapor + +/// Issues and rotates the backend's own session tokens (Β§5 of the auth spec). +struct TokenService: Sendable { + /// Issues an access-token + refresh-token pair for a user. Only the SHA-256 + /// hash of the refresh token is persisted. + func issueTokenPair(for user: User, on req: Request) async throws -> TokenResponse { + let config = req.authConfiguration + let userID = try user.requireID() + + let now = Date() + let payload = AccessTokenPayload( + subject: SubjectClaim(value: userID.uuidString), + expiration: ExpirationClaim(value: now.addingTimeInterval(config.accessTokenTTL)), + issuedAt: IssuedAtClaim(value: now), + role: user.role + ) + let accessToken = try await req.jwt.sign(payload) + + let rawRefreshToken = Self.generateRefreshToken() + let record = RefreshToken( + userID: userID, + tokenHash: Self.hash(rawRefreshToken), + expiresAt: now.addingTimeInterval(config.refreshTokenTTL) + ) + try await record.save(on: req.db) + + return TokenResponse( + accessToken: accessToken, + refreshToken: rawRefreshToken, + expiresIn: Int(config.accessTokenTTL), + user: try user.dto + ) + } + + /// Single-use rotation: revokes the presented refresh token and issues a new + /// pair. Rejects unknown, revoked, or expired tokens. + func rotate(refreshToken raw: String, on req: Request) async throws -> TokenResponse { + let record = try await validRecord(for: raw, on: req.db) + record.revoked = true + try await record.save(on: req.db) + + guard let user = try await User.find(record.$user.id, on: req.db) else { + throw Abort(.unauthorized, reason: "Account no longer exists.") + } + return try await issueTokenPair(for: user, on: req) + } + + /// Revokes the presented refresh token (logout). + func revoke(refreshToken raw: String, on req: Request) async throws { + guard + let record = try await RefreshToken.query(on: req.db) + .filter(\.$tokenHash == Self.hash(raw)) + .first() + else { return } + record.revoked = true + try await record.save(on: req.db) + } + + /// Revokes every refresh token belonging to a user (account deletion). + func revokeAll(for userID: UUID, on db: any Database) async throws { + try await RefreshToken.query(on: db) + .filter(\.$user.$id == userID) + .set(\.$revoked, to: true) + .update() + } + + private func validRecord(for raw: String, on db: any Database) async throws -> RefreshToken { + guard + let record = try await RefreshToken.query(on: db) + .filter(\.$tokenHash == Self.hash(raw)) + .first() + else { + throw Abort(.unauthorized, reason: "Unknown refresh token.") + } + guard !record.revoked else { + throw Abort(.unauthorized, reason: "Refresh token revoked.") + } + guard record.expiresAt > Date() else { + throw Abort(.unauthorized, reason: "Refresh token expired.") + } + return record + } + + static func generateRefreshToken() -> String { + Data((0..<32).map { _ in UInt8.random(in: .min ... .max) }).base64URLEncodedString() + } + + static func hash(_ token: String) -> String { + SHA256.hash(data: Data(token.utf8)).map { String(format: "%02x", $0) }.joined() + } +} + +extension Data { + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/backend/Sources/backend/Controllers/Auth/AttestController.swift b/backend/Sources/backend/Controllers/Auth/AttestController.swift new file mode 100644 index 0000000..8fa1145 --- /dev/null +++ b/backend/Sources/backend/Controllers/Auth/AttestController.swift @@ -0,0 +1,88 @@ +// +// AttestController.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import CocoaHeadsCore +import Fluent +import Vapor + +/// App Attest registration endpoints (Β§6, phase 1). These sit behind the API +/// key gate only β€” a device cannot assert before it has registered a key. +struct AttestController: RouteCollection { + func boot(routes: any RoutesBuilder) throws { + let attest = routes.grouped("attest") + attest.post("challenge", use: challenge) + attest.post("key", use: registerKey) + } + + @Sendable + func challenge(req: Request) async throws -> AttestChallengeResponse { + let challenge = await req.application.challengeStore.issue() + return AttestChallengeResponse( + challenge: challenge.base64EncodedString(), + expiresIn: Int(ChallengeStore.challengeTTL) + ) + } + + @Sendable + func registerKey(req: Request) async throws -> HTTPStatus { + let config = req.authConfiguration + guard let appID = config.appAttestAppID else { + throw Abort(.serviceUnavailable, reason: "App Attest is not configured.") + } + + let registration = try req.content.decode(AttestKeyRegistrationRequest.self) + guard + let keyId = Data(base64Encoded: registration.keyId), + let attestation = Data(base64Encoded: registration.attestation), + let challenge = Data(base64Encoded: registration.challenge) + else { + throw Abort(.badRequest, reason: "Malformed base64 payload.") + } + + guard await req.application.challengeStore.consume(challenge) else { + throw Abort(.unauthorized, reason: "Unknown or expired App Attest challenge.") + } + + let verifier = AppAttestVerifier(environment: config.appAttestEnvironment) + let attested: AppAttestVerifier.AttestedKey + do { + attested = try await verifier.verifyAttestation( + attestation, + keyId: keyId, + challenge: challenge, + appID: appID + ) + } catch { + req.logger.info("App Attest attestation rejected: \(error)") + throw Abort(.unauthorized, reason: "Invalid App Attest attestation.") + } + + if let existing = try await AppAttestKey.query(on: req.db) + .filter(\.$keyId == registration.keyId) + .first() + { + guard existing.publicKey == attested.publicKey, !existing.revoked else { + throw Abort(.conflict, reason: "Key id is already registered.") + } + return .ok + } + + try await AppAttestKey( + keyId: registration.keyId, + publicKey: attested.publicKey, + receipt: attested.receipt, + signCount: attested.signCount + ).save(on: req.db) + return .created + } +} + +extension AttestChallengeResponse: @retroactive RequestDecodable {} +extension AttestChallengeResponse: @retroactive ResponseEncodable {} +extension AttestChallengeResponse: @retroactive AsyncRequestDecodable {} +extension AttestChallengeResponse: @retroactive AsyncResponseEncodable {} +extension AttestChallengeResponse: @retroactive Content {} diff --git a/backend/Sources/backend/Controllers/Auth/AuthController.swift b/backend/Sources/backend/Controllers/Auth/AuthController.swift new file mode 100644 index 0000000..e423c30 --- /dev/null +++ b/backend/Sources/backend/Controllers/Auth/AuthController.swift @@ -0,0 +1,116 @@ +// +// AuthController.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import CocoaHeadsCore +import Fluent +import Vapor + +/// Sign in with Apple + session-token endpoints (Β§4, Β§5). +struct AuthController: RouteCollection { + let appleAuth: any AppleAuthServiceProtocol + let tokens = TokenService() + + func boot(routes: any RoutesBuilder) throws { + let auth = routes.grouped("auth") + auth.post("apple", use: signInWithApple) + auth.post("refresh", use: refresh) + auth.grouped(AccessTokenPayload.authenticator(), AccessTokenPayload.guardMiddleware()) + .post("logout", use: logout) + } + + /// Sign in / sign up. Verifies the Apple identity token, upserts the user, + /// exchanges the authorization code for Apple's refresh token (required for + /// account-deletion revocation, Β§8), and issues a backend token pair. + @Sendable + func signInWithApple(req: Request) async throws -> TokenResponse { + let config = req.authConfiguration + let signIn = try req.content.decode(AppleSignInRequest.self) + + let identity = try await req.jwt.apple.verify( + signIn.identityToken, + applicationIdentifier: config.appleBundleID + ) + + // Exchanging the code for Apple's refresh token is mandatory groundwork + // for account deletion (Β§8). Local development without the `.p8` service + // credentials may skip it β€” never production. + let encryptedRefreshToken: String? + if config.hasAppleServiceCredentials { + let appleRefreshToken = try await appleAuth.exchangeAuthorizationCode( + signIn.authorizationCode, + on: req + ) + encryptedRefreshToken = try req.application.tokenEncryption.encrypt(appleRefreshToken) + } else if req.application.environment != .production { + req.logger.warning( + "Apple service credentials not configured β€” skipping authorization-code exchange. Account deletion cannot revoke Apple's grant for this sign-in." + ) + encryptedRefreshToken = nil + } else { + throw Abort( + .internalServerError, + reason: "Sign in with Apple service credentials are not configured." + ) + } + + let user: User + if let existing = try await User.query(on: req.db) + .filter(\.$appleUserIdentifier == identity.subject.value) + .first() + { + user = existing + } else { + user = User(appleUserIdentifier: identity.subject.value) + } + + // Name and email only arrive on the user's first authorization β€” persist + // them whenever present. The email may be a Hide-My-Email relay. + if let email = signIn.email ?? identity.email { + user.email = email + } + if let fullName = signIn.fullName { + user.fullName = fullName + } + // Keep any previously stored Apple refresh token when the dev-mode + // exchange skip produced none. + if let encryptedRefreshToken { + user.appleRefreshToken = encryptedRefreshToken + } + try await user.save(on: req.db) + + // Associate the attested device key with the signed-in user. + if let attestedKeyID = req.attestedKeyID { + try await AppAttestKey.query(on: req.db) + .filter(\.$keyId == attestedKeyID) + .set(\.$user.$id, to: user.id) + .update() + } + + return try await tokens.issueTokenPair(for: user, on: req) + } + + /// Single-use refresh-token rotation. + @Sendable + func refresh(req: Request) async throws -> TokenResponse { + let body = try req.content.decode(RefreshRequest.self) + return try await tokens.rotate(refreshToken: body.refreshToken, on: req) + } + + /// Revokes the presented refresh token. + @Sendable + func logout(req: Request) async throws -> HTTPStatus { + let body = try req.content.decode(RefreshRequest.self) + try await tokens.revoke(refreshToken: body.refreshToken, on: req) + return .noContent + } +} + +extension TokenResponse: @retroactive RequestDecodable {} +extension TokenResponse: @retroactive ResponseEncodable {} +extension TokenResponse: @retroactive AsyncRequestDecodable {} +extension TokenResponse: @retroactive AsyncResponseEncodable {} +extension TokenResponse: @retroactive Content {} diff --git a/backend/Sources/backend/Controllers/Auth/MeController.swift b/backend/Sources/backend/Controllers/Auth/MeController.swift new file mode 100644 index 0000000..930b437 --- /dev/null +++ b/backend/Sources/backend/Controllers/Auth/MeController.swift @@ -0,0 +1,43 @@ +// +// MeController.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import CocoaHeadsCore +import Vapor + +/// User-scoped routes behind the Bearer JWT gate. +struct MeController: RouteCollection { + let appleAuth: any AppleAuthServiceProtocol + + func boot(routes: any RoutesBuilder) throws { + let me = routes + .grouped(AccessTokenPayload.authenticator(), AccessTokenPayload.guardMiddleware()) + .grouped("me") + me.get(use: current) + me.delete(use: delete) + } + + @Sendable + func current(req: Request) async throws -> UserDTO { + try await req.authenticatedUser().dto + } + + /// Account deletion, required by App Review Guideline 5.1.1(v). Soft-deletes + /// with immediate PII scrub and Apple grant revocation; the row is + /// hard-purged after the grace period (Β§8). + @Sendable + func delete(req: Request) async throws -> HTTPStatus { + let user = try await req.authenticatedUser() + try await AccountDeletionService(appleAuth: appleAuth).delete(user, on: req) + return .noContent + } +} + +extension UserDTO: @retroactive RequestDecodable {} +extension UserDTO: @retroactive ResponseEncodable {} +extension UserDTO: @retroactive AsyncRequestDecodable {} +extension UserDTO: @retroactive AsyncResponseEncodable {} +extension UserDTO: @retroactive Content {} diff --git a/backend/Sources/backend/Middleware/APIKeyMiddleware.swift b/backend/Sources/backend/Middleware/APIKeyMiddleware.swift new file mode 100644 index 0000000..1f86430 --- /dev/null +++ b/backend/Sources/backend/Middleware/APIKeyMiddleware.swift @@ -0,0 +1,33 @@ +// +// APIKeyMiddleware.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Vapor + +/// Coarse first gate of the app-authentication layer: checks `X-API-Key` +/// against the static keys configured via `API_KEYS`. +/// +/// Caveat (intentional, per spec Β§6): a key baked into a shipped iOS app is +/// extractable from the binary. This is not an integrity guarantee β€” App +/// Attest provides that. Fails closed when no keys are configured. +struct APIKeyMiddleware: AsyncMiddleware { + static let header = HTTPHeaders.Name("X-API-Key") + + func respond( + to request: Request, + chainingTo next: any AsyncResponder + ) async throws -> Response { + let keys = request.authConfiguration.apiKeys + guard !keys.isEmpty else { + request.logger.error("API_KEYS is not configured; rejecting request.") + throw Abort(.serviceUnavailable, reason: "API keys are not configured.") + } + guard let key = request.headers.first(name: Self.header), keys.contains(key) else { + throw Abort(.unauthorized, reason: "Missing or invalid API key.") + } + return try await next.respond(to: request) + } +} diff --git a/backend/Sources/backend/Middleware/AppAttestMiddleware.swift b/backend/Sources/backend/Middleware/AppAttestMiddleware.swift new file mode 100644 index 0000000..211c9f2 --- /dev/null +++ b/backend/Sources/backend/Middleware/AppAttestMiddleware.swift @@ -0,0 +1,100 @@ +// +// AppAttestMiddleware.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Crypto +import Fluent +import Foundation +import Vapor + +/// Second gate of the app-authentication layer: verifies a per-request App +/// Attest assertion made with a previously registered key (Β§6). +/// +/// Protocol: the client fetches a one-time challenge (`POST /attest/challenge`) +/// and sends each protected request with: +/// - `X-Attest-Key-Id`: the registered key id (base64) +/// - `X-Attest-Challenge`: the challenge (base64) +/// - `X-Attest-Assertion`: base64 CBOR from +/// `DCAppAttestService.generateAssertion(_:clientDataHash:)`, where +/// `clientDataHash = SHA256(challenge bytes + request body bytes)`. +/// +/// Replay protection: challenges are single-use, and the assertion's sign +/// counter must be strictly increasing. +struct AppAttestMiddleware: AsyncMiddleware { + static let keyIdHeader = HTTPHeaders.Name("X-Attest-Key-Id") + static let challengeHeader = HTTPHeaders.Name("X-Attest-Challenge") + static let assertionHeader = HTTPHeaders.Name("X-Attest-Assertion") + + func respond( + to request: Request, + chainingTo next: any AsyncResponder + ) async throws -> Response { + let config = request.authConfiguration + if config.appAttestDisabled { + request.logger.warning("App Attest verification is DISABLED (APP_ATTEST_DISABLED).") + return try await next.respond(to: request) + } + guard let appID = config.appAttestAppID else { + request.logger.error("APP_ATTEST_TEAM_ID/APPLE_TEAM_ID is not configured.") + throw Abort(.serviceUnavailable, reason: "App Attest is not configured.") + } + + guard + let keyId = request.headers.first(name: Self.keyIdHeader), + let challengeB64 = request.headers.first(name: Self.challengeHeader), + let assertionB64 = request.headers.first(name: Self.assertionHeader), + let challenge = Data(base64Encoded: challengeB64), + let assertion = Data(base64Encoded: assertionB64) + else { + throw Abort(.unauthorized, reason: "Missing App Attest assertion headers.") + } + + guard await request.application.challengeStore.consume(challenge) else { + throw Abort(.unauthorized, reason: "Unknown or expired App Attest challenge.") + } + + guard + let key = try await AppAttestKey.query(on: request.db) + .filter(\.$keyId == keyId) + .first(), + !key.revoked + else { + throw Abort(.unauthorized, reason: "Unknown App Attest key.") + } + + let collected = try await request.body.collect(max: nil).get() + let body = collected.map { Data(buffer: $0) } ?? Data() + let clientDataHash = Data(SHA256.hash(data: challenge + body)) + + let verifier = AppAttestVerifier(environment: config.appAttestEnvironment) + do { + key.signCount = try verifier.verifyAssertion( + assertion, + publicKey: key.publicKey, + clientDataHash: clientDataHash, + appID: appID, + storedSignCount: key.signCount + ) + } catch { + request.logger.info("App Attest assertion rejected: \(error)") + throw Abort(.unauthorized, reason: "Invalid App Attest assertion.") + } + try await key.save(on: request.db) + + request.storage[AttestedKeyIDKey.self] = keyId + return try await next.respond(to: request) + } +} + +/// Request-storage key carrying the verified App Attest key id, so sign-in can +/// associate the device key with the user. +struct AttestedKeyIDKey: StorageKey { + typealias Value = String +} + +extension Request { + var attestedKeyID: String? { storage[AttestedKeyIDKey.self] } +} diff --git a/backend/Sources/backend/Migrations/CreateAuthTables.swift b/backend/Sources/backend/Migrations/CreateAuthTables.swift new file mode 100644 index 0000000..f8f026f --- /dev/null +++ b/backend/Sources/backend/Migrations/CreateAuthTables.swift @@ -0,0 +1,67 @@ +// +// CreateAuthTables.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Fluent + +struct CreateUser: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(User.schema) + .id() + .field("apple_user_identifier", .string, .required) + .field("email", .string) + .field("full_name", .string) + .field("role", .string, .required) + .field("apple_refresh_token", .string) + .field("deleted_at", .datetime) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .unique(on: "apple_user_identifier") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(User.schema).delete() + } +} + +struct CreateRefreshToken: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(RefreshToken.schema) + .id() + .field("user_id", .uuid, .required, .references(User.schema, "id", onDelete: .cascade)) + .field("token_hash", .string, .required) + .field("expires_at", .datetime, .required) + .field("revoked", .bool, .required, .sql(.default(false))) + .field("created_at", .datetime) + .unique(on: "token_hash") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(RefreshToken.schema).delete() + } +} + +struct CreateAppAttestKey: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(AppAttestKey.schema) + .id() + .field("user_id", .uuid, .references(User.schema, "id", onDelete: .setNull)) + .field("key_id", .string, .required) + .field("public_key", .data, .required) + .field("receipt", .data, .required) + .field("sign_count", .int, .required, .sql(.default(0))) + .field("revoked", .bool, .required, .sql(.default(false))) + .field("created_at", .datetime) + .unique(on: "key_id") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(AppAttestKey.schema).delete() + } +} diff --git a/backend/Sources/backend/Models/AppAttestKey.swift b/backend/Sources/backend/Models/AppAttestKey.swift new file mode 100644 index 0000000..553e9ac --- /dev/null +++ b/backend/Sources/backend/Models/AppAttestKey.swift @@ -0,0 +1,64 @@ +// +// AppAttestKey.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Fluent +import Foundation + +final class AppAttestKey: Model, @unchecked Sendable { + static let schema = "app_attest_keys" + + @ID(key: .id) + var id: UUID? + + /// Device-scoped; associated with a user after sign-in. + @OptionalParent(key: "user_id") + var user: User? + + /// The App Attest key identifier (base64), which is also the SHA-256 of the + /// attested public key. + @Field(key: "key_id") + var keyId: String + + /// Attested P-256 public key (X9.63 representation), used to verify assertions. + @Field(key: "public_key") + var publicKey: Data + + /// Apple attestation receipt (kept for future fraud-risk assessment). + @Field(key: "receipt") + var receipt: Data + + /// Monotonic assertion counter; non-increasing values are rejected as replays. + @Field(key: "sign_count") + var signCount: Int + + /// Set when the owning account is deleted; revoked keys fail app-auth. + @Field(key: "revoked") + var revoked: Bool + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + init() {} + + init( + id: UUID? = nil, + userID: UUID? = nil, + keyId: String, + publicKey: Data, + receipt: Data, + signCount: Int = 0, + revoked: Bool = false + ) { + self.id = id + self.$user.id = userID + self.keyId = keyId + self.publicKey = publicKey + self.receipt = receipt + self.signCount = signCount + self.revoked = revoked + } +} diff --git a/backend/Sources/backend/Models/RefreshToken.swift b/backend/Sources/backend/Models/RefreshToken.swift new file mode 100644 index 0000000..9eeb1ed --- /dev/null +++ b/backend/Sources/backend/Models/RefreshToken.swift @@ -0,0 +1,49 @@ +// +// RefreshToken.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import Fluent +import Foundation + +final class RefreshToken: Model, @unchecked Sendable { + static let schema = "refresh_tokens" + + @ID(key: .id) + var id: UUID? + + @Parent(key: "user_id") + var user: User + + /// SHA-256 hash of the opaque token. The raw value is never stored. + @Field(key: "token_hash") + var tokenHash: String + + @Field(key: "expires_at") + var expiresAt: Date + + /// Set on logout, rotation, or account deletion. + @Field(key: "revoked") + var revoked: Bool + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + init() {} + + init( + id: UUID? = nil, + userID: UUID, + tokenHash: String, + expiresAt: Date, + revoked: Bool = false + ) { + self.id = id + self.$user.id = userID + self.tokenHash = tokenHash + self.expiresAt = expiresAt + self.revoked = revoked + } +} diff --git a/backend/Sources/backend/Models/User.swift b/backend/Sources/backend/Models/User.swift new file mode 100644 index 0000000..bddd602 --- /dev/null +++ b/backend/Sources/backend/Models/User.swift @@ -0,0 +1,74 @@ +// +// User.swift +// backend +// +// Created by Mauricio Cardozo on 8/7/26. +// + +import CocoaHeadsCore +import Fluent +import Foundation + +final class User: Model, @unchecked Sendable { + static let schema = "users" + + @ID(key: .id) + var id: UUID? + + /// Apple `sub` claim. Stable per Apple ID + team. + @Field(key: "apple_user_identifier") + var appleUserIdentifier: String + + /// Only delivered on the user's first authorization. May be a Hide-My-Email relay. + @OptionalField(key: "email") + var email: String? + + /// Only delivered on the user's first authorization. + @OptionalField(key: "full_name") + var fullName: String? + + @Enum(key: "role") + var role: UserRole + + /// Apple's refresh token from the authorization-code exchange, AES-GCM + /// encrypted at rest. Needed to revoke Apple's grant on account deletion. + @OptionalField(key: "apple_refresh_token") + var appleRefreshToken: String? + + /// Soft-delete tombstone. PII is scrubbed when this is set; the row is + /// hard-purged after the configured grace period. + @Timestamp(key: "deleted_at", on: .delete) + var deletedAt: Date? + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + init() {} + + init( + id: UUID? = nil, + appleUserIdentifier: String, + email: String? = nil, + fullName: String? = nil, + role: UserRole = .user, + appleRefreshToken: String? = nil + ) { + self.id = id + self.appleUserIdentifier = appleUserIdentifier + self.email = email + self.fullName = fullName + self.role = role + self.appleRefreshToken = appleRefreshToken + } +} + +extension User { + var dto: UserDTO { + get throws { + UserDTO(id: try requireID(), email: email, fullName: fullName, role: role) + } + } +} diff --git a/backend/Sources/backend/configure.swift b/backend/Sources/backend/configure.swift index ef8312a..3245298 100644 --- a/backend/Sources/backend/configure.swift +++ b/backend/Sources/backend/configure.swift @@ -1,5 +1,6 @@ import Fluent import FluentPostgresDriver +import JWT import NIOSSL import Vapor @@ -19,6 +20,47 @@ public func configure(_ app: Application) async throws { tls: .prefer(try .init(configuration: .clientDefault))) ), as: .psql) + app.migrations.add(CreateUser()) + app.migrations.add(CreateRefreshToken()) + app.migrations.add(CreateAppAttestKey()) + + try await configureAuth(app) + + app.lifecycle.use(AccountPurgeService()) + // register routes try routes(app) } + +/// Sets up the auth configuration, JWT signing keys, and secret encryption. +func configureAuth(_ app: Application) async throws { + let config = AuthConfiguration.load(from: app.environment) + app.authConfiguration = config + + // Expected `aud` for Apple identity tokens. + app.jwt.apple.applicationIdentifier = config.appleBundleID + + // Backend access-token signing key: an ES256 private-key PEM (recommended) + // or a plain HS256 secret. + if let signingKey = Environment.get("JWT_SIGNING_KEY") { + if signingKey.contains("BEGIN") { + try await app.jwt.keys.add(ecdsa: ES256PrivateKey(pem: signingKey)) + } else { + await app.jwt.keys.add(hmac: HMACKey(from: signingKey), digestAlgorithm: .sha256) + } + } else if app.environment == .production { + app.logger.critical("JWT_SIGNING_KEY must be set in production.") + throw AuthError.missingSigningKey + } else { + app.logger.warning("JWT_SIGNING_KEY not set β€” using an insecure development key.") + await app.jwt.keys.add(hmac: "insecure-development-key", digestAlgorithm: .sha256) + } + + if Environment.get("TOKEN_ENCRYPTION_KEY") != nil || Environment.get("JWT_SIGNING_KEY") != nil { + app.tokenEncryption = try TokenEncryption.load(from: app.environment) + } else { + app.tokenEncryption = TokenEncryption( + key: .init(data: Array("insecure-development-encryption-".utf8)) + ) + } +} diff --git a/backend/Sources/backend/routes.swift b/backend/Sources/backend/routes.swift index 6454d0f..5d2c68a 100644 --- a/backend/Sources/backend/routes.swift +++ b/backend/Sources/backend/routes.swift @@ -2,5 +2,18 @@ import Fluent import Vapor func routes(_ app: Application) throws { - try app.register(collection: ScrapingController(firecrawl: FirecrawlService())) + // App-authentication layer (Β§6): API key first, then App Attest assertion. + let apiKeyGated = app.grouped(APIKeyMiddleware()) + let appAuthenticated = apiKeyGated.grouped(AppAttestMiddleware()) + + // App Attest registration sits behind the API key only β€” a device cannot + // assert before it has registered a key. + try apiKeyGated.register(collection: AttestController()) + + let appleAuth = AppleAuthService() + try appAuthenticated.register(collection: AuthController(appleAuth: appleAuth)) + try appAuthenticated.register(collection: MeController(appleAuth: appleAuth)) + try appAuthenticated.register( + collection: ScrapingController(firecrawl: FirecrawlService()) + ) } diff --git a/backend/Tests/backendTests/AuthTests.swift b/backend/Tests/backendTests/AuthTests.swift new file mode 100644 index 0000000..5188a6c --- /dev/null +++ b/backend/Tests/backendTests/AuthTests.swift @@ -0,0 +1,240 @@ +import Crypto +import Foundation +import Testing + +@testable import backend + +@Suite("CBOR decoding") +struct CBORTests { + @Test("Decodes the attestation object shape") + func attestationShape() throws { + // {"fmt": "test", "attStmt": {"x5c": [h'0102']}, "authData": h'AABB'} + var bytes: [UInt8] = [0xA3] + bytes += [0x63] + Array("fmt".utf8) + bytes += [0x64] + Array("test".utf8) + bytes += [0x67] + Array("attStmt".utf8) + bytes += [0xA1, 0x63] + Array("x5c".utf8) + [0x81, 0x42, 0x01, 0x02] + bytes += [0x68] + Array("authData".utf8) + bytes += [0x42, 0xAA, 0xBB] + + let value = try CBOR.decode(Data(bytes)) + #expect(value["fmt"]?.textValue == "test") + #expect(value["attStmt"]?["x5c"]?.arrayValue?.first?.bytesValue == Data([0x01, 0x02])) + #expect(value["authData"]?.bytesValue == Data([0xAA, 0xBB])) + } + + @Test("Decodes multi-byte lengths") + func longLengths() throws { + let payload = Data(repeating: 0x7F, count: 300) + let bytes: [UInt8] = [0x59, 0x01, 0x2C] + payload // bytes(300) + let value = try CBOR.decode(Data(bytes)) + #expect(value.bytesValue == payload) + } + + @Test("Rejects truncated input") + func truncated() { + #expect(throws: CBOR.DecodingError.self) { + try CBOR.decode(Data([0x42, 0x01])) // bytes(2) with only 1 byte present + } + } +} + +@Suite("Token service") +struct TokenServiceTests { + @Test("Refresh tokens are unique and URL-safe") + func refreshTokenGeneration() { + let a = TokenService.generateRefreshToken() + let b = TokenService.generateRefreshToken() + #expect(a != b) + #expect(!a.contains("+") && !a.contains("/") && !a.contains("=")) + } + + @Test("Hashing is stable and hides the token") + func hashing() { + let token = "some-refresh-token" + let hash = TokenService.hash(token) + #expect(hash == TokenService.hash(token)) + #expect(hash != TokenService.hash("other-token")) + #expect(hash.count == 64) + #expect(!hash.contains(token)) + } +} + +@Suite("Token encryption") +struct TokenEncryptionTests { + @Test("Round-trips and never stores plaintext") + func roundTrip() throws { + let encryption = TokenEncryption(key: SymmetricKey(size: .bits256)) + let secret = "apple-refresh-token-value" + let encrypted = try encryption.encrypt(secret) + #expect(!encrypted.contains(secret)) + #expect(try encryption.decrypt(encrypted) == secret) + } + + @Test("Distinct keys cannot decrypt each other's output") + func wrongKey() throws { + let a = TokenEncryption(key: SymmetricKey(size: .bits256)) + let b = TokenEncryption(key: SymmetricKey(size: .bits256)) + let encrypted = try a.encrypt("secret") + #expect(throws: (any Error).self) { + try b.decrypt(encrypted) + } + } +} + +@Suite("App Attest verification") +struct AppAttestVerifierTests { + static let appID = "TEAMID1234.com.cocoaheadsbr.conf" + + /// Builds WebAuthn-style authenticator data for assertions (37 bytes). + private func assertionAuthData(appID: String, signCount: UInt32) -> Data { + var data = Data(SHA256.hash(data: Data(appID.utf8))) + data.append(0x40) + data.append(contentsOf: withUnsafeBytes(of: signCount.bigEndian, Array.init)) + return data + } + + /// CBOR-encodes {"signature": ..., "authenticatorData": ...}. + private func assertionCBOR(signature: Data, authenticatorData: Data) -> Data { + func bytes(_ data: Data) -> Data { + precondition(data.count < 256) + return data.count <= 23 ? Data([0x40 | UInt8(data.count)]) + data : Data([0x58, UInt8(data.count)]) + data + } + func text(_ string: String) -> Data { + Data([0x60 | UInt8(string.utf8.count)]) + Data(string.utf8) + } + return Data([0xA2]) + text("signature") + bytes(signature) + + text("authenticatorData") + bytes(authenticatorData) + } + + @Test("Accepts a valid assertion and returns the new sign count") + func validAssertion() throws { + let key = P256.Signing.PrivateKey() + let clientDataHash = Data(SHA256.hash(data: Data("hello".utf8))) + let authData = assertionAuthData(appID: Self.appID, signCount: 7) + let nonce = Data(SHA256.hash(data: authData + clientDataHash)) + let signature = try key.signature(for: nonce) + + let verifier = AppAttestVerifier(environment: .development) + let newCount = try verifier.verifyAssertion( + assertionCBOR(signature: signature.derRepresentation, authenticatorData: authData), + publicKey: key.publicKey.x963Representation, + clientDataHash: clientDataHash, + appID: Self.appID, + storedSignCount: 3 + ) + #expect(newCount == 7) + } + + @Test("Rejects a non-increasing sign count") + func replayedAssertion() throws { + let key = P256.Signing.PrivateKey() + let clientDataHash = Data(SHA256.hash(data: Data("hello".utf8))) + let authData = assertionAuthData(appID: Self.appID, signCount: 3) + let nonce = Data(SHA256.hash(data: authData + clientDataHash)) + let signature = try key.signature(for: nonce) + + let verifier = AppAttestVerifier(environment: .development) + #expect(throws: AppAttestVerifier.VerificationError.nonIncreasingSignCount) { + try verifier.verifyAssertion( + assertionCBOR(signature: signature.derRepresentation, authenticatorData: authData), + publicKey: key.publicKey.x963Representation, + clientDataHash: clientDataHash, + appID: Self.appID, + storedSignCount: 3 + ) + } + } + + @Test("Rejects a tampered body (signature mismatch)") + func tamperedBody() throws { + let key = P256.Signing.PrivateKey() + let authData = assertionAuthData(appID: Self.appID, signCount: 1) + let nonce = Data(SHA256.hash(data: authData + Data(SHA256.hash(data: Data("original".utf8))))) + let signature = try key.signature(for: nonce) + + let verifier = AppAttestVerifier(environment: .development) + #expect(throws: AppAttestVerifier.VerificationError.invalidSignature) { + try verifier.verifyAssertion( + assertionCBOR(signature: signature.derRepresentation, authenticatorData: authData), + publicKey: key.publicKey.x963Representation, + clientDataHash: Data(SHA256.hash(data: Data("tampered".utf8))), + appID: Self.appID, + storedSignCount: 0 + ) + } + } + + @Test("Rejects an assertion for a different app id") + func wrongAppID() throws { + let key = P256.Signing.PrivateKey() + let clientDataHash = Data(SHA256.hash(data: Data("hello".utf8))) + let authData = assertionAuthData(appID: "OTHERTEAM.com.example", signCount: 1) + let nonce = Data(SHA256.hash(data: authData + clientDataHash)) + let signature = try key.signature(for: nonce) + + let verifier = AppAttestVerifier(environment: .development) + #expect(throws: AppAttestVerifier.VerificationError.rpIdMismatch) { + try verifier.verifyAssertion( + assertionCBOR(signature: signature.derRepresentation, authenticatorData: authData), + publicKey: key.publicKey.x963Representation, + clientDataHash: clientDataHash, + appID: Self.appID, + storedSignCount: 0 + ) + } + } + + @Test("Parses attestation authenticator data") + func authenticatorDataParsing() throws { + let credentialId = Data((0..<32).map { UInt8($0) }) + var data = Data(SHA256.hash(data: Data(Self.appID.utf8))) + data.append(0x40) + data.append(contentsOf: [0, 0, 0, 0]) + data.append(AppAttestVerifier.expectedAAGUID(for: .development)) + data.append(contentsOf: [0, 32]) + data.append(credentialId) + + let parsed = try AuthenticatorData(data) + #expect(parsed.rpIdHash == Data(SHA256.hash(data: Data(Self.appID.utf8)))) + #expect(parsed.signCount == 0) + #expect(parsed.aaguid == Data("appattestdevelop".utf8)) + #expect(parsed.credentialId == credentialId) + } + + @Test("Extracts the nonce from the certificate extension DER") + func nonceExtraction() throws { + let nonce = Data((0..<32).map { UInt8($0 &* 3) }) + // SEQUENCE { [1] { OCTET STRING (32) } } + let der: [UInt8] = [0x30, 0x24, 0xA1, 0x22, 0x04, 0x20] + nonce + let extracted = try AppAttestVerifier.extractNonce(from: ArraySlice(der)) + #expect(extracted == nonce) + } + + @Test("Production and development aaguids differ") + func aaguids() { + #expect(AppAttestVerifier.expectedAAGUID(for: .production).count == 16) + #expect(AppAttestVerifier.expectedAAGUID(for: .development).count == 16) + #expect( + AppAttestVerifier.expectedAAGUID(for: .production) + != AppAttestVerifier.expectedAAGUID(for: .development) + ) + } +} + +@Suite("Challenge store") +struct ChallengeStoreTests { + @Test("Challenges are single-use") + func singleUse() async { + let store = ChallengeStore() + let challenge = await store.issue() + #expect(await store.consume(challenge)) + #expect(await store.consume(challenge) == false) + } + + @Test("Unknown challenges are rejected") + func unknown() async { + let store = ChallengeStore() + #expect(await store.consume(Data([1, 2, 3])) == false) + } +} diff --git a/backend/Tests/backendTests/AuthTokenAndMiddlewareTests.swift b/backend/Tests/backendTests/AuthTokenAndMiddlewareTests.swift new file mode 100644 index 0000000..13d2f8b --- /dev/null +++ b/backend/Tests/backendTests/AuthTokenAndMiddlewareTests.swift @@ -0,0 +1,317 @@ +import CocoaHeadsCore +import Crypto +import Foundation +import JWTKit +import SwiftASN1 +import Testing +import VaporTesting +import X509 + +@testable import backend + +@Suite("Access token payload") +struct AccessTokenPayloadTests { + private func keyCollection() async -> JWTKeyCollection { + await JWTKeyCollection().add(hmac: "test-signing-secret", digestAlgorithm: .sha256) + } + + @Test("Signs and verifies a round trip with intact claims") + func roundTrip() async throws { + let keys = await keyCollection() + let userID = UUID() + let now = Date() + let payload = AccessTokenPayload( + subject: SubjectClaim(value: userID.uuidString), + expiration: ExpirationClaim(value: now.addingTimeInterval(900)), + issuedAt: IssuedAtClaim(value: now), + role: .organizer + ) + + let token = try await keys.sign(payload) + let verified = try await keys.verify(token, as: AccessTokenPayload.self) + #expect(try verified.userID == userID) + #expect(verified.role == .organizer) + } + + @Test("Rejects an expired token") + func expired() async throws { + let keys = await keyCollection() + let payload = AccessTokenPayload( + subject: SubjectClaim(value: UUID().uuidString), + expiration: ExpirationClaim(value: Date().addingTimeInterval(-60)), + issuedAt: IssuedAtClaim(value: Date().addingTimeInterval(-960)), + role: .user + ) + + let token = try await keys.sign(payload) + await #expect(throws: (any Error).self) { + try await keys.verify(token, as: AccessTokenPayload.self) + } + } + + @Test("Rejects a token signed with a different key") + func wrongKey() async throws { + let keys = await keyCollection() + let otherKeys = await JWTKeyCollection().add(hmac: "other-secret", digestAlgorithm: .sha256) + let payload = AccessTokenPayload( + subject: SubjectClaim(value: UUID().uuidString), + expiration: ExpirationClaim(value: Date().addingTimeInterval(900)), + issuedAt: IssuedAtClaim(value: Date()), + role: .user + ) + + let token = try await otherKeys.sign(payload) + await #expect(throws: (any Error).self) { + try await keys.verify(token, as: AccessTokenPayload.self) + } + } + + @Test("A malformed subject is rejected when resolving the user id") + func malformedSubject() { + let payload = AccessTokenPayload( + subject: SubjectClaim(value: "not-a-uuid"), + expiration: ExpirationClaim(value: Date().addingTimeInterval(900)), + issuedAt: IssuedAtClaim(value: Date()), + role: .user + ) + #expect(throws: (any Error).self) { + try payload.userID + } + } +} + +@Suite("Apple client secret") +struct AppleClientSecretTests { + @Test("Carries the claims Apple's token endpoint requires, with the kid header") + func claimsAndHeader() async throws { + let privateKey = P256.Signing.PrivateKey() + let kid = JWKIdentifier(string: "TESTKEY123") + let keys = JWTKeyCollection() + try await keys.add(ecdsa: ES256PrivateKey(pem: privateKey.pemRepresentation), kid: kid) + + let now = Date() + let payload = AppleClientSecretPayload( + issuer: IssuerClaim(value: "TEAMID1234"), + issuedAt: IssuedAtClaim(value: now), + expiration: ExpirationClaim(value: now.addingTimeInterval(300)), + audience: AudienceClaim(value: "https://appleid.apple.com"), + subject: SubjectClaim(value: "com.cocoaheadsbr.conf") + ) + let token = try await keys.sign(payload, kid: kid) + + let verified = try await keys.verify(token, as: AppleClientSecretPayload.self) + #expect(verified.issuer.value == "TEAMID1234") + #expect(verified.audience.value.contains("https://appleid.apple.com")) + #expect(verified.subject.value == "com.cocoaheadsbr.conf") + + // Apple requires the key id in the JOSE header. + let headerSegment = String(token.split(separator: ".")[0]) + var base64 = headerSegment.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while base64.count % 4 != 0 { base64.append("=") } + let headerData = try #require(Data(base64Encoded: base64)) + let header = try #require( + try JSONSerialization.jsonObject(with: headerData) as? [String: Any] + ) + #expect(header["kid"] as? String == "TESTKEY123") + #expect(header["alg"] as? String == "ES256") + } +} + +@Suite("API key middleware") +struct APIKeyMiddlewareTests { + private func configuration(apiKeys: Set) -> AuthConfiguration { + AuthConfiguration( + appleBundleID: "com.cocoaheadsbr.conf", + apiKeys: apiKeys, + accessTokenTTL: 900, + refreshTokenTTL: 3600, + accountPurgeGraceDays: 30, + appleTeamID: nil, + appleSignInKeyID: nil, + appleSignInPrivateKey: nil, + appAttestTeamID: nil, + appAttestEnvironment: .production, + appAttestDisabled: true + ) + } + + /// A minimal app with one API-key-gated route β€” no database needed. + private func withApp( + keys: Set, + _ test: (Application) async throws -> Void + ) async throws { + let app = try await Application.make(.testing) + do { + app.authConfiguration = configuration(apiKeys: keys) + app.grouped(APIKeyMiddleware()).get("ping") { _ in "pong" } + try await test(app) + } catch { + try? await app.asyncShutdown() + throw error + } + try await app.asyncShutdown() + } + + @Test("Accepts a configured key") + func validKey() async throws { + try await withApp(keys: ["good-key"]) { app in + try await app.testing().test( + .GET, "ping", + headers: ["X-API-Key": "good-key"], + afterResponse: { res async in + #expect(res.status == .ok) + #expect(res.body.string == "pong") + } + ) + } + } + + @Test("Rejects a missing key") + func missingKey() async throws { + try await withApp(keys: ["good-key"]) { app in + try await app.testing().test( + .GET, "ping", + afterResponse: { res async in + #expect(res.status == .unauthorized) + } + ) + } + } + + @Test("Rejects a wrong key") + func wrongKey() async throws { + try await withApp(keys: ["good-key"]) { app in + try await app.testing().test( + .GET, "ping", + headers: ["X-API-Key": "bad-key"], + afterResponse: { res async in + #expect(res.status == .unauthorized) + } + ) + } + } + + @Test("Fails closed when no keys are configured") + func noKeysConfigured() async throws { + try await withApp(keys: []) { app in + try await app.testing().test( + .GET, "ping", + headers: ["X-API-Key": "any-key"], + afterResponse: { res async in + #expect(res.status == .serviceUnavailable) + } + ) + } + } +} + +@Suite("App Attest attestation failure paths") +struct AttestationFailureTests { + private let verifier = AppAttestVerifier(environment: .development) + private static let appID = "TEAMID1234.com.cocoaheadsbr.conf" + private static let keyId = Data(repeating: 1, count: 32) + private static let challenge = Data("challenge".utf8) + + // Minimal CBOR encoders for building attestation objects in tests. + private func cborText(_ string: String) -> Data { + Data([0x60 | UInt8(string.utf8.count)]) + Data(string.utf8) + } + + private func cborBytes(_ data: Data) -> Data { + precondition(data.count < 65536) + if data.count <= 23 { return Data([0x40 | UInt8(data.count)]) + data } + if data.count < 256 { return Data([0x58, UInt8(data.count)]) + data } + return Data([0x59, UInt8(data.count >> 8), UInt8(data.count & 0xFF)]) + data + } + + private func attestationObject(fmt: String, credCertDER: Data) -> Data { + var object = Data([0xA3]) + object.append(cborText("fmt")) + object.append(cborText(fmt)) + object.append(cborText("attStmt")) + object.append(Data([0xA2])) + object.append(cborText("x5c")) + object.append(Data([0x81])) + object.append(cborBytes(credCertDER)) + object.append(cborText("receipt")) + object.append(cborBytes(Data())) + object.append(cborText("authData")) + object.append(cborBytes(Data(repeating: 0, count: 55))) + return object + } + + @Test("Rejects garbage input") + func garbage() async { + await #expect(throws: (any Error).self) { + try await verifier.verifyAttestation( + Data([0xDE, 0xAD, 0xBE, 0xEF]), + keyId: Self.keyId, + challenge: Self.challenge, + appID: Self.appID + ) + } + } + + @Test("Rejects a non-App-Attest format") + func wrongFormat() async { + let attestation = attestationObject(fmt: "packed", credCertDER: Data([1, 2, 3])) + await #expect(throws: AppAttestVerifier.VerificationError.unexpectedFormat("packed")) { + try await verifier.verifyAttestation( + attestation, + keyId: Self.keyId, + challenge: Self.challenge, + appID: Self.appID + ) + } + } + + @Test("Rejects an unparseable credential certificate") + func malformedCertificate() async { + let attestation = attestationObject(fmt: "apple-appattest", credCertDER: Data([1, 2, 3])) + await #expect(throws: (any Error).self) { + try await verifier.verifyAttestation( + attestation, + keyId: Self.keyId, + challenge: Self.challenge, + appID: Self.appID + ) + } + } + + @Test("Rejects a certificate that does not chain to Apple's root") + func selfSignedCertificate() async throws { + let key = P256.Signing.PrivateKey() + let name = try DistinguishedName { + CommonName("Fake App Attest Credential") + } + let now = Date() + let certificate = try Certificate( + version: .v3, + serialNumber: .init(), + publicKey: .init(key.publicKey), + notValidBefore: now.addingTimeInterval(-3600), + notValidAfter: now.addingTimeInterval(3600), + issuer: name, + subject: name, + signatureAlgorithm: .ecdsaWithSHA256, + extensions: Certificate.Extensions(), + issuerPrivateKey: .init(key) + ) + var serializer = DER.Serializer() + try serializer.serialize(certificate) + let attestation = attestationObject( + fmt: "apple-appattest", + credCertDER: Data(serializer.serializedBytes) + ) + + await #expect(throws: AppAttestVerifier.VerificationError.certificateChainInvalid) { + try await verifier.verifyAttestation( + attestation, + keyId: Self.keyId, + challenge: Self.challenge, + appID: Self.appID + ) + } + } +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index aa4d5d5..8532f5d 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -24,7 +24,21 @@ x-shared_environment: &shared_environment DATABASE_NAME: vapor_database DATABASE_USERNAME: vapor_username DATABASE_PASSWORD: vapor_password - + # Auth (see backend/docs/user-auth-spec.md Β§2 and Sources/backend/Auth/AuthConfiguration.swift) + JWT_SIGNING_KEY: ${JWT_SIGNING_KEY:-} + APPLE_BUNDLE_ID: ${APPLE_BUNDLE_ID:-com.cocoaheadsbr.conf} + API_KEYS: ${API_KEYS:-} + ACCESS_TOKEN_TTL: ${ACCESS_TOKEN_TTL:-900} + REFRESH_TOKEN_TTL: ${REFRESH_TOKEN_TTL:-3888000} + ACCOUNT_PURGE_GRACE_DAYS: ${ACCOUNT_PURGE_GRACE_DAYS:-30} + APPLE_TEAM_ID: ${APPLE_TEAM_ID:-} + APPLE_SIGNIN_KEY_ID: ${APPLE_SIGNIN_KEY_ID:-} + APPLE_SIGNIN_PRIVATE_KEY: ${APPLE_SIGNIN_PRIVATE_KEY:-} + APP_ATTEST_TEAM_ID: ${APP_ATTEST_TEAM_ID:-} + APP_ATTEST_ENVIRONMENT: ${APP_ATTEST_ENVIRONMENT:-production} + APP_ATTEST_DISABLED: ${APP_ATTEST_DISABLED:-false} + TOKEN_ENCRYPTION_KEY: ${TOKEN_ENCRYPTION_KEY:-} + services: app: image: backend:latest diff --git a/backend/docs/user-auth-implementation.md b/backend/docs/user-auth-implementation.md new file mode 100644 index 0000000..51f9335 --- /dev/null +++ b/backend/docs/user-auth-implementation.md @@ -0,0 +1,126 @@ +# User Authentication & App Authentication β€” Implementation Notes + +Implements [`user-auth-spec.md`](user-auth-spec.md). This document covers what a +client (and a reviewer) needs to know about the concrete implementation. + +## Layers + +Every route is behind **app authentication**; user-scoped routes additionally +require a **Bearer JWT**: + +``` +Request ─▢ X-API-Key check ─▢ App Attest assertion ─▢ (Bearer JWT) ─▢ handler +``` + +| Route | API key | App Attest | Bearer JWT | +| --- | --- | --- | --- | +| `POST /attest/challenge` | βœ… | – | – | +| `POST /attest/key` | βœ… | – (registration) | – | +| `POST /auth/apple` | βœ… | βœ… | – | +| `POST /auth/refresh` | βœ… | βœ… | – (valid refresh token) | +| `POST /auth/logout` | βœ… | βœ… | βœ… | +| `GET /me` | βœ… | βœ… | βœ… | +| `DELETE /me` | βœ… | βœ… | βœ… | +| `POST /scrape` | βœ… | βœ… | – | + +## App Attest client protocol + +App Attest verification is implemented server-side from scratch +(`Sources/backend/Auth/AppAttest/`): CBOR decoding, X.509 chain validation +against [Apple's App Attest root CA](https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem) +via `swift-certificates`, nonce-extension (OID `1.2.840.113635.100.8.2`) +comparison, and monotonic `signCount` replay protection. + +**Registration (once per device):** +1. `POST /attest/challenge` β†’ `{ challenge, expiresIn }` (base64, single-use, 5 min TTL). +2. `DCAppAttestService.generateKey()` β†’ `keyId`. +3. `attestKey(keyId, clientDataHash: SHA256(challenge bytes))` β†’ attestation object. +4. `POST /attest/key` with `{ keyId, attestation, challenge }` (all base64). + +**Assertion (per protected request):** +1. `POST /attest/challenge` β†’ fresh challenge. +2. `clientDataHash = SHA256(challenge bytes + exact HTTP body bytes)`. +3. `generateAssertion(keyId, clientDataHash:)` β†’ assertion CBOR. +4. Send the request with headers: + - `X-Attest-Key-Id`: the key id (base64) + - `X-Attest-Challenge`: the challenge (base64) + - `X-Attest-Assertion`: the assertion (base64) + +`APP_ATTEST_DISABLED=true` skips assertion checks for local development β€” App +Attest requires physical hardware (not the simulator). Never set in production. + +## Sign in with Apple + +`POST /auth/apple` body is `AppleSignInRequest` (shared DTO in +`CocoaHeadsCore`). The server verifies the identity token against Apple's JWKS +(`aud` = `APPLE_BUNDLE_ID`), upserts the user on the Apple `sub`, exchanges the +authorization code at `https://appleid.apple.com/auth/token` (client-secret JWT +signed with the Sign in with Apple `.p8` key), stores Apple's refresh token +AES-GCM-encrypted, and returns a `TokenResponse`: + +- **Access token**: backend-signed JWT (ES256 when `JWT_SIGNING_KEY` is a PEM, + HS256 otherwise), TTL `ACCESS_TOKEN_TTL` (default 15 min), claims + `sub`/`role`/`exp`/`iat`. Verified statelessly. +- **Refresh token**: opaque 32-byte random value; only its SHA-256 hash is + stored. Single-use β€” `POST /auth/refresh` revokes it and issues a new pair. + +## Account deletion (Guideline 5.1.1(v)) + +`DELETE /me`: +1. Revokes the Apple grant via `https://appleid.apple.com/auth/revoke` (best + effort; failures are logged, deletion proceeds). +2. Scrubs PII immediately (`email`, `fullName`, stored Apple refresh token). +3. Revokes all refresh tokens and App Attest keys for the user. +4. Soft-deletes (`deleted_at`). `AccountPurgeService` hard-purges rows older + than `ACCOUNT_PURGE_GRACE_DAYS` (default 30) at boot and every 12 h. + +## Environment variables + +| Variable | Purpose | Default | +| --- | --- | --- | +| `JWT_SIGNING_KEY` | ES256 private-key PEM (recommended) or HS256 secret for access tokens | dev-only fallback | +| `APPLE_BUNDLE_ID` | Expected `aud` of Apple identity tokens / client id | `com.cocoaheadsbr.conf` | +| `API_KEYS` | Comma-separated static client keys | *(unset β†’ requests rejected)* | +| `ACCESS_TOKEN_TTL` | Access-token lifetime (seconds) | `900` | +| `REFRESH_TOKEN_TTL` | Refresh-token lifetime (seconds) | `3888000` (45 days) | +| `ACCOUNT_PURGE_GRACE_DAYS` | Hard-purge delay after soft-delete | `30` | +| `APPLE_TEAM_ID` | Apple Developer Team ID | β€” | +| `APPLE_SIGNIN_KEY_ID` | Key ID of the Sign in with Apple `.p8` key | β€” | +| `APPLE_SIGNIN_PRIVATE_KEY` | `.p8` private-key contents (ES256) | β€” | +| `APP_ATTEST_TEAM_ID` | Team ID for the App Attest app id | falls back to `APPLE_TEAM_ID` | +| `APP_ATTEST_ENVIRONMENT` | `production` or `development` (aaguid) | `production` | +| `APP_ATTEST_DISABLED` | Skip assertion checks (local dev only) | `false` | +| `TOKEN_ENCRYPTION_KEY` | Base64 32-byte AES-256 key for secrets at rest | derived from `JWT_SIGNING_KEY` | + +## Local development + +```bash +cd backend +docker compose up -d db +export API_KEYS=dev-key APP_ATTEST_DISABLED=true JWT_SIGNING_KEY=dev-secret +swift run backend migrate --yes +swift run backend serve +``` + +- `APP_ATTEST_DISABLED=true` skips assertion checks (the simulator cannot + attest). The simulator client should send no attest headers in this mode. +- Sign in with Apple works against a local server: identity-token verification + only needs internet access to Apple's JWKS. When the `.p8` service + credentials (`APPLE_TEAM_ID`/`APPLE_SIGNIN_KEY_ID`/`APPLE_SIGNIN_PRIVATE_KEY`) + are **not** configured, non-production environments skip the + authorization-code exchange with a warning and store no Apple refresh token + (account deletion then has no Apple grant to revoke). Production always + requires the credentials and fails sign-in without them. + +## Known limitations / follow-ups + +- **Challenge store is in-memory** (`ChallengeStore`): fine for the current + single-instance deployment; a multi-instance deployment needs a shared store. +- **Roles are foundation-only** (Β§7): `role` is persisted and embedded in + access-token claims, but no `RoleMiddleware` enforces it yet. +- **Rate limiting** on `/auth/*` and `DELETE /me` is future work (Β§10). +- **iOS client** (spec Β§9) is not part of this change: the app still needs an + HTTP layer, the Sign in with Apple capability/entitlement, Keychain storage + for the refresh token + App Attest key id, and an in-app "Delete Account" + action that calls `DELETE /me`. The shared DTOs it will use are already in + `CocoaHeadsCore/Sources/CocoaHeadsCore/Auth/`. diff --git a/backend/docs/user-auth-spec.md b/backend/docs/user-auth-spec.md new file mode 100644 index 0000000..5a87c73 --- /dev/null +++ b/backend/docs/user-auth-spec.md @@ -0,0 +1,278 @@ +# User Authentication & App Authentication β€” Specification + +> **Status:** Design spec. No code in this document β€” it is the blueprint for a +> follow-up implementation task. +> **Branch of record:** `claude/vapor-user-auth-spec-jey32u` +> **Scope:** the Vapor backend in [`/backend`](../) and its iOS client. + +--- + +## 1. Overview & goals + +We are introducing **user accounts** as the foundation for future features. Today the +backend is a single, unauthenticated route β€” `POST /scrape` (a Firecrawl shim, see +[`ScrapingController.swift`](../Sources/backend/Controllers/EventScrape/ScrapingController.swift)) β€” +reachable by anyone on the internet. There are no Fluent models, no migrations, no auth +middleware, and no users. `JWT` is not yet a dependency. + +This spec defines **two independent security layers**. They are orthogonal β€” a request +must pass both to reach a user-scoped route: + +1. **App authentication** β€” *"is this a legitimate client?"* + API key + Apple **App Attest** middleware. Applied to **all** routes, including the + existing `/scrape`. Goal: only well-known clients (a genuine build of our app) can + reach the server. +2. **User authentication** β€” *"who is this person?"* + Sign in with Apple β†’ backend-issued JWT. Applied to user-scoped routes only. + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ App authentication ───────────────────┐ +Request ──▢ [ X-API-Key check ] ──▢ [ App Attest assertion ] ──▢ ... + β”‚ + β”Œβ”€β”€ User authentication ──┐ + ──▢ [ Bearer JWT check ] ──▢ handler +``` + +### Non-goals for this phase +- Role **enforcement** logic (the `role` field exists; gating by role is future work). +- The future features that users will underpin. +- Any non-Apple login provider. + +--- + +## 2. Identifiers & environment + +### Identifiers +- **Main app bundle id:** `com.cocoaheadsbr.conf` β€” this is the `aud` claim to validate + in the Apple identity token, and the client id for Apple's token/revoke endpoints. +- App Clip (`com.cocoaheadsbr.conf.baseClip`) and Watch + (`com.cocoaheadsbr.conf.watchkitapp`) are **out of scope** for sign-in. +- **App Attest app id:** `.com.cocoaheadsbr.conf`. + +### Environment variables +Follow the existing `Environment.get(...)` pattern already used in +[`FirecrawlService.swift`](../Sources/backend/Controllers/EventScrape/FirecrawlService.swift) +and [`configure.swift`](../Sources/backend/configure.swift). Document these alongside the +existing `DATABASE_*` and `FIRECRAWL_API_KEY` vars. + +| Variable | Purpose | +| --- | --- | +| `JWT_SIGNING_KEY` | Secret/keypair the backend uses to sign its own access tokens. ES256 keypair recommended (see Β§5). | +| `APPLE_BUNDLE_ID` | Expected `aud` of the Apple identity token; also the client id. (`com.cocoaheadsbr.conf`) | +| `API_KEYS` | Comma-separated static client keys for the coarse API-key gate (Β§6). | +| `ACCESS_TOKEN_TTL` | Access-token lifetime (default ~15 min). | +| `REFRESH_TOKEN_TTL` | Refresh-token lifetime (default ~30–60 days). | +| `ACCOUNT_PURGE_GRACE_DAYS` | Hard-purge delay after soft-delete (default 30, see Β§8). | +| **Apple Sign In service credentials** (needed to mint the client-secret JWT for Apple's token & revoke endpoints β€” Β§4, Β§8): | | +| `APPLE_TEAM_ID` | Apple Developer Team ID. | +| `APPLE_SIGNIN_KEY_ID` | Key ID of the Sign in with Apple `.p8` key. | +| `APPLE_SIGNIN_PRIVATE_KEY` | The `.p8` private-key contents (ES256). | +| `APP_ATTEST_TEAM_ID` | Team ID used to construct the App Attest app id. | + +--- + +## 3. Data model (Fluent β€” the project's first migrations) + +There are currently **no models or migrations**. These are new. Register them in +[`configure.swift`](../Sources/backend/configure.swift) (which presently registers none), +and they will run through the `migrate` / `revert` services already stubbed in +[`docker-compose.yml`](../docker-compose.yml). + +### `User` +| Field | Type | Notes | +| --- | --- | --- | +| `id` | UUID | PK | +| `appleUserIdentifier` | String | Apple `sub`. **Unique, indexed.** Stable per Apple ID + team. | +| `email` | String? | Apple returns this only on **first** authorization. May be a Hide-My-Email relay (Β§10). | +| `fullName` | String? | Apple returns this only on first authorization. Persist then. | +| `role` | enum (String) | `user` \| `organizer` \| `admin`. Default `user`. (Β§7) | +| `appleRefreshToken` | String? | **Encrypted at rest.** Required to revoke Apple's grant at deletion (Β§8). | +| `deletedAt` | Date? | Soft-delete tombstone (Β§8). | +| `createdAt` | Date | Fluent `@Timestamp`. | +| `updatedAt` | Date | Fluent `@Timestamp`. | + +### `RefreshToken` +| Field | Type | Notes | +| --- | --- | --- | +| `id` | UUID | PK | +| `userID` | UUID | FK β†’ `User`. | +| `tokenHash` | String | Hash of the token. **Never store the raw token.** | +| `expiresAt` | Date | | +| `revoked` | Bool | Set on logout / rotation / deletion. | +| `createdAt` | Date | | + +### `AppAttestKey` +| Field | Type | Notes | +| --- | --- | --- | +| `id` | UUID | PK | +| `userID` | UUID? | Device-scoped; may be associated with a user after sign-in. | +| `keyId` | String | The App Attest key identifier. | +| `publicKey` | Data | Attested public key, used to verify later assertions. | +| `receipt` | Data | Apple attestation receipt. | +| `signCount` | Int | Monotonic counter; reject non-increasing values. | +| `createdAt` | Date | | + +--- + +## 4. Sign in with Apple flow + +### Sequence +1. **Client** runs `ASAuthorizationController` and obtains an Apple `identityToken` + (a JWT) plus an `authorizationCode`. +2. Client calls **`POST /auth/apple`** with the identity token, the `authorizationCode`, + the user's name/email (present only on first run), and an App Attest assertion. +3. **Server**: + - Verifies the Apple identity token using Vapor's JWT package built-in Apple support + β€” `request.jwt.apple.verify(applicationIdentifier:)` β€” which fetches/caches Apple's + JWKS and validates `iss` / `aud` / `exp`. + - **Upserts** a `User` keyed on the Apple `sub`. Persists name/email if provided. + - **Exchanges the `authorizationCode` for Apple tokens:** builds a client-secret JWT + (signed with the `.p8` key via `APPLE_SIGNIN_KEY_ID` / `APPLE_TEAM_ID`) and + `POST`s to `https://appleid.apple.com/auth/token` to obtain Apple's **refresh + token**, then stores it (encrypted) on the `User`. This is mandatory groundwork β€” + without it we cannot satisfy Apple's deletion/revocation requirement (Β§8). + - Issues a backend **access token** + **refresh token** (Β§5). + +### Endpoints +| Method & path | Purpose | Auth required | +| --- | --- | --- | +| `POST /auth/apple` | Sign in / sign up via Apple. Returns token pair. | App-auth only | +| `POST /auth/refresh` | Rotate refresh token, issue new access token. | Valid refresh token | +| `POST /auth/logout` | Revoke the presented refresh token. | Bearer JWT | +| `GET /me` | Return the current user (example protected route). | Bearer JWT | +| `DELETE /me` | Delete the account (Β§8). | Bearer JWT | + +### Dependency to add +Add the `JWT` product from [`vapor/jwt`](https://github.com/vapor/jwt) to +[`backend/Package.swift`](../Package.swift). This provides both the Apple identity-token +verification and the backend's own JWT signing. + +--- + +## 5. Session tokens + +- **Access token** β€” a backend-signed JWT. + - Algorithm: **ES256** recommended (keypair via `JWT_SIGNING_KEY`); HS256 acceptable. + - TTL: short (~15 min, `ACCESS_TOKEN_TTL`). + - Claims: `sub` = user id, `role`, `exp`, `iat`. + - Verified statelessly by a `Bearer` authenticator middleware + (`AsyncBearerAuthenticator`) β€” no DB hit on the hot path. +- **Refresh token** β€” an opaque random string. + - Only its **hash** is stored (`RefreshToken.tokenHash`); the raw value is returned to + the client once and kept in the Keychain. + - TTL: long (~30–60 days, `REFRESH_TOKEN_TTL`). + - **Single-use rotation:** each `/auth/refresh` revokes the presented token and issues + a new one. Revocable on logout and on account deletion. + +--- + +## 6. App authentication layer (API key + App Attest) + +Order of checks for a protected route: **API key β†’ App Attest β†’ (user routes) Bearer JWT.** +The existing `/scrape` route becomes gated behind app-auth. + +### API key middleware +- Checks an `X-API-Key` header against the configured `API_KEYS` (static env var, per the + chosen approach). +- **Caveat (documented intentionally):** a key baked into a shipped iOS app is + extractable from the binary. This is a coarse first gate, **not** a real integrity + guarantee. App Attest provides the actual guarantee. + +### App Attest middleware (two phases) +1. **Attestation / registration** + - `POST /attest/challenge` β€” server issues a one-time challenge. + - `POST /attest/key` β€” client sends its attestation object + key id; server verifies + it against Apple's App Attest **root CA** and stores the public key as an + `AppAttestKey`. +2. **Assertion (per request)** + - Protected requests carry an assertion header signed by the attested key over a + challenge / request hash. + - Middleware verifies the signature and that `signCount` is **monotonically + increasing** (replay protection). + +> **Implementation note / highest-effort item:** no existing Swift *server-side* App +> Attest library is assumed. The verification logic β€” CBOR decoding and X.509 certificate +> chain validation against Apple's App Attest root β€” must be implemented or vendored. Flag +> this as the largest piece of the follow-up implementation. + +--- + +## 7. Roles (foundation only) + +- `enum Role: String, Codable { case user, organizer, admin }`, default `user`. +- Embedded in the access-token claims so future authorization is a stateless check. +- **No enforcement middleware in this phase.** Document the intended shape of a future + `RoleMiddleware` (e.g. `.grouped(RoleMiddleware(.admin))`) so the field is + forward-compatible. Role **assignment** mechanics are deferred. + +--- + +## 8. Account deletion (REQUIRED β€” Apple App Review Guideline 5.1.1(v)) + +Any app offering account creation **must** offer in-app account deletion. For Sign in +with Apple, Apple additionally requires revoking the user's grant via their +server-to-server endpoint. This is a first-class part of the spec, not future work. + +- **Endpoint:** `DELETE /me` (authenticated). A confirmation step is optional. +- **Model: soft-delete + scheduled purge.** A permanent "deleted" flag that retains the + user forever does **not** satisfy the guideline. On deletion: + 1. Set `User.deletedAt`; immediately **scrub/anonymize PII** (`email`, `fullName`) and + treat the account as gone for all app purposes. + 2. Revoke **all** of the user's `RefreshToken`s and `AppAttestKey`s. + 3. Call Apple's revocation endpoint + `POST https://appleid.apple.com/auth/revoke` with the stored `appleRefreshToken` + plus a freshly minted client-secret JWT, to revoke Apple's grant. + 4. **Hard-purge** the row after a short grace period (`ACCOUNT_PURGE_GRACE_DAYS`, + default ~30 days, for support / abuse recovery) β€” via a scheduled job (`Queues` + task) or a startup sweep. +- **Dependency chain:** step 3 only works because Β§4 exchanges and stores the Apple + refresh token at sign-in. +- **iOS requirement:** a clearly reachable in-app "Delete Account" action β€” Apple rejects + flows that only point users to a website or email (see Β§9). + +--- + +## 9. iOS client impact (documentation only β€” no client code in this task) + +- **HTTP layer needed.** None exists today; the app is CloudKit-only and + [`MeetupService.swift`](../../CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/MeetupService.swift) + is a `fatalError` stub. A small `URLSession`-based client must be built. +- **Sign in with Apple capability + entitlement** must be added (not currently present in + the app's entitlements). +- **Keychain storage** for the refresh token and App Attest key id (no Keychain usage + exists today). +- **"Delete Account" UI** is mandatory and must be reachable in-app (Guideline 5.1.1(v)) + β€” calls `DELETE /me`, then clears the local Keychain/session. +- **Shared DTOs** (`AppleSignInRequest`, `TokenResponse`, `RefreshRequest`) belong in + [`CocoaHeadsCore`](../../CocoaHeadsCore), which is already shared by the app and the + backend β€” mirroring how `MeetupEvent` / `MeetupEventRequest` are shared. + +--- + +## 10. Security considerations & open questions + +- **API key extractability** β€” App Attest is the real client-integrity guarantee; the API + key is only a coarse gate. +- **Token replay** β€” refresh-token single-use rotation; revocation on logout; App Attest + `signCount` monotonicity. +- **Hide My Email** β€” the stored `email` may be an Apple relay address; do not assume it + is reachable or stable. +- **Secrets at rest** β€” encryption for the stored Apple refresh token; key management for + the `.p8` Sign in with Apple signing key. +- **Rate limiting** on `/auth/*` and `DELETE /me` (future). + +--- + +## 11. Summary of follow-up implementation work + +1. Add `vapor/jwt` to `Package.swift`. +2. Create `User`, `RefreshToken`, `AppAttestKey` models + migrations; register them in + `configure.swift`. +3. Add new env vars (config + `docker-compose.yml`). +4. Build middleware: API-key, App Attest (attestation + assertion), Bearer JWT. +5. Build controllers/routes: `/auth/apple`, `/auth/refresh`, `/auth/logout`, `/me` + (GET + DELETE), `/attest/challenge`, `/attest/key`. Gate `/scrape` behind app-auth. +6. Implement Apple `authorizationCode` exchange + revocation client. +7. Implement the soft-delete scrub + scheduled hard-purge job. +8. Add shared auth DTOs to `CocoaHeadsCore`. +9. iOS: HTTP client, Sign in with Apple, Keychain, Delete Account UI. From 4d356b7c8010419f77ae0366e1a8e81330e37bf0 Mon Sep 17 00:00:00 2001 From: Mauricio Cardozo Date: Mon, 10 Aug 2026 19:52:02 -0300 Subject: [PATCH 2/2] Address security review: harden CBOR decoding, token rotation, and middleware - CBOR: bound declared array/map counts by remaining input (malicious headers could trap or trigger giant allocations), limit nesting depth to 16, and reject trailing bytes after a complete item - Sign in: prefer the email from the verified Apple identity token over the client-supplied body value; recover from concurrent first sign-in unique-constraint races by adopting the winner's row - AppAttestMiddleware: cap body buffering at the app's default body size limit; persist assertion sign counts with a conditional update so concurrent assertions cannot move the counter backwards - TokenService: run rotation in a transaction with a conditional claim (UPDATE ... AND revoked = false RETURNING) so concurrent rotations have exactly one winner; presenting an already-revoked token now revokes the user's entire token family (theft detection) - ChallengeStore: bound the store, evicting the oldest-expiring entry - AccountPurgeService: also sweep expired refresh tokens (revoked but unexpired rows are kept for reuse detection) - TokenEncryption.load: drop unused Environment parameter - Revert incidental Xcode churn (xcodecloud manifest, Watch scheme) - Tests: CBOR abuse cases, bounded challenge store, and a Postgres-gated rotation/reuse-detection integration suite (TEST_DATABASE=1 to enable) Co-Authored-By: Claude Fable 5 --- .../xcshareddata/xcodecloud/manifest.json | 9 - .../xcschemes/CocoaHeadsBR Watch.xcscheme | 2 + .../backend/Auth/AccountPurgeService.swift | 12 ++ .../Sources/backend/Auth/AppAttest/CBOR.swift | 30 +++- .../Auth/AppAttest/ChallengeStore.swift | 17 +- .../backend/Auth/TokenEncryption.swift | 2 +- .../Sources/backend/Auth/TokenService.swift | 165 +++++++++++++----- .../Controllers/Auth/AuthController.swift | 38 ++-- .../Middleware/AppAttestMiddleware.swift | 16 +- backend/Sources/backend/configure.swift | 2 +- backend/Tests/backendTests/AuthTests.swift | 59 +++++++ .../RotationIntegrationTests.swift | 158 +++++++++++++++++ 12 files changed, 432 insertions(+), 78 deletions(-) delete mode 100644 NSBrazilConf.xcodeproj/xcshareddata/xcodecloud/manifest.json create mode 100644 backend/Tests/backendTests/RotationIntegrationTests.swift diff --git a/NSBrazilConf.xcodeproj/xcshareddata/xcodecloud/manifest.json b/NSBrazilConf.xcodeproj/xcshareddata/xcodecloud/manifest.json deleted file mode 100644 index 8b2ef9e..0000000 --- a/NSBrazilConf.xcodeproj/xcshareddata/xcodecloud/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id" : "b56c993d-8301-4e37-9f79-98d769725e3f", - "targets" : [ - { - "id" : "D8075982-631D-4086-9418-269EA668F614", - "name" : "NSBrazil" - } - ] -} \ No newline at end of file diff --git a/NSBrazilConf.xcodeproj/xcshareddata/xcschemes/CocoaHeadsBR Watch.xcscheme b/NSBrazilConf.xcodeproj/xcshareddata/xcschemes/CocoaHeadsBR Watch.xcscheme index 9055029..c99fafd 100644 --- a/NSBrazilConf.xcodeproj/xcshareddata/xcschemes/CocoaHeadsBR Watch.xcscheme +++ b/NSBrazilConf.xcodeproj/xcshareddata/xcschemes/CocoaHeadsBR Watch.xcscheme @@ -60,6 +60,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "B482AA862C5C721600F66F3B" BuildableName = "CocoaHeadsBR.app" + BlueprintName = "CocoaHeadsBR Watch" ReferencedContainer = "container:NSBrazilConf.xcodeproj"> @@ -76,6 +77,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "B482AA862C5C721600F66F3B" BuildableName = "CocoaHeadsBR.app" + BlueprintName = "CocoaHeadsBR Watch" ReferencedContainer = "container:NSBrazilConf.xcodeproj"> diff --git a/backend/Sources/backend/Auth/AccountPurgeService.swift b/backend/Sources/backend/Auth/AccountPurgeService.swift index d955991..15e73ad 100644 --- a/backend/Sources/backend/Auth/AccountPurgeService.swift +++ b/backend/Sources/backend/Auth/AccountPurgeService.swift @@ -35,6 +35,18 @@ final class AccountPurgeService: LifecycleHandler { static func purge(on application: Application) async { let graceDays = application.authConfiguration.accountPurgeGraceDays let cutoff = Date().addingTimeInterval(-TimeInterval(graceDays) * 24 * 60 * 60) + + // Expired refresh tokens are dead weight; delete them. Revoked-but- + // unexpired rows are kept β€” reuse detection in `TokenService.rotate` + // depends on finding them. + do { + try await RefreshToken.query(on: application.db) + .filter(\.$expiresAt < Date()) + .delete() + } catch { + application.logger.error("Expired refresh-token sweep failed: \(error)") + } + do { let expired = try await User.query(on: application.db) .withDeleted() diff --git a/backend/Sources/backend/Auth/AppAttest/CBOR.swift b/backend/Sources/backend/Auth/AppAttest/CBOR.swift index ecce0b3..ff28276 100644 --- a/backend/Sources/backend/Auth/AppAttest/CBOR.swift +++ b/backend/Sources/backend/Auth/AppAttest/CBOR.swift @@ -18,17 +18,23 @@ enum CBOR: Equatable, Sendable { case array([CBOR]) case map([CBOR: CBOR]) - enum DecodingError: Error { + enum DecodingError: Error, Equatable { case truncated case unsupportedType(UInt8) case indefiniteLengthUnsupported case invalidUTF8 case trailingBytes + case nestingTooDeep } + /// Maximum container nesting; attacker-supplied input must not be able to + /// recurse the decoder into a stack overflow. + static let maxNestingDepth = 16 + static func decode(_ data: Data) throws -> CBOR { var reader = Reader(data: data) - let value = try reader.decodeItem() + let value = try reader.decodeItem(depth: 0) + guard reader.isAtEnd else { throw DecodingError.trailingBytes } return value } @@ -66,7 +72,14 @@ enum CBOR: Equatable, Sendable { self.index = data.startIndex } - mutating func decodeItem() throws -> CBOR { + var isAtEnd: Bool { index >= data.endIndex } + + private var remainingBytes: UInt64 { + UInt64(data.distance(from: index, to: data.endIndex)) + } + + mutating func decodeItem(depth: Int) throws -> CBOR { + guard depth < CBOR.maxNestingDepth else { throw DecodingError.nestingTooDeep } let initial = try readByte() let majorType = initial >> 5 let additional = initial & 0x1F @@ -89,18 +102,23 @@ enum CBOR: Equatable, Sendable { return .text(string) case 4: let count = try readLength(additional) + // Every element occupies at least one byte, so a declared count larger + // than the remaining input is malformed β€” reject it before allocating. + guard count <= remainingBytes else { throw DecodingError.truncated } var items: [CBOR] = [] items.reserveCapacity(Int(count)) for _ in 0.. Data { prune() + if challenges.count >= maxEntries, + let oldest = challenges.min(by: { $0.value < $1.value }) + { + challenges.removeValue(forKey: oldest.key) + } let challenge = Data((0..<32).map { _ in UInt8.random(in: .min ... .max) }) challenges[challenge.base64EncodedString()] = Date().addingTimeInterval(Self.challengeTTL) return challenge @@ -38,6 +51,8 @@ actor ChallengeStore { let now = Date() challenges = challenges.filter { $0.value > now } } + + var count: Int { challenges.count } } extension Application { diff --git a/backend/Sources/backend/Auth/TokenEncryption.swift b/backend/Sources/backend/Auth/TokenEncryption.swift index 49ce47d..a0795c3 100644 --- a/backend/Sources/backend/Auth/TokenEncryption.swift +++ b/backend/Sources/backend/Auth/TokenEncryption.swift @@ -21,7 +21,7 @@ struct TokenEncryption: Sendable { self.key = key } - static func load(from environment: Environment) throws -> TokenEncryption { + static func load() throws -> TokenEncryption { if let base64 = Environment.get("TOKEN_ENCRYPTION_KEY") { guard let data = Data(base64Encoded: base64), data.count == 32 else { throw AuthError.invalidEncryptionKey diff --git a/backend/Sources/backend/Auth/TokenService.swift b/backend/Sources/backend/Auth/TokenService.swift index d8b7acb..a7d8cd4 100644 --- a/backend/Sources/backend/Auth/TokenService.swift +++ b/backend/Sources/backend/Auth/TokenService.swift @@ -9,6 +9,7 @@ import CocoaHeadsCore import Crypto import Fluent import JWT +import SQLKit import Vapor /// Issues and rotates the backend's own session tokens (Β§5 of the auth spec). @@ -16,45 +17,73 @@ struct TokenService: Sendable { /// Issues an access-token + refresh-token pair for a user. Only the SHA-256 /// hash of the refresh token is persisted. func issueTokenPair(for user: User, on req: Request) async throws -> TokenResponse { - let config = req.authConfiguration - let userID = try user.requireID() - - let now = Date() - let payload = AccessTokenPayload( - subject: SubjectClaim(value: userID.uuidString), - expiration: ExpirationClaim(value: now.addingTimeInterval(config.accessTokenTTL)), - issuedAt: IssuedAtClaim(value: now), - role: user.role - ) - let accessToken = try await req.jwt.sign(payload) - - let rawRefreshToken = Self.generateRefreshToken() - let record = RefreshToken( - userID: userID, - tokenHash: Self.hash(rawRefreshToken), - expiresAt: now.addingTimeInterval(config.refreshTokenTTL) - ) - try await record.save(on: req.db) - - return TokenResponse( - accessToken: accessToken, - refreshToken: rawRefreshToken, - expiresIn: Int(config.accessTokenTTL), - user: try user.dto + let rawRefreshToken = try await Self.storeRefreshToken( + userID: user.requireID(), + ttl: req.authConfiguration.refreshTokenTTL, + on: req.db ) + return try await Self.tokenResponse(user: user, rawRefreshToken: rawRefreshToken, on: req) } /// Single-use rotation: revokes the presented refresh token and issues a new - /// pair. Rejects unknown, revoked, or expired tokens. + /// pair. Rejects unknown, revoked, or expired tokens. Runs in a transaction, + /// and the revocation is conditional so concurrent rotations of the same + /// token have exactly one winner. Presenting an already-revoked token is + /// treated as theft: every refresh token of that user is revoked (Β§10). + private enum RotationOutcome { + case rotated(User, rawRefreshToken: String) + case reuseDetected(userID: UUID) + case rejected(reason: String) + } + func rotate(refreshToken raw: String, on req: Request) async throws -> TokenResponse { - let record = try await validRecord(for: raw, on: req.db) - record.revoked = true - try await record.save(on: req.db) + let hash = Self.hash(raw) + let refreshTTL = req.authConfiguration.refreshTokenTTL + + // Throwing inside the transaction would roll back any writes, so reuse + // detection only *reports* here and the family revocation happens after, + // where it persists. + let outcome = try await req.db.transaction { db -> RotationOutcome in + guard + let record = try await RefreshToken.query(on: db) + .filter(\.$tokenHash == hash) + .first() + else { + return .rejected(reason: "Unknown refresh token.") + } + let userID = record.$user.id + + guard !record.revoked else { + // A rotated token came back β€” assume the family is compromised. + return .reuseDetected(userID: userID) + } + guard record.expiresAt > Date() else { + return .rejected(reason: "Refresh token expired.") + } + guard try await Self.claimForRotation(record, on: db) else { + // Lost the race β€” a concurrent rotation just consumed this token. + return .reuseDetected(userID: userID) + } + guard let user = try await User.find(userID, on: db) else { + return .rejected(reason: "Account no longer exists.") + } + let rawRefreshToken = try await Self.storeRefreshToken( + userID: userID, + ttl: refreshTTL, + on: db + ) + return .rotated(user, rawRefreshToken: rawRefreshToken) + } - guard let user = try await User.find(record.$user.id, on: req.db) else { - throw Abort(.unauthorized, reason: "Account no longer exists.") + switch outcome { + case .rotated(let user, let rawRefreshToken): + return try await Self.tokenResponse(user: user, rawRefreshToken: rawRefreshToken, on: req) + case .reuseDetected(let userID): + try await revokeAll(for: userID, on: req.db) + throw Abort(.unauthorized, reason: "Refresh token revoked.") + case .rejected(let reason): + throw Abort(.unauthorized, reason: reason) } - return try await issueTokenPair(for: user, on: req) } /// Revokes the presented refresh token (logout). @@ -68,7 +97,8 @@ struct TokenService: Sendable { try await record.save(on: req.db) } - /// Revokes every refresh token belonging to a user (account deletion). + /// Revokes every refresh token belonging to a user (account deletion, or + /// reuse detection during rotation). func revokeAll(for userID: UUID, on db: any Database) async throws { try await RefreshToken.query(on: db) .filter(\.$user.$id == userID) @@ -76,21 +106,62 @@ struct TokenService: Sendable { .update() } - private func validRecord(for raw: String, on db: any Database) async throws -> RefreshToken { - guard - let record = try await RefreshToken.query(on: db) - .filter(\.$tokenHash == Self.hash(raw)) - .first() - else { - throw Abort(.unauthorized, reason: "Unknown refresh token.") - } - guard !record.revoked else { - throw Abort(.unauthorized, reason: "Refresh token revoked.") + /// Marks the record revoked, returning whether this caller won the claim. + /// On SQL databases the update is conditional (`AND revoked = false` with + /// `RETURNING`), so exactly one concurrent rotation can succeed. + private static func claimForRotation( + _ record: RefreshToken, + on db: any Database + ) async throws -> Bool { + guard let sql = db as? any SQLDatabase else { + record.revoked = true + try await record.save(on: db) + return true } - guard record.expiresAt > Date() else { - throw Abort(.unauthorized, reason: "Refresh token expired.") - } - return record + let claimed = try await sql.raw( + """ + UPDATE \(ident: RefreshToken.schema) + SET revoked = true + WHERE id = \(bind: record.requireID()) AND revoked = false + RETURNING id + """ + ).all() + return !claimed.isEmpty + } + + private static func storeRefreshToken( + userID: UUID, + ttl: TimeInterval, + on db: any Database + ) async throws -> String { + let raw = generateRefreshToken() + try await RefreshToken( + userID: userID, + tokenHash: hash(raw), + expiresAt: Date().addingTimeInterval(ttl) + ).save(on: db) + return raw + } + + private static func tokenResponse( + user: User, + rawRefreshToken: String, + on req: Request + ) async throws -> TokenResponse { + let config = req.authConfiguration + let now = Date() + let payload = AccessTokenPayload( + subject: SubjectClaim(value: try user.requireID().uuidString), + expiration: ExpirationClaim(value: now.addingTimeInterval(config.accessTokenTTL)), + issuedAt: IssuedAtClaim(value: now), + role: user.role + ) + return TokenResponse( + accessToken: try await req.jwt.sign(payload), + refreshToken: rawRefreshToken, + expiresIn: Int(config.accessTokenTTL), + user: try user.dto + ) } static func generateRefreshToken() -> String { diff --git a/backend/Sources/backend/Controllers/Auth/AuthController.swift b/backend/Sources/backend/Controllers/Auth/AuthController.swift index e423c30..c06d6b7 100644 --- a/backend/Sources/backend/Controllers/Auth/AuthController.swift +++ b/backend/Sources/backend/Controllers/Auth/AuthController.swift @@ -57,19 +57,16 @@ struct AuthController: RouteCollection { ) } - let user: User - if let existing = try await User.query(on: req.db) + var user = try await User.query(on: req.db) .filter(\.$appleUserIdentifier == identity.subject.value) .first() - { - user = existing - } else { - user = User(appleUserIdentifier: identity.subject.value) - } + ?? User(appleUserIdentifier: identity.subject.value) // Name and email only arrive on the user's first authorization β€” persist - // them whenever present. The email may be a Hide-My-Email relay. - if let email = signIn.email ?? identity.email { + // them whenever present. The email may be a Hide-My-Email relay. Prefer + // the email from the verified identity token; the request-body value is + // client-supplied and only a fallback (the name has no token source). + if let email = identity.email ?? signIn.email { user.email = email } if let fullName = signIn.fullName { @@ -80,7 +77,22 @@ struct AuthController: RouteCollection { if let encryptedRefreshToken { user.appleRefreshToken = encryptedRefreshToken } - try await user.save(on: req.db) + do { + try await user.save(on: req.db) + } catch let error where (error as? any DatabaseError)?.isConstraintFailure == true { + // Two concurrent first sign-ins raced on the unique Apple `sub`; adopt + // the row the winner created instead of failing. + guard + let existing = try await User.query(on: req.db) + .filter(\.$appleUserIdentifier == identity.subject.value) + .first() + else { throw error } + existing.email = user.email ?? existing.email + existing.fullName = user.fullName ?? existing.fullName + existing.appleRefreshToken = user.appleRefreshToken ?? existing.appleRefreshToken + try await existing.save(on: req.db) + user = existing + } // Associate the attested device key with the signed-in user. if let attestedKeyID = req.attestedKeyID { @@ -109,6 +121,12 @@ struct AuthController: RouteCollection { } } +extension RefreshRequest: @retroactive RequestDecodable {} +extension RefreshRequest: @retroactive ResponseEncodable {} +extension RefreshRequest: @retroactive AsyncRequestDecodable {} +extension RefreshRequest: @retroactive AsyncResponseEncodable {} +extension RefreshRequest: @retroactive Content {} + extension TokenResponse: @retroactive RequestDecodable {} extension TokenResponse: @retroactive ResponseEncodable {} extension TokenResponse: @retroactive AsyncRequestDecodable {} diff --git a/backend/Sources/backend/Middleware/AppAttestMiddleware.swift b/backend/Sources/backend/Middleware/AppAttestMiddleware.swift index 211c9f2..04af7da 100644 --- a/backend/Sources/backend/Middleware/AppAttestMiddleware.swift +++ b/backend/Sources/backend/Middleware/AppAttestMiddleware.swift @@ -65,13 +65,17 @@ struct AppAttestMiddleware: AsyncMiddleware { throw Abort(.unauthorized, reason: "Unknown App Attest key.") } - let collected = try await request.body.collect(max: nil).get() + // Respect the application's body-size limit β€” `max: nil` would buffer + // arbitrarily large request bodies. + let maxBodySize = request.application.routes.defaultMaxBodySize.value + let collected = try await request.body.collect(max: maxBodySize).get() let body = collected.map { Data(buffer: $0) } ?? Data() let clientDataHash = Data(SHA256.hash(data: challenge + body)) let verifier = AppAttestVerifier(environment: config.appAttestEnvironment) + let newSignCount: Int do { - key.signCount = try verifier.verifyAssertion( + newSignCount = try verifier.verifyAssertion( assertion, publicKey: key.publicKey, clientDataHash: clientDataHash, @@ -82,7 +86,13 @@ struct AppAttestMiddleware: AsyncMiddleware { request.logger.info("App Attest assertion rejected: \(error)") throw Abort(.unauthorized, reason: "Invalid App Attest assertion.") } - try await key.save(on: request.db) + // Conditional update so a concurrent assertion cannot move the counter + // backwards: only persist when the stored count is still lower. + try await AppAttestKey.query(on: request.db) + .filter(\.$id == key.requireID()) + .filter(\.$signCount < newSignCount) + .set(\.$signCount, to: newSignCount) + .update() request.storage[AttestedKeyIDKey.self] = keyId return try await next.respond(to: request) diff --git a/backend/Sources/backend/configure.swift b/backend/Sources/backend/configure.swift index 3245298..b05e259 100644 --- a/backend/Sources/backend/configure.swift +++ b/backend/Sources/backend/configure.swift @@ -57,7 +57,7 @@ func configureAuth(_ app: Application) async throws { } if Environment.get("TOKEN_ENCRYPTION_KEY") != nil || Environment.get("JWT_SIGNING_KEY") != nil { - app.tokenEncryption = try TokenEncryption.load(from: app.environment) + app.tokenEncryption = try TokenEncryption.load() } else { app.tokenEncryption = TokenEncryption( key: .init(data: Array("insecure-development-encryption-".utf8)) diff --git a/backend/Tests/backendTests/AuthTests.swift b/backend/Tests/backendTests/AuthTests.swift index 5188a6c..c39ce9f 100644 --- a/backend/Tests/backendTests/AuthTests.swift +++ b/backend/Tests/backendTests/AuthTests.swift @@ -37,6 +37,54 @@ struct CBORTests { try CBOR.decode(Data([0x42, 0x01])) // bytes(2) with only 1 byte present } } + + @Test("Rejects an array header declaring 2^64-1 elements instead of crashing") + func hugeArrayCount() { + #expect(throws: CBOR.DecodingError.truncated) { + try CBOR.decode(Data([0x9B] + [UInt8](repeating: 0xFF, count: 8))) + } + } + + @Test("Rejects a map header declaring 2^64-1 entries instead of crashing") + func hugeMapCount() { + #expect(throws: CBOR.DecodingError.truncated) { + try CBOR.decode(Data([0xBB] + [UInt8](repeating: 0xFF, count: 8))) + } + } + + @Test("Rejects a huge-but-representable array count exceeding the input size") + func oversizedArrayCount() { + // array(1_000_000) with no elements following + #expect(throws: CBOR.DecodingError.truncated) { + try CBOR.decode(Data([0x9A, 0x00, 0x0F, 0x42, 0x40])) + } + } + + @Test("Rejects nesting deeper than the limit instead of overflowing the stack") + func deepNesting() { + // 1000 nested single-element arrays + #expect(throws: CBOR.DecodingError.nestingTooDeep) { + try CBOR.decode(Data([UInt8](repeating: 0x81, count: 1000))) + } + } + + @Test("Accepts nesting within the limit") + func shallowNesting() throws { + // 10 nested arrays around unsigned(0) + let value = try CBOR.decode(Data([UInt8](repeating: 0x81, count: 10) + [0x00])) + var current = value + for _ in 0..<10 { + current = try #require(current.arrayValue?.first) + } + #expect(current.unsignedValue == 0) + } + + @Test("Rejects trailing bytes after a complete item") + func trailingBytes() { + #expect(throws: CBOR.DecodingError.trailingBytes) { + try CBOR.decode(Data([0x00, 0x00])) + } + } } @Suite("Token service") @@ -237,4 +285,15 @@ struct ChallengeStoreTests { let store = ChallengeStore() #expect(await store.consume(Data([1, 2, 3])) == false) } + + @Test("The store is bounded: issuing beyond capacity evicts instead of growing") + func bounded() async { + let store = ChallengeStore(maxEntries: 3) + for _ in 0..<3 { + _ = await store.issue() + } + let latest = await store.issue() + #expect(await store.count == 3) + #expect(await store.consume(latest)) + } } diff --git a/backend/Tests/backendTests/RotationIntegrationTests.swift b/backend/Tests/backendTests/RotationIntegrationTests.swift new file mode 100644 index 0000000..7e8c048 --- /dev/null +++ b/backend/Tests/backendTests/RotationIntegrationTests.swift @@ -0,0 +1,158 @@ +import CocoaHeadsCore +import Fluent +import Foundation +import NIOConcurrencyHelpers +import Testing +import VaporTesting + +@testable import backend + +/// DB-backed rotation tests. Enabled only when `TEST_DATABASE` is set β€” run a +/// Postgres locally first, e.g.: +/// ``` +/// docker compose up -d db # in backend/ +/// TEST_DATABASE=1 swift test +/// ``` +/// (Point `DATABASE_HOST`/`DATABASE_PORT` at the instance if not localhost:5432.) +@Suite( + "Refresh token rotation (Postgres)", + .serialized, + .enabled(if: Environment.get("TEST_DATABASE") != nil) +) +struct RotationIntegrationTests { + private func withApp(_ test: (Application) async throws -> Void) async throws { + let app = try await Application.make(.testing) + do { + try await configure(app) + // configure(_:) read auth settings from the process environment; force + // the values these tests need. + app.authConfiguration = AuthConfiguration( + appleBundleID: "com.cocoaheadsbr.conf", + apiKeys: ["test-key"], + accessTokenTTL: 900, + refreshTokenTTL: 3600, + accountPurgeGraceDays: 30, + appleTeamID: nil, + appleSignInKeyID: nil, + appleSignInPrivateKey: nil, + appAttestTeamID: nil, + appAttestEnvironment: .production, + appAttestDisabled: true + ) + try await app.autoMigrate() + try await test(app) + try await app.autoRevert() + } catch { + try? await app.autoRevert() + try? await app.asyncShutdown() + throw error + } + try await app.asyncShutdown() + } + + private func makeUser(on db: any Database) async throws -> User { + let user = User(appleUserIdentifier: "rotation-test-\(UUID().uuidString)") + try await user.save(on: db) + return user + } + + private func storeRefreshToken(for user: User, on db: any Database) async throws -> String { + let raw = TokenService.generateRefreshToken() + try await RefreshToken( + userID: try user.requireID(), + tokenHash: TokenService.hash(raw), + expiresAt: Date().addingTimeInterval(3600) + ).save(on: db) + return raw + } + + private func refresh( + _ app: Application, + token: String, + afterResponse: @escaping @Sendable (TestingHTTPResponse) async throws -> Void + ) async throws { + try await app.testing().test( + .POST, "auth/refresh", + headers: ["X-API-Key": "test-key"], + beforeRequest: { req in + try req.content.encode(RefreshRequest(refreshToken: token)) + }, + afterResponse: afterResponse + ) + } + + @Test("Rotation issues a new pair and revokes the presented token") + func successfulRotation() async throws { + try await withApp { app in + let user = try await makeUser(on: app.db) + let raw = try await storeRefreshToken(for: user, on: app.db) + + try await refresh(app, token: raw) { res in + #expect(res.status == .ok) + let pair = try res.content.decode(TokenResponse.self) + #expect(pair.user.id == user.id) + #expect(pair.refreshToken != raw) + } + + let original = try await RefreshToken.query(on: app.db) + .filter(\.$tokenHash == TokenService.hash(raw)) + .first() + #expect(original?.revoked == true) + } + } + + @Test("Reusing a rotated token revokes the entire token family") + func reuseRevokesFamily() async throws { + try await withApp { app in + let user = try await makeUser(on: app.db) + let rawA = try await storeRefreshToken(for: user, on: app.db) + + let rawB = NIOLockedValueBox(nil) + try await refresh(app, token: rawA) { res in + #expect(res.status == .ok) + let refreshToken = try res.content.decode(TokenResponse.self).refreshToken + rawB.withLockedValue { $0 = refreshToken } + } + let successor = try #require(rawB.withLockedValue { $0 }) + + // Presenting the consumed token again is treated as theft… + try await refresh(app, token: rawA) { res in + #expect(res.status == .unauthorized) + } + + // …so every token of the user is revoked, including the successor. + let survivors = try await RefreshToken.query(on: app.db) + .filter(\.$user.$id == user.requireID()) + .filter(\.$revoked == false) + .count() + #expect(survivors == 0) + + try await refresh(app, token: successor) { res in + #expect(res.status == .unauthorized) + } + } + } + + @Test("Expired tokens are rejected and swept by the purge job") + func expiredToken() async throws { + try await withApp { app in + let user = try await makeUser(on: app.db) + let raw = TokenService.generateRefreshToken() + try await RefreshToken( + userID: try user.requireID(), + tokenHash: TokenService.hash(raw), + expiresAt: Date().addingTimeInterval(-60) + ).save(on: app.db) + + try await refresh(app, token: raw) { res in + #expect(res.status == .unauthorized) + } + + await AccountPurgeService.purge(on: app) + let remaining = try await RefreshToken.query(on: app.db) + .filter(\.$tokenHash == TokenService.hash(raw)) + .count() + #expect(remaining == 0) + } + } +}