From 599df5a66ef6fe82cdd2392f4f1981e0ae6a3101 Mon Sep 17 00:00:00 2001 From: Coen ten Thije Boonkkamp Date: Thu, 30 Jul 2026 00:40:22 +0200 Subject: [PATCH 1/4] Reconcile authentication cleanup with current main --- .gitignore | 3 + .swift-format | 49 ++- .../Auditing/Identity.Audit.Record.swift | 2 +- .../Identity+AuthenticationQueries.swift | 15 +- ...dentity.Authentication.ApiKey.Record.swift | 10 +- .../Identity.Authentication.Response.swift | 6 +- .../Identity.Backend.Client.Create.live.swift | 2 +- .../Deletion/Identity.Deletion.swift | 5 +- .../Identity.Email.Change.Client.live.swift | 348 +++++++++--------- ....Email.Change.Request.Record+Queries.swift | 11 +- ...Identity.Email.Change.Request.Record.swift | 6 +- .../EnvironmentVariables.swift | 2 +- .../Identity Backend/Identity+Queries.swift | 4 +- .../Identity.Backend.Configuration.swift | 13 +- .../Identity.Backend.Dependencies.swift | 12 +- .../Identity.Backend.Migrator.swift | 58 +-- .../Identity Backend/Identity.Record.swift | 8 +- .../Identity Backend/Identity.Token+JWT.swift | 24 +- .../Identity.MFA.BackupCodes.Record.swift | 10 +- .../MFA/Identity.MFA.Configuration.swift | 8 +- .../Identity.MFA.TOTP.Configuration.swift | 74 ++-- .../MFA/TOTP/Identity.MFA.TOTP.Record.swift | 9 +- .../OAuth/Identity.OAuth.Client.live.swift | 86 +++-- .../OAuth/Identity.OAuth.Configuration.swift | 3 +- .../Identity.OAuth.Connection.Record.swift | 2 +- .../OAuth/Identity.OAuth.State.Record.swift | 2 +- .../Identity.Password.Reset.Client.live.swift | 2 +- .../Password/PasswordHasher+Vapor.swift | 1 + .../Password/PasswordHasher.swift | 4 +- .../Profile/Identity.Profile.Record.swift | 2 +- .../Token/Identity.Token.Record.swift | 12 +- ...y.Consumer.API.Authenticate.response.swift | 1 + ...ty.Consumer.Client.Authenticate.live.swift | 2 +- Sources/Identity Consumer/Cookies.swift | 2 +- ...dentity.Consumer.API.Create.response.swift | 1 + ...Identity.Consumer.Client.Create.live.swift | 2 +- ...dentity.Consumer.API.Delete.response.swift | 1 + ...Identity.Consumer.Client.Delete.live.swift | 2 +- ...Identity.Consumer.API.Email.response.swift | 1 + .../Identity.Consumer.Client.Email.live.swift | 2 +- .../Identity.Consumer.Client.live.swift | 4 +- .../Identity.Consumer.Configuration.swift | 61 +-- .../Identity.Consumer.Middleware.swift | 6 +- ...Identity.Consumer.TokenAuthenticator.swift | 5 +- ...ntity.Consumer.API.Password.response.swift | 1 + ...entity.Consumer.Client.Password.live.swift | 2 +- ...r.API.Router.configureAuthentication.swift | 6 +- .../Identity.Consumer.API.response.swift | 1 + .../Identity.Consumer.Route.response.swift | 1 + .../Identity.Consumer.View.response.swift | 1 + .../API/Identity.Frontend.API.response.swift | 9 +- Sources/Identity Frontend/HTTPCookies.swift | 24 +- ...ntity.Frontend.Configuration.Cookies.swift | 2 +- .../Identity.Frontend.Configuration.swift | 3 +- Sources/Identity Frontend/Response.swift | 7 +- .../Identity.Authentication.response.swift | 3 +- .../Response/Identity.Creation.response.swift | 3 +- .../Response/Identity.Deletion.response.swift | 3 +- .../Response/Identity.Email.response.swift | 3 +- .../Identity.Frontend.View.Utilities.swift | 86 +++-- .../Response/Identity.Logout.response.swift | 5 +- .../Response/Identity.OAuth.response.swift | 3 +- .../Response/Identity.Password.response.swift | 3 +- Sources/Identity Provider/Cookies.swift | 2 +- ...dentity.Provider.API.Create.response.swift | 1 + ...dentity.Provider.API.Delete.response.swift | 1 + ...Identity.Provider.API.Email.response.swift | 1 + .../Identity.Provider.Configuration.swift | 4 +- ...ntity.Provider.API.Password.response.swift | 1 + .../Identity.Provider.API.response.swift | 1 + .../Identity.API+SecurityRequirements.swift | 2 +- .../Identity.API.rateLimit.swift | 9 +- .../Identity.Token.Access.swift | 2 +- .../Identity.Token.Client.swift | 3 +- Sources/Identity Shared/RateLimiters.swift | 2 +- ...ntity.Standalone.Client.Profile.live.swift | 4 +- .../Identity.Standalone.Client.Profile.swift | 4 +- .../Identity+DependencyKey.swift | 6 +- .../Identity.Standalone.Configuration.swift | 2 +- ....Standalone.CredentialsAuthenticator.swift | 2 +- ...entity.Standalone.TokenAuthenticator.swift | 4 +- .../Identity.API.Profile.response.swift | 2 +- .../Response/Identity.API.response.swift | 8 +- .../Response/Identity.Route.response.swift | 4 +- Sources/Identity Views/exports.swift | 3 +- .../Identity API Router Parity Tests.swift | 134 +++---- .../Identity Behavioral Tests.swift | 63 ++++ .../Identity Route Router Parity Tests.swift | 72 ++-- .../Authentication/AuthenticationTests.swift | 2 +- .../Database/DatabaseOperationsTests.swift | 2 +- .../EnvironmentVariables+Development.swift | 2 +- .../Utilities/TestFixtures.swift | 6 +- .../IdentityProviderAPITests.swift | 2 +- .../PlaceholderTests.swift | 6 +- Tests/TEST_PATTERNS.md | 4 +- 95 files changed, 779 insertions(+), 631 deletions(-) create mode 100644 Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift diff --git a/.gitignore b/.gitignore index f437869..a2518b4 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,6 @@ CLAUDE.MD test_output.txt *_output.txt Packages/ + +# SwiftLint Remote Config Cache +.swiftlint/RemoteConfigCache diff --git a/.swift-format b/.swift-format index 63d7258..7846331 100644 --- a/.swift-format +++ b/.swift-format @@ -1,10 +1,55 @@ { "version": 1, - "lineLength": 100, + "lineLength": 200, "indentation": { "spaces": 4 }, + "maximumBlankLines": 1, "respectsExistingLineBreaks": true, "lineBreakBeforeControlFlowKeywords": false, - "lineBreakBeforeEachArgument": true + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": true, + "prioritizeKeepingFunctionOutputTogether": true, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "spacesAroundRangeFormationOperators": false, + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": false, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoBlockComments": false, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoVoidReturnOnFunctionSignature": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": false, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": false, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + } } diff --git a/Sources/Identity Backend/Auditing/Identity.Audit.Record.swift b/Sources/Identity Backend/Auditing/Identity.Audit.Record.swift index 7d8eb62..d159745 100644 --- a/Sources/Identity Backend/Auditing/Identity.Audit.Record.swift +++ b/Sources/Identity Backend/Auditing/Identity.Audit.Record.swift @@ -1,6 +1,6 @@ import Foundation -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity { /// Audit log for tracking changes to identity-related tables diff --git a/Sources/Identity Backend/Authentication/Identity+AuthenticationQueries.swift b/Sources/Identity Backend/Authentication/Identity+AuthenticationQueries.swift index 6bdffd2..c13189d 100644 --- a/Sources/Identity Backend/Authentication/Identity+AuthenticationQueries.swift +++ b/Sources/Identity Backend/Authentication/Identity+AuthenticationQueries.swift @@ -1,8 +1,8 @@ import Dependencies import EmailAddress import Foundation -import Records private import PostgreSQL_Standard_Macros +import Records // MARK: - Selection Types for Combined Queries @@ -26,7 +26,9 @@ extension Identity.Record { /// Single query to get identity with TOTP status for authentication /// Replaces: findByEmail + separate TOTP check - package static func findForAuthentication(email: EmailAddress) async throws + package static func findForAuthentication( + email: EmailAddress + ) async throws -> AuthenticationData? { @Dependency(\.defaultDatabase) var db @@ -50,7 +52,9 @@ extension Identity.Record { /// Single query to get identity with full MFA status /// Useful for MFA management endpoints - package static func findWithMFAStatus(identityId: Identity.ID) async throws + package static func findWithMFAStatus( + identityId: Identity.ID + ) async throws -> IdentityWithMFAStatus? { @Dependency(\.defaultDatabase) var db @@ -103,7 +107,10 @@ extension Identity.Record { /// /// SECURITY: This function prevents timing attacks by always running bcrypt, /// even when the email doesn't exist. This prevents email enumeration. - package static func verifyPasswordOptimized(email: EmailAddress, password: String) async throws + package static func verifyPasswordOptimized( + email: EmailAddress, + password: String + ) async throws -> AuthenticationData? { @Dependency(\.defaultDatabase) var db diff --git a/Sources/Identity Backend/Authentication/Identity.Authentication.ApiKey.Record.swift b/Sources/Identity Backend/Authentication/Identity.Authentication.ApiKey.Record.swift index bf1f493..423d811 100644 --- a/Sources/Identity Backend/Authentication/Identity.Authentication.ApiKey.Record.swift +++ b/Sources/Identity Backend/Authentication/Identity.Authentication.ApiKey.Record.swift @@ -1,8 +1,8 @@ import Dependencies import Foundation import IdentitiesTypes -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity.Authentication { public enum ApiKey {} @@ -94,7 +94,9 @@ extension Identity.Authentication.ApiKey.Record { Self.where { $0.key.eq(key) } } - package static func findByIdentity(_ identityId: Identity.ID) -> Where< + package static func findByIdentity( + _ identityId: Identity.ID + ) -> Where< Identity.Authentication.ApiKey.Record > { Self.where { $0.identityId.eq(identityId) } @@ -191,7 +193,9 @@ extension Identity.Authentication.ApiKey.Record { } /// Batch deactivate API keys - package static func deactivateMultiple(ids: [Identity.Authentication.ApiKey.Record.ID]) + package static func deactivateMultiple( + ids: [Identity.Authentication.ApiKey.Record.ID] + ) async throws { @Dependency(\.defaultDatabase) var db diff --git a/Sources/Identity Backend/Authentication/Identity.Authentication.Response.swift b/Sources/Identity Backend/Authentication/Identity.Authentication.Response.swift index cd647b9..28a1502 100644 --- a/Sources/Identity Backend/Authentication/Identity.Authentication.Response.swift +++ b/Sources/Identity Backend/Authentication/Identity.Authentication.Response.swift @@ -8,10 +8,8 @@ import Server extension Identity.Authentication.Response { - /** - * Creates an authentication response with access and refresh tokens - * for the given identity - */ + /// Creates an authentication response with access and refresh tokens + /// for the given identity package init(_ identity: Identity.Record) async throws { @Dependency(\.tokenClient) var tokenClient diff --git a/Sources/Identity Backend/Creation/Identity.Backend.Client.Create.live.swift b/Sources/Identity Backend/Creation/Identity.Backend.Client.Create.live.swift index f39d7ab..d70a1d9 100644 --- a/Sources/Identity Backend/Creation/Identity.Backend.Client.Create.live.swift +++ b/Sources/Identity Backend/Creation/Identity.Backend.Client.Create.live.swift @@ -12,8 +12,8 @@ import IdentitiesTypes import Logger_Dependencies import Logging import PasswordValidation -import Records private import PostgreSQL_Standard_Macros +import Records import Server @Selection diff --git a/Sources/Identity Backend/Deletion/Identity.Deletion.swift b/Sources/Identity Backend/Deletion/Identity.Deletion.swift index e14fc20..b448421 100644 --- a/Sources/Identity Backend/Deletion/Identity.Deletion.swift +++ b/Sources/Identity Backend/Deletion/Identity.Deletion.swift @@ -1,7 +1,7 @@ import Dependencies import Foundation -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity.Deletion { @Table("identity_deletions") @@ -59,8 +59,7 @@ extension Identity.Deletion.Record.Draft { extension Identity.Deletion.Record { // No change needed - public static func findByIdentity(_ identityId: Identity.ID) -> Where - { + public static func findByIdentity(_ identityId: Identity.ID) -> Where { Self.where { $0.identityId.eq(identityId) } } diff --git a/Sources/Identity Backend/Email/Identity.Email.Change.Client.live.swift b/Sources/Identity Backend/Email/Identity.Email.Change.Client.live.swift index 8e7c969..acebaea 100644 --- a/Sources/Identity Backend/Email/Identity.Email.Change.Client.live.swift +++ b/Sources/Identity Backend/Email/Identity.Email.Change.Client.live.swift @@ -29,215 +29,213 @@ extension Identity.Email.Change.Client { _ currentEmail: EmailAddress, _ newEmail: EmailAddress ) async throws -> Void ) -> Self { - let requestHandler: - @Sendable (String) async throws -> Identity.Email.Change.Request.Result = { - newEmail in - @Dependency(\.logger) var logger - @Dependency(\.tokenClient) var tokenClient + let requestHandler: @Sendable (String) async throws -> Identity.Email.Change.Request.Result = { + newEmail in + @Dependency(\.logger) var logger + @Dependency(\.tokenClient) var tokenClient + do { + @Dependency(\.vapor.request) var request + guard let request else { throw Identity.Backend.Error.requestUnavailable } + + // Check for reauthorization token in headers or cookies + let token = + request.headers.reauthorizationToken?.token + ?? request.cookies["reauthorization_token"]?.string + + guard let token else { + return .requiresReauthentication + } + do { - @Dependency(\.vapor.request) var request - guard let request else { throw Identity.Backend.Error.requestUnavailable } + _ = try await tokenClient.verifyReauthorization(token) + } catch { + return .requiresReauthentication + } - // Check for reauthorization token in headers or cookies - let token = - request.headers.reauthorizationToken?.token - ?? request.cookies["reauthorization_token"]?.string + let identity = try await Identity.Record.get(by: .auth) + let newEmailAddress = try EmailAddress(newEmail) - guard let token else { - return .requiresReauthentication - } + @Dependency(\.defaultDatabase) var db - do { - _ = try await tokenClient.verifyReauthorization(token) - } catch { - return .requiresReauthentication + // Single transaction for EVERYTHING including email availability check + let tokenValue = try await db.write { db in + // 1. Check email availability INSIDE transaction (prevents race conditions) + let emailTaken = + try await Identity.Record + .where { $0.email.eq(newEmailAddress) } + .fetchCount(db) > 0 + + if emailTaken { + throw Identity.Authentication.ValidationError.invalidInput( + "Email address is already in use" + ) } - let identity = try await Identity.Record.get(by: .auth) - let newEmailAddress = try EmailAddress(newEmail) + // 2. Cancel any pending email change requests + try await Identity.Email.Change.Request.Record + .where { $0.identityId.eq(identity.id) } + .where { request in + request.confirmedAt == nil && request.cancelledAt == nil + } + .update { $0.cancelledAt = Date() } + .execute(db) - @Dependency(\.defaultDatabase) var db + // 3. Delete existing email change tokens + try await Identity.Token.Record + .delete() + .where { $0.identityId.eq(identity.id) } + .where { $0.type.eq(Identity.Token.Record.TokenType.emailChange) } + .execute(db) - // Single transaction for EVERYTHING including email availability check - let tokenValue = try await db.write { db in - // 1. Check email availability INSIDE transaction (prevents race conditions) - let emailTaken = - try await Identity.Record - .where { $0.email.eq(newEmailAddress) } - .fetchCount(db) > 0 + @Dependency(\.date) var date - if emailTaken { - throw Identity.Authentication.ValidationError.invalidInput( - "Email address is already in use" + let token = try await Identity.Token.Record + .insert { + Identity.Token.Record.Draft( + identityId: identity.id, + type: .emailChange, + validUntil: date().addingTimeInterval(86400) // 24 hours ) } + .returning(\.self) + .fetchOne(db) - // 2. Cancel any pending email change requests - try await Identity.Email.Change.Request.Record - .where { $0.identityId.eq(identity.id) } - .where { request in - request.confirmedAt == nil && request.cancelledAt == nil - } - .update { $0.cancelledAt = Date() } - .execute(db) - - // 3. Delete existing email change tokens - try await Identity.Token.Record - .delete() - .where { $0.identityId.eq(identity.id) } - .where { $0.type.eq(Identity.Token.Record.TokenType.emailChange) } - .execute(db) - - @Dependency(\.date) var date - - let token = try await Identity.Token.Record - .insert { - Identity.Token.Record.Draft( - identityId: identity.id, - type: .emailChange, - validUntil: date().addingTimeInterval(86400) // 24 hours - ) - } - .returning(\.self) - .fetchOne(db) - - guard let token else { - throw Identity.Backend.Error.failedToCreateToken(type: .emailChange) - } - - // Use UPSERT to handle multiple change requests gracefully - // This ensures only one pending email change request per identity - try await Identity.Email.Change.Request.Record - .upsert { - Identity.Email.Change.Request.Record.Draft( - identityId: identity.id, - newEmail: newEmailAddress, - verificationToken: token.value, // Link to token! - requestedAt: date(), - expiresAt: date().addingTimeInterval(86400), // 24 hours - confirmedAt: nil, - cancelledAt: nil - ) - } - .execute(db) - - return token.value + guard let token else { + throw Identity.Backend.Error.failedToCreateToken(type: .emailChange) } - Task { @Sendable in - try await sendEmailChangeConfirmation( - identity.email, - newEmailAddress, - tokenValue - ) - - logger.debug( - "Email change confirmation sent", - metadata: [ - "component": "Backend.Email", - "operation": "changeRequest", - "identityId": "\(identity.id)", - ] - ) - } + // Use UPSERT to handle multiple change requests gracefully + // This ensures only one pending email change request per identity + try await Identity.Email.Change.Request.Record + .upsert { + Identity.Email.Change.Request.Record.Draft( + identityId: identity.id, + newEmail: newEmailAddress, + verificationToken: token.value, // Link to token! + requestedAt: date(), + expiresAt: date().addingTimeInterval(86400), // 24 hours + confirmedAt: nil, + cancelledAt: nil + ) + } + .execute(db) - Task { @Sendable in - try await sendEmailChangeRequestNotification( - identity.email, - newEmailAddress - ) + return token.value + } - logger.debug( - "Email change notification sent", - metadata: [ - "component": "Backend.Email", - "operation": "changeNotification", - "identityId": "\(identity.id)", - ] - ) - } + Task { @Sendable in + try await sendEmailChangeConfirmation( + identity.email, + newEmailAddress, + tokenValue + ) - return .success - } catch { - logger.error( - "Email change request failed", + logger.debug( + "Email change confirmation sent", metadata: [ "component": "Backend.Email", "operation": "changeRequest", - "error": "\(error)", + "identityId": "\(identity.id)", ] ) - throw error } - } - - let confirmHandler: - @Sendable (String) async throws -> Identity.Email.Change.Confirmation.Response = { - token in - @Dependency(\.logger) var logger - @Dependency(\.tokenClient) var tokenClient - do { - @Dependency(\.defaultDatabase) var db - @Dependency(\.date) var date - - // Single transaction with JOIN for optimal performance - let result = try await performEmailChangeConfirmation( - token: token, - db: db, - date: date(), - onEmailChangeSuccess: onEmailChangeSuccess + Task { @Sendable in + try await sendEmailChangeRequestNotification( + identity.email, + newEmailAddress ) - logger.notice( - "Email change completed", + logger.debug( + "Email change notification sent", metadata: [ "component": "Backend.Email", - "operation": "changeConfirm", - "identityId": "\(result.identity.id)", - "oldEmailDomain": "\(result.oldEmail.domain)", - "newEmailDomain": "\(result.newEmail.domain)", + "operation": "changeNotification", + "identityId": "\(identity.id)", ] ) + } - Task { @Sendable in - do { - try await onEmailChangeSuccess(result.oldEmail, result.newEmail) - } catch { - logger.error( - "Post-email change operation failed", - metadata: [ - "component": "Backend.Email", - "operation": "postChangeCallback", - "error": "\(error)", - ] - ) - } - } - - // Generate new tokens with updated session version - let (accessToken, refreshToken) = try await tokenClient.generateTokenPair( - result.identity.id, - result.newEmail, - result.newSessionVersion - ) + return .success + } catch { + logger.error( + "Email change request failed", + metadata: [ + "component": "Backend.Email", + "operation": "changeRequest", + "error": "\(error)", + ] + ) + throw error + } + } - return Identity.Authentication.Response( - accessToken: accessToken, - refreshToken: refreshToken - ) - } catch { - logger.error( - "Email change confirm failed", - metadata: [ - "component": "Backend.Email", - "operation": "changeConfirm", - "error": "\(error)", - ] - ) - throw error + let confirmHandler: @Sendable (String) async throws -> Identity.Email.Change.Confirmation.Response = { + token in + @Dependency(\.logger) var logger + @Dependency(\.tokenClient) var tokenClient + + do { + @Dependency(\.defaultDatabase) var db + @Dependency(\.date) var date + + // Single transaction with JOIN for optimal performance + let result = try await performEmailChangeConfirmation( + token: token, + db: db, + date: date(), + onEmailChangeSuccess: onEmailChangeSuccess + ) + + logger.notice( + "Email change completed", + metadata: [ + "component": "Backend.Email", + "operation": "changeConfirm", + "identityId": "\(result.identity.id)", + "oldEmailDomain": "\(result.oldEmail.domain)", + "newEmailDomain": "\(result.newEmail.domain)", + ] + ) + + Task { @Sendable in + do { + try await onEmailChangeSuccess(result.oldEmail, result.newEmail) + } catch { + logger.error( + "Post-email change operation failed", + metadata: [ + "component": "Backend.Email", + "operation": "postChangeCallback", + "error": "\(error)", + ] + ) + } } + + // Generate new tokens with updated session version + let (accessToken, refreshToken) = try await tokenClient.generateTokenPair( + result.identity.id, + result.newEmail, + result.newSessionVersion + ) + + return Identity.Authentication.Response( + accessToken: accessToken, + refreshToken: refreshToken + ) + } catch { + logger.error( + "Email change confirm failed", + metadata: [ + "component": "Backend.Email", + "operation": "changeConfirm", + "error": "\(error)", + ] + ) + throw error } + } return .init( request: requestHandler, diff --git a/Sources/Identity Backend/Email/Identity.Email.Change.Request.Record+Queries.swift b/Sources/Identity Backend/Email/Identity.Email.Change.Request.Record+Queries.swift index f32c27e..bf48e06 100644 --- a/Sources/Identity Backend/Email/Identity.Email.Change.Request.Record+Queries.swift +++ b/Sources/Identity Backend/Email/Identity.Email.Change.Request.Record+Queries.swift @@ -16,18 +16,21 @@ extension Identity.Email.Change.Request.Record { // MARK: - Query Helpers extension Identity.Email.Change.Request.Record { - package static func findByToken(_ token: String) -> Where - { + package static func findByToken(_ token: String) -> Where { Self.where { $0.verificationToken.eq(token) } } - package static func findByIdentity(_ identityId: Identity.ID) -> Where< + package static func findByIdentity( + _ identityId: Identity.ID + ) -> Where< Identity.Email.Change.Request.Record > { Self.where { $0.identityId.eq(identityId) } } - package static func findByNewEmail(_ email: EmailAddress) -> Where< + package static func findByNewEmail( + _ email: EmailAddress + ) -> Where< Identity.Email.Change.Request.Record > { Self.where { $0.newEmail.eq(email) } diff --git a/Sources/Identity Backend/Email/Identity.Email.Change.Request.Record.swift b/Sources/Identity Backend/Email/Identity.Email.Change.Request.Record.swift index f5ba9bc..f0987e5 100644 --- a/Sources/Identity Backend/Email/Identity.Email.Change.Request.Record.swift +++ b/Sources/Identity Backend/Email/Identity.Email.Change.Request.Record.swift @@ -2,8 +2,8 @@ import Crypto import Dependencies import EmailAddress import Foundation -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity.Email.Change.Request { @Table("identity_email_change_requests") @@ -148,7 +148,9 @@ extension Identity.Email.Change.Request.Record { /// Find email change request by token with identity data /// Replaces: findByToken + separate identity lookup - package static func findByTokenWithIdentity(_ token: String) async throws + package static func findByTokenWithIdentity( + _ token: String + ) async throws -> EmailChangeRequestWithIdentity? { @Dependency(\.defaultDatabase) var db diff --git a/Sources/Identity Backend/EnvironmentVariables.swift b/Sources/Identity Backend/EnvironmentVariables.swift index e9cb817..cbaf9f9 100644 --- a/Sources/Identity Backend/EnvironmentVariables.swift +++ b/Sources/Identity Backend/EnvironmentVariables.swift @@ -1,6 +1,6 @@ import Dependencies -import Foundation import Environment_Dependencies +import Foundation extension EnvVars { /// Encryption key for sensitive data (TOTP secrets, OAuth tokens) diff --git a/Sources/Identity Backend/Identity+Queries.swift b/Sources/Identity Backend/Identity+Queries.swift index 710ad31..2a4d2cf 100644 --- a/Sources/Identity Backend/Identity+Queries.swift +++ b/Sources/Identity Backend/Identity+Queries.swift @@ -95,7 +95,9 @@ extension Identity.Record { /// /// - Performance: O(1) database query regardless of number of emails /// - Complexity: Single query: `SELECT email FROM identities WHERE email IN (...)` - package static func emailsExistOptimized(_ emails: [EmailAddress]) async throws -> Set< + package static func emailsExistOptimized( + _ emails: [EmailAddress] + ) async throws -> Set< EmailAddress > { @Dependency(\.defaultDatabase) var db diff --git a/Sources/Identity Backend/Identity.Backend.Configuration.swift b/Sources/Identity Backend/Identity.Backend.Configuration.swift index d50f12a..28bb615 100644 --- a/Sources/Identity Backend/Identity.Backend.Configuration.swift +++ b/Sources/Identity Backend/Identity.Backend.Configuration.swift @@ -21,7 +21,6 @@ extension Identity.Backend { public var jwt: Identity.Token.Client /// Router for generating authentication URLs - // public var router: Identity.Authentication.Route.Router public var router: any URLRouting.Router /// Email configuration with all email callbacks @@ -131,16 +130,12 @@ extension Identity.Backend.Configuration { public var sendVerificationEmail: @Sendable (EmailAddress, String) async throws -> Void public var sendPasswordResetEmail: @Sendable (EmailAddress, String) async throws -> Void public var sendPasswordChangeNotification: @Sendable (EmailAddress) async throws -> Void - public var sendEmailChangeConfirmation: - @Sendable (EmailAddress, EmailAddress, String) async throws -> Void - public var sendEmailChangeRequestNotification: - @Sendable (EmailAddress, EmailAddress) async throws -> Void + public var sendEmailChangeConfirmation: @Sendable (EmailAddress, EmailAddress, String) async throws -> Void + public var sendEmailChangeRequestNotification: @Sendable (EmailAddress, EmailAddress) async throws -> Void public var onEmailChangeSuccess: @Sendable (EmailAddress, EmailAddress) async throws -> Void public var sendDeletionRequestNotification: @Sendable (EmailAddress) async throws -> Void - public var sendDeletionConfirmationNotification: - @Sendable (EmailAddress) async throws -> Void - public var onIdentityCreationSuccess: - @Sendable ((id: Identity.ID, email: EmailAddress)) async throws -> Void + public var sendDeletionConfirmationNotification: @Sendable (EmailAddress) async throws -> Void + public var onIdentityCreationSuccess: @Sendable ((id: Identity.ID, email: EmailAddress)) async throws -> Void public init( sendVerificationEmail: @escaping @Sendable (EmailAddress, String) async throws -> Void, diff --git a/Sources/Identity Backend/Identity.Backend.Dependencies.swift b/Sources/Identity Backend/Identity.Backend.Dependencies.swift index dc3e323..f817d6b 100644 --- a/Sources/Identity Backend/Identity.Backend.Dependencies.swift +++ b/Sources/Identity Backend/Identity.Backend.Dependencies.swift @@ -12,28 +12,28 @@ import Records import Server_Vapor ///// Protocol providing common dependencies for Identity Backend operations -//package protocol IdentityBackendDependencies { +// package protocol IdentityBackendDependencies { // var database: any Database.Writer { get } // var logger: Logger { get } // var date: DateGenerator { get } -//} +// } // ///// Default implementation using Dependencies library -//package struct DefaultIdentityBackendDependencies: IdentityBackendDependencies { +// package struct DefaultIdentityBackendDependencies: IdentityBackendDependencies { // @Dependency(\.defaultDatabase) package var database // @Dependency(\.logger) package var logger // @Dependency(\.date) package var date // // package init() {} -//} +// } // ///// Extension for convenient access to common dependencies -//extension Identity.Backend { +// extension Identity.Backend { // /// Shared dependencies instance for reducing duplication // package static var dependencies: IdentityBackendDependencies { // DefaultIdentityBackendDependencies() // } -//} +// } /// Constants for magic numbers used throughout Identity Backend /// diff --git a/Sources/Identity Backend/Identity.Backend.Migrator.swift b/Sources/Identity Backend/Identity.Backend.Migrator.swift index 2034d51..a8c7085 100644 --- a/Sources/Identity Backend/Identity.Backend.Migrator.swift +++ b/Sources/Identity Backend/Identity.Backend.Migrator.swift @@ -157,14 +157,14 @@ extension Identity.Backend { try await db.execute( """ - CREATE INDEX IF NOT EXISTS identity_email_change_requests_token_idx + CREATE INDEX IF NOT EXISTS identity_email_change_requests_token_idx ON identity_email_change_requests("verificationToken") """ ) try await db.execute( """ - CREATE INDEX IF NOT EXISTS identity_email_change_requests_identityId_idx + CREATE INDEX IF NOT EXISTS identity_email_change_requests_identityId_idx ON identity_email_change_requests("identityId") """ ) @@ -172,8 +172,8 @@ extension Identity.Backend { // Add partial unique index to ensure only one pending email change per identity try await db.execute( """ - CREATE UNIQUE INDEX IF NOT EXISTS identity_email_change_requests_identityId_pending_idx - ON identity_email_change_requests("identityId") + CREATE UNIQUE INDEX IF NOT EXISTS identity_email_change_requests_identityId_pending_idx + ON identity_email_change_requests("identityId") WHERE "confirmedAt" IS NULL AND "cancelledAt" IS NULL """ ) @@ -244,15 +244,15 @@ extension Identity.Backend { try await db.execute( """ - CREATE INDEX IF NOT EXISTS identity_backup_codes_identityId_idx + CREATE INDEX IF NOT EXISTS identity_backup_codes_identityId_idx ON identity_backup_codes("identityId") """ ) try await db.execute( """ - CREATE INDEX IF NOT EXISTS identity_backup_codes_unused_idx - ON identity_backup_codes("identityId", "isUsed") + CREATE INDEX IF NOT EXISTS identity_backup_codes_unused_idx + ON identity_backup_codes("identityId", "isUsed") WHERE "isUsed" = false """ ) @@ -267,7 +267,7 @@ extension Identity.Backend { @Dependency(\.passwordHasher) var passwordHasher @Dependency(\.envVars) var envVars - let testEmail: EmailAddress = try! .init("test@test.com") + let testEmail: EmailAddress = EmailAddress(rawValue: "test@test.com")! let testPassword = "test" // Check if test user already exists @@ -391,7 +391,7 @@ extension Identity.Backend { // Composite index for authentication queries try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_identities_email_verification + CREATE INDEX IF NOT EXISTS idx_identities_email_verification ON identities(email, "emailVerificationStatus"); """ ) @@ -399,8 +399,8 @@ extension Identity.Backend { // Index for TOTP lookups - composite index for identity + confirmed status try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_identity_totp_confirmed - ON identity_totp("identityId", "isConfirmed") + CREATE INDEX IF NOT EXISTS idx_identity_totp_confirmed + ON identity_totp("identityId", "isConfirmed") WHERE "isConfirmed" = true; """ ) @@ -408,8 +408,8 @@ extension Identity.Backend { // Index for unused backup codes lookup try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_backup_codes_unused - ON identity_backup_codes("identityId", "isUsed") + CREATE INDEX IF NOT EXISTS idx_backup_codes_unused + ON identity_backup_codes("identityId", "isUsed") WHERE "isUsed" = false; """ ) @@ -417,8 +417,8 @@ extension Identity.Backend { // Index for active API keys try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_api_keys_active - ON identity_api_keys("identityId", "isActive") + CREATE INDEX IF NOT EXISTS idx_api_keys_active + ON identity_api_keys("identityId", "isActive") WHERE "isActive" = true; """ ) @@ -426,8 +426,8 @@ extension Identity.Backend { // Index for API key lookups by key hash try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_api_keys_hash - ON identity_api_keys(key) + CREATE INDEX IF NOT EXISTS idx_api_keys_hash + ON identity_api_keys(key) WHERE "isActive" = true; """ ) @@ -435,8 +435,8 @@ extension Identity.Backend { // Index for email change requests try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_email_change_requests_identity - ON identity_email_change_requests("identityId") + CREATE INDEX IF NOT EXISTS idx_email_change_requests_identity + ON identity_email_change_requests("identityId") WHERE "confirmedAt" IS NULL; """ ) @@ -444,7 +444,7 @@ extension Identity.Backend { // Index for profile lookups try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_profiles_identity + CREATE INDEX IF NOT EXISTS idx_profiles_identity ON identity_profiles("identityId"); """ ) @@ -452,8 +452,8 @@ extension Identity.Backend { // Index for deletion requests try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_deletions_identity_pending - ON identity_deletions("identityId") + CREATE INDEX IF NOT EXISTS idx_deletions_identity_pending + ON identity_deletions("identityId") WHERE "confirmedAt" IS NULL; """ ) @@ -480,8 +480,8 @@ extension Identity.Backend { // Partial index for verified email lookups (common in authentication) try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_identities_email_verified - ON identities(email) + CREATE INDEX IF NOT EXISTS idx_identities_email_verified + ON identities(email) WHERE "emailVerificationStatus" = 'verified' """ ) @@ -491,7 +491,7 @@ extension Identity.Backend { // because CURRENT_TIMESTAMP is not immutable for partial indexes try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_tokens_type_valid + CREATE INDEX IF NOT EXISTS idx_tokens_type_valid ON identity_tokens(value, type, "validUntil") """ ) @@ -499,7 +499,7 @@ extension Identity.Backend { // Composite index for OAuth connection lookups try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_oauth_connections_provider_identity + CREATE INDEX IF NOT EXISTS idx_oauth_connections_provider_identity ON oauth_connections(provider, identity_id) """ ) @@ -507,8 +507,8 @@ extension Identity.Backend { // Index for email change request token lookups try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_email_change_token - ON identity_email_change_requests("verificationToken") + CREATE INDEX IF NOT EXISTS idx_email_change_token + ON identity_email_change_requests("verificationToken") WHERE "confirmedAt" IS NULL """ ) @@ -518,7 +518,7 @@ extension Identity.Backend { // because CURRENT_TIMESTAMP is not immutable for partial indexes try await db.execute( """ - CREATE INDEX IF NOT EXISTS idx_tokens_identity_active + CREATE INDEX IF NOT EXISTS idx_tokens_identity_active ON identity_tokens("identityId", type, "validUntil") """ ) diff --git a/Sources/Identity Backend/Identity.Record.swift b/Sources/Identity Backend/Identity.Record.swift index 44772ca..15a6510 100644 --- a/Sources/Identity Backend/Identity.Record.swift +++ b/Sources/Identity Backend/Identity.Record.swift @@ -1,9 +1,9 @@ import Dependencies import EmailAddress +import Environment_Dependencies import Foundation -import Records private import PostgreSQL_Standard_Macros -import Environment_Dependencies +import Records extension Identity { @Table("identities") @@ -120,7 +120,9 @@ extension Identity.Record { } extension Identity.Record { - package static func update(from identity: Identity.Record) -> (inout Updates) + package static func update( + from identity: Identity.Record + ) -> (inout Updates) -> Void { diff --git a/Sources/Identity Backend/Identity.Token+JWT.swift b/Sources/Identity Backend/Identity.Token+JWT.swift index 1a1d1f5..be22665 100644 --- a/Sources/Identity Backend/Identity.Token+JWT.swift +++ b/Sources/Identity Backend/Identity.Token+JWT.swift @@ -12,12 +12,10 @@ import JWT import Server_Vapor extension Identity.Token.Access { - /** - * Creates an access token from an Identity - * - * IMPORTANT: The subject claim will contain both the identity ID and email - * in the format "ID:email" to ensure both pieces of information are preserved. - */ + /// Creates an access token from an Identity + /// + /// IMPORTANT: The subject claim will contain both the identity ID and email + /// in the format "ID:email" to ensure both pieces of information are preserved. package init(identity: Identity.Record) async throws { @Dependency(\.tokenClient) var tokenClient @@ -34,9 +32,7 @@ extension Identity.Token.Access { } extension Identity.Token.Refresh { - /** - * Creates a refresh token from an Identity - */ + /// Creates a refresh token from an Identity package init(identity: Identity.Record) async throws { @Dependency(\.tokenClient) var tokenClient @@ -52,12 +48,10 @@ extension Identity.Token.Refresh { } extension Identity.Token.Reauthorization { - /** - * Creates a reauthorization token from an Identity - * - * IMPORTANT: This now uses the same "ID:email" format for subject - * to be consistent with access tokens. - */ + /// Creates a reauthorization token from an Identity + /// + /// IMPORTANT: This now uses the same "ID:email" format for subject + /// to be consistent with access tokens. package init( identity: Identity.Record, purpose: String = "general", diff --git a/Sources/Identity Backend/MFA/BackupCodes/Identity.MFA.BackupCodes.Record.swift b/Sources/Identity Backend/MFA/BackupCodes/Identity.MFA.BackupCodes.Record.swift index dcc5c7f..4dd5b78 100644 --- a/Sources/Identity Backend/MFA/BackupCodes/Identity.MFA.BackupCodes.Record.swift +++ b/Sources/Identity Backend/MFA/BackupCodes/Identity.MFA.BackupCodes.Record.swift @@ -1,7 +1,7 @@ import Foundation import IdentitiesTypes -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity.MFA.BackupCodes { @Table("identity_backup_codes") @@ -37,7 +37,9 @@ extension Identity.MFA.BackupCodes.Record.Draft { // MARK: - Query Helpers extension Identity.MFA.BackupCodes.Record { - package static func findByIdentity(_ identityId: Identity.ID) -> Where< + package static func findByIdentity( + _ identityId: Identity.ID + ) -> Where< Identity.MFA.BackupCodes.Record > { Self.where { $0.identityId.eq(identityId) } @@ -51,7 +53,9 @@ extension Identity.MFA.BackupCodes.Record { Self.where { $0.isUsed.eq(true) } } - package static func findUnusedByIdentity(_ identityId: Identity.ID) -> Where< + package static func findUnusedByIdentity( + _ identityId: Identity.ID + ) -> Where< Identity.MFA.BackupCodes.Record > { Self.where { diff --git a/Sources/Identity Backend/MFA/Identity.MFA.Configuration.swift b/Sources/Identity Backend/MFA/Identity.MFA.Configuration.swift index fa0d687..ae89c3f 100644 --- a/Sources/Identity Backend/MFA/Identity.MFA.Configuration.swift +++ b/Sources/Identity Backend/MFA/Identity.MFA.Configuration.swift @@ -159,7 +159,7 @@ extension Identity.MFA { client: Identity.MFA.TOTP.Client.unimplemented(), // Default/empty client router: Identity.MFA.TOTP.API.Router().eraseToAnyParserPrinter() ), - sms: config.sms.map { smsConfig in + sms: config.sms.map { _ in Identity.MFA.SMS( client: Identity.MFA.SMS.Client.unimplemented(), // TODO: Add .live(configuration:) when implemented router: Identity.MFA.SMS.API.Router().eraseToAnyParserPrinter() @@ -169,7 +169,7 @@ extension Identity.MFA { client: Identity.MFA.SMS.Client.unimplemented(), // Default/empty client router: Identity.MFA.SMS.API.Router().eraseToAnyParserPrinter() ), - email: config.email.map { emailConfig in + email: config.email.map { _ in Identity.MFA.Email( client: Identity.MFA.Email.Client.unimplemented(), // TODO: Add .live(configuration:) when implemented router: Identity.MFA.Email.API.Router().eraseToAnyParserPrinter() @@ -179,7 +179,7 @@ extension Identity.MFA { client: Identity.MFA.Email.Client.unimplemented(), // Default/empty client router: Identity.MFA.Email.API.Router().eraseToAnyParserPrinter() ), - webauthn: config.webauthn.map { webauthnConfig in + webauthn: config.webauthn.map { _ in Identity.MFA.WebAuthn( client: Identity.MFA.WebAuthn.Client.unimplemented(), // TODO: Add .live(configuration:) when implemented router: Identity.MFA.WebAuthn.API.Router().eraseToAnyParserPrinter() @@ -189,7 +189,7 @@ extension Identity.MFA { client: Identity.MFA.WebAuthn.Client.unimplemented(), // Default/empty client router: Identity.MFA.WebAuthn.API.Router().eraseToAnyParserPrinter() ), - backupCodes: config.backupCodes.map { backupConfig in + backupCodes: config.backupCodes.map { _ in Identity.MFA.BackupCodes( client: Identity.MFA.BackupCodes.Client.unimplemented(), // TODO: Add .live(configuration:) when implemented router: Identity.MFA.BackupCodes.API.Router().eraseToAnyParserPrinter() diff --git a/Sources/Identity Backend/MFA/TOTP/Identity.MFA.TOTP.Configuration.swift b/Sources/Identity Backend/MFA/TOTP/Identity.MFA.TOTP.Configuration.swift index 4fc384c..8b29270 100644 --- a/Sources/Identity Backend/MFA/TOTP/Identity.MFA.TOTP.Configuration.swift +++ b/Sources/Identity Backend/MFA/TOTP/Identity.MFA.TOTP.Configuration.swift @@ -50,7 +50,7 @@ extension Identity.MFA.TOTP { backupCodeLength: Int = 8, backupCodeCount: Int = 10, qrCodeSize: Int? = 200 - ) throws { + ) throws(ConfigurationError) { // Validation guard !issuer.isEmpty else { throw ConfigurationError.invalidIssuer("Issuer cannot be empty") @@ -122,43 +122,55 @@ extension Identity.MFA.TOTP { extension Identity.MFA.TOTP.Configuration { /// Standard TOTP configuration compatible with most authenticator apps public static func standard(issuer: String) -> Self { - try! Self( - issuer: issuer, - algorithm: .sha1, - digits: 6, - timeStep: 30, - verificationWindow: 1, - backupCodeLength: 8, - backupCodeCount: 10, - qrCodeSize: 200 - ) + do throws(ConfigurationError) { + return try Self( + issuer: issuer, + algorithm: .sha1, + digits: 6, + timeStep: 30, + verificationWindow: 1, + backupCodeLength: 8, + backupCodeCount: 10, + qrCodeSize: 200 + ) + } catch { + preconditionFailure("Invalid standard TOTP configuration: \(error.localizedDescription)") + } } /// More secure configuration with SHA256 public static func secure(issuer: String) -> Self { - try! Self( - issuer: issuer, - algorithm: .sha256, - digits: 8, - timeStep: 30, - verificationWindow: 1, - backupCodeLength: 10, - backupCodeCount: 12, - qrCodeSize: 200 - ) + do throws(ConfigurationError) { + return try Self( + issuer: issuer, + algorithm: .sha256, + digits: 8, + timeStep: 30, + verificationWindow: 1, + backupCodeLength: 10, + backupCodeCount: 12, + qrCodeSize: 200 + ) + } catch { + preconditionFailure("Invalid secure TOTP configuration: \(error.localizedDescription)") + } } /// Test configuration with shorter periods public static var test: Self { - try! Self( - issuer: "Test", - algorithm: .sha1, - digits: 6, - timeStep: 10, // Shorter period for testing - verificationWindow: 2, // More lenient for tests - backupCodeLength: 6, - backupCodeCount: 5, - qrCodeSize: 100 - ) + do throws(ConfigurationError) { + return try Self( + issuer: "Test", + algorithm: .sha1, + digits: 6, + timeStep: 10, // Shorter period for testing + verificationWindow: 2, // More lenient for tests + backupCodeLength: 6, + backupCodeCount: 5, + qrCodeSize: 100 + ) + } catch { + preconditionFailure("Invalid test TOTP configuration: \(error.localizedDescription)") + } } } diff --git a/Sources/Identity Backend/MFA/TOTP/Identity.MFA.TOTP.Record.swift b/Sources/Identity Backend/MFA/TOTP/Identity.MFA.TOTP.Record.swift index 994651f..1b630ec 100644 --- a/Sources/Identity Backend/MFA/TOTP/Identity.MFA.TOTP.Record.swift +++ b/Sources/Identity Backend/MFA/TOTP/Identity.MFA.TOTP.Record.swift @@ -1,8 +1,8 @@ import Foundation import IdentitiesTypes +private import PostgreSQL_Standard_Macros import RFC_6238 import Records -private import PostgreSQL_Standard_Macros import TOTP extension Identity.MFA.TOTP { @@ -55,8 +55,7 @@ extension RFC_6238.TOTP.Algorithm: @retroactive QueryBindable {} // MARK: - Query Helpers extension Identity.MFA.TOTP.Record { - package static func findByIdentity(_ identityId: Identity.ID) -> Where - { + package static func findByIdentity(_ identityId: Identity.ID) -> Where { Self.where { $0.identityId.eq(identityId) } } @@ -68,7 +67,9 @@ extension Identity.MFA.TOTP.Record { Self.where { $0.isConfirmed.eq(false) } } - package static func findConfirmedByIdentity(_ identityId: Identity.ID) -> Where< + package static func findConfirmedByIdentity( + _ identityId: Identity.ID + ) -> Where< Identity.MFA.TOTP.Record > { Self.where { diff --git a/Sources/Identity Backend/OAuth/Identity.OAuth.Client.live.swift b/Sources/Identity Backend/OAuth/Identity.OAuth.Client.live.swift index eafe128..4e11894 100644 --- a/Sources/Identity Backend/OAuth/Identity.OAuth.Client.live.swift +++ b/Sources/Identity Backend/OAuth/Identity.OAuth.Client.live.swift @@ -288,33 +288,32 @@ private func prepareTokensForStorage( } // MARK: - Get Connection -private let connectionImplementation: - @Sendable (String) async throws -> Identity.OAuth.Connection? = { provider in - // Get current authenticated identity - @Dependency(\.defaultDatabase) var database - - do { - let identity = try await Identity.Record.get(by: .auth) +private let connectionImplementation: @Sendable (String) async throws -> Identity.OAuth.Connection? = { provider in + // Get current authenticated identity + @Dependency(\.defaultDatabase) var database - // Find connection for this provider - @Dependency(\.defaultDatabase) var db + do { + let identity = try await Identity.Record.get(by: .auth) - guard - let dbConnection = try await db.read({ db in - try await Identity.OAuth.Connection.Record - .findByIdentityProvider(identity.id, provider) - .fetchOne(db) - }) - else { - return nil - } + // Find connection for this provider + @Dependency(\.defaultDatabase) var db - return Identity.OAuth.Connection(from: dbConnection) - } catch { - // Not authenticated or no connection found + guard + let dbConnection = try await db.read({ db in + try await Identity.OAuth.Connection.Record + .findByIdentityProvider(identity.id, provider) + .fetchOne(db) + }) + else { return nil } + + return Identity.OAuth.Connection(from: dbConnection) + } catch { + // Not authenticated or no connection found + return nil } +} // MARK: - Disconnect Provider private let disconnectImplementation: @Sendable (String) async throws -> Void = { provider in @@ -523,34 +522,33 @@ private func getValidTokenImplementation( } // MARK: - Get All Connections -private let getAllConnectionsImplementation: - @Sendable () async throws -> [Identity.OAuth.Connection] = { - @Dependency(\.defaultDatabase) var database - @Dependency(\.logger) var logger +private let getAllConnectionsImplementation: @Sendable () async throws -> [Identity.OAuth.Connection] = { + @Dependency(\.defaultDatabase) var database + @Dependency(\.logger) var logger - do { - let identity = try await Identity.Record.get(by: .auth) + do { + let identity = try await Identity.Record.get(by: .auth) - // Get all connections for this identity - let dbConnections = try await database.read { db in - try await Identity.OAuth.Connection.Record - .findByIdentity(identity.id) - .fetchAll(db) - } + // Get all connections for this identity + let dbConnections = try await database.read { db in + try await Identity.OAuth.Connection.Record + .findByIdentity(identity.id) + .fetchAll(db) + } - return dbConnections.map { dbConnection in - Identity.OAuth.Connection(from: dbConnection) - } - } catch { - logger.error( - "Failed to get OAuth connections", - metadata: [ - "error": "\(error)" - ] - ) - return [] + return dbConnections.map { dbConnection in + Identity.OAuth.Connection(from: dbConnection) } + } catch { + logger.error( + "Failed to get OAuth connections", + metadata: [ + "error": "\(error)" + ] + ) + return [] } +} extension Identity.OAuth.Connection.Record.Draft { /// Convenience initializer that creates a Draft from OAuth response objects diff --git a/Sources/Identity Backend/OAuth/Identity.OAuth.Configuration.swift b/Sources/Identity Backend/OAuth/Identity.OAuth.Configuration.swift index a3d93f6..b32200e 100644 --- a/Sources/Identity Backend/OAuth/Identity.OAuth.Configuration.swift +++ b/Sources/Identity Backend/OAuth/Identity.OAuth.Configuration.swift @@ -42,8 +42,7 @@ extension Identity.OAuth { public var onOAuthSuccess: (@Sendable (Identity.OAuth.UserInfo) async throws -> Void)? /// Called when linking OAuth account to existing identity - public var onOAuthLink: - (@Sendable (Identity.ID, Identity.OAuth.Connection) async throws -> Void)? + public var onOAuthLink: (@Sendable (Identity.ID, Identity.OAuth.Connection) async throws -> Void)? /// Called when OAuth authentication fails public var onOAuthFailure: (@Sendable (Error) async throws -> Void)? diff --git a/Sources/Identity Backend/OAuth/Identity.OAuth.Connection.Record.swift b/Sources/Identity Backend/OAuth/Identity.OAuth.Connection.Record.swift index 467c359..542729e 100644 --- a/Sources/Identity Backend/OAuth/Identity.OAuth.Connection.Record.swift +++ b/Sources/Identity Backend/OAuth/Identity.OAuth.Connection.Record.swift @@ -8,8 +8,8 @@ import Dependencies import Foundation import IdentitiesTypes -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity.OAuth.Connection { @Table("oauth_connections") diff --git a/Sources/Identity Backend/OAuth/Identity.OAuth.State.Record.swift b/Sources/Identity Backend/OAuth/Identity.OAuth.State.Record.swift index 6330844..c23fd8c 100644 --- a/Sources/Identity Backend/OAuth/Identity.OAuth.State.Record.swift +++ b/Sources/Identity Backend/OAuth/Identity.OAuth.State.Record.swift @@ -8,8 +8,8 @@ import Dependencies import Foundation import IdentitiesTypes -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity.OAuth.State { @Table("oauth_states") diff --git a/Sources/Identity Backend/Password/Identity.Password.Reset.Client.live.swift b/Sources/Identity Backend/Password/Identity.Password.Reset.Client.live.swift index 64dc902..067752c 100644 --- a/Sources/Identity Backend/Password/Identity.Password.Reset.Client.live.swift +++ b/Sources/Identity Backend/Password/Identity.Password.Reset.Client.live.swift @@ -12,8 +12,8 @@ import IdentitiesTypes import Logger_Dependencies import Logging import PasswordValidation -import Records private import PostgreSQL_Standard_Macros +import Records import Server extension Identity.Password.Reset.Client { diff --git a/Sources/Identity Backend/Password/PasswordHasher+Vapor.swift b/Sources/Identity Backend/Password/PasswordHasher+Vapor.swift index 8f1fb89..4830dad 100644 --- a/Sources/Identity Backend/Password/PasswordHasher+Vapor.swift +++ b/Sources/Identity Backend/Password/PasswordHasher+Vapor.swift @@ -1,4 +1,5 @@ import Vapor + // // PasswordHasher+Vapor.swift // swift-identities diff --git a/Sources/Identity Backend/Password/PasswordHasher.swift b/Sources/Identity Backend/Password/PasswordHasher.swift index 46ad034..282e094 100644 --- a/Sources/Identity Backend/Password/PasswordHasher.swift +++ b/Sources/Identity Backend/Password/PasswordHasher.swift @@ -53,7 +53,7 @@ extension PasswordHasher: Dependency.Key.Test { #if !canImport(Vapor) extension PasswordHasher: Dependency.Key { public static let liveValue: PasswordHasher = Self( - hash: { password, cost in + hash: { _, _ in // When Vapor is not available, you must provide your own implementation // This could be Argon2, pure Swift bcrypt, or another hashing algorithm fatalError( @@ -65,7 +65,7 @@ extension PasswordHasher: Dependency.Key.Test { """ ) }, - verify: { password, hash in + verify: { _, _ in fatalError( """ PasswordHasher.liveValue not implemented without Vapor trait. diff --git a/Sources/Identity Backend/Profile/Identity.Profile.Record.swift b/Sources/Identity Backend/Profile/Identity.Profile.Record.swift index 0731437..46ae977 100644 --- a/Sources/Identity Backend/Profile/Identity.Profile.Record.swift +++ b/Sources/Identity Backend/Profile/Identity.Profile.Record.swift @@ -8,8 +8,8 @@ import Dependencies import Foundation import IdentitiesTypes -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity { public enum Profile {} diff --git a/Sources/Identity Backend/Token/Identity.Token.Record.swift b/Sources/Identity Backend/Token/Identity.Token.Record.swift index 5b669f5..3ea6ff9 100644 --- a/Sources/Identity Backend/Token/Identity.Token.Record.swift +++ b/Sources/Identity Backend/Token/Identity.Token.Record.swift @@ -2,8 +2,8 @@ import Crypto import Dependencies import Foundation import IdentitiesTypes -import Records private import PostgreSQL_Standard_Macros +import Records extension Identity.Token { @Table("identity_tokens") @@ -109,7 +109,10 @@ extension Identity.Token.Record { Self.where { $0.type.eq(type) } } - package static func findByIdentityAndType(_ identityId: Identity.ID, _ type: TokenType) + package static func findByIdentityAndType( + _ identityId: Identity.ID, + _ type: TokenType + ) -> Where< Identity.Token.Record > @@ -177,7 +180,10 @@ extension Identity.Token.Record { /// Validate token and get associated identity in single query /// Replaces: findValid + separate identity lookup - package static func validateWithIdentity(value: String, type: TokenType) async throws + package static func validateWithIdentity( + value: String, + type: TokenType + ) async throws -> TokenWithIdentity? { @Dependency(\.defaultDatabase) var db diff --git a/Sources/Identity Consumer/Authenticate/Identity.Consumer.API.Authenticate.response.swift b/Sources/Identity Consumer/Authenticate/Identity.Consumer.API.Authenticate.response.swift index ba3cc22..1ffa8a4 100644 --- a/Sources/Identity Consumer/Authenticate/Identity.Consumer.API.Authenticate.response.swift +++ b/Sources/Identity Consumer/Authenticate/Identity.Consumer.API.Authenticate.response.swift @@ -11,6 +11,7 @@ import Logger_Dependencies import Logging import Server_Vapor import Vapor + import enum Server.Server extension Identity.Authentication.API { diff --git a/Sources/Identity Consumer/Authenticate/Identity.Consumer.Client.Authenticate.live.swift b/Sources/Identity Consumer/Authenticate/Identity.Consumer.Client.Authenticate.live.swift index 28bfecc..e2c4ed7 100644 --- a/Sources/Identity Consumer/Authenticate/Identity.Consumer.Client.Authenticate.live.swift +++ b/Sources/Identity Consumer/Authenticate/Identity.Consumer.Client.Authenticate.live.swift @@ -8,7 +8,6 @@ import Dependencies import EmailAddress import Foundation -import URLRequestHandler import IdentitiesTypes import Identity_Shared import JWT @@ -16,6 +15,7 @@ import Logger_Dependencies import Logging import Server_Vapor import Throttling +import URLRequestHandler import Vapor extension Identity.Authentication.Client { diff --git a/Sources/Identity Consumer/Cookies.swift b/Sources/Identity Consumer/Cookies.swift index 2e6a16f..1644f84 100644 --- a/Sources/Identity Consumer/Cookies.swift +++ b/Sources/Identity Consumer/Cookies.swift @@ -6,12 +6,12 @@ // import Foundation +import HTTP_Cookies import IdentitiesTypes import Identity_Frontend import Identity_Shared import Server_Vapor import URLRouting -import HTTP_Cookies extension Identity.Frontend.Configuration.Cookies { /// Configuration for Consumer applications that connect to an Identity Provider. diff --git a/Sources/Identity Consumer/Create/Identity.Consumer.API.Create.response.swift b/Sources/Identity Consumer/Create/Identity.Consumer.API.Create.response.swift index 833a283..c0ad938 100644 --- a/Sources/Identity Consumer/Create/Identity.Consumer.API.Create.response.swift +++ b/Sources/Identity Consumer/Create/Identity.Consumer.API.Create.response.swift @@ -9,6 +9,7 @@ import Dependencies import IdentitiesTypes import Server_Vapor import Vapor + import enum Server.Server extension Identity.Creation.API { diff --git a/Sources/Identity Consumer/Create/Identity.Consumer.Client.Create.live.swift b/Sources/Identity Consumer/Create/Identity.Consumer.Client.Create.live.swift index bae08db..54a1658 100644 --- a/Sources/Identity Consumer/Create/Identity.Consumer.Client.Create.live.swift +++ b/Sources/Identity Consumer/Create/Identity.Consumer.Client.Create.live.swift @@ -8,12 +8,12 @@ import Dependencies import EmailAddress import Foundation -import URLRequestHandler import IdentitiesTypes import Identity_Shared import JWT import Server_Vapor import Throttling +import URLRequestHandler import Vapor extension Identity.Creation.Client { diff --git a/Sources/Identity Consumer/Delete/Identity.Consumer.API.Delete.response.swift b/Sources/Identity Consumer/Delete/Identity.Consumer.API.Delete.response.swift index 5c3757c..31f0d67 100644 --- a/Sources/Identity Consumer/Delete/Identity.Consumer.API.Delete.response.swift +++ b/Sources/Identity Consumer/Delete/Identity.Consumer.API.Delete.response.swift @@ -9,6 +9,7 @@ import Dependencies import IdentitiesTypes import Server_Vapor import Vapor + import enum Server.Server extension Identity.Deletion.API { diff --git a/Sources/Identity Consumer/Delete/Identity.Consumer.Client.Delete.live.swift b/Sources/Identity Consumer/Delete/Identity.Consumer.Client.Delete.live.swift index 0df8ce1..718ab8c 100644 --- a/Sources/Identity Consumer/Delete/Identity.Consumer.Client.Delete.live.swift +++ b/Sources/Identity Consumer/Delete/Identity.Consumer.Client.Delete.live.swift @@ -8,12 +8,12 @@ import Dependencies import EmailAddress import Foundation -import URLRequestHandler import IdentitiesTypes import Identity_Shared import JWT import Server_Vapor import Throttling +import URLRequestHandler import Vapor extension Identity.Deletion.Client { diff --git a/Sources/Identity Consumer/Email/Identity.Consumer.API.Email.response.swift b/Sources/Identity Consumer/Email/Identity.Consumer.API.Email.response.swift index 449fb63..60ef392 100644 --- a/Sources/Identity Consumer/Email/Identity.Consumer.API.Email.response.swift +++ b/Sources/Identity Consumer/Email/Identity.Consumer.API.Email.response.swift @@ -9,6 +9,7 @@ import Dependencies import IdentitiesTypes import Server_Vapor import Vapor + import enum Server.Server extension Identity.Email.API { diff --git a/Sources/Identity Consumer/Email/Identity.Consumer.Client.Email.live.swift b/Sources/Identity Consumer/Email/Identity.Consumer.Client.Email.live.swift index b80b8cb..befa09a 100644 --- a/Sources/Identity Consumer/Email/Identity.Consumer.Client.Email.live.swift +++ b/Sources/Identity Consumer/Email/Identity.Consumer.Client.Email.live.swift @@ -8,12 +8,12 @@ import Dependencies import EmailAddress import Foundation -import URLRequestHandler import IdentitiesTypes import Identity_Shared import JWT import Server_Vapor import Throttling +import URLRequestHandler import Vapor extension Identity.Email.Change.Client { diff --git a/Sources/Identity Consumer/Identity.Consumer.Client.live.swift b/Sources/Identity Consumer/Identity.Consumer.Client.live.swift index c02bb8a..bf48304 100644 --- a/Sources/Identity Consumer/Identity.Consumer.Client.live.swift +++ b/Sources/Identity Consumer/Identity.Consumer.Client.live.swift @@ -15,7 +15,7 @@ import Throttling // - Identity.Email.Client.live // - Identity.Password.Client.live -//extension Identity.Consumer.Client { +// extension Identity.Consumer.Client { // public static func live( // makeRequest: @escaping @Sendable (_ route: Identity.API) throws -> URLRequest // ) -> Self { @@ -48,4 +48,4 @@ import Throttling // password: .live { try makeRequest(.password($0)) } // ) // } -//} +// } diff --git a/Sources/Identity Consumer/Identity.Consumer.Configuration.swift b/Sources/Identity Consumer/Identity.Consumer.Configuration.swift index 985e914..5e9354f 100644 --- a/Sources/Identity Consumer/Identity.Consumer.Configuration.swift +++ b/Sources/Identity Consumer/Identity.Consumer.Configuration.swift @@ -8,17 +8,17 @@ import Dependencies import Foundation import HTML +import HTTP_Cookies import IdentitiesTypes import Identity_Frontend import Identity_Shared import Identity_Views import Server import Server_Vapor +import Throttling +import URI import URLRouting import Vapor -import HTTP_Cookies -import URI -import Throttling extension Identity.Consumer { public struct Configuration: Sendable { @@ -84,16 +84,23 @@ extension Identity.Consumer.Configuration { public init( baseURL: URL, - domain: String?, + domain: String? = nil, cookies: Identity.Frontend.Configuration.Cookies, router: AnyParserPrinter, currentUserName: @Sendable @escaping () -> String?, - canonicalHref: @Sendable @escaping (Identity.Consumer.View) -> URL?, - hreflang: @Sendable @escaping (Identity.Consumer.View, Translating.Language) -> URL, + canonicalHref: @Sendable @escaping (Identity.Consumer.View) -> URL? = { _ in + nil + }, + hreflang: @Sendable @escaping (Identity.Consumer.View, Translating.Language) -> URL = { + _, + _ in + @Dependency(\.identityConsumerConfiguration) var configuration + return configuration.consumer.baseURL + }, branding: Branding, navigation: Navigation, redirect: Identity.Consumer.Configuration.Redirect, - rateLimiters: RateLimiters + rateLimiters: RateLimiters = .init() ) { self.baseURL = baseURL self.domain = domain @@ -111,40 +118,12 @@ extension Identity.Consumer.Configuration { } extension Identity.Consumer.Configuration.Consumer { - public static func live( - baseURL: URL, - domain: String? = nil, - cookies: Identity.Frontend.Configuration.Cookies, - router: AnyParserPrinter, - currentUserName: @escaping @Sendable () -> String?, - canonicalHref: @escaping @Sendable (Identity.Consumer.View) -> URL? = { view in - // Return nil - canonical URLs should be set by the application - return nil - }, - hreflang: @escaping @Sendable (Identity.Consumer.View, Translating.Language) -> URL = { - view, - _ in - @Dependency(\.identityConsumerConfiguration) var config - return config.consumer.baseURL - }, - branding: Identity.Consumer.Configuration.Branding, - navigation: Identity.Consumer.Configuration.Navigation, - redirect: Identity.Consumer.Configuration.Redirect, - rateLimiters: RateLimiters = .init() - ) -> Self { - .init( - baseURL: baseURL, - domain: domain, - cookies: cookies, - router: router, - currentUserName: currentUserName, - canonicalHref: canonicalHref, - hreflang: hreflang, - branding: branding, - navigation: navigation, - redirect: redirect, - rateLimiters: rateLimiters - ) + /// The live configuration constructor. + /// + /// This metatype preserves the established `Consumer.live(...)` spelling + /// while routing construction through ``init(baseURL:domain:cookies:router:currentUserName:canonicalHref:hreflang:branding:navigation:redirect:rateLimiters:)``. + public static var live: Self.Type { + Self.self } } diff --git a/Sources/Identity Consumer/Middleware/Identity.Consumer.Middleware.swift b/Sources/Identity Consumer/Middleware/Identity.Consumer.Middleware.swift index f4aa945..fa87eac 100644 --- a/Sources/Identity Consumer/Middleware/Identity.Consumer.Middleware.swift +++ b/Sources/Identity Consumer/Middleware/Identity.Consumer.Middleware.swift @@ -10,7 +10,6 @@ import Identity_Shared import JWT import Server_Vapor import Vapor -@preconcurrency import Vapor extension Identity.Consumer { public struct Middleware: AsyncMiddleware { @@ -25,7 +24,10 @@ extension Identity.Consumer { self.credentialsAuthenticator = credentialsAuthenticator } - public func respond(to request: Request, chainingTo next: AsyncResponder) async throws + public func respond( + to request: Request, + chainingTo next: AsyncResponder + ) async throws -> Response { do { diff --git a/Sources/Identity Consumer/Middleware/Identity.Consumer.TokenAuthenticator.swift b/Sources/Identity Consumer/Middleware/Identity.Consumer.TokenAuthenticator.swift index 2218f68..4de830e 100644 --- a/Sources/Identity Consumer/Middleware/Identity.Consumer.TokenAuthenticator.swift +++ b/Sources/Identity Consumer/Middleware/Identity.Consumer.TokenAuthenticator.swift @@ -20,7 +20,10 @@ extension Identity.Consumer { public struct TokenAuthenticator: AsyncMiddleware { public init() {} - public func respond(to request: Request, chainingTo next: AsyncResponder) async throws + public func respond( + to request: Request, + chainingTo next: AsyncResponder + ) async throws -> Vapor.Response { return try await withDependencies { diff --git a/Sources/Identity Consumer/Password/Identity.Consumer.API.Password.response.swift b/Sources/Identity Consumer/Password/Identity.Consumer.API.Password.response.swift index b2e536b..2519d1c 100644 --- a/Sources/Identity Consumer/Password/Identity.Consumer.API.Password.response.swift +++ b/Sources/Identity Consumer/Password/Identity.Consumer.API.Password.response.swift @@ -9,6 +9,7 @@ import Dependencies import IdentitiesTypes import Server_Vapor import Vapor + import enum Server.Server extension Identity.Password.API { diff --git a/Sources/Identity Consumer/Password/Identity.Consumer.Client.Password.live.swift b/Sources/Identity Consumer/Password/Identity.Consumer.Client.Password.live.swift index 7bd7234..8f5d635 100644 --- a/Sources/Identity Consumer/Password/Identity.Consumer.Client.Password.live.swift +++ b/Sources/Identity Consumer/Password/Identity.Consumer.Client.Password.live.swift @@ -8,12 +8,12 @@ import Dependencies import EmailAddress import Foundation -import URLRequestHandler import IdentitiesTypes import Identity_Shared import JWT import Server_Vapor import Throttling +import URLRequestHandler import Vapor extension Identity.Password.Client { diff --git a/Sources/Identity Consumer/Response/Identity.Consumer.API.Router.configureAuthentication.swift b/Sources/Identity Consumer/Response/Identity.Consumer.API.Router.configureAuthentication.swift index 9a7aa0e..94265d0 100644 --- a/Sources/Identity Consumer/Response/Identity.Consumer.API.Router.configureAuthentication.swift +++ b/Sources/Identity Consumer/Response/Identity.Consumer.API.Router.configureAuthentication.swift @@ -61,7 +61,7 @@ extension Identity.Consumer.API.Router { router .setBearerAuth(request.cookies.accessToken?.string) - case .mfa(_): + case .mfa: break case .oauth(let oauth): // OAuth views generally don't require authentication @@ -83,7 +83,9 @@ extension Identity.Consumer.API.Router { } extension ParserPrinter { - package func configureAuthentication(for route: Identity.API) throws -> any ParserPrinter< + package func configureAuthentication( + for route: Identity.API + ) throws -> any ParserPrinter< URLRequestData, Identity.API > { try Identity.Consumer.API.Router.configureAuthentication(baseRouter: self, route: route) diff --git a/Sources/Identity Consumer/Response/Identity.Consumer.API.response.swift b/Sources/Identity Consumer/Response/Identity.Consumer.API.response.swift index dc59ada..ade07bc 100644 --- a/Sources/Identity Consumer/Response/Identity.Consumer.API.response.swift +++ b/Sources/Identity Consumer/Response/Identity.Consumer.API.response.swift @@ -11,6 +11,7 @@ import Identity_Frontend import Identity_Shared import Server_Vapor import Vapor + import enum Server.Server extension Identity.API { diff --git a/Sources/Identity Consumer/Response/Identity.Consumer.Route.response.swift b/Sources/Identity Consumer/Response/Identity.Consumer.Route.response.swift index 983e04c..f447857 100644 --- a/Sources/Identity Consumer/Response/Identity.Consumer.Route.response.swift +++ b/Sources/Identity Consumer/Response/Identity.Consumer.Route.response.swift @@ -10,6 +10,7 @@ import IdentitiesTypes import Identity_Frontend import Server_Vapor import Vapor + import enum Server.Server extension Identity.Route { diff --git a/Sources/Identity Consumer/Response/Identity.Consumer.View.response.swift b/Sources/Identity Consumer/Response/Identity.Consumer.View.response.swift index c3b3a6c..46674ae 100644 --- a/Sources/Identity Consumer/Response/Identity.Consumer.View.response.swift +++ b/Sources/Identity Consumer/Response/Identity.Consumer.View.response.swift @@ -10,6 +10,7 @@ import IdentitiesTypes import Identity_Frontend import Server_Vapor import Vapor + import enum Server.Server extension Identity.View { diff --git a/Sources/Identity Frontend/API/Identity.Frontend.API.response.swift b/Sources/Identity Frontend/API/Identity.Frontend.API.response.swift index cde154a..41c6ff7 100644 --- a/Sources/Identity Frontend/API/Identity.Frontend.API.response.swift +++ b/Sources/Identity Frontend/API/Identity.Frontend.API.response.swift @@ -10,10 +10,11 @@ import Foundation import HTTP_Cookies import HTTP_Standard import IdentitiesTypes -import enum Server.Server import Server_Vapor import Vapor +import enum Server.Server + extension Identity.Frontend { package static func response( api: Identity.API, @@ -212,7 +213,7 @@ extension Identity.Frontend { case .requiresReauthentication: return Server.Response( status: .unauthorized, - headers: HTTP.Headers([try! .init(name: "X-Requires-Reauth", value: "true")]), + headers: HTTP.Headers([try .init(name: "X-Requires-Reauth", value: "true")]), body: Array("Reauthorization required".utf8) ) } @@ -279,7 +280,7 @@ extension Identity.Frontend { ) } else { // For regular form submissions, redirect to the email change page - return Server.Response.redirect( + return try Server.Response.redirect( to: router.url(for: .email(.view(.change(.request)))).absoluteString ) .setting( @@ -336,7 +337,7 @@ extension Identity.Frontend { let accessToken = try Identity.Token.Access(jwt: jwt) let identityId = accessToken.identityId - return Server.Response.redirect( + return try Server.Response.redirect( to: "\(try await config.redirect.loginSuccess(identityId))" ) .withTokens(for: authResponse) diff --git a/Sources/Identity Frontend/HTTPCookies.swift b/Sources/Identity Frontend/HTTPCookies.swift index 4c5ab77..e4299db 100644 --- a/Sources/Identity Frontend/HTTPCookies.swift +++ b/Sources/Identity Frontend/HTTPCookies.swift @@ -9,10 +9,11 @@ import Foundation import HTTP_Cookies import HTTP_Standard import IdentitiesTypes -import enum Server.Server import Server_Vapor import Vapor +import enum Server.Server + extension Server.Response { /// A copy of this response with one `Set-Cookie` line appended for `token` /// under `configuration`. @@ -24,16 +25,17 @@ extension Server.Response { cookie name: String, token: String, configuration: HTTP_Cookies.HTTPCookies.Configuration - ) -> Server.Response { + ) throws(HTTP_Cookies.HTTPCookies.EncodingPolicy.Error) -> Server.Response { var copy = self + let value = try HTTP_Cookies.HTTPCookies.SetCookie( + name: name, + value: .init(token: token), + configuration: configuration + ).headerValue() copy.headers.append( - try! HTTP.Header.Field( - name: "Set-Cookie", - value: try! HTTP_Cookies.HTTPCookies.SetCookie( - name: name, - value: .init(token: token), - configuration: configuration - ).headerValue() + HTTP.Header.Field( + name: .init("Set-Cookie"), + value: .init(unchecked: value) ) ) return copy @@ -41,14 +43,14 @@ extension Server.Response { /// A copy of this response with expired `Set-Cookie` lines for every /// identity cookie, deleting them client-side (logout). - package func expiringIdentityCookies() -> Server.Response { + package func expiringIdentityCookies() throws(HTTP_Cookies.HTTPCookies.EncodingPolicy.Error) -> Server.Response { var copy = self for name in [ Identity.Cookies.Names.accessToken, Identity.Cookies.Names.refreshToken, Identity.Cookies.Names.reauthorizationToken, ] { - copy = copy.setting( + copy = try copy.setting( cookie: name, token: "", configuration: .init( diff --git a/Sources/Identity Frontend/Identity.Frontend.Configuration.Cookies.swift b/Sources/Identity Frontend/Identity.Frontend.Configuration.Cookies.swift index 01bf053..29e5d06 100644 --- a/Sources/Identity Frontend/Identity.Frontend.Configuration.Cookies.swift +++ b/Sources/Identity Frontend/Identity.Frontend.Configuration.Cookies.swift @@ -7,8 +7,8 @@ import Dependencies import Foundation -import IdentitiesTypes import HTTP_Cookies +import IdentitiesTypes import Server_Vapor extension Identity.Frontend.Configuration { diff --git a/Sources/Identity Frontend/Identity.Frontend.Configuration.swift b/Sources/Identity Frontend/Identity.Frontend.Configuration.swift index 728068d..4887e92 100644 --- a/Sources/Identity Frontend/Identity.Frontend.Configuration.swift +++ b/Sources/Identity Frontend/Identity.Frontend.Configuration.swift @@ -215,8 +215,7 @@ extension Identity.Frontend.Configuration: Dependency.Key.Test { } extension Identity.Frontend.Configuration.Redirect { - public static func `default`(router: any ParserPrinter) -> Self - { + public static func `default`(router: any ParserPrinter) -> Self { let home = URL(string: "/")! let loginURL = router.url(for: .authenticate(.view(.credentials))) return .init( diff --git a/Sources/Identity Frontend/Response.swift b/Sources/Identity Frontend/Response.swift index b4e6473..841eec3 100644 --- a/Sources/Identity Frontend/Response.swift +++ b/Sources/Identity Frontend/Response.swift @@ -9,19 +9,20 @@ import Dependencies import Foundation import HTTP_Cookies import IdentitiesTypes -import enum Server.Server import Server_Vapor +import enum Server.Server + extension Server.Response { /// A copy of this response carrying `Set-Cookie` lines for the /// authentication token pair, under the frontend cookie configuration. package func withTokens( for response: Identity.Authentication.Response - ) -> Server.Response { + ) throws(HTTP_Cookies.HTTPCookies.EncodingPolicy.Error) -> Server.Response { @Dependency(\.identityFrontendConfiguration) var configuration return - self + try self .setting( cookie: Identity.Cookies.Names.accessToken, token: response.accessToken, diff --git a/Sources/Identity Frontend/Response/Identity.Authentication.response.swift b/Sources/Identity Frontend/Response/Identity.Authentication.response.swift index c30e454..e385c83 100644 --- a/Sources/Identity Frontend/Response/Identity.Authentication.response.swift +++ b/Sources/Identity Frontend/Response/Identity.Authentication.response.swift @@ -10,10 +10,11 @@ import HTML import IdentitiesTypes import Identity_Views import Language -import enum Server.Server import Server_Vapor import Vapor +import enum Server.Server + // MARK: - Response Dispatcher extension Identity.Authentication { diff --git a/Sources/Identity Frontend/Response/Identity.Creation.response.swift b/Sources/Identity Frontend/Response/Identity.Creation.response.swift index 6bb2f81..99507f3 100644 --- a/Sources/Identity Frontend/Response/Identity.Creation.response.swift +++ b/Sources/Identity Frontend/Response/Identity.Creation.response.swift @@ -10,10 +10,11 @@ import HTML import IdentitiesTypes import Identity_Views import Language -import enum Server.Server import Server_Vapor import Vapor +import enum Server.Server + extension Identity.Creation { /// Dispatches password view requests to appropriate handlers. public static func response( diff --git a/Sources/Identity Frontend/Response/Identity.Deletion.response.swift b/Sources/Identity Frontend/Response/Identity.Deletion.response.swift index cdf6aea..e45df1b 100644 --- a/Sources/Identity Frontend/Response/Identity.Deletion.response.swift +++ b/Sources/Identity Frontend/Response/Identity.Deletion.response.swift @@ -10,10 +10,11 @@ import HTML import IdentitiesTypes import Identity_Views import Language -import enum Server.Server import Server_Vapor import Vapor +import enum Server.Server + // MARK: - Response Dispatcher extension Identity.Deletion { diff --git a/Sources/Identity Frontend/Response/Identity.Email.response.swift b/Sources/Identity Frontend/Response/Identity.Email.response.swift index e665f94..75149ef 100644 --- a/Sources/Identity Frontend/Response/Identity.Email.response.swift +++ b/Sources/Identity Frontend/Response/Identity.Email.response.swift @@ -10,10 +10,11 @@ import HTML import IdentitiesTypes import Identity_Views import Language -import enum Server.Server import Server_Vapor import Vapor +import enum Server.Server + // MARK: - Response Dispatcher extension Identity.Email { diff --git a/Sources/Identity Frontend/Response/Identity.Frontend.View.Utilities.swift b/Sources/Identity Frontend/Response/Identity.Frontend.View.Utilities.swift index b32fa12..f3ad9fa 100644 --- a/Sources/Identity Frontend/Response/Identity.Frontend.View.Utilities.swift +++ b/Sources/Identity Frontend/Response/Identity.Frontend.View.Utilities.swift @@ -11,10 +11,11 @@ import HTML import IdentitiesTypes import Identity_Views import Language -import enum Server.Server import Server_HTML import Server_Vapor +import enum Server.Server + extension Identity.Frontend { /// Creates an HTML document wrapper for identity views. /// This shared utility is used by both Frontend handlers and Standalone handlers. @@ -45,38 +46,37 @@ extension Identity.Frontend { ) async throws -> Server.Response { return try Server.Response.html( await Identity.View.HTMLDocument( - view: view, - title: { view in - switch view { - case .authenticate: return "Sign In" - case .create: return "Create Account" - case .delete: return "Delete Account" - case .logout: return "Sign Out" - case .email: return "Change Email" - case .password: return "Password" - case .mfa: return "MFA" - case .oauth: return "OAuth" - } - }, - description: { view in - switch view { - case .authenticate: return "Sign in to your account" - case .create: return "Create a new account" - case .delete: return "Delete your account" - case .logout: return "Sign out of your account" - case .email: return "Change your email address" - case .password: return "Manage your password" - case .mfa: return "MFA" - case .oauth: return "OAuth" - } - }, - // favicons: branding.favicons, - canonicalHref: canonicalHref, - hreflang: hreflang, - footer_links: footer_links - ) { - try await content() - } + view: view, + title: { view in + switch view { + case .authenticate: return "Sign In" + case .create: return "Create Account" + case .delete: return "Delete Account" + case .logout: return "Sign Out" + case .email: return "Change Email" + case .password: return "Password" + case .mfa: return "MFA" + case .oauth: return "OAuth" + } + }, + description: { view in + switch view { + case .authenticate: return "Sign in to your account" + case .create: return "Create a new account" + case .delete: return "Delete your account" + case .logout: return "Sign out of your account" + case .email: return "Change your email address" + case .password: return "Manage your password" + case .mfa: return "MFA" + case .oauth: return "OAuth" + } + }, + // favicons: branding.favicons, + canonicalHref: canonicalHref, + hreflang: hreflang, + footer_links: footer_links, + body: { try await content() } + ) ) } @@ -92,17 +92,15 @@ extension Identity.Frontend { return try Server.Response.html( await Identity.View.HTMLDocument( - view: view, - title: { _ in title }, - description: { _ in description }, - // favicons: configuration.branding.favicons, - canonicalHref: configuration.canonicalHref, - hreflang: configuration.hreflang, - footer_links: configuration.branding.footer_links - ) { - try await content() - } + view: view, + title: { _ in title }, + description: { _ in description }, + // favicons: configuration.branding.favicons, + canonicalHref: configuration.canonicalHref, + hreflang: configuration.hreflang, + footer_links: configuration.branding.footer_links, + body: { try await content() } + ) ) } } - diff --git a/Sources/Identity Frontend/Response/Identity.Logout.response.swift b/Sources/Identity Frontend/Response/Identity.Logout.response.swift index 92d3d06..7f3e41a 100644 --- a/Sources/Identity Frontend/Response/Identity.Logout.response.swift +++ b/Sources/Identity Frontend/Response/Identity.Logout.response.swift @@ -7,9 +7,10 @@ import Dependencies import IdentitiesTypes -import enum Server.Server import Server_Vapor +import enum Server.Server + // MARK: - Response Handler extension Identity.Logout { @@ -20,7 +21,7 @@ extension Identity.Logout { ) async throws -> Server.Response { try? await client.current() - return Server.Response.redirect(to: try await redirect.logoutSuccess().absoluteString) + return try Server.Response.redirect(to: try await redirect.logoutSuccess().absoluteString) .expiringIdentityCookies() } } diff --git a/Sources/Identity Frontend/Response/Identity.OAuth.response.swift b/Sources/Identity Frontend/Response/Identity.OAuth.response.swift index 1edbd30..0790e05 100644 --- a/Sources/Identity Frontend/Response/Identity.OAuth.response.swift +++ b/Sources/Identity Frontend/Response/Identity.OAuth.response.swift @@ -11,10 +11,11 @@ import HTML import IdentitiesTypes import Identity_Views import Language -import enum Server.Server import Server_Vapor import Vapor +import enum Server.Server + // MARK: - Response Dispatcher extension Identity.OAuth { diff --git a/Sources/Identity Frontend/Response/Identity.Password.response.swift b/Sources/Identity Frontend/Response/Identity.Password.response.swift index c723b82..67ede6d 100644 --- a/Sources/Identity Frontend/Response/Identity.Password.response.swift +++ b/Sources/Identity Frontend/Response/Identity.Password.response.swift @@ -10,10 +10,11 @@ import HTML import IdentitiesTypes import Identity_Views import Language -import enum Server.Server import Server_Vapor import Vapor +import enum Server.Server + // MARK: - Response Dispatcher extension Identity.Password { diff --git a/Sources/Identity Provider/Cookies.swift b/Sources/Identity Provider/Cookies.swift index 01701ba..fbc038c 100644 --- a/Sources/Identity Provider/Cookies.swift +++ b/Sources/Identity Provider/Cookies.swift @@ -6,12 +6,12 @@ // import Foundation +import HTTP_Cookies import IdentitiesTypes import Identity_Backend import Identity_Shared import Server_Vapor import URLRouting -import HTTP_Cookies extension Identity.Provider { /// Cookie configuration for Provider services that serve identity to Consumer apps. diff --git a/Sources/Identity Provider/Create/Identity.Provider.API.Create.response.swift b/Sources/Identity Provider/Create/Identity.Provider.API.Create.response.swift index b34b6ae..fb5cffb 100644 --- a/Sources/Identity Provider/Create/Identity.Provider.API.Create.response.swift +++ b/Sources/Identity Provider/Create/Identity.Provider.API.Create.response.swift @@ -11,6 +11,7 @@ import IdentitiesTypes import Logger_Dependencies import Server import Server_Vapor + import struct Vapor.Abort extension Identity.Creation.API { diff --git a/Sources/Identity Provider/Delete/Identity.Provider.API.Delete.response.swift b/Sources/Identity Provider/Delete/Identity.Provider.API.Delete.response.swift index 15518ae..06cd014 100644 --- a/Sources/Identity Provider/Delete/Identity.Provider.API.Delete.response.swift +++ b/Sources/Identity Provider/Delete/Identity.Provider.API.Delete.response.swift @@ -10,6 +10,7 @@ import Foundation import IdentitiesTypes import Server import Server_Vapor + import struct Vapor.Abort extension Identity.Deletion.API { diff --git a/Sources/Identity Provider/Email/Identity.Provider.API.Email.response.swift b/Sources/Identity Provider/Email/Identity.Provider.API.Email.response.swift index dd1b7e3..c94c407 100644 --- a/Sources/Identity Provider/Email/Identity.Provider.API.Email.response.swift +++ b/Sources/Identity Provider/Email/Identity.Provider.API.Email.response.swift @@ -12,6 +12,7 @@ import Logger_Dependencies import Logging import Server import Server_Vapor + import struct Vapor.Abort extension Identity.Email.API { diff --git a/Sources/Identity Provider/Identity.Provider.Configuration.swift b/Sources/Identity Provider/Identity.Provider.Configuration.swift index 6742b7e..32d2ed5 100644 --- a/Sources/Identity Provider/Identity.Provider.Configuration.swift +++ b/Sources/Identity Provider/Identity.Provider.Configuration.swift @@ -9,9 +9,9 @@ import Dependencies import Foundation import IdentitiesTypes import Identity_Shared -import URLRouting -import URI import Throttling +import URI +import URLRouting extension Identity.Provider { public struct Configuration: Sendable { diff --git a/Sources/Identity Provider/Password/Identity.Provider.API.Password.response.swift b/Sources/Identity Provider/Password/Identity.Provider.API.Password.response.swift index 9be2ee2..626606d 100644 --- a/Sources/Identity Provider/Password/Identity.Provider.API.Password.response.swift +++ b/Sources/Identity Provider/Password/Identity.Provider.API.Password.response.swift @@ -12,6 +12,7 @@ import Logger_Dependencies import Logging import Server import Server_Vapor + import struct Vapor.Abort extension Identity.Password.API { diff --git a/Sources/Identity Provider/Response/Identity.Provider.API.response.swift b/Sources/Identity Provider/Response/Identity.Provider.API.response.swift index 1289a80..cee84f5 100644 --- a/Sources/Identity Provider/Response/Identity.Provider.API.response.swift +++ b/Sources/Identity Provider/Response/Identity.Provider.API.response.swift @@ -10,6 +10,7 @@ import Foundation import IdentitiesTypes import Server import Server_Vapor + import struct Vapor.Abort extension Identity.Provider.API { diff --git a/Sources/Identity Shared/Identity.API+SecurityRequirements.swift b/Sources/Identity Shared/Identity.API+SecurityRequirements.swift index 694dc3c..741f097 100644 --- a/Sources/Identity Shared/Identity.API+SecurityRequirements.swift +++ b/Sources/Identity Shared/Identity.API+SecurityRequirements.swift @@ -15,7 +15,7 @@ package enum SecurityRequirement: Equatable, Sendable { /// Requires valid authentication case authenticated - /// Future: Requires specific permission + // Future: Requires specific permission // case authorized(Permission) } diff --git a/Sources/Identity Shared/Identity.API.rateLimit.swift b/Sources/Identity Shared/Identity.API.rateLimit.swift index 1e65114..e05d976 100644 --- a/Sources/Identity Shared/Identity.API.rateLimit.swift +++ b/Sources/Identity Shared/Identity.API.rateLimit.swift @@ -15,7 +15,8 @@ private func rateLimitAbort( nextAllowedAttempt: Date? = nil, defaultDelay: TimeInterval = 60 ) -> Abort { - let delay = nextAllowedAttempt.map { max(1, Int($0.timeIntervalSinceNow)) } + let delay = + nextAllowedAttempt.map { max(1, Int($0.timeIntervalSinceNow)) } ?? Int(defaultDelay) return Abort(.tooManyRequests, headers: ["Retry-After": "\(delay)"]) } @@ -192,7 +193,7 @@ extension Identity.API { guard rateLimit.isAllowed else { - throw rateLimitAbort(nextAllowedAttempt: rateLimit.nextAllowedAttempt) + throw rateLimitAbort(nextAllowedAttempt: rateLimit.nextAllowedAttempt) } return .init(limiter: rateLimiter.deleteRequest, key: key) @@ -202,7 +203,7 @@ extension Identity.API { guard rateLimit.isAllowed else { - throw rateLimitAbort(nextAllowedAttempt: rateLimit.nextAllowedAttempt) + throw rateLimitAbort(nextAllowedAttempt: rateLimit.nextAllowedAttempt) } return .init(limiter: rateLimiter.deleteConfirm, key: key) @@ -212,7 +213,7 @@ extension Identity.API { guard rateLimit.isAllowed else { - throw rateLimitAbort(nextAllowedAttempt: rateLimit.nextAllowedAttempt) + throw rateLimitAbort(nextAllowedAttempt: rateLimit.nextAllowedAttempt) } return .init(limiter: rateLimiter.deleteCancel, key: key) diff --git a/Sources/Identity Shared/Identity.Token.Access.swift b/Sources/Identity Shared/Identity.Token.Access.swift index 9f5842e..563e2ab 100644 --- a/Sources/Identity Shared/Identity.Token.Access.swift +++ b/Sources/Identity Shared/Identity.Token.Access.swift @@ -36,7 +36,7 @@ extension Identity.Token { else { // Return placeholder email for invalid tokens // This allows the token to fail validation downstream rather than crashing - return try! EmailAddress("invalid@token.invalid") + return EmailAddress(rawValue: "invalid@token.invalid")! } return emailAddress } diff --git a/Sources/Identity Shared/Identity.Token.Client.swift b/Sources/Identity Shared/Identity.Token.Client.swift index b334493..23c2c1c 100644 --- a/Sources/Identity Shared/Identity.Token.Client.swift +++ b/Sources/Identity Shared/Identity.Token.Client.swift @@ -69,8 +69,7 @@ extension Identity.Token { ) async throws -> String /// Parse and verify a reauthorization token - public var verifyReauthorization: - (_ token: String) async throws -> Identity.Token.Reauthorization + public var verifyReauthorization: (_ token: String) async throws -> Identity.Token.Reauthorization // MARK: - Token Pair Operations diff --git a/Sources/Identity Shared/RateLimiters.swift b/Sources/Identity Shared/RateLimiters.swift index 68cc85d..4af30f7 100644 --- a/Sources/Identity Shared/RateLimiters.swift +++ b/Sources/Identity Shared/RateLimiters.swift @@ -9,8 +9,8 @@ import Dependencies import Logger_Dependencies import Logging import Server -import Vapor import Throttling +import Vapor extension RateLimiter { public struct Client { diff --git a/Sources/Identity Standalone/Client/Identity.Standalone.Client.Profile.live.swift b/Sources/Identity Standalone/Client/Identity.Standalone.Client.Profile.live.swift index 09870f6..af21122 100644 --- a/Sources/Identity Standalone/Client/Identity.Standalone.Client.Profile.live.swift +++ b/Sources/Identity Standalone/Client/Identity.Standalone.Client.Profile.live.swift @@ -11,7 +11,7 @@ import IdentitiesTypes import Identity_Backend import Records -//extension Identity.Standalone.Client.Profile { +// extension Identity.Standalone.Client.Profile { // /// Live implementation of the profile client for standalone deployments. // /// // /// This implementation directly accesses the database to manage user profiles. @@ -85,4 +85,4 @@ import Records // } // ) // } -//} +// } diff --git a/Sources/Identity Standalone/Client/Identity.Standalone.Client.Profile.swift b/Sources/Identity Standalone/Client/Identity.Standalone.Client.Profile.swift index 46dd45a..3c7289d 100644 --- a/Sources/Identity Standalone/Client/Identity.Standalone.Client.Profile.swift +++ b/Sources/Identity Standalone/Client/Identity.Standalone.Client.Profile.swift @@ -9,7 +9,7 @@ import Dependencies import Foundation import IdentitiesTypes -//extension Identity.Standalone.Client { +// extension Identity.Standalone.Client { // /// Profile management client for standalone deployments. // /// // /// Provides methods to get and update user profiles with display name management. @@ -30,4 +30,4 @@ import IdentitiesTypes // self.updateDisplayName = updateDisplayName // } // } -//} +// } diff --git a/Sources/Identity Standalone/Identity+DependencyKey.swift b/Sources/Identity Standalone/Identity+DependencyKey.swift index c982596..4d623cd 100644 --- a/Sources/Identity Standalone/Identity+DependencyKey.swift +++ b/Sources/Identity Standalone/Identity+DependencyKey.swift @@ -33,7 +33,7 @@ extension Identity: @retroactive Dependency.Key { router: router.authentication, token: .live() ), - logout: Logout.init( + logout: Logout( client: .init( current: { @Dependency(\.request) var request @@ -49,7 +49,7 @@ extension Identity: @retroactive Dependency.Key { try await Identity.Record .where { $0.id.eq(identity.id) } .update { record in - record.sessionVersion = record.sessionVersion + 1 + record.sessionVersion += 1 record.updatedAt = date() } .execute(db) @@ -74,7 +74,7 @@ extension Identity: @retroactive Dependency.Key { try await Identity.Record .where { $0.id.eq(identity.id) } .update { record in - record.sessionVersion = record.sessionVersion + 1 + record.sessionVersion += 1 record.updatedAt = date() } .execute(db) diff --git a/Sources/Identity Standalone/Identity.Standalone.Configuration.swift b/Sources/Identity Standalone/Identity.Standalone.Configuration.swift index 2adf7f3..2ec01d5 100644 --- a/Sources/Identity Standalone/Identity.Standalone.Configuration.swift +++ b/Sources/Identity Standalone/Identity.Standalone.Configuration.swift @@ -13,8 +13,8 @@ import Identity_Backend import Identity_Shared import Language import Server -import URLRouting import Throttling +import URLRouting extension Identity.Standalone { /// Standalone configuration that includes both Frontend and Backend configurations diff --git a/Sources/Identity Standalone/Middleware/Identity.Standalone.CredentialsAuthenticator.swift b/Sources/Identity Standalone/Middleware/Identity.Standalone.CredentialsAuthenticator.swift index cd302ad..537b1ec 100644 --- a/Sources/Identity Standalone/Middleware/Identity.Standalone.CredentialsAuthenticator.swift +++ b/Sources/Identity Standalone/Middleware/Identity.Standalone.CredentialsAuthenticator.swift @@ -5,10 +5,10 @@ // Created by Coen ten Thije Boonkkamp on 29/01/2025. // +import HTTP_Cookies import IdentitiesTypes import Server_Vapor import Vapor -import HTTP_Cookies extension Identity.Standalone { /// Credentials authenticator for username/password authentication in standalone deployments. diff --git a/Sources/Identity Standalone/Middleware/Identity.Standalone.TokenAuthenticator.swift b/Sources/Identity Standalone/Middleware/Identity.Standalone.TokenAuthenticator.swift index 79f7448..f698243 100644 --- a/Sources/Identity Standalone/Middleware/Identity.Standalone.TokenAuthenticator.swift +++ b/Sources/Identity Standalone/Middleware/Identity.Standalone.TokenAuthenticator.swift @@ -147,7 +147,7 @@ extension Identity.Standalone { // Process request and add new tokens to response let httpResponse = try await next.respond(to: request) - return httpResponse.withTokens(for: response) + return try httpResponse.withTokens(for: response) } catch { logger.debug( "Proactive refresh failed, continuing with existing token", @@ -212,7 +212,7 @@ extension Identity.Standalone { // Now process the authenticated request let httpResponse = try await next.respond(to: request) - return httpResponse.withTokens(for: response) + return try httpResponse.withTokens(for: response) } catch { logger.debug( "Authentication failed, continuing as unauthenticated", diff --git a/Sources/Identity Standalone/Profile/Identity.API.Profile.response.swift b/Sources/Identity Standalone/Profile/Identity.API.Profile.response.swift index ba92da8..b30a6ff 100644 --- a/Sources/Identity Standalone/Profile/Identity.API.Profile.response.swift +++ b/Sources/Identity Standalone/Profile/Identity.API.Profile.response.swift @@ -117,7 +117,7 @@ extension Identity.API.Profile { response.expire(cookies: .identity) return - response + try response .withTokens( for: .init(accessToken: newAccessToken, refreshToken: newRefreshToken) ) diff --git a/Sources/Identity Standalone/Response/Identity.API.response.swift b/Sources/Identity Standalone/Response/Identity.API.response.swift index 23794c5..ac5d000 100644 --- a/Sources/Identity Standalone/Response/Identity.API.response.swift +++ b/Sources/Identity Standalone/Response/Identity.API.response.swift @@ -11,8 +11,8 @@ import Identity_Frontend import Identity_Shared import JWT import Server_Vapor -import Vapor import Throttling +import Vapor extension Identity.API { public static func response( @@ -347,7 +347,7 @@ extension Identity.API { ) // Return success with authentication tokens - return Response.success(true) + return try Response.success(true) .withTokens(for: authResponse) case .disable(let disable): @@ -414,10 +414,10 @@ extension Identity.API { ] ) // Set authentication cookies - return response.withTokens(for: authResponse) + return try response.withTokens(for: authResponse) } else { // API request - return JSON with tokens - return Response.success(true) + return try Response.success(true) .withTokens(for: authResponse) } diff --git a/Sources/Identity Standalone/Response/Identity.Route.response.swift b/Sources/Identity Standalone/Response/Identity.Route.response.swift index 83dd7ab..cbaa526 100644 --- a/Sources/Identity Standalone/Response/Identity.Route.response.swift +++ b/Sources/Identity Standalone/Response/Identity.Route.response.swift @@ -96,7 +96,9 @@ extension Identity.Route { } } - private static func mapAuthView(_ view: Identity.Authentication.View) + private static func mapAuthView( + _ view: Identity.Authentication.View + ) -> Identity.Authentication.View { switch view { diff --git a/Sources/Identity Views/exports.swift b/Sources/Identity Views/exports.swift index 8179225..f446d18 100644 --- a/Sources/Identity Views/exports.swift +++ b/Sources/Identity Views/exports.swift @@ -6,9 +6,8 @@ // @_exported import HTML -@_exported import Webpage @_exported import IdentitiesTypes - +@_exported import Webpage // MARK: - Component disambiguation // diff --git a/Tests/Authentication Router Parity Tests/Identity API Router Parity Tests.swift b/Tests/Authentication Router Parity Tests/Identity API Router Parity Tests.swift index 3c4de94..58145bd 100644 --- a/Tests/Authentication Router Parity Tests/Identity API Router Parity Tests.swift +++ b/Tests/Authentication Router Parity Tests/Identity API Router Parity Tests.swift @@ -15,72 +15,74 @@ import URL_Routing_Test_Support @Suite("Identity API Router Parity") struct IdentityAPIRouterParityTests { - static func routes() throws -> [(name: String, route: Identity.API)] { [ - // Authentication - ("authenticate.credentials", .authenticate(.credentials(.init(username: Fixed.email, password: Fixed.password)))), - ("authenticate.token.access", .authenticate(.token(.access(Fixed.jwt)))), - ("authenticate.token.refresh", .authenticate(.token(.refresh(Fixed.jwt)))), - ("authenticate.apiKey", .authenticate(.apiKey(try .init(token: Fixed.token)))), - // Reauthorization - ("reauthorize", .reauthorize(.init(password: Fixed.password))), - // Creation - ("create.request", .create(.request(.init(email: Fixed.email, password: Fixed.password)))), - ("create.verify", .create(.verify(.init(token: Fixed.token, email: Fixed.email)))), - // Deletion - ("delete.request", .delete(.request(.init(reauthToken: Fixed.reauthToken)))), - ("delete.cancel", .delete(.cancel)), - ("delete.confirm", .delete(.confirm)), - // Logout - ("logout.current", .logout(.current)), - ("logout.all", .logout(.all)), - // Email change - ("email.change.request", .email(.change(.request(.init(newEmail: Fixed.newEmail))))), - ("email.change.confirm", .email(.change(.confirm(.init(token: Fixed.token))))), - // Password - ("password.reset.request", .password(.reset(.request(.init(email: Fixed.email))))), - ("password.reset.confirm", .password(.reset(.confirm(.init(token: Fixed.token, newPassword: Fixed.password))))), - ("password.change.request", .password(.change(.request(.init(currentPassword: Fixed.password, newPassword: Fixed.password))))), - // MFA — TOTP - ("mfa.totp.setup", .mfa(.totp(.setup))), - ("mfa.totp.confirmSetup", .mfa(.totp(.confirmSetup(.init(code: Fixed.code))))), - ("mfa.totp.verify", .mfa(.totp(.verify(.init(code: Fixed.code, sessionToken: Fixed.sessionToken))))), - ("mfa.totp.disable", .mfa(.totp(.disable(.init(reauthorizationToken: Fixed.reauthToken))))), - // MFA — SMS - ("mfa.sms.setup", .mfa(.sms(.setup(.init(phoneNumber: Fixed.phoneNumber))))), - ("mfa.sms.requestCode", .mfa(.sms(.requestCode))), - ("mfa.sms.verify", .mfa(.sms(.verify(.init(code: Fixed.code, sessionToken: Fixed.sessionToken))))), - ("mfa.sms.updatePhoneNumber", .mfa(.sms(.updatePhoneNumber(.init(phoneNumber: Fixed.phoneNumber, reauthorizationToken: Fixed.reauthToken))))), - ("mfa.sms.disable", .mfa(.sms(.disable(.init(reauthorizationToken: Fixed.reauthToken))))), - // MFA — Email - ("mfa.email.setup", .mfa(.email(.setup(.init(email: Fixed.email))))), - ("mfa.email.requestCode", .mfa(.email(.requestCode))), - ("mfa.email.verify", .mfa(.email(.verify(.init(code: Fixed.code, sessionToken: Fixed.sessionToken))))), - ("mfa.email.updateEmail", .mfa(.email(.updateEmail(.init(email: Fixed.newEmail, reauthorizationToken: Fixed.reauthToken))))), - ("mfa.email.disable", .mfa(.email(.disable(.init(reauthorizationToken: Fixed.reauthToken))))), - // MFA — WebAuthn - ("mfa.webauthn.beginRegistration", .mfa(.webauthn(.beginRegistration))), - ("mfa.webauthn.finishRegistration", .mfa(.webauthn(.finishRegistration(.init(credentialName: "parity-key", response: "{\"fixed\":true}"))))), - ("mfa.webauthn.beginAuthentication", .mfa(.webauthn(.beginAuthentication))), - ("mfa.webauthn.finishAuthentication", .mfa(.webauthn(.finishAuthentication(.init(response: "{\"fixed\":true}", sessionToken: Fixed.sessionToken))))), - ("mfa.webauthn.listCredentials", .mfa(.webauthn(.listCredentials))), - ("mfa.webauthn.removeCredential", .mfa(.webauthn(.removeCredential(.init(credentialId: "fixed-credential-id", reauthorizationToken: Fixed.reauthToken))))), - ("mfa.webauthn.disable", .mfa(.webauthn(.disable(.init(reauthorizationToken: Fixed.reauthToken))))), - // MFA — Backup codes - ("mfa.backupCodes.regenerate", .mfa(.backupCodes(.regenerate))), - ("mfa.backupCodes.verify", .mfa(.backupCodes(.verify(.init(code: Fixed.code, sessionToken: Fixed.sessionToken))))), - ("mfa.backupCodes.remaining", .mfa(.backupCodes(.remaining))), - // MFA — Status - ("mfa.status.get", .mfa(.status(.get))), - ("mfa.status.challenge", .mfa(.status(.challenge))), - // MFA — Verify - ("mfa.verify", .mfa(.verify(.init(sessionToken: Fixed.sessionToken, method: .totp, code: Fixed.code)))), - // OAuth - ("oauth.providers", .oauth(.providers)), - ("oauth.authorize", .oauth(.authorize(provider: Fixed.provider))), - ("oauth.callback", .oauth(.callback(.init(provider: Fixed.provider, code: Fixed.code, state: Fixed.state)))), - ("oauth.connections", .oauth(.connections)), - ("oauth.disconnect", .oauth(.disconnect(provider: Fixed.provider))) - ] } + static func routes() throws -> [(name: String, route: Identity.API)] { + [ + // Authentication + ("authenticate.credentials", .authenticate(.credentials(.init(username: Fixed.email, password: Fixed.password)))), + ("authenticate.token.access", .authenticate(.token(.access(Fixed.jwt)))), + ("authenticate.token.refresh", .authenticate(.token(.refresh(Fixed.jwt)))), + ("authenticate.apiKey", .authenticate(.apiKey(try .init(token: Fixed.token)))), + // Reauthorization + ("reauthorize", .reauthorize(.init(password: Fixed.password))), + // Creation + ("create.request", .create(.request(.init(email: Fixed.email, password: Fixed.password)))), + ("create.verify", .create(.verify(.init(token: Fixed.token, email: Fixed.email)))), + // Deletion + ("delete.request", .delete(.request(.init(reauthToken: Fixed.reauthToken)))), + ("delete.cancel", .delete(.cancel)), + ("delete.confirm", .delete(.confirm)), + // Logout + ("logout.current", .logout(.current)), + ("logout.all", .logout(.all)), + // Email change + ("email.change.request", .email(.change(.request(.init(newEmail: Fixed.newEmail))))), + ("email.change.confirm", .email(.change(.confirm(.init(token: Fixed.token))))), + // Password + ("password.reset.request", .password(.reset(.request(.init(email: Fixed.email))))), + ("password.reset.confirm", .password(.reset(.confirm(.init(token: Fixed.token, newPassword: Fixed.password))))), + ("password.change.request", .password(.change(.request(.init(currentPassword: Fixed.password, newPassword: Fixed.password))))), + // MFA — TOTP + ("mfa.totp.setup", .mfa(.totp(.setup))), + ("mfa.totp.confirmSetup", .mfa(.totp(.confirmSetup(.init(code: Fixed.code))))), + ("mfa.totp.verify", .mfa(.totp(.verify(.init(code: Fixed.code, sessionToken: Fixed.sessionToken))))), + ("mfa.totp.disable", .mfa(.totp(.disable(.init(reauthorizationToken: Fixed.reauthToken))))), + // MFA — SMS + ("mfa.sms.setup", .mfa(.sms(.setup(.init(phoneNumber: Fixed.phoneNumber))))), + ("mfa.sms.requestCode", .mfa(.sms(.requestCode))), + ("mfa.sms.verify", .mfa(.sms(.verify(.init(code: Fixed.code, sessionToken: Fixed.sessionToken))))), + ("mfa.sms.updatePhoneNumber", .mfa(.sms(.updatePhoneNumber(.init(phoneNumber: Fixed.phoneNumber, reauthorizationToken: Fixed.reauthToken))))), + ("mfa.sms.disable", .mfa(.sms(.disable(.init(reauthorizationToken: Fixed.reauthToken))))), + // MFA — Email + ("mfa.email.setup", .mfa(.email(.setup(.init(email: Fixed.email))))), + ("mfa.email.requestCode", .mfa(.email(.requestCode))), + ("mfa.email.verify", .mfa(.email(.verify(.init(code: Fixed.code, sessionToken: Fixed.sessionToken))))), + ("mfa.email.updateEmail", .mfa(.email(.updateEmail(.init(email: Fixed.newEmail, reauthorizationToken: Fixed.reauthToken))))), + ("mfa.email.disable", .mfa(.email(.disable(.init(reauthorizationToken: Fixed.reauthToken))))), + // MFA — WebAuthn + ("mfa.webauthn.beginRegistration", .mfa(.webauthn(.beginRegistration))), + ("mfa.webauthn.finishRegistration", .mfa(.webauthn(.finishRegistration(.init(credentialName: "parity-key", response: "{\"fixed\":true}"))))), + ("mfa.webauthn.beginAuthentication", .mfa(.webauthn(.beginAuthentication))), + ("mfa.webauthn.finishAuthentication", .mfa(.webauthn(.finishAuthentication(.init(response: "{\"fixed\":true}", sessionToken: Fixed.sessionToken))))), + ("mfa.webauthn.listCredentials", .mfa(.webauthn(.listCredentials))), + ("mfa.webauthn.removeCredential", .mfa(.webauthn(.removeCredential(.init(credentialId: "fixed-credential-id", reauthorizationToken: Fixed.reauthToken))))), + ("mfa.webauthn.disable", .mfa(.webauthn(.disable(.init(reauthorizationToken: Fixed.reauthToken))))), + // MFA — Backup codes + ("mfa.backupCodes.regenerate", .mfa(.backupCodes(.regenerate))), + ("mfa.backupCodes.verify", .mfa(.backupCodes(.verify(.init(code: Fixed.code, sessionToken: Fixed.sessionToken))))), + ("mfa.backupCodes.remaining", .mfa(.backupCodes(.remaining))), + // MFA — Status + ("mfa.status.get", .mfa(.status(.get))), + ("mfa.status.challenge", .mfa(.status(.challenge))), + // MFA — Verify + ("mfa.verify", .mfa(.verify(.init(sessionToken: Fixed.sessionToken, method: .totp, code: Fixed.code)))), + // OAuth + ("oauth.providers", .oauth(.providers)), + ("oauth.authorize", .oauth(.authorize(provider: Fixed.provider))), + ("oauth.callback", .oauth(.callback(.init(provider: Fixed.provider, code: Fixed.code, state: Fixed.state)))), + ("oauth.connections", .oauth(.connections)), + ("oauth.disconnect", .oauth(.disconnect(provider: Fixed.provider))), + ] + } @Test("Identity.API.Router corpus") func apiRouterCorpus() throws { diff --git a/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift b/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift new file mode 100644 index 0000000..a65dc0f --- /dev/null +++ b/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift @@ -0,0 +1,63 @@ +import Foundation +import IdentitiesTypes +import Identity_Consumer +import Identity_Frontend +import Server +import Server_Vapor +import Testing +import URLRouting + +@Suite("Identity behavioral contracts") +struct IdentityBehavioralTests { + + @Test + func `Consumer live construction preserves the public access spelling`() { + let baseURL = URL(string: "https://example.com")! + let router = Identity.Route.Router().eraseToAnyParserPrinter() + + let configuration = Identity.Consumer.Configuration.Consumer.live.init( + baseURL: baseURL, + cookies: .consumer(domain: "example.com", router: router), + router: router, + currentUserName: { "currentuser" }, + branding: .init( + logo: Identity.View.Logo(logo: "🔐", href: baseURL), + footer_links: [] + ), + navigation: .default, + redirect: .live() + ) + + #expect(configuration.baseURL == baseURL) + #expect(configuration.currentUserName() == "currentuser") + } + + @Test + func `Set-Cookie rendering succeeds for a valid token`() throws { + let response = try Server.Response().setting( + cookie: Identity.Cookies.Names.accessToken, + token: "signed-token", + configuration: .init(path: "/", isHTTPOnly: true) + ) + + let cookieHeader = response.headers["Set-Cookie"]?.first + #expect(cookieHeader.map(String.init(describing:))?.contains("signed-token") == true) + } + + @Test + func `Set-Cookie encoding failures propagate`() { + var didThrow = false + + do { + _ = try Server.Response().setting( + cookie: Identity.Cookies.Names.accessToken, + token: "invalid\u{0001}token", + configuration: .init(path: "/", isHTTPOnly: true) + ) + } catch { + didThrow = true + } + + #expect(didThrow) + } +} diff --git a/Tests/Authentication Router Parity Tests/Identity Route Router Parity Tests.swift b/Tests/Authentication Router Parity Tests/Identity Route Router Parity Tests.swift index 33dc57b..0ab3e27 100644 --- a/Tests/Authentication Router Parity Tests/Identity Route Router Parity Tests.swift +++ b/Tests/Authentication Router Parity Tests/Identity Route Router Parity Tests.swift @@ -18,41 +18,43 @@ import URL_Routing_Test_Support @Suite("Identity Route Router Parity") struct IdentityRouteRouterParityTests { - static func routes() throws -> [(name: String, route: Identity.Route)] { [ - // API nesting — one representative per domain - ("api.authenticate.credentials", .authenticate(.api(.credentials(.init(username: Fixed.email, password: Fixed.password))))), - ("api.create.request", .create(.api(.request(.init(email: Fixed.email, password: Fixed.password))))), - ("api.delete.request", .delete(.api(.request(.init(reauthToken: Fixed.reauthToken))))), - ("api.email.change.request", .email(.api(.change(.request(.init(newEmail: Fixed.newEmail)))))), - ("api.password.reset.request", .password(.api(.reset(.request(.init(email: Fixed.email)))))), - ("api.mfa.verify", .mfa(.api(.verify(.init(sessionToken: Fixed.sessionToken, method: .totp, code: Fixed.code))))), - ("api.logout.current", .logout(.api(.current))), - ("api.reauthorize", .reauthorize(.api(.init(password: Fixed.password)))), - ("api.oauth.providers", .oauth(.api(.providers))), - // Views — exhaustive leaves - ("view.authenticate.credentials", .authenticate(.view(.credentials))), - ("view.create.request", .create(.view(.request))), - ("view.create.verify", .create(.view(.verify(.init(token: Fixed.token, email: Fixed.email))))), - ("view.delete.request", .delete(.view(.request))), - ("view.logout", .logout(.view)), - ("view.email.change.request", .email(.view(.change(.request)))), - ("view.email.change.confirm", .email(.view(.change(.confirm(.init(token: Fixed.token)))))), - ("view.email.change.reauthorization", .email(.view(.change(.reauthorization)))), - ("view.password.reset.request", .password(.view(.reset(.request)))), - ("view.password.reset.confirm", .password(.view(.reset(.confirm(.init(token: Fixed.token, newPassword: Fixed.password)))))), - ("view.password.change.request", .password(.view(.change(.request)))), - ("view.mfa.verify", .mfa(.view(.verify(.init(sessionToken: Fixed.sessionToken, attemptsRemaining: 3))))), - ("view.mfa.manage", .mfa(.view(.manage))), - ("view.mfa.totp.setup", .mfa(.view(.totp(.setup)))), - ("view.mfa.totp.confirmSetup", .mfa(.view(.totp(.confirmSetup)))), - ("view.mfa.totp.manage", .mfa(.view(.totp(.manage)))), - ("view.mfa.backupCodes.display", .mfa(.view(.backupCodes(.display)))), - ("view.mfa.backupCodes.verify", .mfa(.view(.backupCodes(.verify(.init(sessionToken: Fixed.sessionToken, attemptsRemaining: 3)))))), - ("view.oauth.login", .oauth(.view(.login))), - ("view.oauth.callback", .oauth(.view(.callback(.init(provider: Fixed.provider, code: Fixed.code, state: Fixed.state))))), - ("view.oauth.connections", .oauth(.view(.connections))), - ("view.oauth.error", .oauth(.view(.error("access_denied")))) - ] } + static func routes() throws -> [(name: String, route: Identity.Route)] { + [ + // API nesting — one representative per domain + ("api.authenticate.credentials", .authenticate(.api(.credentials(.init(username: Fixed.email, password: Fixed.password))))), + ("api.create.request", .create(.api(.request(.init(email: Fixed.email, password: Fixed.password))))), + ("api.delete.request", .delete(.api(.request(.init(reauthToken: Fixed.reauthToken))))), + ("api.email.change.request", .email(.api(.change(.request(.init(newEmail: Fixed.newEmail)))))), + ("api.password.reset.request", .password(.api(.reset(.request(.init(email: Fixed.email)))))), + ("api.mfa.verify", .mfa(.api(.verify(.init(sessionToken: Fixed.sessionToken, method: .totp, code: Fixed.code))))), + ("api.logout.current", .logout(.api(.current))), + ("api.reauthorize", .reauthorize(.api(.init(password: Fixed.password)))), + ("api.oauth.providers", .oauth(.api(.providers))), + // Views — exhaustive leaves + ("view.authenticate.credentials", .authenticate(.view(.credentials))), + ("view.create.request", .create(.view(.request))), + ("view.create.verify", .create(.view(.verify(.init(token: Fixed.token, email: Fixed.email))))), + ("view.delete.request", .delete(.view(.request))), + ("view.logout", .logout(.view)), + ("view.email.change.request", .email(.view(.change(.request)))), + ("view.email.change.confirm", .email(.view(.change(.confirm(.init(token: Fixed.token)))))), + ("view.email.change.reauthorization", .email(.view(.change(.reauthorization)))), + ("view.password.reset.request", .password(.view(.reset(.request)))), + ("view.password.reset.confirm", .password(.view(.reset(.confirm(.init(token: Fixed.token, newPassword: Fixed.password)))))), + ("view.password.change.request", .password(.view(.change(.request)))), + ("view.mfa.verify", .mfa(.view(.verify(.init(sessionToken: Fixed.sessionToken, attemptsRemaining: 3))))), + ("view.mfa.manage", .mfa(.view(.manage))), + ("view.mfa.totp.setup", .mfa(.view(.totp(.setup)))), + ("view.mfa.totp.confirmSetup", .mfa(.view(.totp(.confirmSetup)))), + ("view.mfa.totp.manage", .mfa(.view(.totp(.manage)))), + ("view.mfa.backupCodes.display", .mfa(.view(.backupCodes(.display)))), + ("view.mfa.backupCodes.verify", .mfa(.view(.backupCodes(.verify(.init(sessionToken: Fixed.sessionToken, attemptsRemaining: 3)))))), + ("view.oauth.login", .oauth(.view(.login))), + ("view.oauth.callback", .oauth(.view(.callback(.init(provider: Fixed.provider, code: Fixed.code, state: Fixed.state))))), + ("view.oauth.connections", .oauth(.view(.connections))), + ("view.oauth.error", .oauth(.view(.error("access_denied")))), + ] + } @Test("Identity.Route.Router corpus") func routeRouterCorpus() throws { diff --git a/Tests/Identity Backend Tests/Integration/Authentication/AuthenticationTests.swift b/Tests/Identity Backend Tests/Integration/Authentication/AuthenticationTests.swift index 30cafe3..9e4a252 100644 --- a/Tests/Identity Backend Tests/Integration/Authentication/AuthenticationTests.swift +++ b/Tests/Identity Backend Tests/Integration/Authentication/AuthenticationTests.swift @@ -157,7 +157,7 @@ struct Test { try await Identity.Record .where { $0.id.eq(initialIdentity.id) } .update { record in - record.sessionVersion = record.sessionVersion + 1 + record.sessionVersion += 1 } .execute(db) } diff --git a/Tests/Identity Backend Tests/Integration/Database/DatabaseOperationsTests.swift b/Tests/Identity Backend Tests/Integration/Database/DatabaseOperationsTests.swift index 870131e..99b1886 100644 --- a/Tests/Identity Backend Tests/Integration/Database/DatabaseOperationsTests.swift +++ b/Tests/Identity Backend Tests/Integration/Database/DatabaseOperationsTests.swift @@ -140,7 +140,7 @@ struct Test { .update { record in record.email = newEmail record.emailVerificationStatus = .verified - record.sessionVersion = record.sessionVersion + 1 + record.sessionVersion += 1 } .execute(db) } diff --git a/Tests/Identity Backend Tests/Utilities/EnvironmentVariables+Development.swift b/Tests/Identity Backend Tests/Utilities/EnvironmentVariables+Development.swift index f0759af..b1015c2 100644 --- a/Tests/Identity Backend Tests/Utilities/EnvironmentVariables+Development.swift +++ b/Tests/Identity Backend Tests/Utilities/EnvironmentVariables+Development.swift @@ -32,6 +32,6 @@ extension EnvironmentVariables { } } - return try! Self(dictionary: dictionary, requiredKeys: []) + return Self(dictionary) } } diff --git a/Tests/Identity Backend Tests/Utilities/TestFixtures.swift b/Tests/Identity Backend Tests/Utilities/TestFixtures.swift index 4129592..339fdb3 100644 --- a/Tests/Identity Backend Tests/Utilities/TestFixtures.swift +++ b/Tests/Identity Backend Tests/Utilities/TestFixtures.swift @@ -8,20 +8,20 @@ import Vapor /// Test fixtures for creating test data enum TestFixtures { /// Default test email - static let testEmail = try! EmailAddress("test@example.com") + static let testEmail = EmailAddress(rawValue: "test@example.com")! /// Default test password static let testPassword = "SecurePassword123!" /// Default admin email - static let adminEmail = try! EmailAddress("admin@example.com") + static let adminEmail = EmailAddress(rawValue: "admin@example.com")! /// Generate unique email for test isolation /// - Parameter prefix: Email prefix (default: "test") /// - Returns: Unique email address with UUID suffix static func uniqueEmail(prefix: String = "test") -> EmailAddress { let uuid = UUID().uuidString.prefix(8) - return try! EmailAddress("\(prefix)-\(uuid)@example.com") + return EmailAddress(rawValue: "\(prefix)-\(uuid)@example.com") } /// Creates a test identity in the database diff --git a/Tests/Identity Provider Tests/IdentityProviderAPITests.swift b/Tests/Identity Provider Tests/IdentityProviderAPITests.swift index 164183a..7e019e3 100644 --- a/Tests/Identity Provider Tests/IdentityProviderAPITests.swift +++ b/Tests/Identity Provider Tests/IdentityProviderAPITests.swift @@ -31,7 +31,7 @@ struct Test { $0[Identity.Provider.Configuration.self] = .testValue $0.date = .constant(Date()) // Rate limiter needs date dependency // No request needed - OAuth providers is a public endpoint - } operation: { () async throws -> Void in + } operation: { () async throws in let api = Identity.Provider.API.oauth(.providers) do { diff --git a/Tests/Identity Standalone Tests/PlaceholderTests.swift b/Tests/Identity Standalone Tests/PlaceholderTests.swift index 6a0a89c..208633e 100644 --- a/Tests/Identity Standalone Tests/PlaceholderTests.swift +++ b/Tests/Identity Standalone Tests/PlaceholderTests.swift @@ -11,15 +11,15 @@ import JWT import Records import RecordsTestSupport import Testing +import Throttling import URLRouting import Vapor -import Throttling // MARK: - Test Fixtures enum TestFixtures { /// Default test email - static let testEmail = try! EmailAddress("test@example.com") + static let testEmail = EmailAddress(rawValue: "test@example.com")! /// Default test password static let testPassword = "SecurePassword123!" @@ -27,7 +27,7 @@ enum TestFixtures { /// Generate unique email for test isolation static func uniqueEmail(prefix: String = "test") -> EmailAddress { let uuid = UUID().uuidString.prefix(8) - return try! EmailAddress("\(prefix)-\(uuid)@example.com") + return EmailAddress(rawValue: "\(prefix)-\(uuid)@example.com") } /// Creates a test identity in the database diff --git a/Tests/TEST_PATTERNS.md b/Tests/TEST_PATTERNS.md index f9dde44..6826fff 100644 --- a/Tests/TEST_PATTERNS.md +++ b/Tests/TEST_PATTERNS.md @@ -425,7 +425,7 @@ func assertQuery( func testQuerySQL() async { await assertQuery( Identity.Record - .where { $0.email.eq(try! EmailAddress("test@example.com")) } + .where { $0.email.eq(EmailAddress(rawValue: "test@example.com")!) } .asSelect(), sql: { """ @@ -458,7 +458,7 @@ extension TestFixtures { /// Generate unique email for test isolation static func uniqueEmail(prefix: String = "test") -> EmailAddress { let uuid = UUID().uuidString.prefix(8) - return try! EmailAddress("\(prefix)-\(uuid)@example.com") + return EmailAddress(rawValue: "\(prefix)-\(uuid)@example.com") } /// Generate unique test identity From 9db5bb78e9c73cdc380d46d21f8cdfd939e1f2a0 Mon Sep 17 00:00:00 2001 From: Coen ten Thije Boonkkamp Date: Thu, 30 Jul 2026 07:37:58 +0200 Subject: [PATCH 2/4] Restore authentication cookie response behavior --- Package.swift | 4 ++ .../Identity.Consumer.Configuration.swift | 35 +++++++++++++--- .../Identity Behavioral Tests.swift | 40 ++++++++++++++++++- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/Package.swift b/Package.swift index 9694d7c..9857e94 100644 --- a/Package.swift +++ b/Package.swift @@ -189,6 +189,10 @@ let package = Package( .identityProvider, .identityConsumer, .identitiesTypes, + .identityFrontend, + .server, + .serverVapor, + .product(name: "URLRouting", package: "swift-url-routing"), .product(name: "URL Routing Test Support", package: "swift-url-routing") ], path: "Tests/Authentication Router Parity Tests", diff --git a/Sources/Identity Consumer/Identity.Consumer.Configuration.swift b/Sources/Identity Consumer/Identity.Consumer.Configuration.swift index 5e9354f..f432fb7 100644 --- a/Sources/Identity Consumer/Identity.Consumer.Configuration.swift +++ b/Sources/Identity Consumer/Identity.Consumer.Configuration.swift @@ -118,12 +118,35 @@ extension Identity.Consumer.Configuration { } extension Identity.Consumer.Configuration.Consumer { - /// The live configuration constructor. - /// - /// This metatype preserves the established `Consumer.live(...)` spelling - /// while routing construction through ``init(baseURL:domain:cookies:router:currentUserName:canonicalHref:hreflang:branding:navigation:redirect:rateLimiters:)``. - public static var live: Self.Type { - Self.self + public static func live( + baseURL: URL, + domain: String? = nil, + cookies: Identity.Frontend.Configuration.Cookies, + router: AnyParserPrinter, + currentUserName: @escaping @Sendable () -> String?, + canonicalHref: @escaping @Sendable (Identity.Consumer.View) -> URL? = { _ in nil }, + hreflang: @escaping @Sendable (Identity.Consumer.View, Translating.Language) -> URL = { _, _ in + @Dependency(\.identityConsumerConfiguration) var configuration + return configuration.consumer.baseURL + }, + branding: Identity.Consumer.Configuration.Branding, + navigation: Identity.Consumer.Configuration.Navigation, + redirect: Identity.Consumer.Configuration.Redirect, + rateLimiters: RateLimiters = .init() + ) -> Self { + .init( + baseURL: baseURL, + domain: domain, + cookies: cookies, + router: router, + currentUserName: currentUserName, + canonicalHref: canonicalHref, + hreflang: hreflang, + branding: branding, + navigation: navigation, + redirect: redirect, + rateLimiters: rateLimiters + ) } } diff --git a/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift b/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift index a65dc0f..63a5eb0 100644 --- a/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift +++ b/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift @@ -1,4 +1,5 @@ import Foundation +import Dependencies import IdentitiesTypes import Identity_Consumer import Identity_Frontend @@ -15,7 +16,7 @@ struct IdentityBehavioralTests { let baseURL = URL(string: "https://example.com")! let router = Identity.Route.Router().eraseToAnyParserPrinter() - let configuration = Identity.Consumer.Configuration.Consumer.live.init( + let configuration = Identity.Consumer.Configuration.Consumer.live( baseURL: baseURL, cookies: .consumer(domain: "example.com", router: router), router: router, @@ -60,4 +61,41 @@ struct IdentityBehavioralTests { #expect(didThrow) } + + @Test + func `High-level token cookies render successfully`() throws { + try withDependencies { + $0[Identity.Frontend.Configuration.self] = .testValue + } operation: { + let response = try Server.Response.json(success: true).withTokens( + for: .init(accessToken: "access-token", refreshToken: "refresh-token") + ) + + #expect(response.headers["Set-Cookie"]?.count == 2) + } + } + + @Test + func `High-level logout cookies expire successfully`() throws { + let response = try Server.Response.json(success: true).expiringIdentityCookies() + + #expect(response.headers["Set-Cookie"]?.count == 3) + } + + @Test + func `High-level token cookie encoding failures propagate`() { + var didThrow = false + + do { + _ = try Server.Response.json(success: true).setting( + cookie: Identity.Cookies.Names.reauthorizationToken, + token: "invalid\u{0001}token", + configuration: .init(path: "/", isHTTPOnly: true) + ) + } catch { + didThrow = true + } + + #expect(didThrow) + } } From 1b360be90d7f3a45879f8c56b301587b70e758cf Mon Sep 17 00:00:00 2001 From: Coen ten Thije Boonkkamp Date: Thu, 30 Jul 2026 07:47:09 +0200 Subject: [PATCH 3/4] Exercise high-level token error propagation --- .../Identity Behavioral Tests.swift | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift b/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift index 63a5eb0..72a943a 100644 --- a/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift +++ b/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift @@ -86,14 +86,19 @@ struct IdentityBehavioralTests { func `High-level token cookie encoding failures propagate`() { var didThrow = false - do { - _ = try Server.Response.json(success: true).setting( - cookie: Identity.Cookies.Names.reauthorizationToken, - token: "invalid\u{0001}token", - configuration: .init(path: "/", isHTTPOnly: true) - ) - } catch { - didThrow = true + withDependencies { + $0[Identity.Frontend.Configuration.self] = .testValue + } operation: { + do { + _ = try Server.Response.json(success: true).withTokens( + for: .init( + accessToken: "access-token", + refreshToken: "invalid\u{0001}token" + ) + ) + } catch { + didThrow = true + } } #expect(didThrow) From 61e05747dba996470705e5247761eeadd868cc57 Mon Sep 17 00:00:00 2001 From: Coen ten Thije Boonkkamp Date: Thu, 30 Jul 2026 07:51:09 +0200 Subject: [PATCH 4/4] Fix behavioral test import order --- .../Identity Behavioral Tests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift b/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift index 72a943a..5d4dbbb 100644 --- a/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift +++ b/Tests/Authentication Router Parity Tests/Identity Behavioral Tests.swift @@ -1,5 +1,5 @@ -import Foundation import Dependencies +import Foundation import IdentitiesTypes import Identity_Consumer import Identity_Frontend