diff --git a/Sources/WorkOS/Helpers/SessionHelpers.swift b/Sources/WorkOS/Helpers/SessionHelpers.swift index 363c1d7..4faa512 100644 --- a/Sources/WorkOS/Helpers/SessionHelpers.swift +++ b/Sources/WorkOS/Helpers/SessionHelpers.swift @@ -64,7 +64,8 @@ public struct AuthenticateSessionResult: Sendable { /// the user as unauthenticated. public let needsRefresh: Bool /// Populated on failure: `no_session_cookie_provided`, - /// `invalid_session_cookie`, `invalid_jwt`, or `session_expired`. + /// `invalid_session_cookie`, `invalid_jwt`, `session_expired`, or (for + /// verified authentication) `client_required` / `invalid_cookie_password`. public let reason: String? init( @@ -123,6 +124,10 @@ public enum SessionError: Error, Equatable, Sendable { case missingSessionID /// The operation requires a `WorkOSClient`, but the session was created without one. case clientRequired + /// Verified sessions require a password of at least 32 characters. + case invalidCookiePassword + /// The access token could not be cryptographically validated. + case invalidJWT } /// Session-cookie management: load a sealed session, authenticate it, @@ -134,15 +139,99 @@ public struct Session: Sendable { /// The sealed session cookie value. public let sessionData: String + @available( + *, deprecated, + message: + "Use init(client:sessionData:validatingCookiePassword:) and authenticateVerified()." + ) public init(client: WorkOSClient? = nil, sessionData: String, cookiePassword: String) { + self.init(client: client, sessionData: sessionData, uncheckedCookiePassword: cookiePassword) + } + + // Retain legacy password compatibility for refresh without using a deprecated API. + fileprivate init( + client: WorkOSClient?, sessionData: String, uncheckedCookiePassword cookiePassword: String + ) { self.client = client self.sessionData = sessionData self.cookiePassword = cookiePassword } + /// Construct a session with a password of at least 32 characters. Use a + /// cryptographically random password; length alone does not ensure entropy. + public init( + client: WorkOSClient, sessionData: String, validatingCookiePassword cookiePassword: String + ) throws { + guard cookiePassword.count >= 32 else { throw SessionError.invalidCookiePassword } + self.init(client: client, sessionData: sessionData, uncheckedCookiePassword: cookiePassword) + } + + /// Authenticate against the configured client's JWKS. Requires a signed + /// RS256 token with `sub` and `exp`. A cookie user must match the signed sub; + /// its other profile fields remain cookie data, not verified JWT claims. + /// Cookie impersonator data is not returned because it is not signed. + /// Fetches JWKS on every call (no cache). Fetch/verification failures fail + /// closed with `invalid_jwt`. + public func authenticateVerified() async -> AuthenticateSessionResult { + guard cookiePassword.count >= 32 else { + return AuthenticateSessionResult( + authenticated: false, reason: "invalid_cookie_password") + } + guard !sessionData.isEmpty else { + return AuthenticateSessionResult( + authenticated: false, reason: "no_session_cookie_provided") + } + guard let client else { + return AuthenticateSessionResult(authenticated: false, reason: "client_required") + } + guard + let session = try? SessionSealing.unseal( + sessionData, password: cookiePassword, as: SessionData.self) + else { + return AuthenticateSessionResult(authenticated: false, reason: "invalid_session_cookie") + } + guard + let (claims, verified) = try? await SessionTokenVerification.verify( + session.accessToken, client: client), + session.user.map({ $0.id == verified.sub }) ?? true + else { + return AuthenticateSessionResult(authenticated: false, reason: "invalid_jwt") + } + let expired = Date().timeIntervalSince1970 >= Double(verified.exp) + return AuthenticateSessionResult( + authenticated: !expired, + sessionId: claims.sessionId, + organizationId: claims.organizationId, + role: claims.role, + permissions: claims.permissions ?? [], + entitlements: claims.entitlements ?? [], + user: session.user, + needsRefresh: expired, + reason: expired ? "session_expired" : nil + ) + } + + /// One-shot cryptographically verified authentication. + public static func authenticateVerified( + client: WorkOSClient, sealedSession: String, cookiePassword: String + ) async -> AuthenticateSessionResult { + guard + let session = try? Session( + client: client, sessionData: sealedSession, validatingCookiePassword: cookiePassword + ) + else { + return AuthenticateSessionResult( + authenticated: false, reason: "invalid_cookie_password") + } + return await session.authenticateVerified() + } + /// Validate the sealed session: unseal it, check the access token, and /// extract the JWT claims. Never throws — failures are reported through /// `authenticated == false` plus `reason`. + @available( + *, deprecated, message: "Does not verify JWT signatures. Use await authenticateVerified()." + ) public func authenticate() -> AuthenticateSessionResult { guard !sessionData.isEmpty else { return AuthenticateSessionResult( @@ -165,7 +254,10 @@ public struct Session: Sendable { // Enforce JWT expiration: an expired access token signals the caller // to refresh the session rather than treat the user as logged out. - if let exp = claims.exp, Date().timeIntervalSince1970 >= Double(exp) { + guard let exp = claims.exp else { + return AuthenticateSessionResult(authenticated: false, reason: "invalid_jwt") + } + if Date().timeIntervalSince1970 >= Double(exp) { return AuthenticateSessionResult( authenticated: false, sessionId: claims.sessionId, @@ -264,6 +356,9 @@ public struct Session: Sendable { /// Build the logout URL for this session. An expired access token is /// fine — the logout endpoint only needs the session ID. + @available( + *, deprecated, message: "Uses unverified claims. Use await getVerifiedLogoutUrl(returnTo:)." + ) public func getLogoutUrl(returnTo: String? = nil) throws -> URL { guard !sessionData.isEmpty else { throw SessionError.noSessionData } @@ -284,8 +379,32 @@ public struct Session: Sendable { return components.url! } + /// Build a logout URL using a cryptographically verified session ID. + /// Expired but validly signed tokens can still be used to log out. + public func getVerifiedLogoutUrl(returnTo: String? = nil) async throws -> URL { + guard !sessionData.isEmpty else { throw SessionError.noSessionData } + guard let client else { throw SessionError.clientRequired } + let result = await authenticateVerified() + guard result.authenticated || result.needsRefresh else { throw SessionError.invalidJWT } + guard let sessionID = result.sessionId, !sessionID.isEmpty else { + throw SessionError.missingSessionID + } + var base = client.configuration.baseURL.absoluteString + if base.hasSuffix("/") { base.removeLast() } + var components = URLComponents(string: "\(base)/user_management/sessions/logout")! + var query = [URLQueryItem(name: "session_id", value: sessionID)] + if let returnTo { query.append(URLQueryItem(name: "return_to", value: returnTo)) } + components.queryItems = query + return components.url! + } + /// One-shot session authentication that needs no client — only the /// sealed session and the cookie password. + @available( + *, deprecated, + message: + "Does not verify JWT signatures. Use await authenticateVerified(client:sealedSession:cookiePassword:)." + ) public static func authenticate( sealedSession: String, cookiePassword: String ) -> AuthenticateSessionResult { @@ -323,8 +442,8 @@ public struct Session: Sendable { } /// Decode the payload (claims) of a JWT without verifying its signature. - /// Acceptable because the token was sealed by us and is trusted after - /// unsealing. + /// Used only by legacy authentication and as a refresh request hint. + /// These claims MUST NOT be trusted for authentication or authorization. static func parseJWTPayload(_ token: String) throws -> JWTClaims { let parts = token.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == 3 else { @@ -340,16 +459,28 @@ public struct Session: Sendable { extension WorkOSClient { /// Load a sealed session cookie into a `Session` bound to this client. + @available( + *, deprecated, + message: + "Use try loadVerifiedSession(sessionData:cookiePassword:) and await authenticateVerified()." + ) public func loadSealedSession(sessionData: String, cookiePassword: String) -> Session { Session(client: self, sessionData: sessionData, cookiePassword: cookiePassword) } + /// Load a session with password validation. Authenticate it with + /// `await session.authenticateVerified()` before trusting its claims. + public func loadVerifiedSession(sessionData: String, cookiePassword: String) throws -> Session { + try Session( + client: self, sessionData: sessionData, validatingCookiePassword: cookiePassword) + } + /// One-shot refresh of a sealed session. public func refreshSession( sealedSession: String, cookiePassword: String, requestOptions: RequestOptions? = nil ) async throws -> RefreshSessionResult { try await Session( - client: self, sessionData: sealedSession, cookiePassword: cookiePassword + client: self, sessionData: sealedSession, uncheckedCookiePassword: cookiePassword ).refresh(requestOptions: requestOptions) } } diff --git a/Sources/WorkOS/Helpers/SessionSealing.swift b/Sources/WorkOS/Helpers/SessionSealing.swift index c56446d..5322ac3 100644 --- a/Sources/WorkOS/Helpers/SessionSealing.swift +++ b/Sources/WorkOS/Helpers/SessionSealing.swift @@ -1,5 +1,6 @@ // @oagen-ignore-file — hand-maintained; oagen must never overwrite this file. +import CommonCrypto import CryptoKit import Foundation @@ -15,10 +16,12 @@ public enum SessionSealingError: Error, Equatable, Sendable { /// Raw seal/unseal helpers for session payloads. /// -/// `seal` encrypts any JSON-serializable value with AES-256-GCM and returns a -/// base64 string of `nonce(12) || ciphertext || tag`. The password is used -/// directly as the key when it is a hex-encoded 32-byte string (64 hex -/// characters); otherwise it is hashed with SHA-256 to derive the key. +/// New seals use `wos2.` followed by base64 `salt(16) || nonce(12) || ciphertext || tag`. +/// Keys are derived with PBKDF2-HMAC-SHA256 (600,000 iterations), costing roughly +/// 100–300 ms of CPU per seal/unseal depending on the device. These synchronous +/// operations should run off the UI thread. Legacy seals remain readable and +/// are upgraded the next time the session is sealed. +/// Use a cryptographically random password of at least 32 characters. public enum SessionSealing { /// Encrypt a JSON-serializable value into a sealed base64 string. public static func seal(_ value: T, password: String) throws -> String { @@ -36,13 +39,16 @@ public enum SessionSealing { /// Encrypt raw bytes with AES-256-GCM using the derived key. static func sealBytes(_ plaintext: Data, password: String) throws -> String { - let key = deriveKey(password) + let salt = SymmetricKey(size: .bits128).withUnsafeBytes { Data($0) } + let key = try deriveKey(password, salt: salt) do { - let sealedBox = try AES.GCM.seal(plaintext, using: key, nonce: AES.GCM.Nonce()) - var output = Data(sealedBox.nonce) + let sealedBox = try AES.GCM.seal( + plaintext, using: key, authenticating: Data("wos2.".utf8)) + var output = salt + output.append(contentsOf: sealedBox.nonce) output.append(sealedBox.ciphertext) output.append(sealedBox.tag) - return output.base64EncodedString() + return "wos2." + output.base64EncodedString() } catch { throw SessionSealingError.cryptoFailure("encryption failed: \(error)") } @@ -50,28 +56,51 @@ public enum SessionSealing { /// Decrypt a sealed base64 string back to raw bytes. static func unsealBytes(_ sealed: String, password: String) throws -> Data { - guard let raw = Data(base64Encoded: sealed) else { + let versioned = sealed.hasPrefix("wos2.") + guard let raw = Data(base64Encoded: versioned ? String(sealed.dropFirst(5)) : sealed) else { throw SessionSealingError.invalidSealedData } // nonce(12) plus tag(16) with at least some ciphertext. - guard raw.count > 28 else { + guard raw.count > (versioned ? 44 : 28) else { throw SessionSealingError.sealedDataTooShort } - let key = deriveKey(password) + let key = + versioned ? try deriveKey(password, salt: Data(raw.prefix(16))) : deriveKey(password) + let payload = versioned ? Data(raw.dropFirst(16)) : raw do { let sealedBox = try AES.GCM.SealedBox( - nonce: AES.GCM.Nonce(data: raw.prefix(12)), - ciphertext: raw.dropFirst(12).dropLast(16), - tag: raw.suffix(16) + nonce: AES.GCM.Nonce(data: payload.prefix(12)), + ciphertext: payload.dropFirst(12).dropLast(16), + tag: payload.suffix(16) ) - return try AES.GCM.open(sealedBox, using: key) + return try AES.GCM.open( + sealedBox, using: key, authenticating: versioned ? Data("wos2.".utf8) : Data()) } catch { throw SessionSealingError.cryptoFailure("decryption failed: \(error)") } } - /// Derive a 32-byte AES key from the password: hex-decode when the + private static func deriveKey(_ password: String, salt: Data) throws -> SymmetricKey { + let passwordBytes = Array(password.utf8) + var key = [UInt8](repeating: 0, count: 32) + let status = passwordBytes.withUnsafeBytes { passwordBuffer in + salt.withUnsafeBytes { saltBuffer in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBuffer.baseAddress?.assumingMemoryBound(to: Int8.self), + passwordBytes.count, + saltBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self), salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), 600_000, &key, key.count) + } + } + guard status == kCCSuccess else { + throw SessionSealingError.cryptoFailure("key derivation failed") + } + return SymmetricKey(data: key) + } + + /// Legacy read-only derivation. Derive a 32-byte AES key from the password: hex-decode when the /// password is exactly 64 hex characters, otherwise SHA-256 the UTF-8 bytes. static func deriveKey(_ password: String) -> SymmetricKey { if password.count == 64, let decoded = decodeHex(password), decoded.count == 32 { diff --git a/Sources/WorkOS/Helpers/SessionTokenVerification.swift b/Sources/WorkOS/Helpers/SessionTokenVerification.swift new file mode 100644 index 0000000..6ddf0a4 --- /dev/null +++ b/Sources/WorkOS/Helpers/SessionTokenVerification.swift @@ -0,0 +1,99 @@ +// @oagen-ignore-file — hand-maintained; oagen must never overwrite this file. + +import Foundation +import Security + +/// Internal RS256 verification. Only keys from the configured WorkOS client's +/// JWKS are trusted; token-provided key URLs and certificates are never used. +enum SessionTokenVerification { + struct VerifiedClaims: Decodable { + let sub: String + let exp: Int + let nbf: Int? + } + + private struct Header: Decodable { + let alg: String + let kid: String + + private enum CodingKeys: String, CodingKey { + case alg, kid, crit, b64 + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard !container.contains(.crit), !container.contains(.b64) else { + throw SessionError.invalidJWT + } + alg = try container.decode(String.self, forKey: .alg) + kid = try container.decode(String.self, forKey: .kid) + } + } + + static func verify(_ token: String, client: WorkOSClient) async throws + -> (JWTClaims, VerifiedClaims) + { + let parts = token.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3, + let headerData = HelperSupport.base64URLDecode(String(parts[0])), + let payload = HelperSupport.base64URLDecode(String(parts[1])), + let signature = HelperSupport.base64URLDecode(String(parts[2])), !signature.isEmpty + else { throw SessionError.invalidJWT } + let header = try JSONDecoder().decode(Header.self, from: headerData) + guard header.alg == "RS256", !header.kid.isEmpty else { throw SessionError.invalidJWT } + + let jwks = try await client.getJwks() + let matches = jwks.keys.filter { $0.kid == header.kid } + guard matches.count == 1, let jwk = matches.first, + jwk.alg == "RS256", jwk.kty == "RSA", jwk.use == "sig", + let modulus = HelperSupport.base64URLDecode(jwk.n), !modulus.isEmpty, + let exponent = HelperSupport.base64URLDecode(jwk.e), !exponent.isEmpty + else { throw SessionError.invalidJWT } + + // Security expects a PKCS#1 DER RSAPublicKey (SEQUENCE of two INTEGERs). + let keyData = der(tag: 0x30, content: integer(modulus) + integer(exponent)) + guard + let key = SecKeyCreateWithData( + keyData as CFData, + [kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPublic] + as CFDictionary, nil), + SecKeyIsAlgorithmSupported(key, .verify, .rsaSignatureMessagePKCS1v15SHA256), + SecKeyVerifySignature( + key, .rsaSignatureMessagePKCS1v15SHA256, + Data("\(parts[0]).\(parts[1])".utf8) as CFData, signature as CFData, nil) + else { throw SessionError.invalidJWT } + + // Parse claims only after the signature has been checked. Expiration is + // mandatory; expired signed tokens are handled by the session caller. + let verified = try JSONDecoder().decode(VerifiedClaims.self, from: payload) + guard !verified.sub.isEmpty, + verified.nbf.map({ Double($0) <= Date().timeIntervalSince1970 }) ?? true + else { throw SessionError.invalidJWT } + return (try JSONDecoder().decode(JWTClaims.self, from: payload), verified) + } + + private static func integer(_ bytes: Data) -> Data { + var value = Data(bytes.drop(while: { $0 == 0 })) + if value.isEmpty { value.append(0) } + if value[0] & 0x80 != 0 { value.insert(0, at: 0) } + return der(tag: 0x02, content: value) + } + + private static func der(tag: UInt8, content: Data) -> Data { + var result = Data([tag]) + if content.count < 128 { + result.append(UInt8(content.count)) + } else { + var length = content.count + var bytes: [UInt8] = [] + while length > 0 { + bytes.insert(UInt8(length & 0xff), at: 0) + length >>= 8 + } + result.append(0x80 | UInt8(bytes.count)) + result.append(contentsOf: bytes) + } + result.append(content) + return result + } +} diff --git a/Tests/WorkOSTests/SessionHelpersTests.swift b/Tests/WorkOSTests/SessionHelpersTests.swift index 41970af..fc3db55 100644 --- a/Tests/WorkOSTests/SessionHelpersTests.swift +++ b/Tests/WorkOSTests/SessionHelpersTests.swift @@ -68,6 +68,36 @@ import Testing #expect(result.reason == nil) } + @Test(arguments: ["missing", "null", "string"]) + func authenticateRejectsInvalidExpiration(expiration: String) throws { + var claims: [String: Any] = [ + "sid": "session_forged", "org_id": "org_VICTIM", "role": "admin", + "permissions": ["billing:write"], + ] + if expiration == "null" { claims["exp"] = NSNull() } + if expiration == "string" { claims["exp"] = "4102444800" } + let sealed = try Session.sealSession( + accessToken: makeTestJWT(claims: claims), refreshToken: "rt_forged", + cookiePassword: Self.cookiePassword) + let session = Session(sessionData: sealed, cookiePassword: Self.cookiePassword) + let results = [ + session.authenticate(), + Session.authenticate(sealedSession: sealed, cookiePassword: Self.cookiePassword), + ] + for result in results { + #expect(!result.authenticated) + #expect(result.reason == "invalid_jwt") + #expect(!result.needsRefresh) + #expect(result.sessionId == nil) + #expect(result.organizationId == nil) + #expect(result.role == nil) + #expect(result.permissions.isEmpty) + #expect(result.entitlements.isEmpty) + #expect(result.user == nil) + #expect(result.impersonator == nil) + } + } + @Test func authenticateFlagsExpiredTokenForRefresh() throws { let sealed = try Self.makeSealedSession(expiresIn: -3600) let result = Session.authenticate( diff --git a/Tests/WorkOSTests/SessionSecurityTests.swift b/Tests/WorkOSTests/SessionSecurityTests.swift new file mode 100644 index 0000000..652bd6b --- /dev/null +++ b/Tests/WorkOSTests/SessionSecurityTests.swift @@ -0,0 +1,317 @@ +// @oagen-ignore-file — hand-maintained; oagen must never overwrite this file. + +import CryptoKit +import Foundation +import Security +import Testing + +@testable import WorkOS + +@Suite struct SessionSecurityTests { + private static let password = String(repeating: "a", count: 32) + // Public test-only RSA key, generated for this suite; never used by WorkOS. + private static let privateKeyBase64 = + "MIIEogIBAAKCAQEAxB7RX4rn8XMOe/Q8YvmQJDsybvAlGr33Y1LmvIrKKX5lAYh/MI18mrZscMaoYkZH0Wsk8iYl43bSdsGCuM6z5I//3jKzzYsPGdYoTd6vmKRcZiWghXbdrcFnto1/7FCtMiGW2vsur+yfCuU+ebSrS3rAr0cNmnj8F0lVVP1GDXKv53HzQ+ECSsFoJfymLa6GbrwdHVoXIj6XQTM91GtNH1ht0HOtNxLf0a8fKL7PIDfdsrFlHbqP5lOuGE/NdUOawbQyAW1phRXQQ9J1pB5vCYAhnUH1m5wN2cknKRjjGP+JMVTN+XIR8HNH8GhO9slTuMS5tnUBQPfzNw0BzjhodQIDAQABAoIBAFIr42fXqHT20zPGUmLZ07YKg4gN4E4DGBsqifinYirehW2OBlSOg43DL05VPgnnDoJFFTbMGwXiLC6Lx7ytBpyWZQtxTPqq8AnQPBTcX9Bh1UELNOWWtyztIwpO4TFfYCHoBu/7XEVjrAOBp5qQw1CdvwvxhlaZqG4NUM6KTAansaUHkSCSfIYK6Q8qL5WKSLTyl29EPgoHBwBfYuaiDjayXoBjVqOc/1I3JC8w1MZFAd8eCiEIVclAxVFDAR4IA+uEoWStH+N3+BOULQpMytkq76KfYU0taK/13phviYDbKihgVLU321Q8QsUmwqH/NXK3oPvlvz8uLETxeU4P6KUCgYEA8It0ABKc/eviDsYAoL0pIMgZ4poixH3Wjqoo4/mBKPi78PRjGj4Qi0K+xYaKnIzAydRJaoa6Zu/7doCEMGbxB3USkF6Chc6EOAe/Jg8ktGVHEHDWmg1HpueM/kPvRutLbhUKiEhuKnOuMNIn1uAN/BAaFFHWRy/FoH5AH4IQ+/MCgYEA0LipGZexETQTbShaFU9QVm8CjNX9FqyZxX0cDYSvykBkq0y5qjmErPde5f9ZiVW4lGKR2JtcBWiCPWkCdQXPNwPxt3ownGMCEkukeS+eFSsJDjaBtwJijkIBswy6Yyouuyc7EzFqYKEkh5sEN3Rrhi5nd6ZIy6RFrGhwRF7Oq/cCgYBJI63ew8oWbx2qLkxMk5eozw8H1qQRqM2PTW/neZrrMU48AqMLfKmdHmtRNgp5dVa9R54XFOYinH+SVZtb+ED7an59hS8crmGHg9t8IAiiDVVhS14FM1qBBlDZkyBzKOIjk6RDMfrFT608TPouHKxD40V6vjNwK7dkiF7I9cxiPwKBgChVBpgjb9vbLEXTnlSv1t5c5SlB0H4pLC21V05lbXKvrsRLNzVll/W0d2oKRcr7/Ybu5S/uFYIWB9TGDet/C+Odp3/E5M/TcfsHEuk4AlwkzMMqVTaAB3tl1d47f2jaJd2UXx3+VogFm4F4uv/cR0rOfL/qKfbv72a5Z7hOebFRAoGATQZEP0ga4h5+vOwuHDimQWhnral9doEJAI7zW8IFsXxq/aVzedom+wlzjB/FIS341bkfMpp3jmuyxJsvBiXsvDwXIXjH65LYKDe9J8+8gMSwn6twxbkKFSU/8eJJAcbokdlZRiIjYeKGjfht8UZrUa1HmLfouH+Qo4NKqsXMSSg=" + private static let modulus = + "xB7RX4rn8XMOe_Q8YvmQJDsybvAlGr33Y1LmvIrKKX5lAYh_MI18mrZscMaoYkZH0Wsk8iYl43bSdsGCuM6z5I__3jKzzYsPGdYoTd6vmKRcZiWghXbdrcFnto1_7FCtMiGW2vsur-yfCuU-ebSrS3rAr0cNmnj8F0lVVP1GDXKv53HzQ-ECSsFoJfymLa6GbrwdHVoXIj6XQTM91GtNH1ht0HOtNxLf0a8fKL7PIDfdsrFlHbqP5lOuGE_NdUOawbQyAW1phRXQQ9J1pB5vCYAhnUH1m5wN2cknKRjjGP-JMVTN-XIR8HNH8GhO9slTuMS5tnUBQPfzNw0BzjhodQ" + private static let exponent = "AQAB" + + private static func encode(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + + private static func token( + claims: [String: Any]? = nil, header: [String: Any]? = nil + ) throws -> String { + let header = header ?? ["alg": "RS256", "kid": "test-key"] + let claims = + claims ?? [ + "sub": "user_123", "sid": "session_123", "role": "admin", + "org_id": "org_456", "permissions": ["posts:read"], + "exp": Int(Date().timeIntervalSince1970) + 3600, + ] + let input = + try encode(JSONSerialization.data(withJSONObject: header)) + "." + + encode(JSONSerialization.data(withJSONObject: claims)) + let key = try #require( + SecKeyCreateWithData( + Data(base64Encoded: privateKeyBase64)! as CFData, + [kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPrivate] + as CFDictionary, nil)) + let signature = try #require( + SecKeyCreateSignature( + key, .rsaSignatureMessagePKCS1v15SHA256, Data(input.utf8) as CFData, nil)) + return input + "." + encode(signature as Data) + } + + private static func jwks(overrides: [String: Any] = [:], duplicate: Bool = false) throws + -> String + { + var key: [String: Any] = [ + "kid": "test-key", "alg": "RS256", "kty": "RSA", "use": "sig", + "n": modulus, "e": exponent, "x5c": [], "x5t#S256": "", + ] + key.merge(overrides) { _, new in new } + return String( + data: try JSONSerialization.data( + withJSONObject: ["keys": duplicate ? [key, key] : [key]]), encoding: .utf8)! + } + + private static func sealed(_ token: String, user: User? = nil) throws -> String { + try Session.sealSession( + accessToken: token, refreshToken: "rt_123", user: user, + impersonator: AuthenticateResponseImpersonator(email: "attacker@example.com"), + cookiePassword: password) + } + + @Test func verifiesSignedClaimsAndOmitsUnsignedImpersonator() async throws { + let (client, recorder) = makeHelperTestClient(responding: try Self.jwks()) + let session = try client.loadVerifiedSession( + sessionData: Self.sealed(Self.token()), cookiePassword: Self.password) + let result = await session.authenticateVerified() + #expect(result.authenticated) + #expect(result.sessionId == "session_123") + #expect(result.organizationId == "org_456") + #expect(result.role == "admin") + #expect(result.permissions == ["posts:read"]) + #expect(result.impersonator == nil) + #expect(!result.needsRefresh) + #expect(recorder.lastRequest?.url?.path == "/sso/jwks/client_test_123") + } + + @Test(arguments: [ + "unsigned", "unsigned-with-exp", "missing-exp", "null-exp", "string-exp", "missing-sub", + "tampered-payload", "tampered-signature", "unknown-kid", "hs256", "critical", + "null-critical", "b64", "null-b64", "future-nbf", "malformed", "empty", + ]) + func rejectsInvalidTokens(attack: String) async throws { + var token = try Self.token() + switch attack { + case "unsigned": + token = makeTestJWT(claims: [ + "sid": "session_forged", "org_id": "org_VICTIM", "role": "admin", + "permissions": ["billing:write"], + ]) + case "unsigned-with-exp": + token = makeTestJWT(claims: [ + "sub": "user_123", "role": "admin", "sid": "forged", "exp": 4_102_444_800, + ]) + case "missing-exp": token = try Self.token(claims: ["sub": "user_123"]) + case "null-exp": token = try Self.token(claims: ["sub": "user_123", "exp": NSNull()]) + case "string-exp": token = try Self.token(claims: ["sub": "user_123", "exp": "4102444800"]) + case "missing-sub": + token = try Self.token(claims: ["exp": Int(Date().timeIntervalSince1970) + 3600]) + case "tampered-payload": + var parts = token.components(separatedBy: ".") + parts[1] = Self.encode(Data(#"{"sub":"victim","role":"admin","exp":4102444800}"#.utf8)) + token = parts.joined(separator: ".") + case "tampered-signature": + var parts = token.components(separatedBy: ".") + parts[2] = Self.encode(Data(repeating: 0, count: 256)) + token = parts.joined(separator: ".") + case "unknown-kid": token = try Self.token(header: ["alg": "RS256", "kid": "unknown"]) + case "hs256": token = try Self.token(header: ["alg": "HS256", "kid": "test-key"]) + case "critical": + token = try Self.token(header: ["alg": "RS256", "kid": "test-key", "crit": ["custom"]]) + case "null-critical": + token = try Self.token(header: ["alg": "RS256", "kid": "test-key", "crit": NSNull()]) + case "b64": + token = try Self.token(header: ["alg": "RS256", "kid": "test-key", "b64": false]) + case "null-b64": + token = try Self.token(header: ["alg": "RS256", "kid": "test-key", "b64": NSNull()]) + case "future-nbf": + token = try Self.token(claims: [ + "sub": "user_123", "exp": 4_102_444_800, "nbf": 4_102_444_800, + ]) + case "malformed": token = "a.b.c.d" + case "empty": token = "" + default: Issue.record("Unhandled attack") + } + let (client, _) = makeHelperTestClient(responding: try Self.jwks()) + let result = await Session.authenticateVerified( + client: client, sealedSession: try Self.sealed(token), cookiePassword: Self.password) + #expect(!result.authenticated) + #expect(!result.needsRefresh) + #expect(result.reason == "invalid_jwt") + #expect(result.sessionId == nil) + #expect(result.organizationId == nil) + #expect(result.role == nil) + #expect(result.permissions.isEmpty) + #expect(result.entitlements.isEmpty) + #expect(result.user == nil) + #expect(result.impersonator == nil) + } + + @Test(arguments: [ + "wrong-key", "wrong-alg", "wrong-type", "wrong-use", "duplicate", "malformed", + "unavailable", "missing-client-id", + ]) + func rejectsUntrustedJWKS(attack: String) async throws { + var overrides: [String: Any] = [:] + switch attack { + case "wrong-key": overrides["n"] = Self.encode(Data(repeating: 0x99, count: 256)) + case "wrong-alg": overrides["alg"] = "HS256" + case "wrong-type": overrides["kty"] = "EC" + case "wrong-use": overrides["use"] = "enc" + default: break + } + let body = + attack == "malformed" + ? "not-json" + : try Self.jwks( + overrides: overrides, duplicate: attack == "duplicate") + let (client, _) = makeHelperTestClient( + clientID: attack == "missing-client-id" ? nil : "client_test_123", + statusCode: attack == "unavailable" ? 401 : 200, responding: body) + let result = await Session.authenticateVerified( + client: client, sealedSession: try Self.sealed(Self.token()), + cookiePassword: Self.password) + #expect(!result.authenticated) + #expect(result.reason == "invalid_jwt") + #expect(!result.needsRefresh) + #expect(result.sessionId == nil) + #expect(result.organizationId == nil) + #expect(result.role == nil) + #expect(result.permissions.isEmpty) + #expect(result.entitlements.isEmpty) + #expect(result.user == nil) + #expect(result.impersonator == nil) + } + + @Test func signedExpiredTokenCanRefreshAndLogOut() async throws { + let token = try Self.token(claims: [ + "sub": "user_123", "sid": "session_expired", + "exp": Int(Date().timeIntervalSince1970) - 1, + ]) + let (client, _) = makeHelperTestClient(responding: try Self.jwks()) + let session = try client.loadVerifiedSession( + sessionData: Self.sealed(token), cookiePassword: Self.password) + let result = await session.authenticateVerified() + #expect(!result.authenticated) + #expect(result.needsRefresh) + #expect(result.reason == "session_expired") + let url = try await session.getVerifiedLogoutUrl(returnTo: "https://example.com") + #expect(queryDictionary(of: url)["session_id"] == "session_expired") + #expect(queryDictionary(of: url)["return_to"] == "https://example.com") + } + + @Test(arguments: ["user_123", "victim"]) + func bindsCookieUserToSignedSubject(userID: String) async throws { + let user = User( + object: "user", id: userID, email: "test@example.com", emailVerified: true, + createdAt: Date(), updatedAt: Date()) + let (client, _) = makeHelperTestClient(responding: try Self.jwks()) + let result = await Session.authenticateVerified( + client: client, sealedSession: try Self.sealed(Self.token(), user: user), + cookiePassword: Self.password) + #expect(result.authenticated == (userID == "user_123")) + #expect(result.user?.id == (userID == "user_123" ? userID : nil)) + } + + @Test(arguments: ["", "short", String(repeating: "a", count: 31)]) + func rejectsWeakPasswords(password: String) async throws { + let (client, _) = makeHelperTestClient() + #expect(throws: SessionError.invalidCookiePassword) { + try client.loadVerifiedSession(sessionData: "cookie", cookiePassword: password) + } + let result = await Session.authenticateVerified( + client: client, sealedSession: "cookie", cookiePassword: password) + #expect(!result.authenticated) + #expect(result.reason == "invalid_cookie_password") + let legacy = Session(client: client, sessionData: "cookie", cookiePassword: password) + #expect(await legacy.authenticateVerified().reason == "invalid_cookie_password") + } + + @Test func failsClosedWithoutClientOrCookie() async throws { + let legacy = Session(sessionData: "cookie", cookiePassword: Self.password) + #expect(await legacy.authenticateVerified().reason == "client_required") + let (client, _) = makeHelperTestClient() + #expect( + await Session.authenticateVerified( + client: client, sealedSession: "", cookiePassword: Self.password + ).reason == "no_session_cookie_provided") + #expect( + await Session.authenticateVerified( + client: client, sealedSession: "garbage", cookiePassword: Self.password + ).reason == "invalid_session_cookie") + } + + @Test func opensIndependentPBKDF2Fixture() throws { + // Produced with Node crypto.pbkdf2Sync(..., 600000, 32, "sha256") and + // createCipheriv("aes-256-gcm"), with salt 00...0f and nonce 00...0b. + let fixture = + "wos2.AAECAwQFBgcICQoLDA0ODwABAgMEBQYHCAkKCxs633xXNNoSZr9AxAQ3mA5NkFaPlITBhb3s4h5v3hs=" + let decoded: [String: String] = try SessionSealing.unseal(fixture, password: Self.password) + #expect(decoded == ["key": "value"]) + + // Same independent fixture, but encrypted without the version as AAD. + let withoutAAD = + "wos2.AAECAwQFBgcICQoLDA0ODwABAgMEBQYHCAkKCxs633xXNNoSZr9AxAQ3mLMvh3EFuu4CXJu/elSmrHA=" + #expect(throws: SessionSealingError.self) { + let _: [String: String] = try SessionSealing.unseal( + withoutAAD, password: Self.password) + } + } + + @Test func verifiedLogoutRejectsForgedToken() async throws { + let (client, _) = makeHelperTestClient(responding: try Self.jwks()) + let session = try client.loadVerifiedSession( + sessionData: Self.sealed(makeTestJWT(claims: ["sid": "forged", "exp": 1])), + cookiePassword: Self.password) + await #expect(throws: SessionError.invalidJWT) { + _ = try await session.getVerifiedLogoutUrl() + } + let result = await session.authenticateVerified() + #expect(!result.authenticated) + #expect(!result.needsRefresh) + #expect(result.sessionId == nil) + } + + @Test func saltedSealsAreRandomizedAndAuthenticated() throws { + let first = try SessionSealing.seal(["key": "value"], password: Self.password) + let second = try SessionSealing.seal(["key": "value"], password: Self.password) + #expect(first.hasPrefix("wos2.")) + #expect(first != second) + let raw = try #require(Data(base64Encoded: String(first.dropFirst(5)))) + let other = try #require(Data(base64Encoded: String(second.dropFirst(5)))) + #expect(raw.prefix(16) != other.prefix(16)) + let decoded: [String: String] = try SessionSealing.unseal(first, password: Self.password) + #expect(decoded == ["key": "value"]) + for offset in [0, 16, 28, raw.count - 1] { + var corrupt = raw + corrupt[offset] ^= 1 + #expect(throws: SessionSealingError.self) { + let _: [String: String] = try SessionSealing.unseal( + "wos2." + corrupt.base64EncodedString(), password: Self.password) + } + } + #expect(throws: SessionSealingError.self) { + let _: [String: String] = try SessionSealing.unseal( + first, password: String(repeating: "b", count: 32)) + } + #expect(throws: SessionSealingError.self) { + let _: [String: String] = try SessionSealing.unseal( + String(first.dropFirst(5)), password: Self.password) + } + for invalid in ["wos3." + String(first.dropFirst(5)), "wos2.AA==", "wos2.!invalid"] { + #expect(throws: SessionSealingError.self) { + let _: [String: String] = try SessionSealing.unseal( + invalid, password: Self.password) + } + } + } + + @Test(arguments: [password, String(repeating: "ab", count: 32), "short", ""]) + func legacySealsMigrateWithoutLogout(password: String) throws { + let plaintext = try Coding.makeEncoder().encode(["key": "value"]) + let legacy = try AES.GCM.seal(plaintext, using: SessionSealing.deriveKey(password)) + let sealed = try #require(legacy.combined).base64EncodedString() + let decoded: [String: String] = try SessionSealing.unseal(sealed, password: password) + #expect(decoded == ["key": "value"]) + let upgraded = try SessionSealing.seal(decoded, password: password) + #expect(upgraded.hasPrefix("wos2.")) + let restored: [String: String] = try SessionSealing.unseal(upgraded, password: password) + #expect(restored == decoded) + } +}