From ae3bc5bca89f93fe6f2a3318273b86837602547a Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 16:31:33 -0500 Subject: [PATCH 01/14] Rewrite the OAuth2 core for 2.0: instance config, PKCE, typed errors Security fixes: - A profile without a user id now throws OAuth2Error.missingUserID; 1.x returned userid "" when the profile fetch failed or lacked an id. - HTTP errors are no longer swallowed: typed OAuth2Error cases for provider error bodies (RFC 6749, GitHub, Slack ok:false, Facebook Graph), HTTP status, non-JSON, timeouts, network failures and oversized responses. - URLSessionTransport: ephemeral session per request, whole-request deadline, response size cap (Content-Length pre-check plus streaming cap), redirects not followed, HTTPS required (HTTP only to loopback). - State: stored value must be 32-512 characters, returned value non-empty, constant-time compare; AuthorizationState.generate() uses SystemRandomNumberGenerator. - PKCE (RFC 7636 S256) via swift-crypto's SHA256; on by default for Google, GitHub and Salesforce, off for Facebook, LinkedIn and Slack. - Access tokens go in the Authorization header for every provider (Google, Facebook and Slack put them in the query string). Facebook also sends appsecret_proof. - Every URL is built with URLComponents and fully encoded (hd=, redirect_uri); the session token is no longer appended to the redirect URI. - id_token payloads are decoded as base64url; Google checks iss, aud/azp, exp, iat and sub, and restrictedDomain requires the validated hd claim. - Secrets are redacted from description, dump and Mirror output, and from provider error text. API: - OAuth2ClientConfiguration passed to each provider's init replaces the nonisolated(unsafe) global *Config statics; the static helpers are removed. - OAuth2 is final and checked Sendable; providers are final classes conforming to the new OAuth2Provider protocol (the extension point for custom providers). - JSONValue replaces [String: Any]; OAuth2Token and OAuthUserProfile are structs. Linkedin -> LinkedIn, SalesForce -> Salesforce. - Provider endpoints updated: Google OIDC userinfo, Slack Sign in with Slack (OIDC), Facebook Graph v23.0 (configurable), Salesforce login host configurable; GitHub default scope read:user. Tests are hermetic: a stub transport and a local socket HTTP server. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + Package.swift | 6 + Sources/PerfectOAuth2/Authorization.swift | 220 +++++++++++ Sources/PerfectOAuth2/AuthorizationCode.swift | 14 - Sources/PerfectOAuth2/IDToken.swift | 54 +++ Sources/PerfectOAuth2/JSONValue.swift | 115 ++++++ Sources/PerfectOAuth2/OAuth2.swift | 314 ++++++++++++---- Sources/PerfectOAuth2/OAuth2Error.swift | 162 ++++++-- Sources/PerfectOAuth2/OAuth2Provider.swift | 76 ++++ Sources/PerfectOAuth2/OAuth2Token.swift | 118 ++++-- Sources/PerfectOAuth2/OAuthUserProfile.swift | 80 +++- Sources/PerfectOAuth2/Providers/Common.swift | 15 - .../PerfectOAuth2/Providers/Facebook.swift | 103 +++--- Sources/PerfectOAuth2/Providers/GitHub.swift | 108 +++--- Sources/PerfectOAuth2/Providers/Google.swift | 164 ++++----- .../PerfectOAuth2/Providers/LinkedIn.swift | 89 ++--- Sources/PerfectOAuth2/Providers/Removed.swift | 28 ++ .../PerfectOAuth2/Providers/Salesforce.swift | 108 +++--- Sources/PerfectOAuth2/Providers/Slack.swift | 97 ++--- Sources/PerfectOAuth2/Transport.swift | 230 ++++++++++++ Sources/PerfectOAuth2/makeRequest.swift | 51 --- Tests/PerfectOAuth2Tests/CoreTests.swift | 216 +++++++++++ Tests/PerfectOAuth2Tests/FlowTests.swift | 301 +++++++++++++++ .../PerfectOAuth2Tests.swift | 262 ------------- Tests/PerfectOAuth2Tests/ProviderTests.swift | 348 ++++++++++++++++++ Tests/PerfectOAuth2Tests/RefreshTests.swift | 187 ---------- .../Support/LocalHTTPServer.swift | 179 +++++++++ .../Support/StubTransport.swift | 90 +++++ Tests/PerfectOAuth2Tests/TransportTests.swift | 136 +++++++ 29 files changed, 2803 insertions(+), 1069 deletions(-) create mode 100644 Sources/PerfectOAuth2/Authorization.swift delete mode 100644 Sources/PerfectOAuth2/AuthorizationCode.swift create mode 100644 Sources/PerfectOAuth2/IDToken.swift create mode 100644 Sources/PerfectOAuth2/JSONValue.swift create mode 100644 Sources/PerfectOAuth2/OAuth2Provider.swift delete mode 100644 Sources/PerfectOAuth2/Providers/Common.swift create mode 100644 Sources/PerfectOAuth2/Providers/Removed.swift create mode 100644 Sources/PerfectOAuth2/Transport.swift delete mode 100644 Sources/PerfectOAuth2/makeRequest.swift create mode 100644 Tests/PerfectOAuth2Tests/CoreTests.swift create mode 100644 Tests/PerfectOAuth2Tests/FlowTests.swift delete mode 100644 Tests/PerfectOAuth2Tests/PerfectOAuth2Tests.swift create mode 100644 Tests/PerfectOAuth2Tests/ProviderTests.swift delete mode 100644 Tests/PerfectOAuth2Tests/RefreshTests.swift create mode 100644 Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift create mode 100644 Tests/PerfectOAuth2Tests/Support/StubTransport.swift create mode 100644 Tests/PerfectOAuth2Tests/TransportTests.swift diff --git a/.gitignore b/.gitignore index 339c5e7..957318f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /*.xcodeproj /config/ApplicationConfiguration.json Package.pins +Package.resolved diff --git a/Package.swift b/Package.swift index e7cbab3..9796c1e 100644 --- a/Package.swift +++ b/Package.swift @@ -7,9 +7,15 @@ let package = Package( products: [ .library(name: "PerfectOAuth2", targets: ["PerfectOAuth2"]), ], + dependencies: [ + // SHA-256 for PKCE (RFC 7636 S256) and HMAC-SHA256 for Facebook's appsecret_proof. + // The range overlaps Perfect-SMTP (4.0.0..<5.0.0) and Perfect-NIO (3.0.0..<5.0.0). + .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"5.0.0"), + ], targets: [ .target( name: "PerfectOAuth2", + dependencies: [.product(name: "Crypto", package: "swift-crypto")], path: "Sources/PerfectOAuth2", swiftSettings: [.swiftLanguageMode(.v6)] ), diff --git a/Sources/PerfectOAuth2/Authorization.swift b/Sources/PerfectOAuth2/Authorization.swift new file mode 100644 index 0000000..4c934c8 --- /dev/null +++ b/Sources/PerfectOAuth2/Authorization.swift @@ -0,0 +1,220 @@ +import Crypto +import Foundation + +/// CSRF `state` generation and checking. +public enum AuthorizationState { + /// The shortest stored state accepted: 32 characters (a UUID string, 16 random bytes in hex, or + /// the 43-character value `generate()` returns). + public static let minimumLength = 32 + /// The longest stored state accepted. + public static let maximumLength = 512 + + /// 32 bytes from `SystemRandomNumberGenerator`, base64url-encoded without padding (43 characters). + public static func generate() -> String { + randomBase64URL(byteCount: 32) + } + + /// Checks the `state` returned to the callback against the one stored before redirecting. + /// + /// The stored value must be `minimumLength...maximumLength` characters, the returned one must be + /// non-empty, and they are compared in constant time (only the lengths can leak). + /// - Throws: `OAuth2Error.invalidState` for an unusable stored value, otherwise + /// `OAuth2Error.stateMismatch`. + public static func verify(returned: String?, expected: String) throws { + let length = expected.utf8.count + guard length >= minimumLength else { + throw OAuth2Error.invalidState("stored state must be at least \(minimumLength) characters") + } + guard length <= maximumLength else { + throw OAuth2Error.invalidState("stored state must be at most \(maximumLength) characters") + } + guard let returned, !returned.isEmpty, constantTimeEquals(returned, expected) else { + throw OAuth2Error.stateMismatch + } + } + + static func validateStored(_ state: String) throws { + let length = state.utf8.count + guard length >= minimumLength, length <= maximumLength else { + throw OAuth2Error.invalidState( + "state must be \(minimumLength) to \(maximumLength) characters; use AuthorizationState.generate()" + ) + } + } +} + +/// PKCE (RFC 7636) with the S256 method. +public enum PKCE { + /// A code verifier: 32 random bytes, base64url-encoded (43 characters, the RFC 7636 §4.1 minimum + /// length and the encoding its appendix uses). + public static func generateVerifier() -> String { + randomBase64URL(byteCount: 32) + } + + /// `BASE64URL(SHA256(ASCII(verifier)))`, RFC 7636 §4.2. + public static func challenge(for verifier: String) -> String { + base64URLEncode(Data(SHA256.hash(data: Data(verifier.utf8)))) + } +} + +/// What to keep in the server-side session between redirecting the user and handling the callback. +/// +/// It's `Codable` so it can be stored as JSON. Keep it server-side: the code verifier only protects +/// the exchange if the browser never sees it. +public struct PendingAuthorization: Sendable, Codable, Equatable, CustomStringConvertible, CustomReflectable { + public let state: String + /// The PKCE code verifier, or nil when PKCE is disabled for this provider. + public let codeVerifier: String? + /// The `redirect_uri` sent in the authorization request; sent again, unchanged, on exchange. + public let redirectURL: URL + + public init(state: String, codeVerifier: String?, redirectURL: URL) { + self.state = state + self.codeVerifier = codeVerifier + self.redirectURL = redirectURL + } + + public var description: String { + "PendingAuthorization(state: , codeVerifier: \(codeVerifier == nil ? "nil" : ""), redirectURL: \(redirectURL))" + } + + public var customMirror: Mirror { + Mirror(self, children: [ + "state": "", + "codeVerifier": codeVerifier == nil ? "nil" : "", + "redirectURL": redirectURL, + ], displayStyle: .struct) + } +} + +/// A login link plus what to store for the callback. +public struct AuthorizationRequest: Sendable, CustomStringConvertible { + /// Redirect the user's browser here. + public let url: URL + /// Store this in the server-side session and pass it to `completeAuthorization`. + public let pending: PendingAuthorization + + public var description: String { "AuthorizationRequest(url: , pending: \(pending))" } +} + +/// What the provider sent back to the redirect URL. +public struct AuthorizationCallback: Sendable, Equatable { + public let code: String? + public let state: String? + public let error: OAuth2ErrorResponse? + + public init(code: String?, state: String?, error: OAuth2ErrorResponse? = nil) { + self.code = code + self.state = state + self.error = error + } + + /// Reads `code`, `state`, `error`, `error_description` and `error_uri` from query items. + /// A repeated parameter makes the callback unusable (`code` or `state` comes back nil). + public init(queryItems: [URLQueryItem]) { + func single(_ name: String) -> String? { + let values = queryItems.filter { $0.name == name } + return values.count == 1 ? values[0].value : nil + } + code = single("code") + state = single("state") + if let errorCode = single("error") { + error = OAuth2ErrorResponse( + code: errorCode, + errorDescription: single("error_description"), + errorURI: single("error_uri") + ) + } else { + error = nil + } + } + + /// Reads the callback parameters from the request URL the provider redirected to. + public init(url: URL) { + self.init(queryItems: URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []) + } +} + +// MARK: - Encoding helpers + +func randomBase64URL(byteCount: Int) -> String { + var generator = SystemRandomNumberGenerator() + var bytes = [UInt8](repeating: 0, count: byteCount) + for index in bytes.indices { bytes[index] = generator.next() } + return base64URLEncode(Data(bytes)) +} + +func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} + +/// Decodes base64url (RFC 4648 §5), with or without padding. Rejects standard-alphabet `+` and `/`. +func base64URLDecode(_ text: String) -> Data? { + guard !text.contains("+"), !text.contains("/") else { return nil } + var base64 = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + base64 = base64.replacingOccurrences(of: "=", with: "") + if base64.count % 4 == 1 { return nil } + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + return Data(base64Encoded: base64) +} + +/// Compares two strings' UTF-8 bytes in time that depends only on their lengths. +func constantTimeEquals(_ lhs: String, _ rhs: String) -> Bool { + let left = Array(lhs.utf8), right = Array(rhs.utf8) + var difference = left.count ^ right.count + for index in 0.. Data { + let text = fields.map { key, value in + let k = key.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? "" + let v = value.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? "" + return "\(k)=\(v)" + }.joined(separator: "&") + return Data(text.utf8) +} + +/// Appends query items to a URL with `URLComponents`, encoding `+` too (URLComponents leaves it +/// alone, and many servers read a literal `+` in a query as a space). +func appendingQueryItems(_ items: [URLQueryItem], to url: URL) throws -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw OAuth2Error.invalidConfiguration("unparseable URL") + } + var existing = components.percentEncodedQueryItems ?? [] + for item in items { + let name = item.name.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? "" + let value = item.value?.addingPercentEncoding(withAllowedCharacters: formValueAllowed) + existing.append(URLQueryItem(name: name, value: value)) + } + components.percentEncodedQueryItems = existing.isEmpty ? nil : existing + guard let result = components.url else { throw OAuth2Error.invalidConfiguration("unbuildable URL") } + return result +} + +func isLoopback(host: String?) -> Bool { + guard let host = host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" + || host.hasSuffix(".localhost") +} + +/// HTTPS, or HTTP to a loopback host (for local testing). +func requireSecure(_ url: URL, _ what: String) throws { + switch url.scheme?.lowercased() { + case "https": return + case "http" where isLoopback(host: url.host): return + default: throw OAuth2Error.insecureURL("\(what) must use https") + } +} diff --git a/Sources/PerfectOAuth2/AuthorizationCode.swift b/Sources/PerfectOAuth2/AuthorizationCode.swift deleted file mode 100644 index c5e18b1..0000000 --- a/Sources/PerfectOAuth2/AuthorizationCode.swift +++ /dev/null @@ -1,14 +0,0 @@ -public struct AuthorizationCode: Sendable { - public let code: String - public let redirectURL: String - - public init(code: String, redirectURL: String) { - self.code = code - self.redirectURL = redirectURL - } -} - -public struct InvalidAuthorizationCodeError: Error, Sendable { - public init() {} - public let description = "The authorization code supplied could not be verified" -} diff --git a/Sources/PerfectOAuth2/IDToken.swift b/Sources/PerfectOAuth2/IDToken.swift new file mode 100644 index 0000000..1a4c5a5 --- /dev/null +++ b/Sources/PerfectOAuth2/IDToken.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Claim checks for an id_token received *directly from a provider's token endpoint over TLS*. +/// +/// OpenID Connect Core §3.1.3.7 allows skipping the signature check in that case (the TLS server +/// authentication of the token endpoint stands in for it), but the issuer, audience and expiry must +/// still be checked. Never use this for an id_token from anywhere else (a browser, a query string, a +/// stored value): its claims can be forged. +struct IDTokenClaims: Sendable { + let claims: JSONValue + + var subject: String? { claims["sub"]?.stringValue } + + static func validate( + _ idToken: String, + issuers: Set, + clientID: String, + now: Date = Date(), + leeway: TimeInterval = 300 + ) throws -> IDTokenClaims { + let parts = idToken.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { throw OAuth2Error.invalidIDToken("not a compact JWS") } + guard let payload = base64URLDecode(String(parts[1])), + let json = try? JSONValue(data: payload), json.objectValue != nil else { + throw OAuth2Error.invalidIDToken("payload is not base64url JSON") + } + guard let issuer = json["iss"]?.stringValue, issuers.contains(issuer) else { + throw OAuth2Error.invalidIDToken("unexpected issuer") + } + let audiences: [String] + if let single = json["aud"]?.stringValue { + audiences = [single] + } else if let many = json["aud"]?.arrayValue { + audiences = many.compactMap(\.stringValue) + } else { + audiences = [] + } + guard audiences.contains(clientID) else { throw OAuth2Error.invalidIDToken("audience is not this client") } + if audiences.count > 1 || json["azp"] != nil { + guard json["azp"]?.stringValue == clientID else { + throw OAuth2Error.invalidIDToken("authorized party is not this client") + } + } + guard let exp = json["exp"]?.doubleValue else { throw OAuth2Error.invalidIDToken("no expiry") } + guard now.timeIntervalSince1970 < exp + leeway else { throw OAuth2Error.invalidIDToken("expired") } + if let iat = json["iat"]?.doubleValue, iat > now.timeIntervalSince1970 + leeway { + throw OAuth2Error.invalidIDToken("issued in the future") + } + guard let sub = json["sub"]?.stringValue, !sub.isEmpty else { + throw OAuth2Error.invalidIDToken("no subject") + } + return IDTokenClaims(claims: json) + } +} diff --git a/Sources/PerfectOAuth2/JSONValue.swift b/Sources/PerfectOAuth2/JSONValue.swift new file mode 100644 index 0000000..6ae3fc3 --- /dev/null +++ b/Sources/PerfectOAuth2/JSONValue.swift @@ -0,0 +1,115 @@ +import Foundation + +/// A Sendable JSON value, used for provider responses (`OAuthUserProfile.raw`). +public enum JSONValue: Sendable, Equatable, Hashable, Codable { + case null + case bool(Bool) + case integer(Int64) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int64.self) { + self = .integer(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Not a JSON value") + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .integer(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } + + /// Parses JSON data. Throws `OAuth2Error.invalidResponse` if it isn't JSON. + public init(data: Data) throws { + do { + self = try JSONDecoder().decode(JSONValue.self, from: data) + } catch { + throw OAuth2Error.invalidResponse("response is not JSON") + } + } + + /// The member named `key` if this is an object. + public subscript(key: String) -> JSONValue? { + if case .object(let object) = self { return object[key] } + return nil + } + + public var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + public var boolValue: Bool? { + switch self { + case .bool(let value): return value + // Some providers send "true"/"false" as strings (e.g. older id_token email_verified). + case .string("true"): return true + case .string("false"): return false + default: return nil + } + } + + public var int64Value: Int64? { + switch self { + case .integer(let value): return value + case .double(let value): + guard value.rounded() == value, abs(value) < 9.2e18 else { return nil } + return Int64(value) + case .string(let value): return Int64(value) + default: return nil + } + } + + public var doubleValue: Double? { + switch self { + case .integer(let value): return Double(value) + case .double(let value): return value + case .string(let value): return Double(value) + default: return nil + } + } + + public var objectValue: [String: JSONValue]? { + if case .object(let value) = self { return value } + return nil + } + + public var arrayValue: [JSONValue]? { + if case .array(let value) = self { return value } + return nil + } + + /// A string or integer identifier as a string (GitHub user ids are numbers, most others strings). + public var identifierValue: String? { + switch self { + case .string(let value): return value + case .integer(let value): return String(value) + default: return nil + } + } +} diff --git a/Sources/PerfectOAuth2/OAuth2.swift b/Sources/PerfectOAuth2/OAuth2.swift index c7bff59..f9d4b9b 100644 --- a/Sources/PerfectOAuth2/OAuth2.swift +++ b/Sources/PerfectOAuth2/OAuth2.swift @@ -1,100 +1,276 @@ import Foundation -private let urlValueAllowed = CharacterSet( - charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" -) +/// Credentials and options for one OAuth2 client registration. +/// +/// The description and mirror hide `clientSecret`. +public struct OAuth2ClientConfiguration: Sendable, CustomStringConvertible, CustomReflectable { + public var clientID: String + /// Nil for a public client that authenticates with `client_id` and PKCE only. + public var clientSecret: String? + /// The registered redirect URI: the route that handles the callback. Sent unchanged in both the + /// authorization request and the code exchange. Must be absolute HTTPS (or HTTP to a loopback host) + /// with no fragment. + public var redirectURL: URL + /// Scopes to request; nil uses the provider's defaults. + public var scopes: [String]? + /// Whether to use PKCE; nil uses the provider's default (see the README's provider table). + public var usePKCE: Bool? + public var http: OAuth2HTTPOptions -extension String { - var urlEncoded: String { - addingPercentEncoding(withAllowedCharacters: urlValueAllowed) ?? self + public init( + clientID: String, + clientSecret: String?, + redirectURL: URL, + scopes: [String]? = nil, + usePKCE: Bool? = nil, + http: OAuth2HTTPOptions = OAuth2HTTPOptions() + ) { + self.clientID = clientID + self.clientSecret = clientSecret + self.redirectURL = redirectURL + self.scopes = scopes + self.usePKCE = usePKCE + self.http = http + } + + public var description: String { + "OAuth2ClientConfiguration(clientID: \(clientID), clientSecret: \(clientSecret == nil ? "nil" : ""), redirectURL: \(redirectURL))" + } + + public var customMirror: Mirror { + Mirror(self, children: [ + "clientID": clientID, + "clientSecret": clientSecret == nil ? "nil" : "", + "redirectURL": redirectURL, + "scopes": scopes as Any, + "usePKCE": usePKCE as Any, + "http": http, + ], displayStyle: .struct) } } -open class OAuth2: @unchecked Sendable { - public let clientID: String - public let clientSecret: String - public let authorizationURL: String - public let tokenURL: String +/// HTTP limits for token and profile requests. +public struct OAuth2HTTPOptions: Sendable { + /// Deadline for each whole request, in seconds. + public var timeout: TimeInterval + /// Largest response body accepted. Token and profile responses are a few KB. + public var maximumResponseBytes: Int + /// Replaces the default `URLSessionTransport(timeout:maximumResponseBytes:)`. + public var transport: (any OAuth2Transport)? + public var userAgent: String - public init(clientID: String, clientSecret: String, authorizationURL: String, tokenURL: String) { - self.clientID = clientID - self.clientSecret = clientSecret + public init( + timeout: TimeInterval = 30, + maximumResponseBytes: Int = 1 << 20, + transport: (any OAuth2Transport)? = nil, + userAgent: String = "PerfectOAuth2/2.0" + ) { + self.timeout = timeout + self.maximumResponseBytes = maximumResponseBytes + self.transport = transport + self.userAgent = userAgent + } +} + +/// The authorization-code flow (RFC 6749 §4.1) with PKCE (RFC 7636) for one provider's endpoints. +/// +/// Use it directly for any standards-following provider, or wrap it in an `OAuth2Provider` that adds +/// a profile fetch. It is `final` and immutable, so it is checked `Sendable`; customise providers by +/// composition (`OAuth2Provider`), not subclassing. +public final class OAuth2: Sendable { + public let configuration: OAuth2ClientConfiguration + public let authorizationURL: URL + public let tokenURL: URL + public let defaultScopes: [String] + /// Whether authorization requests carry `code_challenge` and exchanges `code_verifier`. + public let usesPKCE: Bool + let transport: any OAuth2Transport + + public var clientID: String { configuration.clientID } + + /// - Parameters: + /// - defaultScopes: used when `configuration.scopes` is nil. + /// - usesPKCEByDefault: used when `configuration.usePKCE` is nil. + public init( + configuration: OAuth2ClientConfiguration, + authorizationURL: URL, + tokenURL: URL, + defaultScopes: [String] = [], + usesPKCEByDefault: Bool = true + ) { + self.configuration = configuration self.authorizationURL = authorizationURL self.tokenURL = tokenURL + self.defaultScopes = defaultScopes + self.usesPKCE = configuration.usePKCE ?? usesPKCEByDefault + self.transport = configuration.http.transport ?? URLSessionTransport( + timeout: configuration.http.timeout, + maximumResponseBytes: configuration.http.maximumResponseBytes + ) } - open func getLoginLink(redirectURL: String, state: String, scopes: [String] = []) -> String { - var url = "\(authorizationURL)?response_type=code" - url += "&client_id=\(clientID.urlEncoded)" - url += "&redirect_uri=\(redirectURL.urlEncoded)" - url += "&state=\(state.urlEncoded)" - url += "&scope=\((scopes.joined(separator: " ")).urlEncoded)" - return url - } + static let reservedParameters: Set = [ + "response_type", "client_id", "redirect_uri", "state", "scope", + "code_challenge", "code_challenge_method", + ] - open func exchange(authorizationCode: AuthorizationCode) async throws -> OAuth2Token { - let postBody = [ - "grant_type": "authorization_code", - "client_id": clientID, - "client_secret": clientSecret, - "redirect_uri": authorizationCode.redirectURL, - "code": authorizationCode.code, + /// Builds the login link and the values to keep for the callback. + /// + /// - Parameters: + /// - scopes: overrides the configured or default scopes. + /// - state: a state you generated; nil generates one with `AuthorizationState.generate()`. It + /// must be at least `AuthorizationState.minimumLength` characters. + /// - additionalParameters: extra query parameters (e.g. `prompt`). They can't replace the ones + /// this method sets. + public func authorizationRequest( + scopes: [String]? = nil, + state: String? = nil, + additionalParameters: [URLQueryItem] = [] + ) throws -> AuthorizationRequest { + let redirectURL = configuration.redirectURL + try Self.validateRedirectURL(redirectURL) + try requireSecure(authorizationURL, "authorization URL") + let state = state ?? AuthorizationState.generate() + try AuthorizationState.validateStored(state) + for item in additionalParameters where Self.reservedParameters.contains(item.name) { + throw OAuth2Error.invalidConfiguration("parameter \(item.name) is set by authorizationRequest") + } + + var items = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURL.absoluteString), + URLQueryItem(name: "state", value: state), ] - let data = await makeRequest(.post, tokenURL, body: urlencode(dict: postBody), encoding: "form") - guard let token = OAuth2Token(json: data) else { - if let error = OAuth2Error(json: data) { - throw error - } - throw InvalidAPIResponse() + let scopeList = scopes ?? configuration.scopes ?? defaultScopes + if !scopeList.isEmpty { + items.append(URLQueryItem(name: "scope", value: scopeList.joined(separator: " "))) + } + var verifier: String? + if usesPKCE { + let codeVerifier = PKCE.generateVerifier() + verifier = codeVerifier + items.append(URLQueryItem(name: "code_challenge", value: PKCE.challenge(for: codeVerifier))) + items.append(URLQueryItem(name: "code_challenge_method", value: "S256")) } - return token + items += additionalParameters + let url = try appendingQueryItems(items, to: authorizationURL) + return AuthorizationRequest( + url: url, + pending: PendingAuthorization(state: state, codeVerifier: verifier, redirectURL: redirectURL) + ) } - open func exchange(code: String, state: String, redirectURL: String) async throws -> OAuth2Token { - return try await exchange(authorizationCode: AuthorizationCode(code: code, redirectURL: redirectURL)) + /// Checks the callback's `state` against `pending.state`, then exchanges the code. + /// + /// - Throws: `OAuth2Error.authorizationDenied` if the provider sent an error, + /// `.stateMismatch`/`.invalidState` if the state check fails (before any network request), + /// `.missingAuthorizationCode`, or the errors of the token request. + public func exchange(callback: AuthorizationCallback, pending: PendingAuthorization) async throws -> OAuth2Token { + try AuthorizationState.verify(returned: callback.state, expected: pending.state) + if let error = callback.error { throw OAuth2Error.authorizationDenied(error) } + guard let code = callback.code, !code.isEmpty else { throw OAuth2Error.missingAuthorizationCode } + if usesPKCE, pending.codeVerifier == nil { + throw OAuth2Error.invalidConfiguration("PKCE is enabled but the pending authorization has no code verifier") + } + + var fields: [(String, String)] = [ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", pending.redirectURL.absoluteString), + ("client_id", clientID), + ] + if let secret = configuration.clientSecret { fields.append(("client_secret", secret)) } + if let verifier = pending.codeVerifier { fields.append(("code_verifier", verifier)) } + let json = try await postForm(tokenURL, fields: fields, secrets: [code, pending.codeVerifier]) + return try OAuth2Token(json: json) } - /// Obtains a new access token using a refresh token (RFC 6749 §6). + /// Obtains a new access token with a refresh token (RFC 6749 §6). /// - /// Many providers (Google among them) omit `refresh_token` from the refresh response; in that case - /// the returned token carries the `refreshToken` passed in, so it can be stored as-is. + /// Many providers (Google among them) omit `refresh_token` from the response; the returned token + /// then carries the `refreshToken` passed in, so it can be stored as is. /// /// - Parameters: - /// - refreshToken: the refresh token issued with an earlier access token. - /// - scopes: optional subset of the originally granted scopes; omitted from the request when empty. - /// - includeClientSecret: send `client_secret` in the body. Confidential clients (Google, - /// Salesforce, LinkedIn) need it; pass `false` for public clients that only send `client_id`. - /// - /// Provider-specific checks done at exchange time (e.g. Google's `restrictedDomain`) are not - /// repeated here; the refresh token already came from a checked exchange. - /// - /// - Throws: `OAuth2Error` if the provider returns an RFC 6749 error code, otherwise `InvalidAPIResponse`. - open func refresh( + /// - scopes: optional subset of the granted scopes; omitted from the request when empty. + /// - includeClientSecret: send `client_secret` (when configured). Pass `false` for providers that + /// reject it on refresh. + public func refresh( refreshToken: String, scopes: [String] = [], includeClientSecret: Bool = true ) async throws -> OAuth2Token { - var postBody = [ - "grant_type": "refresh_token", - "client_id": clientID, - "refresh_token": refreshToken, + var fields: [(String, String)] = [ + ("grant_type", "refresh_token"), + ("refresh_token", refreshToken), + ("client_id", clientID), ] - if !scopes.isEmpty { - postBody["scope"] = scopes.joined(separator: " ") - } - if includeClientSecret { - postBody["client_secret"] = clientSecret + if !scopes.isEmpty { fields.append(("scope", scopes.joined(separator: " "))) } + if includeClientSecret, let secret = configuration.clientSecret { + fields.append(("client_secret", secret)) } - var data = await makeRequest(.post, tokenURL, body: urlencode(dict: postBody), encoding: "form") - if data["access_token"] != nil, data["refresh_token"] == nil { - data["refresh_token"] = refreshToken + let json = try await postForm(tokenURL, fields: fields, secrets: [refreshToken]) + let token = try OAuth2Token(json: json) + return token.refreshToken == nil ? token.replacingRefreshToken(refreshToken) : token + } + + /// GETs a JSON object from a provider API with `Authorization: Bearer `. + /// + /// - Throws: `.insecureURL` for a non-HTTPS URL, `.providerError` for an error object, + /// `.httpStatus` for other non-2xx responses, `.invalidResponse` if the body isn't a JSON object, + /// and the transport's errors. + public func getJSON( + _ url: URL, + accessToken: String, + headers: [String: String] = [:] + ) async throws -> JSONValue { + try requireSecure(url, "API URL") + var allHeaders = baseHeaders + allHeaders["Authorization"] = "Bearer \(accessToken)" + allHeaders.merge(headers) { _, new in new } + let request = OAuth2HTTPRequest(method: .get, url: url, headers: allHeaders) + return try await sendExpectingObject(request, secrets: [accessToken]) + } + + // MARK: - Internals + + private var baseHeaders: [String: String] { + [ + "Accept": "application/json", + "Cache-Control": "no-cache", + "User-Agent": configuration.http.userAgent, + ] + } + + private func postForm(_ url: URL, fields: [(String, String)], secrets: [String?]) async throws -> JSONValue { + try requireSecure(url, "token URL") + var headers = baseHeaders + headers["Content-Type"] = "application/x-www-form-urlencoded" + let request = OAuth2HTTPRequest(method: .post, url: url, headers: headers, body: formEncode(fields)) + return try await sendExpectingObject(request, secrets: secrets) + } + + private func sendExpectingObject(_ request: OAuth2HTTPRequest, secrets: [String?]) async throws -> JSONValue { + let response = try await transport.send(request) + let limit = configuration.http.maximumResponseBytes + // Also checked here so a custom transport can't hand back an oversized body. + guard response.body.count <= limit else { throw OAuth2Error.responseTooLarge(limit: limit) } + let json = try? JSONValue(data: response.body) + let redact = (secrets + [configuration.clientSecret]).compactMap { $0 } + if let json, let error = OAuth2ErrorResponse.parse(json, redacting: redact) { + throw OAuth2Error.providerError(error) } - guard let token = OAuth2Token(json: data) else { - if let error = OAuth2Error(json: data) { - throw error - } - throw InvalidAPIResponse() + guard (200..<300).contains(response.status) else { throw OAuth2Error.httpStatus(response.status) } + guard let json else { throw OAuth2Error.invalidResponse("response is not JSON") } + guard json.objectValue != nil else { throw OAuth2Error.invalidResponse("response is not a JSON object") } + return json + } + + static func validateRedirectURL(_ url: URL) throws { + try requireSecure(url, "redirect URL") + guard url.host?.isEmpty == false else { throw OAuth2Error.invalidConfiguration("redirect URL has no host") } + guard url.fragment == nil else { + throw OAuth2Error.invalidConfiguration("redirect URL must not have a fragment (RFC 6749 §3.1.2)") } - return token } } diff --git a/Sources/PerfectOAuth2/OAuth2Error.swift b/Sources/PerfectOAuth2/OAuth2Error.swift index dc85df7..345a199 100644 --- a/Sources/PerfectOAuth2/OAuth2Error.swift +++ b/Sources/PerfectOAuth2/OAuth2Error.swift @@ -1,31 +1,136 @@ -public struct OAuth2Error: Error, Sendable { - public let code: OAuth2ErrorCode - public let description: String - public let uri: String - - public init(code: OAuth2ErrorCode, description: String? = nil, uri: String? = nil) { - self.code = code - self.description = description ?? code.rawValue - self.uri = uri ?? "" +import Foundation + +/// Every error PerfectOAuth2 throws. +/// +/// Descriptions never contain client secrets, codes, verifiers or tokens. Text taken from a provider +/// (`OAuth2ErrorResponse.errorDescription`) is truncated, stripped of control characters, and has any +/// secret sent in the failed request replaced with ``. +public enum OAuth2Error: Error, Sendable, Equatable, CustomStringConvertible { + /// The `state` returned to the callback is missing or doesn't match the one stored for the session. + case stateMismatch + /// The stored state is unusable: empty, shorter than `AuthorizationState.minimumLength`, or too long. + case invalidState(String) + /// The provider redirected back with `error=...` instead of a code (e.g. the user declined). + case authorizationDenied(OAuth2ErrorResponse) + /// The callback carried neither `code` nor `error`. + case missingAuthorizationCode + /// An error object returned by a token or API endpoint (RFC 6749 §5.2, or a provider's own shape). + case providerError(OAuth2ErrorResponse) + /// A non-2xx HTTP status without a recognisable error body. Redirects are not followed, so 3xx + /// ends up here too. + case httpStatus(Int) + /// The response wasn't the JSON expected (not JSON, wrong shape, or a required field is missing). + case invalidResponse(String) + /// The response body was larger than `OAuth2HTTPOptions.maximumResponseBytes`. + case responseTooLarge(limit: Int) + /// The request didn't complete within `OAuth2HTTPOptions.timeout`. + case timedOut + /// A network-level failure (DNS, TLS, connection reset...). + case network(String) + /// The provider's profile had no user identifier. Never treat a sign-in without one as a login. + case missingUserID + /// An id_token failed validation (issuer, audience, expiry, subject or encoding). + case invalidIDToken(String) + /// Google: the account's hosted domain (`hd`) doesn't match `restrictedDomain`. + case restrictedDomainMismatch + /// A URL that would carry credentials isn't HTTPS (plain HTTP is only allowed to loopback hosts). + case insecureURL(String) + /// The configuration can't be used (e.g. a redirect URL with a fragment). + case invalidConfiguration(String) + + public var description: String { + switch self { + case .stateMismatch: return "OAuth2: state does not match the session (possible CSRF)" + case .invalidState(let why): return "OAuth2: unusable state: \(why)" + case .authorizationDenied(let response): return "OAuth2: authorization denied: \(response)" + case .missingAuthorizationCode: return "OAuth2: callback has no authorization code" + case .providerError(let response): return "OAuth2: provider error: \(response)" + case .httpStatus(let status): return "OAuth2: unexpected HTTP status \(status)" + case .invalidResponse(let why): return "OAuth2: invalid response: \(why)" + case .responseTooLarge(let limit): return "OAuth2: response larger than \(limit) bytes" + case .timedOut: return "OAuth2: request timed out" + case .network(let why): return "OAuth2: network error: \(why)" + case .missingUserID: return "OAuth2: provider returned no user id" + case .invalidIDToken(let why): return "OAuth2: invalid id_token: \(why)" + case .restrictedDomainMismatch: return "OAuth2: account is not in the restricted domain" + case .insecureURL(let why): return "OAuth2: insecure URL: \(why)" + case .invalidConfiguration(let why): return "OAuth2: invalid configuration: \(why)" + } + } +} + +/// An error reported by a provider. +public struct OAuth2ErrorResponse: Sendable, Equatable, CustomStringConvertible { + /// The provider's error code, e.g. `invalid_grant`, `bad_verification_code`, `OAuthException`. + public let code: String + /// The provider's human-readable text, sanitised (see `OAuth2Error`). + public let errorDescription: String? + public let errorURI: String? + + /// The RFC 6749 code, when `code` is one of them. + public var standardCode: OAuth2ErrorCode? { OAuth2ErrorCode(rawValue: code) } + + public init(code: String, errorDescription: String? = nil, errorURI: String? = nil) { + self.init(code: code, errorDescription: errorDescription, errorURI: errorURI, redacting: []) + } + + init(code: String, errorDescription: String?, errorURI: String?, redacting secrets: [String]) { + self.code = Self.sanitize(code, limit: 64, redacting: secrets) ?? "unknown" + self.errorDescription = Self.sanitize(errorDescription, limit: 256, redacting: secrets) + self.errorURI = Self.sanitize(errorURI, limit: 256, redacting: secrets) } - init?(json: [String: Any]) { - guard let errorCode = json["error"] as? String, - let code = OAuth2ErrorCode(rawValue: errorCode) else { return nil } - self.init( - code: code, - description: json["error_description"] as? String, - uri: json["error_uri"] as? String - ) + public var description: String { + var text = code + if let errorDescription { text += " (\(errorDescription))" } + return text } - public init?(dict: [String: String]) { - guard let errorCode = dict["error"], - let code = OAuth2ErrorCode(rawValue: errorCode) else { return nil } - self.init(code: code, description: dict["error_description"], uri: dict["error_uri"]) + static func sanitize(_ text: String?, limit: Int, redacting secrets: [String]) -> String? { + guard var text, !text.isEmpty else { return nil } + for secret in secrets where secret.count >= 8 { // shorter values would mangle ordinary words + text = text.replacingOccurrences(of: secret, with: "") + } + let scalars = text.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) } + var cleaned = String(String.UnicodeScalarView(scalars)) + if cleaned.count > limit { cleaned = String(cleaned.prefix(limit)) + "…" } + return cleaned + } + + /// Recognises the error shapes providers use: + /// - RFC 6749: `{"error": "invalid_grant", "error_description": "...", "error_uri": "..."}` + /// (GitHub sends this with HTTP 200); + /// - Slack: `{"ok": false, "error": "invalid_code"}`; + /// - Facebook Graph: `{"error": {"message": "...", "type": "OAuthException", "code": 190}}`. + static func parse(_ json: JSONValue, redacting secrets: [String]) -> OAuth2ErrorResponse? { + guard let error = json["error"] else { + if json["ok"]?.boolValue == false { + return OAuth2ErrorResponse(code: "unknown", errorDescription: nil, errorURI: nil, redacting: secrets) + } + return nil + } + if let code = error.stringValue { + return OAuth2ErrorResponse( + code: code, + errorDescription: json["error_description"]?.stringValue, + errorURI: json["error_uri"]?.stringValue, + redacting: secrets + ) + } + if error.objectValue != nil { + let code = error["type"]?.stringValue ?? error["code"]?.identifierValue ?? "unknown" + return OAuth2ErrorResponse( + code: code, + errorDescription: error["message"]?.stringValue, + errorURI: nil, + redacting: secrets + ) + } + return nil } } +/// The error codes defined by RFC 6749 §4.1.2.1 and §5.2. public enum OAuth2ErrorCode: String, Sendable { case invalidRequest = "invalid_request" case invalidClient = "invalid_client" @@ -38,18 +143,3 @@ public enum OAuth2ErrorCode: String, Sendable { case serverError = "server_error" case temporarilyUnavailable = "temporarily_unavailable" } - -public struct InvalidAPIResponse: Error, Sendable { - public let description = "Invalid API Response" - public init() {} -} - -public struct APIConnectionError: Error, Sendable { - public let description = "Unable to connect to the external API" - public init() {} -} - -public struct InvalidInput: Error, Sendable { - public let description = "Unexpected error occurred." - public init() {} -} diff --git a/Sources/PerfectOAuth2/OAuth2Provider.swift b/Sources/PerfectOAuth2/OAuth2Provider.swift new file mode 100644 index 0000000..5d18f68 --- /dev/null +++ b/Sources/PerfectOAuth2/OAuth2Provider.swift @@ -0,0 +1,76 @@ +import Foundation + +/// A provider: an `OAuth2` client plus how to read the signed-in user's profile. +/// +/// Conform your own types to add providers this package doesn't ship. Implement `oauth2`, +/// `loginType` and `fetchProfile(token:)`; override `authorizationParameters` and `validate(_:)` if +/// you need extra login-link parameters or token checks. +public protocol OAuth2Provider: Sendable { + var oauth2: OAuth2 { get } + /// Identifies the provider in `OAuthUserProfile.loginType`. + var loginType: String { get } + /// Extra query parameters for every login link (e.g. Google's `hd`). + var authorizationParameters: [URLQueryItem] { get } + /// Checks run on every token from a code exchange, before it is returned (e.g. Google's domain check). + func validate(_ token: OAuth2Token) throws + /// Fetches the user's profile. Must throw (not return an empty id) when there is no user id; + /// `OAuthUserProfile.init` does that for you. + func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile +} + +extension OAuth2Provider { + public var authorizationParameters: [URLQueryItem] { [] } + public func validate(_ token: OAuth2Token) throws {} + + /// Builds the login link; store `pending` in the server-side session. + public func authorizationRequest( + scopes: [String]? = nil, + state: String? = nil, + additionalParameters: [URLQueryItem] = [] + ) throws -> AuthorizationRequest { + try oauth2.authorizationRequest( + scopes: scopes, + state: state, + additionalParameters: authorizationParameters + additionalParameters + ) + } + + /// Checks state, exchanges the code, and runs `validate(_:)`. + public func exchange(callback: AuthorizationCallback, pending: PendingAuthorization) async throws -> OAuth2Token { + let token = try await oauth2.exchange(callback: callback, pending: pending) + try validate(token) + return token + } + + /// Checks state, exchanges the code, and fetches the profile. + public func completeAuthorization( + callback: AuthorizationCallback, + pending: PendingAuthorization + ) async throws -> OAuthUserProfile { + let token = try await exchange(callback: callback, pending: pending) + return try await fetchProfile(token: token) + } + + /// `completeAuthorization` with the callback request's URL (its `code`, `state` and `error`). + public func completeAuthorization(callbackURL: URL, pending: PendingAuthorization) async throws -> OAuthUserProfile { + try await completeAuthorization(callback: AuthorizationCallback(url: callbackURL), pending: pending) + } + + /// `completeAuthorization` with the callback's `code` and `state` parameters. + public func completeAuthorization( + code: String?, + state: String?, + pending: PendingAuthorization + ) async throws -> OAuthUserProfile { + try await completeAuthorization(callback: AuthorizationCallback(code: code, state: state), pending: pending) + } + + /// See `OAuth2.refresh(refreshToken:scopes:includeClientSecret:)`. + public func refresh( + refreshToken: String, + scopes: [String] = [], + includeClientSecret: Bool = true + ) async throws -> OAuth2Token { + try await oauth2.refresh(refreshToken: refreshToken, scopes: scopes, includeClientSecret: includeClientSecret) + } +} diff --git a/Sources/PerfectOAuth2/OAuth2Token.swift b/Sources/PerfectOAuth2/OAuth2Token.swift index 84de75d..d920804 100644 --- a/Sources/PerfectOAuth2/OAuth2Token.swift +++ b/Sources/PerfectOAuth2/OAuth2Token.swift @@ -1,68 +1,106 @@ import Foundation -public class OAuth2Token: @unchecked Sendable { +/// A token endpoint response (RFC 6749 §5.1). +/// +/// `description`, `debugDescription`, `dump` and `Mirror` hide the access, refresh and id tokens. +public struct OAuth2Token: Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { public let accessToken: String - public let refreshToken: String? + /// `token_type`, `Bearer` when the provider omits it. + public let tokenType: String + public var refreshToken: String? { refreshTokenStorage } + private var refreshTokenStorage: String? + /// `expires_in` in seconds, accepted as a JSON number or a numeric string. + public let expiresIn: Int? + /// When the access token expires: the time the response was parsed plus `expiresIn`. public let expiration: Date? - public let tokenType: String? + /// Granted scopes. RFC 6749 says space-delimited; GitHub and Slack use commas. Both are split. + public let scope: [String]? + /// The raw OpenID Connect id_token, if any. It is *not* validated here; see the README's trust + /// model before relying on any claim in it. + public let idToken: String? + /// Salesforce: `instance_url`. public let instanceURL: String? + /// Salesforce: `id`, the identity URL. public let idURL: String? - public let scope: [String]? - public let webToken: [String: Any]? public init( accessToken: String, - tokenType: String, - instanceURL: String? = nil, - idURL: String? = nil, - expiresIn: Int? = nil, + tokenType: String = "Bearer", refreshToken: String? = nil, + expiresIn: Int? = nil, scope: [String]? = nil, - webToken: [String: Any]? = nil + idToken: String? = nil, + instanceURL: String? = nil, + idURL: String? = nil, + now: Date = Date() ) { self.accessToken = accessToken self.tokenType = tokenType - self.refreshToken = refreshToken - self.expiration = expiresIn.map { Date(timeIntervalSinceNow: Double($0)) } + self.refreshTokenStorage = refreshToken + self.expiresIn = expiresIn + self.expiration = expiresIn.map { now.addingTimeInterval(TimeInterval($0)) } self.scope = scope - self.webToken = webToken + self.idToken = idToken self.instanceURL = instanceURL self.idURL = idURL } - public convenience init?(json: [String: Any]) { - guard let accessToken = json["access_token"] as? String else { return nil } - let tokenType = json["token_type"] as? String ?? "Bearer" - let instanceURL = json["instance_url"] as? String - let idURL = json["id"] as? String - let expiresIn = json["expires_in"] as? Int - let refreshToken = json["refresh_token"] as? String - let scope = (json["scope"] as? String)?.components(separatedBy: " ") - let webToken = OAuth2Token.decodeWebToken(json: json) + /// Parses a token response. Throws `OAuth2Error.invalidResponse` without a non-empty `access_token`. + init(json: JSONValue, now: Date = Date()) throws { + guard let accessToken = json["access_token"]?.stringValue, !accessToken.isEmpty else { + throw OAuth2Error.invalidResponse("token response has no access_token") + } + var expiresIn: Int? + if let value = json["expires_in"]?.int64Value, value >= 0, value <= Int64(Int32.max) { + expiresIn = Int(value) + } + let scope = json["scope"]?.stringValue.map { + $0.split(whereSeparator: { $0 == " " || $0 == "," }).map(String.init) + } + let tokenType = json["token_type"]?.stringValue self.init( accessToken: accessToken, - tokenType: tokenType, - instanceURL: instanceURL, - idURL: idURL, + tokenType: (tokenType?.isEmpty == false ? tokenType : nil) ?? "Bearer", + refreshToken: json["refresh_token"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 }, expiresIn: expiresIn, - refreshToken: refreshToken, scope: scope, - webToken: webToken + idToken: json["id_token"]?.stringValue, + instanceURL: json["instance_url"]?.stringValue, + idURL: json["id"]?.stringValue, + now: now ) } - private static func decodeWebToken(json: [String: Any]) -> [String: Any]? { - guard let id = json["id_token"] as? String else { return nil } - let parts = id.components(separatedBy: ".") - guard parts.count >= 2 else { return nil } - var content = parts[1] - let padlen = (4 - content.count % 4) % 4 - content += String(repeating: "=", count: padlen) - guard let data = Data(base64Encoded: content), - let str = String(data: data, encoding: .utf8), - let jsonData = str.data(using: .utf8), - let decoded = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] - else { return nil } - return decoded + /// The same token with `refreshToken` replaced (the refresh grant keeps the old refresh token + /// when the provider doesn't rotate it). + func replacingRefreshToken(_ refreshToken: String?) -> OAuth2Token { + var copy = self + copy.refreshTokenStorage = refreshToken + return copy + } + + public var description: String { + var parts = ["accessToken: ", "tokenType: \(tokenType)"] + parts.append("refreshToken: \(refreshToken == nil ? "nil" : "")") + if let expiration { parts.append("expiration: \(expiration)") } + if let scope { parts.append("scope: \(scope)") } + if idToken != nil { parts.append("idToken: ") } + return "OAuth2Token(\(parts.joined(separator: ", ")))" + } + + public var debugDescription: String { description } + + public var customMirror: Mirror { + Mirror(self, children: [ + "accessToken": "", + "tokenType": tokenType, + "refreshToken": refreshToken == nil ? "nil" : "", + "expiresIn": expiresIn as Any, + "expiration": expiration as Any, + "scope": scope as Any, + "idToken": idToken == nil ? "nil" : "", + "instanceURL": instanceURL as Any, + "idURL": idURL as Any, + ], displayStyle: .struct) } } diff --git a/Sources/PerfectOAuth2/OAuthUserProfile.swift b/Sources/PerfectOAuth2/OAuthUserProfile.swift index 24bb9e6..7d5c805 100644 --- a/Sources/PerfectOAuth2/OAuthUserProfile.swift +++ b/Sources/PerfectOAuth2/OAuthUserProfile.swift @@ -1,27 +1,81 @@ -public struct OAuthUserProfile: Sendable { +import Foundation + +/// The signed-in user, as reported by the provider. +/// +/// `userid` is never empty: the initializer throws `OAuth2Error.missingUserID` instead, so a failed +/// or partial profile fetch can't log everyone into one empty-id account. Key accounts on +/// `(loginType, userid)`. The description and mirror hide the token. +public struct OAuthUserProfile: Sendable, Equatable, CustomStringConvertible, CustomReflectable { + /// The provider's stable user identifier (Google/LinkedIn/Slack `sub`, GitHub and Facebook `id`, + /// Salesforce `user_id`). public let userid: String public let firstName: String? public let lastName: String? + public let displayName: String? public let picture: String? - public let accessToken: String - public let refreshToken: String? + /// The email address the provider returned, if any. Only treat it as belonging to the user when + /// `emailVerified` is true. + public let email: String? + /// True only when the provider asserted that `email` is verified. + public let emailVerified: Bool + /// `"google"`, `"github"`, `"facebook"`, `"slack"`, `"linkedin"`, `"salesforce"`, or a custom + /// provider's `loginType`. public let loginType: String + public let token: OAuth2Token + /// The provider's profile response, for fields not mapped above. + public let raw: JSONValue + + public var accessToken: String { token.accessToken } + public var refreshToken: String? { token.refreshToken } + /// - Throws: `OAuth2Error.missingUserID` if `userid` is nil, empty or only whitespace. public init( - userid: String, + userid: String?, firstName: String? = nil, lastName: String? = nil, + displayName: String? = nil, picture: String? = nil, - accessToken: String, - refreshToken: String? = nil, - loginType: String - ) { + email: String? = nil, + emailVerified: Bool = false, + loginType: String, + token: OAuth2Token, + raw: JSONValue = .null + ) throws { + guard let userid, !userid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw OAuth2Error.missingUserID + } + func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } self.userid = userid - self.firstName = firstName - self.lastName = lastName - self.picture = picture - self.accessToken = accessToken - self.refreshToken = refreshToken + self.firstName = nonEmpty(firstName) + self.lastName = nonEmpty(lastName) + self.displayName = nonEmpty(displayName) + self.picture = nonEmpty(picture) + self.email = nonEmpty(email) + self.emailVerified = emailVerified && nonEmpty(email) != nil self.loginType = loginType + self.token = token + self.raw = raw + } + + public var description: String { + "OAuthUserProfile(loginType: \(loginType), userid: \(userid), displayName: \(displayName ?? "nil"), token: )" + } + + public var customMirror: Mirror { + Mirror(self, children: [ + "userid": userid, + "firstName": firstName as Any, + "lastName": lastName as Any, + "displayName": displayName as Any, + "picture": picture as Any, + "email": email as Any, + "emailVerified": emailVerified, + "loginType": loginType, + "token": token, + "raw": raw, + ], displayStyle: .struct) } } diff --git a/Sources/PerfectOAuth2/Providers/Common.swift b/Sources/PerfectOAuth2/Providers/Common.swift deleted file mode 100644 index 290deed..0000000 --- a/Sources/PerfectOAuth2/Providers/Common.swift +++ /dev/null @@ -1,15 +0,0 @@ -func digIntoDictionary(mineFor: [String], data: [String: Any]) -> Any { - if mineFor.isEmpty { return "" } - for (key, value) in data { - if key == mineFor[0] { - var remaining = mineFor - remaining.removeFirst() - if remaining.isEmpty { - return value - } else if let nested = value as? [String: Any] { - return digIntoDictionary(mineFor: remaining, data: nested) - } - } - } - return "" -} diff --git a/Sources/PerfectOAuth2/Providers/Facebook.swift b/Sources/PerfectOAuth2/Providers/Facebook.swift index d1988bc..1bedee7 100644 --- a/Sources/PerfectOAuth2/Providers/Facebook.swift +++ b/Sources/PerfectOAuth2/Providers/Facebook.swift @@ -1,68 +1,55 @@ +import Crypto import Foundation -public struct FacebookConfig { - nonisolated(unsafe) public static var appid = "" - nonisolated(unsafe) public static var secret = "" - nonisolated(unsafe) public static var endpointAfterAuth = "" - nonisolated(unsafe) public static var redirectAfterAuth = "" - public init() {} -} +/// Facebook Login (manual flow) and the Graph API `/me` endpoint. +/// +/// - The Graph API version is configurable (default `v23.0`; a value that isn't ASCII letters, digits +/// and dots falls back to it). Meta retires versions about two years after release, so bump it. +/// - Default scopes: none (`public_profile` is implied). +/// - PKCE off by default: Meta documents PKCE for some Facebook Login flows, but not clearly for the +/// web code flow with a client secret. Enable it with `usePKCE: true` if your app is set up for it. +/// - The access token is sent in the `Authorization: Bearer` header, not the query string, along with +/// `appsecret_proof` (HMAC-SHA256 of the token keyed with the app secret) when a secret is configured, +/// which apps with "Require App Secret" need. +public final class Facebook: OAuth2Provider { + public let oauth2: OAuth2 + public let loginType = "facebook" + public let graphAPIVersion: String -public class Facebook: OAuth2, @unchecked Sendable { - public init(clientID: String, clientSecret: String) { - super.init( - clientID: clientID, - clientSecret: clientSecret, - authorizationURL: "https://www.facebook.com/dialog/oauth", - tokenURL: "https://graph.facebook.com/v2.3/oauth/access_token" + public init(configuration: OAuth2ClientConfiguration, graphAPIVersion: String = "v23.0") { + let version = !graphAPIVersion.isEmpty && graphAPIVersion.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == ".") }) ? graphAPIVersion : "v23.0" + self.graphAPIVersion = version + oauth2 = OAuth2( + configuration: configuration, + authorizationURL: URL(string: "https://www.facebook.com/\(version)/dialog/oauth")!, + tokenURL: URL(string: "https://graph.facebook.com/\(version)/oauth/access_token")!, + defaultScopes: [], + usesPKCEByDefault: false ) } - public func getUserData(_ accessToken: String) async -> [String: Any] { - let fields = ["id", "first_name", "last_name", "picture"].joined(separator: "%2C") - let url = "https://graph.facebook.com/v2.8/me?fields=\(fields)&access_token=\(accessToken)" - let data = await makeRequest(.get, url) - var out = [String: Any]() - if let n = data["id"] as? String { out["userid"] = n } - if let n = data["first_name"] as? String { out["first_name"] = n } - if let n = data["last_name"] as? String { out["last_name"] = n } - out["picture"] = digIntoDictionary(mineFor: ["picture", "data", "url"], data: data) as? String ?? "" - return out - } - - public func loginURL(state: String, sessionToken: String, scopes: [String] = []) -> String { - let redirectURL = "\(FacebookConfig.endpointAfterAuth)?session=\(sessionToken)" - return getLoginLink(redirectURL: redirectURL, state: state, scopes: scopes) - } - - public func exchange(code: String, state: String, sessionToken: String) async throws -> OAuth2Token { - let redirectURL = "\(FacebookConfig.endpointAfterAuth)?session=\(sessionToken)" - return try await exchange(code: code, state: state, redirectURL: redirectURL) - } - - public static func loginURL(state: String, sessionToken: String, scopes: [String] = []) -> String { - Facebook(clientID: FacebookConfig.appid, clientSecret: FacebookConfig.secret) - .loginURL(state: state, sessionToken: sessionToken, scopes: scopes) + public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + var items = [URLQueryItem(name: "fields", value: "id,name,first_name,last_name,picture")] + if let secret = oauth2.configuration.clientSecret { + items.append(URLQueryItem(name: "appsecret_proof", value: Self.appSecretProof(accessToken: token.accessToken, appSecret: secret))) + } + let url = try appendingQueryItems(items, to: URL(string: "https://graph.facebook.com/\(graphAPIVersion)/me")!) + let me = try await oauth2.getJSON(url, accessToken: token.accessToken) + return try OAuthUserProfile( + userid: me["id"]?.identifierValue, + firstName: me["first_name"]?.stringValue, + lastName: me["last_name"]?.stringValue, + displayName: me["name"]?.stringValue, + picture: me["picture"]?["data"]?["url"]?.stringValue, + loginType: loginType, + token: token, + raw: me + ) } - public static func processAuthResponse( - code: String, - state: String, - sessionCSRF: String, - sessionToken: String - ) async throws -> OAuthUserProfile { - guard state == sessionCSRF else { throw OAuth2Error(code: .unsupportedResponseType) } - let provider = Facebook(clientID: FacebookConfig.appid, clientSecret: FacebookConfig.secret) - let token = try await provider.exchange(code: code, state: state, sessionToken: sessionToken) - let userdata = await provider.getUserData(token.accessToken) - return OAuthUserProfile( - userid: userdata["userid"] as? String ?? "", - firstName: userdata["first_name"] as? String, - lastName: userdata["last_name"] as? String, - picture: userdata["picture"] as? String, - accessToken: token.accessToken, - refreshToken: token.refreshToken, - loginType: "facebook" - ) + /// Lowercase hex HMAC-SHA256 of the access token, keyed with the app secret. + static func appSecretProof(accessToken: String, appSecret: String) -> String { + let mac = HMAC.authenticationCode(for: Data(accessToken.utf8), using: SymmetricKey(data: Data(appSecret.utf8))) + return mac.map { String(format: "%02x", $0) }.joined() } } diff --git a/Sources/PerfectOAuth2/Providers/GitHub.swift b/Sources/PerfectOAuth2/Providers/GitHub.swift index 26c595b..fa2b787 100644 --- a/Sources/PerfectOAuth2/Providers/GitHub.swift +++ b/Sources/PerfectOAuth2/Providers/GitHub.swift @@ -1,69 +1,57 @@ import Foundation -public struct GitHubConfig { - nonisolated(unsafe) public static var appid = "" - nonisolated(unsafe) public static var secret = "" - nonisolated(unsafe) public static var endpointAfterAuth = "" - nonisolated(unsafe) public static var redirectAfterAuth = "" - public init() {} -} - -public class GitHub: OAuth2, @unchecked Sendable { - public init(clientID: String, clientSecret: String) { - super.init( - clientID: clientID, - clientSecret: clientSecret, - authorizationURL: "https://github.com/login/oauth/authorize", - tokenURL: "https://github.com/login/oauth/access_token" - ) - } - - public func getUserData(_ accessToken: String) async -> [String: Any] { - let data = await makeRequest(.get, "https://api.github.com/user", bearerToken: accessToken) - var out = [String: Any]() - if let n = data["id"] { out["userid"] = "\(n)" } - if let n = data["name"] as? String { - let parts = n.components(separatedBy: " ") - if !parts.isEmpty { out["first_name"] = parts.first } - if parts.count > 1 { out["last_name"] = parts.last } - } - if let n = data["avatar_url"] as? String { out["picture"] = n } - return out - } +/// GitHub OAuth apps (and GitHub Apps' user authorization). +/// +/// - Endpoints: `github.com/login/oauth/authorize`, `github.com/login/oauth/access_token`, and the +/// REST API's `api.github.com/user`. +/// - Default scope `read:user` (1.x requested `user`, which also grants profile *write* access). +/// - PKCE on by default (GitHub added S256 support in 2025). +/// - GitHub reports token errors with HTTP 200 and an `error` field; they surface as +/// `OAuth2Error.providerError` (e.g. `bad_verification_code`). +/// - `email` is the public profile email and is never marked verified; use the `user:email` scope and +/// `GET /user/emails` if you need a verified address. +public final class GitHub: OAuth2Provider { + public let oauth2: OAuth2 + public let loginType = "github" - public func loginURL(state: String, sessionToken: String, scopes: [String] = []) -> String { - let redirectURL = "\(GitHubConfig.endpointAfterAuth)?session=\(sessionToken)" - return getLoginLink(redirectURL: redirectURL, state: state, scopes: scopes) - } - - public func exchange(code: String, state: String, sessionToken: String) async throws -> OAuth2Token { - let redirectURL = "\(GitHubConfig.endpointAfterAuth)?session=\(sessionToken)" - return try await exchange(code: code, state: state, redirectURL: redirectURL) - } + static let userURL = URL(string: "https://api.github.com/user")! - public static func loginURL(state: String, sessionToken: String, scopes: [String] = ["user"]) -> String { - GitHub(clientID: GitHubConfig.appid, clientSecret: GitHubConfig.secret) - .loginURL(state: state, sessionToken: sessionToken, scopes: scopes) + public init(configuration: OAuth2ClientConfiguration) { + oauth2 = OAuth2( + configuration: configuration, + authorizationURL: URL(string: "https://github.com/login/oauth/authorize")!, + tokenURL: URL(string: "https://github.com/login/oauth/access_token")!, + defaultScopes: ["read:user"], + usesPKCEByDefault: true + ) } - public static func processAuthResponse( - code: String, - state: String, - sessionCSRF: String, - sessionToken: String - ) async throws -> OAuthUserProfile { - guard state == sessionCSRF else { throw OAuth2Error(code: .unsupportedResponseType) } - let provider = GitHub(clientID: GitHubConfig.appid, clientSecret: GitHubConfig.secret) - let token = try await provider.exchange(code: code, state: state, sessionToken: sessionToken) - let userdata = await provider.getUserData(token.accessToken) - return OAuthUserProfile( - userid: userdata["userid"] as? String ?? "", - firstName: userdata["first_name"] as? String, - lastName: userdata["last_name"] as? String, - picture: userdata["picture"] as? String, - accessToken: token.accessToken, - refreshToken: token.refreshToken, - loginType: "github" + public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + let user = try await oauth2.getJSON(Self.userURL, accessToken: token.accessToken, headers: [ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ]) + let name = user["name"]?.stringValue + let (first, last) = splitName(name) + return try OAuthUserProfile( + userid: user["id"]?.identifierValue, + firstName: first, + lastName: last, + displayName: name ?? user["login"]?.stringValue, + picture: user["avatar_url"]?.stringValue, + email: user["email"]?.stringValue, + emailVerified: false, + loginType: loginType, + token: token, + raw: user ) } } + +/// Splits "First Middle Last" into ("First", "Last"); a single word is a first name. +func splitName(_ name: String?) -> (String?, String?) { + guard let name else { return (nil, nil) } + let parts = name.split(separator: " ").map(String.init) + guard let first = parts.first else { return (nil, nil) } + return (first, parts.count > 1 ? parts.last : nil) +} diff --git a/Sources/PerfectOAuth2/Providers/Google.swift b/Sources/PerfectOAuth2/Providers/Google.swift index f0a3e81..d298d18 100644 --- a/Sources/PerfectOAuth2/Providers/Google.swift +++ b/Sources/PerfectOAuth2/Providers/Google.swift @@ -1,112 +1,96 @@ import Foundation -public struct GoogleConfig { - nonisolated(unsafe) public static var appid = "" - nonisolated(unsafe) public static var secret = "" - nonisolated(unsafe) public static var endpointAfterAuth = "" - nonisolated(unsafe) public static var redirectAfterAuth = "" - nonisolated(unsafe) public static var restrictedDomain: String? = nil - public init() {} -} +/// Google sign-in (OpenID Connect over the authorization-code flow). +/// +/// - Endpoints: `accounts.google.com/o/oauth2/v2/auth`, `oauth2.googleapis.com/token`, and the OIDC +/// userinfo endpoint `openidconnect.googleapis.com/v1/userinfo`. +/// - Default scopes `openid profile`; `openid` is always added, so every exchange returns an id_token. +/// - PKCE on by default. +/// - Every id_token from the exchange is checked: issuer, audience (this client ID), expiry and +/// subject. With `restrictedDomain`, its `hd` claim must match (see the README's trust model). +public final class Google: OAuth2Provider { + public let oauth2: OAuth2 + public let loginType = "google" + /// Only accept accounts in this Google Workspace domain (the id_token's `hd` claim). + public let restrictedDomain: String? -public class Google: OAuth2, @unchecked Sendable { - public init(clientID: String, clientSecret: String) { - super.init( - clientID: clientID, - clientSecret: clientSecret, - authorizationURL: "https://accounts.google.com/o/oauth2/auth", - tokenURL: "https://www.googleapis.com/oauth2/v4/token" - ) - } + static let issuers: Set = ["https://accounts.google.com", "accounts.google.com"] + static let userInfoURL = URL(string: "https://openidconnect.googleapis.com/v1/userinfo")! - public func getUserData(_ accessToken: String) async -> [String: Any] { - let fields = ["family_name", "given_name", "id", "picture"].joined(separator: "%2C") - let url = "https://www.googleapis.com/oauth2/v2/userinfo?fields=\(fields)&access_token=\(accessToken)" - let data = await makeRequest(.get, url) - var out = [String: Any]() - if let n = data["id"] as? String { out["userid"] = n } - if let n = data["given_name"] as? String { out["first_name"] = n } - if let n = data["family_name"] as? String { out["last_name"] = n } - if let n = data["picture"] as? String { out["picture"] = n } - return out + public init(configuration: OAuth2ClientConfiguration, restrictedDomain: String? = nil) { + var configuration = configuration + if let scopes = configuration.scopes, !scopes.contains("openid") { + configuration.scopes = ["openid"] + scopes + } + self.oauth2 = OAuth2( + configuration: configuration, + authorizationURL: URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!, + tokenURL: URL(string: "https://oauth2.googleapis.com/token")!, + defaultScopes: ["openid", "profile"], + usesPKCEByDefault: true + ) + self.restrictedDomain = restrictedDomain?.lowercased() } - /// Builds the Google sign-in link without requesting offline access. - public func loginURL(state: String, sessionToken: String, scopes: [String] = ["profile"]) -> String { - loginURL(state: state, sessionToken: sessionToken, scopes: scopes, offlineAccess: false) + public var authorizationParameters: [URLQueryItem] { + restrictedDomain.map { [URLQueryItem(name: "hd", value: $0)] } ?? [] } - /// Builds the Google sign-in link. + /// Builds the login link. /// /// - Parameter offlineAccess: request a refresh token. Adds `access_type=offline` and /// `prompt=consent`: Google only issues a refresh token the first time a user grants offline - /// access or when consent is prompted again, so without `prompt=consent` a returning user would - /// get none. Pass `false` for ordinary sign-ins once a refresh token is stored, so users aren't - /// asked to consent every time. - public func loginURL( - state: String, - sessionToken: String, - scopes: [String] = ["profile"], - offlineAccess: Bool - ) -> String { - let redirectURL = "\(GoogleConfig.endpointAfterAuth)?session=\(sessionToken)" - var url = getLoginLink(redirectURL: redirectURL, state: state, scopes: scopes) - if let domain = GoogleConfig.restrictedDomain { - url += "&hd=\(domain)" - } + /// access or when consent is prompted again. Leave it off for routine sign-ins once a refresh + /// token is stored, so users aren't asked to consent every time. + public func authorizationRequest( + scopes: [String]? = nil, + state: String? = nil, + offlineAccess: Bool, + additionalParameters: [URLQueryItem] = [] + ) throws -> AuthorizationRequest { + var extra = additionalParameters if offlineAccess { - url += "&access_type=offline&prompt=consent" - } - return url - } - - public override func exchange(code: String, state: String, redirectURL: String) async throws -> OAuth2Token { - let token = try await super.exchange(code: code, state: state, redirectURL: redirectURL) - if let domain = GoogleConfig.restrictedDomain { - guard let hd = token.webToken?["hd"] as? String, hd == domain else { - throw OAuth2Error(code: .unsupportedResponseType) - } + extra += [URLQueryItem(name: "access_type", value: "offline"), URLQueryItem(name: "prompt", value: "consent")] } - return token - } - - public func exchange(code: String, state: String, sessionToken: String) async throws -> OAuth2Token { - let redirectURL = "\(GoogleConfig.endpointAfterAuth)?session=\(sessionToken)" - return try await exchange(code: code, state: state, redirectURL: redirectURL) + let scopes = scopes.map { $0.contains("openid") ? $0 : ["openid"] + $0 } + return try authorizationRequest(scopes: scopes, state: state, additionalParameters: extra) } - public static func loginURL(state: String, sessionToken: String, scopes: [String] = ["profile"]) -> String { - loginURL(state: state, sessionToken: sessionToken, scopes: scopes, offlineAccess: false) + public func validate(_ token: OAuth2Token) throws { + _ = try validatedClaims(token) } - public static func loginURL( - state: String, - sessionToken: String, - scopes: [String] = ["profile"], - offlineAccess: Bool - ) -> String { - Google(clientID: GoogleConfig.appid, clientSecret: GoogleConfig.secret) - .loginURL(state: state, sessionToken: sessionToken, scopes: scopes, offlineAccess: offlineAccess) + func validatedClaims(_ token: OAuth2Token) throws -> IDTokenClaims? { + guard let idToken = token.idToken else { + if restrictedDomain != nil { throw OAuth2Error.invalidIDToken("no id_token; request the openid scope") } + return nil + } + let claims = try IDTokenClaims.validate(idToken, issuers: Self.issuers, clientID: oauth2.clientID) + if let restrictedDomain { + guard claims.claims["hd"]?.stringValue?.lowercased() == restrictedDomain else { + throw OAuth2Error.restrictedDomainMismatch + } + } + return claims } - public static func processAuthResponse( - code: String, - state: String, - sessionCSRF: String, - sessionToken: String - ) async throws -> OAuthUserProfile { - guard state == sessionCSRF else { throw OAuth2Error(code: .unsupportedResponseType) } - let provider = Google(clientID: GoogleConfig.appid, clientSecret: GoogleConfig.secret) - let token = try await provider.exchange(code: code, state: state, sessionToken: sessionToken) - let userdata = await provider.getUserData(token.accessToken) - return OAuthUserProfile( - userid: userdata["userid"] as? String ?? "", - firstName: userdata["first_name"] as? String, - lastName: userdata["last_name"] as? String, - picture: userdata["picture"] as? String, - accessToken: token.accessToken, - refreshToken: token.refreshToken, - loginType: "google" + public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + let claims = try validatedClaims(token) + let info = try await oauth2.getJSON(Self.userInfoURL, accessToken: token.accessToken) + if let claims, let sub = info["sub"]?.stringValue, sub != claims.subject { + throw OAuth2Error.invalidResponse("userinfo subject differs from the id_token subject") + } + return try OAuthUserProfile( + userid: info["sub"]?.stringValue, + firstName: info["given_name"]?.stringValue, + lastName: info["family_name"]?.stringValue, + displayName: info["name"]?.stringValue, + picture: info["picture"]?.stringValue, + email: info["email"]?.stringValue, + emailVerified: info["email_verified"]?.boolValue == true, + loginType: loginType, + token: token, + raw: info ) } } diff --git a/Sources/PerfectOAuth2/Providers/LinkedIn.swift b/Sources/PerfectOAuth2/Providers/LinkedIn.swift index 5ab5fa1..95271b2 100644 --- a/Sources/PerfectOAuth2/Providers/LinkedIn.swift +++ b/Sources/PerfectOAuth2/Providers/LinkedIn.swift @@ -1,66 +1,41 @@ import Foundation -public struct LinkedinConfig { - nonisolated(unsafe) public static var appid = "" - nonisolated(unsafe) public static var secret = "" - nonisolated(unsafe) public static var endpointAfterAuth = "" - nonisolated(unsafe) public static var redirectAfterAuth = "" - public init() {} -} - -public class Linkedin: OAuth2, @unchecked Sendable { - public init(clientID: String, clientSecret: String) { - super.init( - clientID: clientID, - clientSecret: clientSecret, - authorizationURL: "https://www.linkedin.com/oauth/v2/authorization", - tokenURL: "https://www.linkedin.com/oauth/v2/accessToken" - ) - } - - // Uses LinkedIn OpenID Connect userinfo endpoint (v2, requires openid + profile scopes) - public func getUserData(_ accessToken: String) async -> [String: Any] { - let data = await makeRequest(.get, "https://api.linkedin.com/v2/userinfo", bearerToken: accessToken) - var out = [String: Any]() - if let n = data["sub"] as? String { out["userid"] = n } - if let n = data["given_name"] as? String { out["first_name"] = n } - if let n = data["family_name"] as? String { out["last_name"] = n } - if let n = data["picture"] as? String { out["picture"] = n } - return out - } +/// Sign In with LinkedIn using OpenID Connect. +/// +/// - Endpoints: `www.linkedin.com/oauth/v2/authorization`, `www.linkedin.com/oauth/v2/accessToken`, +/// and the OIDC userinfo endpoint `api.linkedin.com/v2/userinfo` (the `r_liteprofile` APIs are gone). +/// - Default scopes `openid profile`; add `email` for `email`/`email_verified`. +/// - PKCE off by default: LinkedIn documents PKCE only for its native-client flow, which needs +/// LinkedIn's approval, so web apps should leave it off. +public final class LinkedIn: OAuth2Provider { + public let oauth2: OAuth2 + public let loginType = "linkedin" - public func loginURL(state: String, sessionToken: String, scopes: [String] = ["openid", "profile"]) -> String { - let redirectURL = LinkedinConfig.endpointAfterAuth - return getLoginLink(redirectURL: redirectURL, state: state, scopes: scopes) - } + static let userInfoURL = URL(string: "https://api.linkedin.com/v2/userinfo")! - public func exchange(code: String, state: String, sessionToken: String) async throws -> OAuth2Token { - return try await exchange(code: code, state: state, redirectURL: LinkedinConfig.endpointAfterAuth) - } - - public static func loginURL(state: String, sessionToken: String, scopes: [String] = ["openid", "profile"]) -> String { - Linkedin(clientID: LinkedinConfig.appid, clientSecret: LinkedinConfig.secret) - .loginURL(state: state, sessionToken: sessionToken, scopes: scopes) + public init(configuration: OAuth2ClientConfiguration) { + oauth2 = OAuth2( + configuration: configuration, + authorizationURL: URL(string: "https://www.linkedin.com/oauth/v2/authorization")!, + tokenURL: URL(string: "https://www.linkedin.com/oauth/v2/accessToken")!, + defaultScopes: ["openid", "profile"], + usesPKCEByDefault: false + ) } - public static func processAuthResponse( - code: String, - state: String, - sessionCSRF: String, - sessionToken: String - ) async throws -> OAuthUserProfile { - guard state == sessionCSRF else { throw OAuth2Error(code: .unsupportedResponseType) } - let provider = Linkedin(clientID: LinkedinConfig.appid, clientSecret: LinkedinConfig.secret) - let token = try await provider.exchange(code: code, state: state, sessionToken: sessionToken) - let userdata = await provider.getUserData(token.accessToken) - return OAuthUserProfile( - userid: userdata["userid"] as? String ?? "", - firstName: userdata["first_name"] as? String, - lastName: userdata["last_name"] as? String, - picture: userdata["picture"] as? String, - accessToken: token.accessToken, - refreshToken: token.refreshToken, - loginType: "linkedin" + public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + let info = try await oauth2.getJSON(Self.userInfoURL, accessToken: token.accessToken) + return try OAuthUserProfile( + userid: info["sub"]?.stringValue, + firstName: info["given_name"]?.stringValue, + lastName: info["family_name"]?.stringValue, + displayName: info["name"]?.stringValue, + picture: info["picture"]?.stringValue, + email: info["email"]?.stringValue, + emailVerified: info["email_verified"]?.boolValue == true, + loginType: loginType, + token: token, + raw: info ) } } diff --git a/Sources/PerfectOAuth2/Providers/Removed.swift b/Sources/PerfectOAuth2/Providers/Removed.swift new file mode 100644 index 0000000..5a1bbf2 --- /dev/null +++ b/Sources/PerfectOAuth2/Providers/Removed.swift @@ -0,0 +1,28 @@ +// Names removed in 2.0.0, kept as unavailable declarations so old call sites get a pointer to the +// replacement instead of "cannot find in scope". See the README's "Migrating to 2.0". + + +@available(*, unavailable, message: "2.0 removed the global config: pass an OAuth2ClientConfiguration to Google(configuration:restrictedDomain:)") +public enum GoogleConfig {} +@available(*, unavailable, message: "2.0 removed the global config: pass an OAuth2ClientConfiguration to GitHub(configuration:)") +public enum GitHubConfig {} +@available(*, unavailable, message: "2.0 removed the global config: pass an OAuth2ClientConfiguration to Facebook(configuration:)") +public enum FacebookConfig {} +@available(*, unavailable, message: "2.0 removed the global config: pass an OAuth2ClientConfiguration to Slack(configuration:)") +public enum SlackConfig {} +@available(*, unavailable, message: "2.0 removed the global config: pass an OAuth2ClientConfiguration to LinkedIn(configuration:)") +public enum LinkedinConfig {} +@available(*, unavailable, message: "2.0 removed the global config: pass an OAuth2ClientConfiguration to Salesforce(configuration:loginHost:)") +public enum SalesForceConfig {} + +@available(*, unavailable, renamed: "LinkedIn") +public typealias Linkedin = LinkedIn +@available(*, unavailable, renamed: "Salesforce") +public typealias SalesForce = Salesforce + +@available(*, unavailable, message: "use PendingAuthorization and completeAuthorization(callback:pending:)") +public struct AuthorizationCode {} +@available(*, unavailable, message: "errors are now OAuth2Error cases") +public struct InvalidAPIResponse {} +@available(*, unavailable, message: "errors are now OAuth2Error cases (.network, .timedOut)") +public struct APIConnectionError {} diff --git a/Sources/PerfectOAuth2/Providers/Salesforce.swift b/Sources/PerfectOAuth2/Providers/Salesforce.swift index 69a31a8..6919ce7 100644 --- a/Sources/PerfectOAuth2/Providers/Salesforce.swift +++ b/Sources/PerfectOAuth2/Providers/Salesforce.swift @@ -1,68 +1,62 @@ import Foundation -public struct SalesForceConfig { - nonisolated(unsafe) public static var appid = "" - nonisolated(unsafe) public static var secret = "" - nonisolated(unsafe) public static var endpointAfterAuth = "" - nonisolated(unsafe) public static var redirectAfterAuth = "" - public init() {} -} +/// Salesforce (production, sandbox or My Domain login hosts). +/// +/// - Endpoints: `/services/oauth2/authorize` and `/services/oauth2/token`; the profile +/// comes from the identity URL (`id`) in the token response. +/// - Default scope `id`. Add `refresh_token` to get a refresh token. +/// - PKCE on by default (Salesforce supports S256, and newer connected apps require it). +/// - The identity URL must be HTTPS on a Salesforce host (`*.salesforce.com`, +/// `*.salesforce.mil`, or the login host) before the access token is sent to it. +public final class Salesforce: OAuth2Provider { + public let oauth2: OAuth2 + public let loginType = "salesforce" + public let loginHost: String -public class SalesForce: OAuth2, @unchecked Sendable { - public init(clientID: String, clientSecret: String) { - super.init( - clientID: clientID, - clientSecret: clientSecret, - authorizationURL: "https://login.salesforce.com/services/oauth2/authorize", - tokenURL: "https://login.salesforce.com/services/oauth2/token" + /// - Parameter loginHost: `login.salesforce.com` (default), `test.salesforce.com` for sandboxes, + /// or a My Domain host such as `example.my.salesforce.com`. + public init(configuration: OAuth2ClientConfiguration, loginHost: String = "login.salesforce.com") throws { + let host = loginHost.lowercased() + guard !host.isEmpty, host.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "-") }), + let authorizationURL = URL(string: "https://\(host)/services/oauth2/authorize"), + let tokenURL = URL(string: "https://\(host)/services/oauth2/token") else { + throw OAuth2Error.invalidConfiguration("loginHost must be a host name") + } + self.loginHost = host + oauth2 = OAuth2( + configuration: configuration, + authorizationURL: authorizationURL, + tokenURL: tokenURL, + defaultScopes: ["id"], + usesPKCEByDefault: true ) } - // idURL is the user info endpoint returned in the token response - public func getUserData(_ accessToken: String, _ idURL: String) async -> [String: Any] { - let data = await makeRequest(.get, idURL, bearerToken: accessToken) - var out = [String: Any]() - if let n = data["user_id"] as? String { out["userid"] = n } - if let n = data["first_name"] as? String { out["first_name"] = n } - if let n = data["last_name"] as? String { out["last_name"] = n } - out["picture"] = digIntoDictionary(mineFor: ["photos", "picture"], data: data) as? String ?? "" - return out - } - - public func loginURL(state: String, sessionToken: String, scopes: [String] = ["id"]) -> String { - let redirectURL = "\(SalesForceConfig.endpointAfterAuth)?session=\(sessionToken)" - return getLoginLink(redirectURL: redirectURL, state: state, scopes: scopes) - } - - public func exchange(code: String, state: String, sessionToken: String) async throws -> OAuth2Token { - let redirectURL = "\(SalesForceConfig.endpointAfterAuth)?session=\(sessionToken)" - return try await exchange(code: code, state: state, redirectURL: redirectURL) - } - - public static func loginURL(state: String, sessionToken: String, scopes: [String] = ["id"]) -> String { - SalesForce(clientID: SalesForceConfig.appid, clientSecret: SalesForceConfig.secret) - .loginURL(state: state, sessionToken: sessionToken, scopes: scopes) + func isSalesforceHost(_ host: String?) -> Bool { + guard let host = host?.lowercased() else { return false } + if host == loginHost { return true } + return [".salesforce.com", ".salesforce.mil"].contains { host.hasSuffix($0) } } - public static func processAuthResponse( - code: String, - state: String, - sessionCSRF: String, - sessionToken: String - ) async throws -> OAuthUserProfile { - guard state == sessionCSRF else { throw OAuth2Error(code: .unsupportedResponseType) } - let provider = SalesForce(clientID: SalesForceConfig.appid, clientSecret: SalesForceConfig.secret) - let token = try await provider.exchange(code: code, state: state, sessionToken: sessionToken) - guard let idURL = token.idURL else { throw InvalidAPIResponse() } - let userdata = await provider.getUserData(token.accessToken, idURL) - return OAuthUserProfile( - userid: userdata["userid"] as? String ?? "", - firstName: userdata["first_name"] as? String, - lastName: userdata["last_name"] as? String, - picture: userdata["picture"] as? String, - accessToken: token.accessToken, - refreshToken: token.refreshToken, - loginType: "salesforce" + public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + guard let idURLString = token.idURL, let idURL = URL(string: idURLString) else { + throw OAuth2Error.invalidResponse("token response has no identity URL (id)") + } + guard idURL.scheme?.lowercased() == "https", isSalesforceHost(idURL.host) else { + throw OAuth2Error.insecureURL("identity URL is not an https Salesforce URL") + } + let identity = try await oauth2.getJSON(idURL, accessToken: token.accessToken) + return try OAuthUserProfile( + userid: identity["user_id"]?.stringValue, + firstName: identity["first_name"]?.stringValue, + lastName: identity["last_name"]?.stringValue, + displayName: identity["display_name"]?.stringValue, + picture: identity["photos"]?["picture"]?.stringValue, + email: identity["email"]?.stringValue, + emailVerified: identity["email_verified"]?.boolValue == true, + loginType: loginType, + token: token, + raw: identity ) } } diff --git a/Sources/PerfectOAuth2/Providers/Slack.swift b/Sources/PerfectOAuth2/Providers/Slack.swift index d2934f2..35ed334 100644 --- a/Sources/PerfectOAuth2/Providers/Slack.swift +++ b/Sources/PerfectOAuth2/Providers/Slack.swift @@ -1,69 +1,46 @@ import Foundation -public struct SlackConfig { - nonisolated(unsafe) public static var appid = "" - nonisolated(unsafe) public static var secret = "" - nonisolated(unsafe) public static var endpointAfterAuth = "" - nonisolated(unsafe) public static var redirectAfterAuth = "" - public init() {} -} - -public class Slack: OAuth2, @unchecked Sendable { - public init(clientID: String, clientSecret: String) { - super.init( - clientID: clientID, - clientSecret: clientSecret, - authorizationURL: "https://slack.com/oauth/authorize", - tokenURL: "https://slack.com/api/oauth.access" - ) - } - - public func getUserData(_ accessToken: String) async -> [String: Any] { - let url = "https://slack.com/api/users.identity?token=\(accessToken)" - let data = await makeRequest(.get, url) - var out = [String: Any]() - out["userid"] = digIntoDictionary(mineFor: ["user", "id"], data: data) as? String ?? "" - let fullName = digIntoDictionary(mineFor: ["user", "name"], data: data) as? String ?? "" - let parts = fullName.components(separatedBy: " ") - if !parts.isEmpty { out["first_name"] = parts.first } - if parts.count > 1 { out["last_name"] = parts.last } - out["picture"] = digIntoDictionary(mineFor: ["user", "image_192"], data: data) as? String ?? "" - return out - } +/// Sign in with Slack (OpenID Connect). +/// +/// - Endpoints: `slack.com/openid/connect/authorize`, `slack.com/api/openid.connect.token`, and +/// `slack.com/api/openid.connect.userInfo`. 1.x used the legacy `oauth/authorize` + +/// `users.identity` flow with `identity.basic` scopes, which only classic Slack apps can use. +/// - Default scopes `openid profile`; add `email` for the address. +/// - PKCE off by default (Slack's PKCE support is recent and not documented for this flow; enable it +/// with `usePKCE: true` if your app supports it). +/// - Slack errors come back as HTTP 200 `{"ok": false, "error": "..."}`; they surface as +/// `OAuth2Error.providerError`. +/// - Slack user ids are scoped to a workspace: key accounts on `userid` together with the team id in +/// `raw["https://slack.com/team_id"]` if users can come from several workspaces. +public final class Slack: OAuth2Provider { + public let oauth2: OAuth2 + public let loginType = "slack" - public func loginURL(state: String, sessionToken: String, scopes: [String] = ["identity.basic", "identity.avatar"]) -> String { - let redirectURL = "\(SlackConfig.endpointAfterAuth)?session=\(sessionToken)" - return getLoginLink(redirectURL: redirectURL, state: state, scopes: scopes) - } + static let userInfoURL = URL(string: "https://slack.com/api/openid.connect.userInfo")! - public func exchange(code: String, state: String, sessionToken: String) async throws -> OAuth2Token { - let redirectURL = "\(SlackConfig.endpointAfterAuth)?session=\(sessionToken)" - return try await exchange(code: code, state: state, redirectURL: redirectURL) - } - - public static func loginURL(state: String, sessionToken: String, scopes: [String] = ["identity.basic", "identity.avatar"]) -> String { - Slack(clientID: SlackConfig.appid, clientSecret: SlackConfig.secret) - .loginURL(state: state, sessionToken: sessionToken, scopes: scopes) + public init(configuration: OAuth2ClientConfiguration) { + oauth2 = OAuth2( + configuration: configuration, + authorizationURL: URL(string: "https://slack.com/openid/connect/authorize")!, + tokenURL: URL(string: "https://slack.com/api/openid.connect.token")!, + defaultScopes: ["openid", "profile"], + usesPKCEByDefault: false + ) } - public static func processAuthResponse( - code: String, - state: String, - sessionCSRF: String, - sessionToken: String - ) async throws -> OAuthUserProfile { - guard state == sessionCSRF else { throw OAuth2Error(code: .unsupportedResponseType) } - let provider = Slack(clientID: SlackConfig.appid, clientSecret: SlackConfig.secret) - let token = try await provider.exchange(code: code, state: state, sessionToken: sessionToken) - let userdata = await provider.getUserData(token.accessToken) - return OAuthUserProfile( - userid: userdata["userid"] as? String ?? "", - firstName: userdata["first_name"] as? String, - lastName: userdata["last_name"] as? String, - picture: userdata["picture"] as? String, - accessToken: token.accessToken, - refreshToken: token.refreshToken, - loginType: "slack" + public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + let info = try await oauth2.getJSON(Self.userInfoURL, accessToken: token.accessToken) + return try OAuthUserProfile( + userid: info["sub"]?.stringValue ?? info["https://slack.com/user_id"]?.stringValue, + firstName: info["given_name"]?.stringValue, + lastName: info["family_name"]?.stringValue, + displayName: info["name"]?.stringValue, + picture: info["picture"]?.stringValue, + email: info["email"]?.stringValue, + emailVerified: info["email_verified"]?.boolValue == true, + loginType: loginType, + token: token, + raw: info ) } } diff --git a/Sources/PerfectOAuth2/Transport.swift b/Sources/PerfectOAuth2/Transport.swift new file mode 100644 index 0000000..12fdd18 --- /dev/null +++ b/Sources/PerfectOAuth2/Transport.swift @@ -0,0 +1,230 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// An HTTP request made by PerfectOAuth2. Its description and mirror hide the body and the +/// `Authorization` header. +public struct OAuth2HTTPRequest: Sendable, CustomStringConvertible, CustomReflectable { + public enum Method: String, Sendable { case get = "GET", post = "POST" } + + public var method: Method + public var url: URL + public var headers: [String: String] + public var body: Data? + + public init(method: Method, url: URL, headers: [String: String] = [:], body: Data? = nil) { + self.method = method + self.url = url + self.headers = headers + self.body = body + } + + public var description: String { + "\(method.rawValue) \(url.scheme ?? "")://\(url.host ?? "")\(url.path) (headers and body redacted)" + } + + public var customMirror: Mirror { + Mirror(self, children: [ + "method": method.rawValue, + "url": "\(url.scheme ?? "")://\(url.host ?? "")\(url.path)", + "headers": headers.keys.sorted().map { "\($0): " }, + "body": body == nil ? "nil" : "", + ], displayStyle: .struct) + } +} + +public struct OAuth2HTTPResponse: Sendable { + public var status: Int + public var body: Data + + public init(status: Int, body: Data) { + self.status = status + self.body = body + } +} + +/// Sends PerfectOAuth2's HTTP requests. The default is `URLSessionTransport`; supply your own (for +/// example one on AsyncHTTPClient) through `OAuth2HTTPOptions.transport`. +/// +/// Implementations should not follow redirects, should enforce a deadline, and should stop reading +/// a body past a size limit, throwing the matching `OAuth2Error`s. +public protocol OAuth2Transport: Sendable { + func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse +} + +/// The default transport: Foundation's URLSession, one ephemeral session per request. +/// +/// - No cookies, cache or credential storage. +/// - Redirects are not followed: the 3xx comes back as `OAuth2Error.httpStatus`. +/// - `timeout` is a deadline for the whole request, not just the gap between bytes. +/// - A `Content-Length` over `maximumResponseBytes` is refused before the body is read; otherwise the +/// task is cancelled as soon as the received body passes the limit. On Darwin that stops the +/// download. On Linux, corelibs Foundation may keep reading after cancellation until the deadline, +/// discarding what it reads (see the README's "Response limits on Linux"). +public struct URLSessionTransport: OAuth2Transport { + public let timeout: TimeInterval + public let maximumResponseBytes: Int + + public init(timeout: TimeInterval = 30, maximumResponseBytes: Int = 1 << 20) { + self.timeout = timeout + self.maximumResponseBytes = maximumResponseBytes + } + + public func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.httpBody = request.body + urlRequest.timeoutInterval = timeout + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCredentialStorage = nil + + let delegate = CappedDataDelegate(limit: maximumResponseBytes) + let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + let task = session.dataTask(with: urlRequest) + delegate.task = task + + let deadline = Task { [timeout] in + try await Task.sleep(nanoseconds: UInt64(max(timeout, 0) * 1_000_000_000)) + delegate.fail(.timedOut) + } + defer { + deadline.cancel() + // Off the cooperative pool for the same reason as `cancelOffThread`. + DispatchQueue.global().async { session.invalidateAndCancel() } + } + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + delegate.start(continuation) + task.resume() + } + } onCancel: { + delegate.fail(.network("cancelled")) + } + } +} + +private func cancelOffThread(_ task: URLSessionTask) { + DispatchQueue.global().async { task.cancel() } +} + +/// Collects one task's response, enforcing the size limit, and resumes the continuation once. +private final class CappedDataDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + // All mutable state is guarded by `lock`. + private let lock = NSLock() + private let limit: Int + private var continuation: CheckedContinuation? + private var pendingFailure: OAuth2Error? + private var finished = false + private var status = 0 + private var data = Data() + private var _task: URLSessionTask? + + init(limit: Int) { self.limit = limit } + + var task: URLSessionTask? { + get { lock.withLock { _task } } + set { lock.withLock { _task = newValue } } + } + + func start(_ continuation: CheckedContinuation) { + let failure: OAuth2Error? = lock.withLock { + if let pendingFailure, !finished { + finished = true + return pendingFailure + } + self.continuation = continuation + return nil + } + if let failure { continuation.resume(throwing: failure) } + } + + /// Ends the request with `error` (timeout, cancellation, size limit) unless it already finished. + func fail(_ error: OAuth2Error) { + let (continuation, task): (CheckedContinuation?, URLSessionTask?) = lock.withLock { + guard !finished else { return (nil, nil) } + guard let continuation = self.continuation else { + pendingFailure = pendingFailure ?? error + return (nil, _task) + } + finished = true + self.continuation = nil + data = Data() + return (continuation, _task) + } + // Resume first: on Linux, corelibs' cancel() can block for as long as the session's queue is + // busy reading, which would hold the caller (and the deadline) hostage. + continuation?.resume(throwing: error) + if let task { cancelOffThread(task) } + } + + func urlSession( + _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void + ) { + if response.expectedContentLength > Int64(limit) { + fail(.responseTooLarge(limit: limit)) + completionHandler(.cancel) + return + } + lock.withLock { status = (response as? HTTPURLResponse)?.statusCode ?? 0 } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive chunk: Data) { + let tooLarge: Bool = lock.withLock { + guard !finished else { return false } + if data.count + chunk.count > limit { return true } + data.append(chunk) + return false + } + if tooLarge { fail(.responseTooLarge(limit: limit)) } + } + + func urlSession( + _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(nil) // never follow: a redirected POST could carry the client secret elsewhere + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + let outcome: (CheckedContinuation, Result)? = lock.withLock { + guard !finished, let continuation else { + if !finished, continuation == nil { + pendingFailure = pendingFailure ?? Self.map(error) ?? .network("finished before start") + } + return nil + } + finished = true + self.continuation = nil + if let mapped = Self.map(error) { + return (continuation, .failure(mapped)) + } + let response = OAuth2HTTPResponse(status: status, body: data) + data = Data() + return (continuation, .success(response)) + } + if let (continuation, result) = outcome { continuation.resume(with: result) } + } + + private static func map(_ error: (any Error)?) -> OAuth2Error? { + guard let error else { return nil } + if let urlError = error as? URLError { + if urlError.code == .timedOut { return .timedOut } + return .network("URLError \(urlError.code.rawValue)") + } + return .network(String(describing: type(of: error))) + } +} diff --git a/Sources/PerfectOAuth2/makeRequest.swift b/Sources/PerfectOAuth2/makeRequest.swift deleted file mode 100644 index 3222cce..0000000 --- a/Sources/PerfectOAuth2/makeRequest.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -enum HTTPVerb { case get, post } - -func urlencode(dict: [String: String]) -> String { - dict.map { key, value in - "\(key.urlEncoded)=\(value.urlEncoded)" - }.joined(separator: "&") -} - -extension OAuth2 { - func makeRequest( - _ method: HTTPVerb, - _ url: String, - body: String = "", - encoding: String = "JSON", - bearerToken: String = "" - ) async -> [String: Any] { - guard let requestURL = URL(string: url) else { return [:] } - var request = URLRequest(url: requestURL) - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") - request.setValue("PerfectAPI2.0", forHTTPHeaderField: "User-Agent") - - if !bearerToken.isEmpty { - request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") - } - - if method == .post { - request.httpMethod = "POST" - request.httpBody = body.data(using: .utf8) - let contentType = encoding == "form" - ? "application/x-www-form-urlencoded" - : "application/json" - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - } - - do { - let (data, _) = try await URLSession.shared.data(for: request) - guard let obj = try? JSONSerialization.jsonObject(with: data) else { return [:] } - if let dict = obj as? [String: Any] { return dict } - if let arr = obj as? [Any] { return ["response": arr] } - return [:] - } catch { - return [:] - } - } -} diff --git a/Tests/PerfectOAuth2Tests/CoreTests.swift b/Tests/PerfectOAuth2Tests/CoreTests.swift new file mode 100644 index 0000000..b671a26 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/CoreTests.swift @@ -0,0 +1,216 @@ +import Foundation +import Testing +@testable import PerfectOAuth2 + +@Suite("State (CSRF)") +struct StateTests { + @Test func generatedStateIsLongRandomBase64URL() { + let a = AuthorizationState.generate(), b = AuthorizationState.generate() + #expect(a.count == 43) + #expect(a != b) + #expect(a.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }) + #expect(base64URLDecode(a)?.count == 32) + } + + @Test func matchingStatePasses() throws { + let state = AuthorizationState.generate() + try AuthorizationState.verify(returned: state, expected: state) + } + + @Test func emptyStatesAreRejected() { + // 1.x accepted state "" against a session value "". + #expect(throws: OAuth2Error.invalidState("stored state must be at least 32 characters")) { + try AuthorizationState.verify(returned: "", expected: "") + } + #expect(throws: OAuth2Error.stateMismatch) { + try AuthorizationState.verify(returned: "", expected: validState) + } + #expect(throws: OAuth2Error.stateMismatch) { + try AuthorizationState.verify(returned: nil, expected: validState) + } + } + + @Test func shortOrHugeStoredStateIsRejected() { + let short = String(repeating: "a", count: 31) + #expect(throws: OAuth2Error.self) { try AuthorizationState.verify(returned: short, expected: short) } + let huge = String(repeating: "a", count: 513) + #expect(throws: OAuth2Error.self) { try AuthorizationState.verify(returned: huge, expected: huge) } + let ok = String(repeating: "a", count: 32) + #expect(throws: Never.self) { try AuthorizationState.verify(returned: ok, expected: ok) } + } + + @Test func mismatchesAreRejected() { + let state = AuthorizationState.generate() + var flipped = Array(state) + flipped[flipped.count - 1] = flipped.last == "A" ? "B" : "A" + for returned in [String(flipped), state + "x", String(state.dropLast()), state.uppercased() == state ? "x" : state.uppercased()] { + #expect(throws: OAuth2Error.stateMismatch) { + try AuthorizationState.verify(returned: returned, expected: state) + } + } + } + + @Test func constantTimeCompare() { + #expect(constantTimeEquals("", "")) + #expect(constantTimeEquals("abc", "abc")) + #expect(!constantTimeEquals("abc", "abd")) + #expect(!constantTimeEquals("abc", "abc\u{0}")) + #expect(!constantTimeEquals("abc", "ab")) + #expect(constantTimeEquals("é", "é")) + } +} + +@Suite("PKCE and encoding") +struct PKCETests { + @Test func rfc7636AppendixBVector() { + #expect(PKCE.challenge(for: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") + } + + @Test func verifierMeetsRFC7636() { + let verifier = PKCE.generateVerifier() + #expect((43...128).contains(verifier.count)) + #expect(verifier.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || "-._~".contains($0)) }) + #expect(PKCE.generateVerifier() != verifier) + #expect(PKCE.challenge(for: verifier).count == 43) + } + + @Test func base64URLRoundTripAndRejection() { + for length in 0..<70 { + let data = Data((0..".utf8)) + } + } +} + +@Suite("Profile") +struct ProfileTests { + @Test func emptyUserIDIsRejected() { + let token = OAuth2Token(accessToken: "a") + for id in [nil, "", " ", "\n"] as [String?] { + #expect(throws: OAuth2Error.missingUserID) { + try OAuthUserProfile(userid: id, loginType: "x", token: token) + } + } + } + + @Test func emailVerifiedNeedsAnEmail() throws { + let profile = try OAuthUserProfile(userid: "1", email: "", emailVerified: true, loginType: "x", token: OAuth2Token(accessToken: "a")) + #expect(profile.email == nil) + #expect(profile.emailVerified == false) + } +} + +@Suite("Callback parsing") +struct CallbackTests { + @Test func readsCodeStateAndError() { + let ok = AuthorizationCallback(url: URL(string: "https://app.test/cb?code=a%2Bb&state=s%26t")!) + #expect(ok.code == "a+b") + #expect(ok.state == "s&t") + #expect(ok.error == nil) + let denied = AuthorizationCallback(url: URL(string: "https://app.test/cb?error=access_denied&error_description=User%20said%20no&state=x")!) + #expect(denied.error?.standardCode == .accessDenied) + #expect(denied.error?.errorDescription == "User said no") + } + + @Test func repeatedParametersAreIgnored() { + let callback = AuthorizationCallback(url: URL(string: "https://app.test/cb?code=a&code=b&state=s&state=t")!) + #expect(callback.code == nil) + #expect(callback.state == nil) + } +} diff --git a/Tests/PerfectOAuth2Tests/FlowTests.swift b/Tests/PerfectOAuth2Tests/FlowTests.swift new file mode 100644 index 0000000..65bbd7a --- /dev/null +++ b/Tests/PerfectOAuth2Tests/FlowTests.swift @@ -0,0 +1,301 @@ +import Foundation +import Testing +@testable import PerfectOAuth2 + +private let tokenURL = "https://auth.test/token" + +private func generic(_ transport: StubTransport, pkce: Bool? = nil, redirect: String = "https://app.test/auth/callback", secret: String? = "s3cr3t/+=&") -> OAuth2 { + OAuth2( + configuration: testConfiguration(transport: transport, redirect: redirect, usePKCE: pkce, secret: secret), + authorizationURL: URL(string: "https://auth.test/authorize?tenant=a%20b")!, + tokenURL: URL(string: tokenURL)!, + defaultScopes: ["openid", "profile"] + ) +} + +@Suite("OAuth2 flow") +struct FlowTests { + @Test func loginLinkHasEncodedParametersStateAndPKCE() throws { + let client = generic(StubTransport()) + let request = try client.authorizationRequest(state: validState) + let query = try #require(queryParameters(request.url)) + #expect(request.url.absoluteString.hasPrefix("https://auth.test/authorize?tenant=a%20b&response_type=code&")) + #expect(query["tenant"] == "a b") + #expect(query["response_type"] == "code") + #expect(query["client_id"] == "client id&x") + #expect(query["redirect_uri"] == "https://app.test/auth/callback") + #expect(query["state"] == validState) + #expect(query["scope"] == "openid profile") + #expect(query["code_challenge_method"] == "S256") + let verifier = try #require(request.pending.codeVerifier) + #expect(query["code_challenge"] == PKCE.challenge(for: verifier)) + #expect(request.pending.state == validState) + #expect(request.pending.redirectURL.absoluteString == "https://app.test/auth/callback") + // `&`, `+` and spaces never appear raw in values. + #expect(request.url.absoluteString.contains("client_id=client%20id%26x")) + } + + @Test func generatesStateWhenNoneGiven() throws { + let request = try generic(StubTransport()).authorizationRequest() + #expect(request.pending.state.count == 43) + #expect(queryParameters(request.url)?["state"] == request.pending.state) + } + + @Test func redirectURLWithQueryIsOneEncodedValue() throws { + let client = generic(StubTransport(), redirect: "https://app.test/cb?tenant=a&b=c+d") + let request = try client.authorizationRequest(state: validState) + let query = try #require(queryParameters(request.url)) + #expect(query["redirect_uri"] == "https://app.test/cb?tenant=a&b=c+d") + #expect(query["b"] == nil) + } + + @Test func pkceCanBeDisabled() throws { + let request = try generic(StubTransport(), pkce: false).authorizationRequest(state: validState) + let query = try #require(queryParameters(request.url)) + #expect(query["code_challenge"] == nil) + #expect(query["code_challenge_method"] == nil) + #expect(request.pending.codeVerifier == nil) + } + + @Test func rejectsBadInputs() { + let client = generic(StubTransport()) + #expect(throws: OAuth2Error.self) { try client.authorizationRequest(state: "short") } + #expect(throws: OAuth2Error.self) { try client.authorizationRequest(state: "") } + #expect(throws: OAuth2Error.self) { + try client.authorizationRequest(additionalParameters: [URLQueryItem(name: "redirect_uri", value: "https://evil.test")]) + } + #expect(throws: OAuth2Error.self) { + try client.authorizationRequest(additionalParameters: [URLQueryItem(name: "state", value: validState)]) + } + for redirect in ["http://app.test/cb", "https://app.test/cb#frag", "javascript:alert(1)", "ftp://app.test/"] { + #expect(throws: OAuth2Error.self) { + try generic(StubTransport(), redirect: redirect).authorizationRequest() + } + } + #expect(throws: Never.self) { + try generic(StubTransport(), redirect: "http://localhost:8080/cb").authorizationRequest() + } + } + + @Test func exchangeSendsCodeVerifierAndRedirect() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT","token_type":"bearer","expires_in":"60","refresh_token":"RT"}"#) + let client = generic(transport) + let request = try client.authorizationRequest(state: validState) + let token = try await client.exchange(callback: AuthorizationCallback(code: "c/o+d=e", state: validState), pending: request.pending) + #expect(token.accessToken == "AT") + #expect(token.refreshToken == "RT") + #expect(token.expiresIn == 60) + + let sent = try #require(transport.requests.first) + #expect(sent.method == .post) + #expect(sent.headers["Content-Type"] == "application/x-www-form-urlencoded") + #expect(sent.headers["Accept"] == "application/json") + #expect(formFields(sent.body) == [ + "grant_type": "authorization_code", + "code": "c/o+d=e", + "redirect_uri": "https://app.test/auth/callback", + "client_id": "client id&x", + "client_secret": "s3cr3t/+=&", + "code_verifier": request.pending.codeVerifier!, + ]) + } + + @Test func publicClientOmitsSecret() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) + let client = generic(transport, secret: nil) + let request = try client.authorizationRequest(state: validState) + _ = try await client.exchange(callback: AuthorizationCallback(code: "c", state: validState), pending: request.pending) + #expect(formFields(transport.requests.first?.body)["client_secret"] == nil) + } + + @Test func stateIsCheckedBeforeAnyRequest() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) + let client = generic(transport) + let request = try client.authorizationRequest(state: validState) + for state in [nil, "", "wrong", validState + "x"] as [String?] { + await #expect(throws: OAuth2Error.stateMismatch) { + try await client.exchange(callback: AuthorizationCallback(code: "c", state: state), pending: request.pending) + } + } + let emptyPending = PendingAuthorization(state: "", codeVerifier: nil, redirectURL: URL(string: "https://app.test/cb")!) + await #expect(throws: OAuth2Error.self) { + try await client.exchange(callback: AuthorizationCallback(code: "c", state: ""), pending: emptyPending) + } + #expect(transport.requests.isEmpty) + } + + @Test func providerErrorOnCallbackAndMissingCode() async throws { + let client = generic(StubTransport()) + let request = try client.authorizationRequest(state: validState) + let denied = AuthorizationCallback(url: URL(string: "https://app.test/auth/callback?error=access_denied&state=\(validState)")!) + await #expect(throws: OAuth2Error.authorizationDenied(OAuth2ErrorResponse(code: "access_denied"))) { + try await client.exchange(callback: denied, pending: request.pending) + } + await #expect(throws: OAuth2Error.missingAuthorizationCode) { + try await client.exchange(callback: AuthorizationCallback(code: "", state: validState), pending: request.pending) + } + let noVerifier = PendingAuthorization(state: validState, codeVerifier: nil, redirectURL: request.pending.redirectURL) + await #expect(throws: OAuth2Error.self) { + try await client.exchange(callback: AuthorizationCallback(code: "c", state: validState), pending: noVerifier) + } + } + + @Test func pendingAuthorizationRoundTripsThroughJSON() throws { + let request = try generic(StubTransport()).authorizationRequest() + let data = try JSONEncoder().encode(request.pending) + #expect(try JSONDecoder().decode(PendingAuthorization.self, from: data) == request.pending) + } +} + +@Suite("Error mapping") +struct ErrorMappingTests { + private func exchange(status: Int, body: String) async throws -> OAuth2Token { + let transport = StubTransport().on(tokenURL, status: status, body) + let client = generic(transport, pkce: false) + let pending = PendingAuthorization(state: validState, codeVerifier: nil, redirectURL: URL(string: "https://app.test/auth/callback")!) + return try await client.exchange(callback: AuthorizationCallback(code: "the-code-123", state: validState), pending: pending) + } + + @Test func rfc6749ErrorWithAnyStatus() async { + for status in [200, 400, 401] { + let error = await #expect(throws: OAuth2Error.self) { + try await exchange(status: status, body: #"{"error":"invalid_grant","error_description":"Bad code","error_uri":"https://x.test"}"#) + } + guard case .providerError(let response)? = error else { Issue.record("\(String(describing: error))"); continue } + #expect(response.standardCode == .invalidGrant) + #expect(response.errorDescription == "Bad code") + #expect(response.errorURI == "https://x.test") + } + } + + @Test func nonStandardCodesAreKept() async { + // GitHub: HTTP 200 with an error field. 1.x turned unknown codes into InvalidAPIResponse. + await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "bad_verification_code", errorDescription: "The code passed is incorrect or expired."))) { + try await exchange(status: 200, body: #"{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired."}"#) + } + // Slack + await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "invalid_code"))) { + try await exchange(status: 200, body: #"{"ok":false,"error":"invalid_code"}"#) + } + // Facebook Graph + await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "OAuthException", errorDescription: "Invalid verification code format."))) { + try await exchange(status: 400, body: #"{"error":{"message":"Invalid verification code format.","type":"OAuthException","code":100}}"#) + } + } + + @Test func statusAndShapeErrors() async { + // 1.x accepted this: a 500 with an access_token was a successful exchange. + await #expect(throws: OAuth2Error.httpStatus(500)) { + try await exchange(status: 500, body: #"{"access_token":"AT"}"#) + } + await #expect(throws: OAuth2Error.httpStatus(502)) { try await exchange(status: 502, body: "Bad Gateway") } + await #expect(throws: OAuth2Error.httpStatus(302)) { try await exchange(status: 302, body: "") } + await #expect(throws: OAuth2Error.invalidResponse("response is not JSON")) { try await exchange(status: 200, body: "access_token=AT") } + await #expect(throws: OAuth2Error.invalidResponse("response is not a JSON object")) { try await exchange(status: 200, body: "[1]") } + await #expect(throws: OAuth2Error.invalidResponse("token response has no access_token")) { try await exchange(status: 200, body: "{}") } + } + + @Test func providerTextIsSanitisedAndSecretsRedacted() async { + let error = await #expect(throws: OAuth2Error.self) { + try await exchange(status: 400, body: #"{"error":"invalid_client\r\nX: y","error_description":"bad secret s3cr3t/+=& for code the-code-123\u0007 \#(String(repeating: "x", count: 400))"}"#) + } + let text = "\(String(describing: error))" + #expect(!text.contains("s3cr3t")) + #expect(!text.contains("the-code-123")) + #expect(!text.contains("\r") && !text.contains("\n") && !text.contains("\u{7}")) + #expect(text.contains("")) + guard case .providerError(let response)? = error else { Issue.record("wrong error"); return } + #expect((response.errorDescription?.count ?? 0) <= 257) + } + + @Test func oversizedBodyFromCustomTransportIsRejected() async { + let transport = StubTransport().on(tokenURL, #"{"access_token":"\#(String(repeating: "a", count: 2000))"}"#) + let configuration = OAuth2ClientConfiguration( + clientID: "id", clientSecret: "s", redirectURL: URL(string: "https://app.test/cb")!, usePKCE: false, + http: OAuth2HTTPOptions(maximumResponseBytes: 1000, transport: transport) + ) + let client = OAuth2(configuration: configuration, authorizationURL: URL(string: "https://a.test/a")!, tokenURL: URL(string: tokenURL)!) + await #expect(throws: OAuth2Error.responseTooLarge(limit: 1000)) { + try await client.refresh(refreshToken: "r") + } + } + + @Test func insecureEndpointsAreRefused() async { + let configuration = testConfiguration(transport: StubTransport()) + let client = OAuth2(configuration: configuration, authorizationURL: URL(string: "http://a.test/a")!, tokenURL: URL(string: "http://a.test/t")!) + #expect(throws: OAuth2Error.insecureURL("authorization URL must use https")) { try client.authorizationRequest() } + await #expect(throws: OAuth2Error.insecureURL("token URL must use https")) { try await client.refresh(refreshToken: "r") } + await #expect(throws: OAuth2Error.insecureURL("API URL must use https")) { + try await client.getJSON(URL(string: "http://api.test/me")!, accessToken: "AT") + } + } +} + +@Suite("Refresh grant") +struct RefreshTests { + @Test func sendsRefreshGrantAndParsesRotatedToken() async throws { + let transport = StubTransport().on(tokenURL, """ + {"access_token":"new-access","token_type":"Bearer","expires_in":3600,"refresh_token":"rotated","scope":"openid email"} + """) + let token = try await generic(transport).refresh(refreshToken: "old/refresh+token", scopes: ["openid", "email"]) + #expect(token.accessToken == "new-access") + #expect(token.refreshToken == "rotated") + #expect(token.scope == ["openid", "email"]) + let sent = try #require(transport.requests.first) + #expect(sent.method == .post) + #expect(formFields(sent.body) == [ + "grant_type": "refresh_token", + "refresh_token": "old/refresh+token", + "client_id": "client id&x", + "client_secret": "s3cr3t/+=&", + "scope": "openid email", + ]) + } + + @Test func keepsOriginalRefreshTokenWhenNotRotated() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"new","expires_in":3599}"#) + let token = try await generic(transport).refresh(refreshToken: "long-lived") + #expect(token.refreshToken == "long-lived") + #expect(token.expiresIn == 3599) + #expect(token.expiration != nil) + #expect(formFields(transport.requests.first?.body)["scope"] == nil) + } + + @Test func omitsSecretWhenAsked() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"a"}"#) + _ = try await generic(transport).refresh(refreshToken: "r", includeClientSecret: false) + #expect(formFields(transport.requests.first?.body)["client_secret"] == nil) + } + + @Test func rejectedGrant() async { + let transport = StubTransport().on(tokenURL, status: 400, #"{"error":"invalid_grant","error_description":"Token has been expired or revoked."}"#) + await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "invalid_grant", errorDescription: "Token has been expired or revoked."))) { + try await generic(transport).refresh(refreshToken: "revoked") + } + } +} + +@Suite("Redaction") +struct RedactionTests { + private func rendered(_ value: Any) -> String { + var text = "\(value) \(String(reflecting: value))" + dump(value, to: &text) + text += Mirror(reflecting: value).children.map { "\($0.label ?? ""): \($0.value)" }.joined() + return text + } + + @Test func secretsNeverAppear() throws { + let configuration = OAuth2ClientConfiguration(clientID: "id", clientSecret: "TOP-SECRET", redirectURL: URL(string: "https://app.test/cb")!) + let token = OAuth2Token(accessToken: "ACCESS-TOK", refreshToken: "REFRESH-TOK", idToken: "ID-TOK") + let profile = try OAuthUserProfile(userid: "42", loginType: "x", token: token) + let pending = PendingAuthorization(state: "STATE-VALUE-0123456789-0123456789", codeVerifier: "VERIFIER-VALUE", redirectURL: URL(string: "https://app.test/cb")!) + let google = Google(configuration: configuration) + let request = OAuth2HTTPRequest(method: .post, url: URL(string: "https://a.test/t?x=1")!, headers: ["Authorization": "Bearer ACCESS-TOK"], body: Data("client_secret=TOP-SECRET".utf8)) + let all = [configuration, token, profile, pending, google, google.oauth2, request] as [Any] + let text = all.map(rendered).joined(separator: "\n") + for secret in ["TOP-SECRET", "ACCESS-TOK", "REFRESH-TOK", "ID-TOK", "VERIFIER-VALUE", "STATE-VALUE"] { + #expect(!text.contains(secret), "\(secret) leaked") + } + #expect(text.contains("")) + } +} diff --git a/Tests/PerfectOAuth2Tests/PerfectOAuth2Tests.swift b/Tests/PerfectOAuth2Tests/PerfectOAuth2Tests.swift deleted file mode 100644 index ff1cab6..0000000 --- a/Tests/PerfectOAuth2Tests/PerfectOAuth2Tests.swift +++ /dev/null @@ -1,262 +0,0 @@ -import Foundation -import Testing -@testable import PerfectOAuth2 - -@Suite("OAuth2 Core", .serialized) -struct OAuth2CoreTests { - - @Test func urlEncodeBasic() { - #expect("hello world".urlEncoded == "hello%20world") - } - - @Test func urlEncodeSpecialChars() { - let raw = "https://app.com/callback?session=abc" - let encoded = raw.urlEncoded - #expect(encoded.contains("%3A")) // : encoded - #expect(encoded.contains("%2F")) // / encoded - #expect(encoded.contains("%3F")) // ? encoded - #expect(encoded.contains("%3D")) // = encoded - } - - @Test func urlencodeDict() { - let encoded = urlencode(dict: ["grant_type": "authorization_code", "code": "abc"]) - #expect(encoded.contains("grant_type=authorization_code")) - #expect(encoded.contains("code=abc")) - #expect(encoded.contains("&")) - } - - @Test func loginLinkStructure() { - let oauth = OAuth2( - clientID: "test_id", - clientSecret: "test_secret", - authorizationURL: "https://auth.example.com/oauth", - tokenURL: "https://auth.example.com/token" - ) - let link = oauth.getLoginLink(redirectURL: "https://myapp.com/cb", state: "abc", scopes: ["profile"]) - #expect(link.hasPrefix("https://auth.example.com/oauth?response_type=code")) - #expect(link.contains("client_id=test_id")) - #expect(link.contains("state=abc")) - #expect(link.contains("scope=profile")) - #expect(link.contains("redirect_uri=")) - } - - @Test func loginLinkEncodesRedirectURI() { - let oauth = OAuth2( - clientID: "id", - clientSecret: "secret", - authorizationURL: "https://auth.example.com/oauth", - tokenURL: "https://auth.example.com/token" - ) - let link = oauth.getLoginLink(redirectURL: "https://app.com/cb?session=tok", state: "s") - #expect(link.contains("redirect_uri=https%3A%2F%2Fapp.com%2Fcb%3Fsession%3Dtok")) - } - - @Test func authorizationCodeInit() { - let code = AuthorizationCode(code: "auth123", redirectURL: "https://app.com/callback") - #expect(code.code == "auth123") - #expect(code.redirectURL == "https://app.com/callback") - } - - @Test func oauth2ErrorFromJSON() { - let json: [String: Any] = ["error": "invalid_client", "error_description": "Client not found"] - let error = OAuth2Error(json: json) - #expect(error != nil) - #expect(error?.code == .invalidClient) - #expect(error?.description == "Client not found") - } - - @Test func oauth2ErrorInvalidJSONReturnsNil() { - let json: [String: Any] = ["message": "not_an_error"] - #expect(OAuth2Error(json: json) == nil) - } - - @Test func oauth2ErrorUnknownCodeReturnsNil() { - let json: [String: Any] = ["error": "made_up_error"] - #expect(OAuth2Error(json: json) == nil) - } - - @Test func oauth2TokenFromJSON() { - let json: [String: Any] = [ - "access_token": "ya29.token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "1//refresh", - "scope": "profile email", - ] - let token = OAuth2Token(json: json) - #expect(token != nil) - #expect(token?.accessToken == "ya29.token") - #expect(token?.tokenType == "Bearer") - #expect(token?.refreshToken == "1//refresh") - #expect(token?.scope == ["profile", "email"]) - #expect(token?.expiration != nil) - } - - @Test func oauth2TokenNilWithoutAccessToken() { - let json: [String: Any] = ["token_type": "Bearer"] - #expect(OAuth2Token(json: json) == nil) - } - - @Test func oauth2TokenDefaultsBearerType() { - let json: [String: Any] = ["access_token": "tok"] - let token = OAuth2Token(json: json) - #expect(token?.tokenType == "Bearer") - } - - @Test func oauthUserProfileInit() { - let profile = OAuthUserProfile( - userid: "12345", - firstName: "Jane", - lastName: "Doe", - picture: "https://example.com/pic.jpg", - accessToken: "tok", - refreshToken: "refresh", - loginType: "google" - ) - #expect(profile.userid == "12345") - #expect(profile.firstName == "Jane") - #expect(profile.lastName == "Doe") - #expect(profile.loginType == "google") - #expect(profile.refreshToken == "refresh") - } - - @Test func oauthUserProfileOptionalFields() { - let profile = OAuthUserProfile(userid: "1", accessToken: "tok", loginType: "github") - #expect(profile.firstName == nil) - #expect(profile.picture == nil) - #expect(profile.refreshToken == nil) - } - - @Test func digIntoDictionaryNestedLookup() { - let data: [String: Any] = [ - "picture": ["data": ["url": "https://example.com/photo.jpg"]] - ] - let result = digIntoDictionary(mineFor: ["picture", "data", "url"], data: data) - #expect(result as? String == "https://example.com/photo.jpg") - } - - @Test func digIntoDictionaryMissingKeyReturnsEmpty() { - let data: [String: Any] = ["other": "value"] - let result = digIntoDictionary(mineFor: ["picture", "data", "url"], data: data) - #expect(result as? String == "") - } - - @Test func digIntoDictionaryEmptyKeysReturnsEmpty() { - let data: [String: Any] = ["key": "value"] - let result = digIntoDictionary(mineFor: [], data: data) - #expect(result as? String == "") - } -} - -@Suite("Provider URL Construction", .serialized) -struct ProviderURLTests { - - @Test func googleLoginURLContainsExpectedParams() { - GoogleConfig.appid = "google_id" - GoogleConfig.secret = "google_secret" - GoogleConfig.endpointAfterAuth = "https://myapp.com/auth/google" - GoogleConfig.restrictedDomain = nil - let g = Google(clientID: "google_id", clientSecret: "google_secret") - let url = g.loginURL(state: "csrf123", sessionToken: "sess456") - #expect(url.contains("accounts.google.com")) - #expect(url.contains("response_type=code")) - #expect(url.contains("state=csrf123")) - #expect(url.contains("scope=profile")) - #expect(!url.contains("hd=")) - } - - @Test func googleLoginURLIncludesHdWhenRestricted() { - GoogleConfig.appid = "google_id" - GoogleConfig.secret = "google_secret" - GoogleConfig.endpointAfterAuth = "https://myapp.com/auth/google" - GoogleConfig.restrictedDomain = "mycompany.com" - let g = Google(clientID: "google_id", clientSecret: "google_secret") - let url = g.loginURL(state: "state", sessionToken: "tok") - #expect(url.contains("hd=mycompany.com")) - GoogleConfig.restrictedDomain = nil - } - - @Test func googleLoginURLOmitsOfflineAccessByDefault() throws { - GoogleConfig.appid = "google_id" - GoogleConfig.secret = "google_secret" - GoogleConfig.endpointAfterAuth = "https://myapp.com/auth/google" - GoogleConfig.restrictedDomain = nil - let url = Google(clientID: "google_id", clientSecret: "google_secret") - .loginURL(state: "state", sessionToken: "tok") - let items = try #require(URLComponents(string: url)?.queryItems) - #expect(!items.contains { $0.name == "access_type" || $0.name == "prompt" }) - #expect(url == Google.loginURL(state: "state", sessionToken: "tok", offlineAccess: false)) - // The pre-existing three-argument signatures still work as function references. - let instanceRef: (String, String, [String]) -> String = Google(clientID: "google_id", clientSecret: "google_secret").loginURL - let staticRef: (String, String, [String]) -> String = Google.loginURL - #expect(instanceRef("state", "tok", ["profile"]) == url) - #expect(staticRef("state", "tok", ["profile"]) == url) - } - - @Test func googleLoginURLRequestsOfflineAccess() throws { - GoogleConfig.appid = "google_id" - GoogleConfig.secret = "google_secret" - GoogleConfig.endpointAfterAuth = "https://myapp.com/auth/google" - GoogleConfig.restrictedDomain = "mycompany.com" - defer { GoogleConfig.restrictedDomain = nil } - let instanceURL = Google(clientID: "google_id", clientSecret: "google_secret") - .loginURL(state: "state", sessionToken: "tok", offlineAccess: true) - let staticURL = Google.loginURL(state: "state", sessionToken: "tok", offlineAccess: true) - #expect(instanceURL == staticURL) - let items = try #require(URLComponents(string: instanceURL)?.queryItems) - let query = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first }) - #expect(items.count == query.count) // no duplicated parameters - #expect(query["access_type"] == "offline") - #expect(query["prompt"] == "consent") - #expect(query["hd"] == "mycompany.com") - #expect(query["client_id"] == "google_id") - } - - @Test func githubLoginURLContainsExpectedParams() { - GitHubConfig.appid = "gh_id" - GitHubConfig.secret = "gh_secret" - GitHubConfig.endpointAfterAuth = "https://myapp.com/auth/github" - let g = GitHub(clientID: "gh_id", clientSecret: "gh_secret") - let url = g.loginURL(state: "state", sessionToken: "tok", scopes: ["user"]) - #expect(url.contains("github.com/login/oauth/authorize")) - #expect(url.contains("scope=user")) - } - - @Test func facebookLoginURLContainsExpectedParams() { - FacebookConfig.appid = "fb_id" - FacebookConfig.secret = "fb_secret" - FacebookConfig.endpointAfterAuth = "https://myapp.com/auth/facebook" - let f = Facebook(clientID: "fb_id", clientSecret: "fb_secret") - let url = f.loginURL(state: "state", sessionToken: "tok") - #expect(url.contains("facebook.com/dialog/oauth")) - #expect(url.contains("client_id=fb_id")) - } - - @Test func slackLoginURLContainsExpectedParams() { - SlackConfig.appid = "slack_id" - SlackConfig.secret = "slack_secret" - SlackConfig.endpointAfterAuth = "https://myapp.com/auth/slack" - let s = Slack(clientID: "slack_id", clientSecret: "slack_secret") - let url = s.loginURL(state: "state", sessionToken: "tok") - #expect(url.contains("slack.com/oauth/authorize")) - } - - @Test func salesforceLoginURLContainsExpectedParams() { - SalesForceConfig.appid = "sf_id" - SalesForceConfig.secret = "sf_secret" - SalesForceConfig.endpointAfterAuth = "https://myapp.com/auth/salesforce" - let s = SalesForce(clientID: "sf_id", clientSecret: "sf_secret") - let url = s.loginURL(state: "state", sessionToken: "tok") - #expect(url.contains("salesforce.com/services/oauth2/authorize")) - } - - @Test func linkedinLoginURLContainsExpectedParams() { - LinkedinConfig.appid = "li_id" - LinkedinConfig.secret = "li_secret" - LinkedinConfig.endpointAfterAuth = "https://myapp.com/auth/linkedin" - let l = Linkedin(clientID: "li_id", clientSecret: "li_secret") - let url = l.loginURL(state: "state", sessionToken: "tok") - #expect(url.contains("linkedin.com/oauth/v2/authorization")) - #expect(url.contains("scope=openid")) - } -} diff --git a/Tests/PerfectOAuth2Tests/ProviderTests.swift b/Tests/PerfectOAuth2Tests/ProviderTests.swift new file mode 100644 index 0000000..350b79f --- /dev/null +++ b/Tests/PerfectOAuth2Tests/ProviderTests.swift @@ -0,0 +1,348 @@ +import Foundation +import Testing +@testable import PerfectOAuth2 + +/// Runs a full sign-in against canned responses and returns the profile and the transport. +private func signIn( + _ make: (OAuth2ClientConfiguration) throws -> P, + token: (url: String, body: String), + profile: (url: String, status: Int, body: String)?, + scopes: [String]? = nil +) async throws -> (OAuthUserProfile, StubTransport, AuthorizationRequest) { + let transport = StubTransport().on(token.url, token.body) + if let profile { transport.on(profile.url, status: profile.status, profile.body) } + let provider = try make(testConfiguration(transport: transport, scopes: scopes)) + let request = try provider.authorizationRequest() + let callback = URL(string: "https://app.test/auth/callback?code=CODE&state=\(request.pending.state)")! + let result = try await provider.completeAuthorization(callbackURL: callback, pending: request.pending) + return (result, transport, request) +} + +private func assertBearer(_ request: OAuth2HTTPRequest?, _ token: String = "AT") { + #expect(request?.headers["Authorization"] == "Bearer \(token)") + #expect(request?.url.absoluteString.contains(token) == false) + #expect(request?.method == .get) +} + +private let now = Date().timeIntervalSince1970 + +private func googleIDToken(_ overrides: [String: Any] = [:], removing: [String] = []) -> String { + var claims: [String: Any] = [ + "iss": "https://accounts.google.com", "aud": "client id&x", "sub": "1234567890", + "exp": now + 3600, "iat": now, "hd": "corp.test", "email": "a@corp.test", "email_verified": true, + ] + claims.merge(overrides) { _, new in new } + for key in removing { claims.removeValue(forKey: key) } + return makeJWT(claims) +} + +private let googleToken = "https://oauth2.googleapis.com/token" +private let googleUserInfo = "https://openidconnect.googleapis.com/v1/userinfo" + +@Suite("Google") +struct GoogleTests { + @Test func loginLink() throws { + let google = Google(configuration: testConfiguration(transport: StubTransport()), restrictedDomain: "Corp.Test&prompt=none") + let request = try google.authorizationRequest() + let query = try #require(queryParameters(request.url)) + #expect(request.url.host == "accounts.google.com") + #expect(request.url.path == "/o/oauth2/v2/auth") + #expect(query["scope"] == "openid profile") + #expect(query["hd"] == "corp.test&prompt=none") // one encoded value, not an injected prompt + #expect(query["prompt"] == nil) + #expect(query["code_challenge_method"] == "S256") + #expect(query["access_type"] == nil) + } + + @Test func offlineAccessAndOpenIDAlwaysRequested() throws { + let google = Google(configuration: testConfiguration(transport: StubTransport(), scopes: ["email"])) + let query = try #require(queryParameters(try google.authorizationRequest(offlineAccess: true).url)) + #expect(query["access_type"] == "offline") + #expect(query["prompt"] == "consent") + #expect(query["scope"] == "openid email") + let custom = try #require(queryParameters(try google.authorizationRequest(scopes: ["profile"], offlineAccess: false).url)) + #expect(custom["scope"] == "openid profile") + #expect(custom["access_type"] == nil) + } + + @Test func signInUsesBearerHeaderAndValidatedIDToken() async throws { + let (profile, transport, request) = try await signIn( + { Google(configuration: $0, restrictedDomain: "corp.test") }, + token: (googleToken, #"{"access_token":"AT","id_token":"\#(googleIDToken())","expires_in":3599}"#), + profile: (googleUserInfo, 200, #"{"sub":"1234567890","given_name":"Ada","family_name":"Lovelace","name":"Ada Lovelace","picture":"https://p.test/a.png","email":"a@corp.test","email_verified":true,"hd":"corp.test"}"#) + ) + #expect(profile.userid == "1234567890") + #expect(profile.firstName == "Ada") + #expect(profile.lastName == "Lovelace") + #expect(profile.email == "a@corp.test") + #expect(profile.emailVerified) + #expect(profile.loginType == "google") + assertBearer(transport.requests(to: googleUserInfo).first) + let fields = formFields(transport.requests(to: googleToken).first?.body) + #expect(fields["code"] == "CODE") + #expect(fields["code_verifier"] == request.pending.codeVerifier) + #expect(fields["redirect_uri"] == "https://app.test/auth/callback") + } + + @Test func emptyUserIDFromUserInfoIsRejected() async throws { + // 1.x returned a profile with userid "" here. + for body in [#"{}"#, #"{"sub":""}"#, #"{"id":"legacy-field"}"#] { + await #expect(throws: OAuth2Error.missingUserID) { + _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, #"{"access_token":"AT"}"#), profile: (googleUserInfo, 200, body)) + } + } + await #expect(throws: OAuth2Error.httpStatus(500)) { + _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, #"{"access_token":"AT"}"#), profile: (googleUserInfo, 500, "oops")) + } + await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "invalid_token"))) { + _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, #"{"access_token":"AT"}"#), profile: (googleUserInfo, 401, #"{"error":"invalid_token"}"#)) + } + } + + @Test func idTokenChecks() async throws { + let cases: [(String, OAuth2Error)] = [ + (googleIDToken(["iss": "https://evil.test"]), .invalidIDToken("unexpected issuer")), + (googleIDToken(["aud": "someone-else"]), .invalidIDToken("audience is not this client")), + (googleIDToken(["aud": ["client id&x", "other"]]), .invalidIDToken("authorized party is not this client")), + (googleIDToken(["exp": now - 600]), .invalidIDToken("expired")), + (googleIDToken(removing: ["exp"]), .invalidIDToken("no expiry")), + (googleIDToken(["iat": now + 3600]), .invalidIDToken("issued in the future")), + (googleIDToken(["sub": ""]), .invalidIDToken("no subject")), + (googleIDToken(["hd": "other.test"]), .restrictedDomainMismatch), + (googleIDToken(removing: ["hd"]), .restrictedDomainMismatch), + ("not.a-jwt", .invalidIDToken("not a compact JWS")), + ("a.b+/=.c", .invalidIDToken("payload is not base64url JSON")), + ] + for (idToken, expected) in cases { + await #expect(throws: expected) { + _ = try await signIn( + { Google(configuration: $0, restrictedDomain: "corp.test") }, + token: (googleToken, #"{"access_token":"AT","id_token":"\#(idToken)"}"#), + profile: (googleUserInfo, 200, #"{"sub":"1234567890"}"#) + ) + } + } + // Restricted domain without an id_token at all. + await #expect(throws: OAuth2Error.invalidIDToken("no id_token; request the openid scope")) { + _ = try await signIn({ Google(configuration: $0, restrictedDomain: "corp.test") }, token: (googleToken, #"{"access_token":"AT"}"#), profile: nil) + } + // Accepted: azp matches with multiple audiences, issuer without scheme, small clock skew. + _ = try await signIn( + { Google(configuration: $0, restrictedDomain: "CORP.test") }, + token: (googleToken, #"{"access_token":"AT","id_token":"\#(googleIDToken(["aud": ["client id&x", "other"], "azp": "client id&x", "iss": "accounts.google.com", "exp": now - 60]))"}"#), + profile: (googleUserInfo, 200, #"{"sub":"1234567890"}"#) + ) + } + + @Test func base64URLIDTokenPayloadDecodes() async throws { + // A payload whose base64url form contains '-' and '_' (1.x used standard base64 and failed). + let token = googleIDToken(["name": "??>>~~ÿ"]) + #expect(token.split(separator: ".")[1].contains { $0 == "-" || $0 == "_" }) + let claims = try IDTokenClaims.validate(token, issuers: Google.issuers, clientID: "client id&x") + #expect(claims.claims["hd"]?.stringValue == "corp.test") + } + + @Test func subjectMismatchBetweenIDTokenAndUserInfo() async { + await #expect(throws: OAuth2Error.invalidResponse("userinfo subject differs from the id_token subject")) { + _ = try await signIn({ Google(configuration: $0) }, + token: (googleToken, #"{"access_token":"AT","id_token":"\#(googleIDToken())"}"#), + profile: (googleUserInfo, 200, #"{"sub":"someone-else"}"#)) + } + } +} + +@Suite("GitHub") +struct GitHubTests { + @Test func loginLinkAndSignIn() async throws { + let (profile, transport, request) = try await signIn( + { GitHub(configuration: $0) }, + token: ("https://github.com/login/oauth/access_token", #"{"access_token":"AT","token_type":"bearer","scope":"read:user"}"#), + profile: ("https://api.github.com/user", 200, #"{"id":5833,"login":"octo","name":"Mona Lisa Octocat","avatar_url":"https://a.test/o.png","email":"m@x.test"}"#) + ) + let query = try #require(queryParameters(request.url)) + #expect(request.url.absoluteString.hasPrefix("https://github.com/login/oauth/authorize?")) + #expect(query["scope"] == "read:user") + #expect(query["code_challenge"] != nil) + #expect(profile.userid == "5833") + #expect(profile.firstName == "Mona") + #expect(profile.lastName == "Octocat") + #expect(profile.displayName == "Mona Lisa Octocat") + #expect(profile.email == "m@x.test") + #expect(!profile.emailVerified) + let userRequest = transport.requests(to: "https://api.github.com/user").first + assertBearer(userRequest) + #expect(userRequest?.headers["User-Agent"] == "PerfectOAuth2/2.0") + #expect(userRequest?.headers["Accept"] == "application/vnd.github+json") + } + + @Test func http200ErrorAndMissingID() async { + await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "bad_verification_code"))) { + _ = try await signIn({ GitHub(configuration: $0) }, token: ("https://github.com/login/oauth/access_token", #"{"error":"bad_verification_code"}"#), profile: nil) + } + await #expect(throws: OAuth2Error.missingUserID) { + _ = try await signIn({ GitHub(configuration: $0) }, token: ("https://github.com/login/oauth/access_token", #"{"access_token":"AT"}"#), profile: ("https://api.github.com/user", 200, #"{"login":"octo","id":null}"#)) + } + } +} + +@Suite("Facebook") +struct FacebookTests { + @Test func loginLinkAndSignIn() async throws { + let (profile, transport, request) = try await signIn( + { Facebook(configuration: $0) }, + token: ("https://graph.facebook.com/v23.0/oauth/access_token", #"{"access_token":"AT","token_type":"bearer","expires_in":5183944}"#), + profile: ("https://graph.facebook.com/v23.0/me", 200, #"{"id":"10153","name":"Ada L","first_name":"Ada","last_name":"L","picture":{"data":{"url":"https://p.test/a.jpg","height":50}}}"#) + ) + let query = try #require(queryParameters(request.url)) + #expect(request.url.absoluteString.hasPrefix("https://www.facebook.com/v23.0/dialog/oauth?")) + #expect(query["code_challenge"] == nil) // PKCE off by default + #expect(query["scope"] == nil) + #expect(profile.userid == "10153") + #expect(profile.picture == "https://p.test/a.jpg") + let me = try #require(transport.requests(to: "https://graph.facebook.com/v23.0/me").first) + assertBearer(me) + let meQuery = try #require(queryParameters(me.url)) + #expect(meQuery["access_token"] == nil) + #expect(meQuery["fields"] == "id,name,first_name,last_name,picture") + #expect(meQuery["appsecret_proof"] == Facebook.appSecretProof(accessToken: "AT", appSecret: "s3cr3t/+=&")) + #expect(formFields(transport.requests(to: "https://graph.facebook.com/v23.0/oauth/access_token").first?.body)["code_verifier"] == nil) + } + + @Test func graphErrorObject() async { + await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "OAuthException", errorDescription: "Invalid OAuth access token."))) { + _ = try await signIn({ Facebook(configuration: $0) }, + token: ("https://graph.facebook.com/v23.0/oauth/access_token", #"{"access_token":"AT"}"#), + profile: ("https://graph.facebook.com/v23.0/me", 400, #"{"error":{"message":"Invalid OAuth access token.","type":"OAuthException","code":190}}"#)) + } + } + + @Test func graphVersionIsConfigurable() throws { + let facebook = Facebook(configuration: testConfiguration(transport: StubTransport()), graphAPIVersion: "v24.0") + #expect(try facebook.authorizationRequest().url.path == "/v24.0/dialog/oauth") + #expect(facebook.oauth2.tokenURL.absoluteString == "https://graph.facebook.com/v24.0/oauth/access_token") + for bad in ["", "v1/../x", "vé", "v1?x"] { + #expect(Facebook(configuration: testConfiguration(transport: StubTransport()), graphAPIVersion: bad).graphAPIVersion == "v23.0") + } + } +} + +@Suite("LinkedIn") +struct LinkedInTests { + @Test func loginLinkAndSignIn() async throws { + let (profile, transport, request) = try await signIn( + { LinkedIn(configuration: $0) }, + token: ("https://www.linkedin.com/oauth/v2/accessToken", #"{"access_token":"AT","expires_in":5184000,"scope":"openid,profile"}"#), + profile: ("https://api.linkedin.com/v2/userinfo", 200, #"{"sub":"782bbtaQ","name":"Ada L","given_name":"Ada","family_name":"L","picture":"https://p.test/l.jpg","email":"a@x.test","email_verified":true}"#), + scopes: ["openid", "profile", "email"] + ) + let query = try #require(queryParameters(request.url)) + #expect(query["scope"] == "openid profile email") + #expect(query["code_challenge"] == nil) + #expect(profile.userid == "782bbtaQ") + #expect(profile.emailVerified) + #expect(profile.token.scope == ["openid", "profile"]) + assertBearer(transport.requests(to: "https://api.linkedin.com/v2/userinfo").first) + } + + @Test func defaultScopes() throws { + let request = try LinkedIn(configuration: testConfiguration(transport: StubTransport())).authorizationRequest() + #expect(queryParameters(request.url)?["scope"] == "openid profile") + } +} + +@Suite("Salesforce") +struct SalesforceTests { + private let idURL = "https://login.salesforce.com/id/00Dx0000000BV7z/005x00000012Q9P" + + @Test func loginLinkAndSignIn() async throws { + let (profile, transport, request) = try await signIn( + { try Salesforce(configuration: $0) }, + token: ("https://login.salesforce.com/services/oauth2/token", #"{"access_token":"AT","instance_url":"https://na1.salesforce.com","id":"\#(idURL)","token_type":"Bearer","issued_at":"1278448101416"}"#), + profile: (idURL, 200, #"{"user_id":"005x00000012Q9P","first_name":"Ada","last_name":"L","display_name":"Ada L","photos":{"picture":"https://p.test/s.png"},"email":"a@x.test","email_verified":true}"#) + ) + #expect(queryParameters(request.url)?["scope"] == "id") + #expect(queryParameters(request.url)?["code_challenge"] != nil) + #expect(profile.userid == "005x00000012Q9P") + #expect(profile.picture == "https://p.test/s.png") + #expect(profile.token.instanceURL == "https://na1.salesforce.com") + assertBearer(transport.requests(to: idURL).first) + #expect(formFields(transport.requests(to: "https://login.salesforce.com/services/oauth2/token").first?.body)["code_verifier"] == request.pending.codeVerifier) + } + + @Test func identityURLMustBeSalesforceHTTPS() async { + for bad in ["http://login.salesforce.com/id/1/2", "https://evil.test/id/1/2", "https://salesforce.com.evil.test/id", "https://x.force.com/id/1/2"] { + await #expect(throws: OAuth2Error.insecureURL("identity URL is not an https Salesforce URL")) { + _ = try await signIn({ try Salesforce(configuration: $0) }, token: ("https://login.salesforce.com/services/oauth2/token", #"{"access_token":"AT","id":"\#(bad)"}"#), profile: nil) + } + } + await #expect(throws: OAuth2Error.invalidResponse("token response has no identity URL (id)")) { + _ = try await signIn({ try Salesforce(configuration: $0) }, token: ("https://login.salesforce.com/services/oauth2/token", #"{"access_token":"AT"}"#), profile: nil) + } + } + + @Test func sandboxHost() throws { + let salesforce = try Salesforce(configuration: testConfiguration(transport: StubTransport()), loginHost: "test.salesforce.com") + #expect(try salesforce.authorizationRequest().url.host == "test.salesforce.com") + #expect(throws: OAuth2Error.self) { + try Salesforce(configuration: testConfiguration(transport: StubTransport()), loginHost: "evil.test/x?") + } + #expect(throws: OAuth2Error.self) { + try Salesforce(configuration: testConfiguration(transport: StubTransport()), loginHost: "tést.salesforce.com") + } + } +} + +@Suite("Slack") +struct SlackTests { + @Test func loginLinkAndSignIn() async throws { + let (profile, transport, request) = try await signIn( + { Slack(configuration: $0) }, + token: ("https://slack.com/api/openid.connect.token", #"{"ok":true,"access_token":"xoxp-AT","token_type":"Bearer","id_token":"x.y.z"}"#), + profile: ("https://slack.com/api/openid.connect.userInfo", 200, #"{"ok":true,"sub":"U0R7JM","https://slack.com/user_id":"U0R7JM","https://slack.com/team_id":"T0R7GR","name":"Ada L","given_name":"Ada","family_name":"L","picture":"https://p.test/s.png"}"#) + ) + #expect(request.url.absoluteString.hasPrefix("https://slack.com/openid/connect/authorize?")) + #expect(queryParameters(request.url)?["scope"] == "openid profile") + #expect(queryParameters(request.url)?["code_challenge"] == nil) + #expect(profile.userid == "U0R7JM") + #expect(profile.raw["https://slack.com/team_id"]?.stringValue == "T0R7GR") + assertBearer(transport.requests(to: "https://slack.com/api/openid.connect.userInfo").first, "xoxp-AT") + } + + @Test func okFalseIsAnError() async { + await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "invalid_auth"))) { + _ = try await signIn({ Slack(configuration: $0) }, + token: ("https://slack.com/api/openid.connect.token", #"{"ok":true,"access_token":"AT"}"#), + profile: ("https://slack.com/api/openid.connect.userInfo", 200, #"{"ok":false,"error":"invalid_auth"}"#)) + } + } +} + +@Suite("Custom providers") +struct CustomProviderTests { + struct Gitea: OAuth2Provider { + let oauth2: OAuth2 + let loginType = "gitea" + init(configuration: OAuth2ClientConfiguration) { + oauth2 = OAuth2( + configuration: configuration, + authorizationURL: URL(string: "https://gitea.test/login/oauth/authorize")!, + tokenURL: URL(string: "https://gitea.test/login/oauth/access_token")!, + defaultScopes: ["read:user"] + ) + } + func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + let user = try await oauth2.getJSON(URL(string: "https://gitea.test/api/v1/user")!, accessToken: token.accessToken) + return try OAuthUserProfile(userid: user["id"]?.identifierValue, loginType: loginType, token: token, raw: user) + } + } + + @Test func conformingTypeGetsTheWholeFlow() async throws { + let (profile, _, _) = try await signIn( + { Gitea(configuration: $0) }, + token: ("https://gitea.test/login/oauth/access_token", #"{"access_token":"AT"}"#), + profile: ("https://gitea.test/api/v1/user", 200, #"{"id":7}"#) + ) + #expect(profile.userid == "7") + #expect(profile.loginType == "gitea") + } +} diff --git a/Tests/PerfectOAuth2Tests/RefreshTests.swift b/Tests/PerfectOAuth2Tests/RefreshTests.swift deleted file mode 100644 index c841e62..0000000 --- a/Tests/PerfectOAuth2Tests/RefreshTests.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -import Testing -@testable import PerfectOAuth2 - -/// Intercepts requests to hosts registered with `StubTokenEndpoint.serve(...)` and lets every other -/// request through, so tests using distinct hosts can run in parallel. -final class StubTokenEndpoint: URLProtocol, @unchecked Sendable { - struct Captured: Sendable { - let method: String? - let contentType: String? - let body: String - } - - private struct Route { - let status: Int - let response: Data - var captured: [Captured] = [] - } - - private static let lock = NSLock() - nonisolated(unsafe) private static var routes: [String: Route] = [:] - private static let registered: Void = { URLProtocol.registerClass(StubTokenEndpoint.self) }() - - /// Registers a canned response for `https:///token` and returns its URL. - static func serve(host: String, status: Int = 200, json: String) -> String { - _ = registered - lock.withLock { routes[host] = Route(status: status, response: Data(json.utf8)) } - return "https://\(host)/token" - } - - static func requests(host: String) -> [Captured] { - lock.withLock { routes[host]?.captured ?? [] } - } - - override class func canInit(with request: URLRequest) -> Bool { - guard let host = request.url?.host else { return false } - return lock.withLock { routes[host] != nil } - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - let host = request.url?.host ?? "" - let captured = Captured( - method: request.httpMethod, - contentType: request.value(forHTTPHeaderField: "Content-Type"), - body: String(decoding: Self.bodyData(of: request), as: UTF8.self) - ) - let route: Route? = Self.lock.withLock { - Self.routes[host]?.captured.append(captured) - return Self.routes[host] - } - guard let route, let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: route.status, httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/json"]) - else { - client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) - return - } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: route.response) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} - - /// URLSession moves `httpBody` into `httpBodyStream` before handing the request to a protocol. - private static func bodyData(of request: URLRequest) -> Data { - if let body = request.httpBody { return body } - guard let stream = request.httpBodyStream else { return Data() } - stream.open() - defer { stream.close() } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while stream.hasBytesAvailable { - let n = stream.read(&buffer, maxLength: buffer.count) - if n <= 0 { break } - data.append(buffer, count: n) - } - return data - } -} - -/// Decodes an `application/x-www-form-urlencoded` body into a dictionary (`+` means space). -private func formFields(_ body: String) -> [String: String] { - var fields: [String: String] = [:] - for pair in body.split(separator: "&") { - let parts = pair.replacingOccurrences(of: "+", with: " ") - .split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2, - let key = String(parts[0]).removingPercentEncoding, - let value = String(parts[1]).removingPercentEncoding else { continue } - fields[key] = value - } - return fields -} - -private func client(tokenURL: String) -> OAuth2 { - OAuth2( - clientID: "client id", - clientSecret: "s3cr3t/+=", - authorizationURL: "https://auth.invalid/authorize", - tokenURL: tokenURL - ) -} - -@Suite("Refresh token grant") -struct RefreshTests { - - @Test func sendsRefreshGrantAndParsesRotatedToken() async throws { - let host = "refresh-rotated.test.invalid" - let url = StubTokenEndpoint.serve(host: host, json: """ - {"access_token":"new-access","token_type":"Bearer","expires_in":3600, - "refresh_token":"rotated-refresh","scope":"openid email"} - """) - - let token = try await client(tokenURL: url) - .refresh(refreshToken: "old/refresh+token", scopes: ["openid", "email"]) - - #expect(token.accessToken == "new-access") - #expect(token.refreshToken == "rotated-refresh") - #expect(token.tokenType == "Bearer") - #expect(token.scope == ["openid", "email"]) - #expect(token.expiration != nil) - - let requests = StubTokenEndpoint.requests(host: host) - try #require(requests.count == 1) - #expect(requests[0].method == "POST") - #expect(requests[0].contentType == "application/x-www-form-urlencoded") - #expect(formFields(requests[0].body) == [ - "grant_type": "refresh_token", - "refresh_token": "old/refresh+token", - "client_id": "client id", - "client_secret": "s3cr3t/+=", - "scope": "openid email", - ]) - } - - @Test func keepsOriginalRefreshTokenWhenResponseOmitsIt() async throws { - let host = "refresh-kept.test.invalid" - let url = StubTokenEndpoint.serve(host: host, json: """ - {"access_token":"new-access","token_type":"Bearer","expires_in":3599} - """) - - let token = try await client(tokenURL: url).refresh(refreshToken: "long-lived") - - #expect(token.accessToken == "new-access") - #expect(token.refreshToken == "long-lived") - let fields = formFields(try #require(StubTokenEndpoint.requests(host: host).first).body) - #expect(fields["scope"] == nil) - #expect(fields["client_secret"] == "s3cr3t/+=") - } - - @Test func omitsClientSecretForPublicClients() async throws { - let host = "refresh-public.test.invalid" - let url = StubTokenEndpoint.serve(host: host, json: #"{"access_token":"a"}"#) - - _ = try await client(tokenURL: url).refresh(refreshToken: "r", includeClientSecret: false) - - let fields = formFields(try #require(StubTokenEndpoint.requests(host: host).first).body) - #expect(fields["client_secret"] == nil) - #expect(fields["client_id"] == "client id") - } - - @Test func throwsOAuth2ErrorForRejectedGrant() async throws { - let url = StubTokenEndpoint.serve(host: "refresh-rejected.test.invalid", status: 400, json: """ - {"error":"invalid_grant","error_description":"Token has been expired or revoked."} - """) - - let error = await #expect(throws: OAuth2Error.self) { - try await client(tokenURL: url).refresh(refreshToken: "revoked") - } - #expect(error?.code == .invalidGrant) - #expect(error?.description == "Token has been expired or revoked.") - } - - @Test func throwsInvalidAPIResponseForUnexpectedBody() async throws { - let url = StubTokenEndpoint.serve(host: "refresh-garbage.test.invalid", status: 502, json: "Bad Gateway") - - await #expect(throws: InvalidAPIResponse.self) { - try await client(tokenURL: url).refresh(refreshToken: "r") - } - } -} diff --git a/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift b/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift new file mode 100644 index 0000000..8574f81 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift @@ -0,0 +1,179 @@ +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#endif + +/// A tiny HTTP/1.1 server on 127.0.0.1 (ephemeral port) for exercising `URLSessionTransport`. +/// One thread per connection; each connection gets one response and is closed. +final class LocalHTTPServer: @unchecked Sendable { + enum Behavior: Sendable { + /// A complete response. + case respond(status: Int, headers: [String: String], body: Data) + /// Headers without a length, then body bytes until the client goes away. + case endless + /// `Content-Length: declared`, then one byte every `interval` seconds. + case drip(declared: Int, interval: TimeInterval) + /// Accepts and never answers. + case silent + } + + struct Received: Sendable { + let head: String + let body: Data + } + + let port: UInt16 + private let listener: Int32 + // Guarded by `lock`. + private let lock = NSLock() + private var clients: [Int32] = [] + private var received: [Received] = [] + private var stopped = false + private var bytesSent = 0 + private let behavior: @Sendable (Received) -> Behavior + + var requests: [Received] { lock.withLock { received } } + var totalBytesSent: Int { lock.withLock { bytesSent } } + var baseURL: String { "http://127.0.0.1:\(port)" } + + init(_ behavior: @escaping @Sendable (Received) -> Behavior) throws { + self.behavior = behavior + #if canImport(Darwin) + let fd = socket(AF_INET, SOCK_STREAM, 0) + #else + let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) + #endif + guard fd >= 0 else { throw POSIXError(.EIO) } + var one: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size)) + var address = sockaddr_in() + #if canImport(Darwin) + address.sin_len = UInt8(MemoryLayout.size) + #endif + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(fd, 64) == 0 else { + close(fd) + throw POSIXError(.EADDRINUSE) + } + var length = socklen_t(MemoryLayout.size) + _ = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) } + } + listener = fd + port = UInt16(bigEndian: address.sin_port) + let thread = Thread { [self] in acceptLoop() } + thread.start() + } + + func stop() { + let fds: [Int32] = lock.withLock { + stopped = true + defer { clients = [] } + return clients + } + shutdown(listener, Int32(SHUT_RDWR)) + close(listener) + for fd in fds { shutdown(fd, Int32(SHUT_RDWR)) } + } + + deinit { stop() } + + private func acceptLoop() { + while true { + let client = accept(listener, nil, nil) + if client < 0 { return } + #if canImport(Darwin) + var one: Int32 = 1 + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + #endif + let accepted: Bool = lock.withLock { + if stopped { return false } + clients.append(client) + return true + } + guard accepted else { close(client); return } + Thread { [self] in handle(client) }.start() + } + } + + private func handle(_ fd: Int32) { + defer { + lock.withLock { clients.removeAll { $0 == fd } } + close(fd) + } + guard let request = readRequest(fd) else { return } + lock.withLock { received.append(request) } + switch behavior(request) { + case .respond(let status, let headers, let body): + var head = "HTTP/1.1 \(status) Status\r\nContent-Length: \(body.count)\r\nConnection: close\r\n" + for (name, value) in headers { head += "\(name): \(value)\r\n" } + _ = write(fd, Data((head + "\r\n").utf8) + body) + case .endless: + guard write(fd, Data("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n".utf8)) else { return } + let chunk = Data(repeating: UInt8(ascii: " "), count: 64 * 1024) + while write(fd, chunk) {} + case .drip(let declared, let interval): + guard write(fd, Data("HTTP/1.1 200 OK\r\nContent-Length: \(declared)\r\nConnection: close\r\n\r\n".utf8)) else { return } + for _ in 0.. Bool { + if lock.withLock({ stopped }) { return false } + #if canImport(Darwin) + let flags: Int32 = 0 + #else + let flags = Int32(MSG_NOSIGNAL) + #endif + return data.withUnsafeBytes { raw -> Bool in + var offset = 0 + while offset < raw.count { + let n = send(fd, raw.baseAddress! + offset, raw.count - offset, flags) + if n <= 0 { return false } + offset += n + lock.withLock { bytesSent += n } + } + return true + } + } + + private func readRequest(_ fd: Int32) -> Received? { + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 4096) + let separator = Data("\r\n\r\n".utf8) + while buffer.range(of: separator) == nil { + let n = recv(fd, &chunk, chunk.count, 0) + if n <= 0 { return nil } + buffer.append(chunk, count: n) + } + let split = buffer.range(of: separator)! + let head = String(decoding: buffer[.. StubTransport { + let key = Self.key(URL(string: url)!) + lock.withLock { routes[key] = OAuth2HTTPResponse(status: status, body: Data(body.utf8)) } + return self + } + + var requests: [OAuth2HTTPRequest] { lock.withLock { recorded } } + + func requests(to url: String) -> [OAuth2HTTPRequest] { + let key = Self.key(URL(string: url)!) + return requests.filter { Self.key($0.url) == key } + } + + func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse { + let response: OAuth2HTTPResponse? = lock.withLock { + recorded.append(request) + return routes[Self.key(request.url)] + } + guard let response else { throw OAuth2Error.network("no stub for \(Self.key(request.url))") } + return response + } + + private static func key(_ url: URL) -> String { "\(url.host ?? "")\(url.path)" } +} + +/// Decodes an `application/x-www-form-urlencoded` body (`+` means space). +func formFields(_ body: Data?) -> [String: String] { + var fields: [String: String] = [:] + for pair in String(decoding: body ?? Data(), as: UTF8.self).split(separator: "&") { + let parts = pair.replacingOccurrences(of: "+", with: " ") + .split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, + let key = String(parts[0]).removingPercentEncoding, + let value = String(parts[1]).removingPercentEncoding else { continue } + fields[key] = value + } + return fields +} + +/// Query parameters of a URL; fails the lookup (returns nil) on a repeated name. +func queryParameters(_ url: URL) -> [String: String]? { + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + var result: [String: String] = [:] + for item in items { + if result[item.name] != nil { return nil } + result[item.name] = item.value ?? "" + } + return result +} + +func testConfiguration( + transport: StubTransport, + redirect: String = "https://app.test/auth/callback", + scopes: [String]? = nil, + usePKCE: Bool? = nil, + secret: String? = "s3cr3t/+=&" +) -> OAuth2ClientConfiguration { + OAuth2ClientConfiguration( + clientID: "client id&x", + clientSecret: secret, + redirectURL: URL(string: redirect)!, + scopes: scopes, + usePKCE: usePKCE, + http: OAuth2HTTPOptions(transport: transport) + ) +} + +/// An unsigned compact JWT with the given payload. +func makeJWT(_ payload: [String: Any]) -> String { + let header = base64URLEncode(Data(#"{"alg":"RS256","typ":"JWT"}"#.utf8)) + let body = base64URLEncode(try! JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])) + return "\(header).\(body).c2lnbmF0dXJl" +} + +let validState = String(repeating: "S", count: 43) + diff --git a/Tests/PerfectOAuth2Tests/TransportTests.swift b/Tests/PerfectOAuth2Tests/TransportTests.swift new file mode 100644 index 0000000..aa68929 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/TransportTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +@testable import PerfectOAuth2 + +/// `URLSessionTransport` against a real socket on 127.0.0.1. +@Suite("URLSessionTransport", .serialized) +struct TransportTests { + private func client(_ server: LocalHTTPServer, timeout: TimeInterval = 10, limit: Int = 1 << 20) -> OAuth2 { + OAuth2( + configuration: OAuth2ClientConfiguration( + clientID: "id", clientSecret: "secret-value-123", redirectURL: URL(string: "https://app.test/cb")!, + usePKCE: false, http: OAuth2HTTPOptions(timeout: timeout, maximumResponseBytes: limit) + ), + authorizationURL: URL(string: "https://auth.test/a")!, + tokenURL: URL(string: "\(server.baseURL)/token")! + ) + } + + private static func json(_ status: Int, _ body: String, headers: [String: String] = [:]) -> LocalHTTPServer.Behavior { + .respond(status: status, headers: headers.merging(["Content-Type": "application/json"]) { a, _ in a }, body: Data(body.utf8)) + } + + @Test func postsFormAndParsesJSON() async throws { + let server = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"AT","expires_in":10}"#) } + defer { server.stop() } + let token = try await client(server).refresh(refreshToken: "refresh-value") + #expect(token.accessToken == "AT") + let request = try #require(server.requests.first) + #expect(request.head.hasPrefix("POST /token HTTP/1.1")) + #expect(request.head.lowercased().contains("content-type: application/x-www-form-urlencoded")) + #expect(request.head.lowercased().contains("user-agent: perfectoauth2/2.0")) + #expect(formFields(request.body)["refresh_token"] == "refresh-value") + } + + @Test func bearerHeaderOnGet() async throws { + let server = try LocalHTTPServer { _ in Self.json(200, #"{"sub":"1"}"#) } + defer { server.stop() } + let json = try await client(server).getJSON(URL(string: "\(server.baseURL)/me")!, accessToken: "AT-123") + #expect(json["sub"]?.stringValue == "1") + let head = try #require(server.requests.first?.head) + #expect(head.hasPrefix("GET /me HTTP/1.1")) + #expect(head.contains("Authorization: Bearer AT-123") || head.contains("authorization: Bearer AT-123")) + } + + @Test func statusAndBodyErrors() async throws { + let server = try LocalHTTPServer { request in + if request.head.hasPrefix("POST /token?") { return Self.json(400, #"{"error":"invalid_grant"}"#) } + return Self.json(503, "down") + } + defer { server.stop() } + await #expect(throws: OAuth2Error.httpStatus(503)) { try await client(server).refresh(refreshToken: "r") } + } + + @Test func redirectsAreNotFollowed() async throws { + let target = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"stolen"}"#) } + defer { target.stop() } + let server = try LocalHTTPServer { _ in + .respond(status: 307, headers: ["Location": "\(target.baseURL)/steal"], body: Data()) + } + defer { server.stop() } + await #expect(throws: OAuth2Error.httpStatus(307)) { try await client(server).refresh(refreshToken: "r") } + #expect(target.requests.isEmpty) + } + + @Test func declaredOversizeIsRefused() async throws { + let big = String(repeating: " ", count: 5000) + #"{"access_token":"AT"}"# + let server = try LocalHTTPServer { _ in Self.json(200, big) } + defer { server.stop() } + await #expect(throws: OAuth2Error.responseTooLarge(limit: 4096)) { + try await client(server, limit: 4096).refresh(refreshToken: "r") + } + } + + @Test func endlessBodyStopsAtTheLimit() async throws { + let server = try LocalHTTPServer { _ in .endless } + defer { server.stop() } + let start = Date() + await #expect(throws: OAuth2Error.responseTooLarge(limit: 64 * 1024)) { + try await client(server, timeout: 20, limit: 64 * 1024).refresh(refreshToken: "r") + } + // The call returns as soon as the limit is passed, well before the deadline. (On Linux, + // corelibs Foundation may go on reading and discarding in the background; see the README.) + #expect(Date().timeIntervalSince(start) < 5) + } + + @Test func slowDripHitsTheWholeRequestDeadline() async throws { + // One byte every 0.2 s never trips URLSession's idle timeout; the deadline must. + let server = try LocalHTTPServer { _ in .drip(declared: 1000, interval: 0.2) } + defer { server.stop() } + let start = Date() + await #expect(throws: OAuth2Error.timedOut) { try await client(server, timeout: 1.5).refresh(refreshToken: "r") } + let elapsed = Date().timeIntervalSince(start) + #expect(elapsed >= 1.4 && elapsed < 5) + } + + @Test func silentServerTimesOut() async throws { + let server = try LocalHTTPServer { _ in .silent } + defer { server.stop() } + await #expect(throws: OAuth2Error.timedOut) { try await client(server, timeout: 1).refresh(refreshToken: "r") } + } + + @Test func connectionRefusedIsANetworkError() async throws { + let server = try LocalHTTPServer { _ in .silent } + let url = server.baseURL + server.stop() + let client = OAuth2( + configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: nil, redirectURL: URL(string: "https://app.test/cb")!, http: OAuth2HTTPOptions(timeout: 5)), + authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(url)/token")! + ) + let error = await #expect(throws: OAuth2Error.self) { try await client.refresh(refreshToken: "r") } + if case .network? = error {} else { Issue.record("expected .network, got \(String(describing: error))") } + } + + @Test func cancellationEndsTheRequest() async throws { + let server = try LocalHTTPServer { _ in .silent } + defer { server.stop() } + let oauth = client(server, timeout: 30) + let task = Task { try await oauth.refresh(refreshToken: "r") } + try await Task.sleep(nanoseconds: 300_000_000) + let start = Date() + task.cancel() + let result = await task.result + #expect(Date().timeIntervalSince(start) < 3) + if case .success = result { Issue.record("cancelled request succeeded") } + } + + @Test func concurrentRequests() async throws { + let server = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"AT"}"#) } + defer { server.stop() } + let oauth = client(server) + try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0..<20 { group.addTask { try await oauth.refresh(refreshToken: "r").accessToken } } + for try await token in group { #expect(token == "AT") } + } + } +} From 72786c084ecf339c1a28454cdaa37b8440cd20d4 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 16:35:08 -0500 Subject: [PATCH 02/14] Add CI workflow and the 2.0.0 README CI: Linux Swift 6.2/6.4 and macOS Xcode 26.0.1/26.6, debug and release, warnings as errors, contents: read, 30-minute timeouts, no Swift backtracer on Linux. No iOS job: this is a server-side confidential-client library. README: install from 2.0.0, requirements, a quick start checked by building it in a scratch package, the security model (state, PKCE, redirect URI, id_token trust, HTTP limits and the Linux caveat), the provider table, custom providers, and migration from 1.x and the pre-release main API. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 68 +++++++ README.md | 390 ++++++++++++++++++++++----------------- 2 files changed, 285 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..749fbd5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + linux: + name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }}) + runs-on: ubuntu-24.04 + container: swift:${{ matrix.swift }}-noble + timeout-minutes: 30 + env: + SWIFT_BACKTRACE: enable=no + strategy: + fail-fast: false + matrix: + # The oldest supported Swift (swift-tools-version 6.2) and the newest. + swift: ["6.2", "6.4"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Toolchain + run: swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS + + macos: + name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) + runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest Xcode with Swift 6.2, and the runner's default. + xcode: ["26.0.1", "26.6"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS diff --git a/README.md b/README.md index 648b3c1..fd942a5 100644 --- a/README.md +++ b/README.md @@ -1,221 +1,265 @@ # PerfectOAuth2

- Swift 6.2 - Platforms macOS 12+ - License Apache 2.0 + Swift 6.2+ + Platforms macOS 12+ and Linux + License Apache 2.0

-Swift 6 OAuth2 client library with providers for Google, GitHub, Facebook, Slack, LinkedIn, and -Salesforce. No external dependencies — Foundation and URLSession only. Both library and test -targets build under strict concurrency. +Server-side OAuth 2.0 sign-in for Swift 6: the authorization-code flow with PKCE and CSRF state, +token refresh, and profile fetching for Google, GitHub, Facebook, Slack, LinkedIn and Salesforce. +Framework-agnostic: you supply two routes and a server-side session. -**Note the naming:** this repo is `Perfect-Authentication`, but the package is -`PerfectAuthentication` and the library product you import is `PerfectOAuth2`. +**Naming:** the repo is `Perfect-Authentication`, the package is `PerfectAuthentication`, and the +library product you import is `PerfectOAuth2`. -**Ecosystem status:** standalone infrastructure — nothing else here currently depends on it. It's -finished and tested, awaiting a consumer (e.g. a future `PerfectNIOOAuth2` wrapper in Perfect-NIO), -not dead or abandoned code. - -The pre-Swift-6 version of this package, including the original Swift 3 `OAuth2` target and the -un-resurrected `LocalAuthentication` username/password system, is preserved on the -[`legacy`](https://github.com/PerfectlySoft/Perfect-Authentication/tree/legacy) branch. - -## Package +## Install ```swift -.package(url: "https://github.com/PerfectlySoft/Perfect-Authentication.git", branch: "main"), +.package(url: "https://github.com/PerfectlySoft/Perfect-Authentication.git", from: "2.0.0"), // target dependency .product(name: "PerfectOAuth2", package: "Perfect-Authentication"), ``` -```swift -import PerfectOAuth2 -``` - -## Configuration - -Set config before your server starts. All properties are `nonisolated(unsafe) static var` to satisfy Swift 6 global state rules. - -```swift -GoogleConfig.appid = "your-client-id" -GoogleConfig.secret = "your-client-secret" -GoogleConfig.endpointAfterAuth = "https://yourapp.com/auth/response/google" -GoogleConfig.redirectAfterAuth = "https://yourapp.com/" - -// Google only: restrict to a G Suite / Workspace domain -GoogleConfig.restrictedDomain = "yourcompany.com" - -GitHubConfig.appid = "your-client-id" -GitHubConfig.secret = "your-client-secret" -GitHubConfig.endpointAfterAuth = "https://yourapp.com/auth/response/github" -GitHubConfig.redirectAfterAuth = "https://yourapp.com/" - -FacebookConfig.appid = "your-app-id" -FacebookConfig.secret = "your-app-secret" -FacebookConfig.endpointAfterAuth = "https://yourapp.com/auth/response/facebook" -FacebookConfig.redirectAfterAuth = "https://yourapp.com/" - -SlackConfig.appid = "your-client-id" -SlackConfig.secret = "your-client-secret" -SlackConfig.endpointAfterAuth = "https://yourapp.com/auth/response/slack" -SlackConfig.redirectAfterAuth = "https://yourapp.com/" - -LinkedinConfig.appid = "your-client-id" -LinkedinConfig.secret = "your-client-secret" -LinkedinConfig.endpointAfterAuth = "https://yourapp.com/auth/response/linkedin" -LinkedinConfig.redirectAfterAuth = "https://yourapp.com/" - -SalesForceConfig.appid = "your-client-id" -SalesForceConfig.secret = "your-client-secret" -SalesForceConfig.endpointAfterAuth = "https://yourapp.com/auth/response/salesforce" -SalesForceConfig.redirectAfterAuth = "https://yourapp.com/" -``` - -## Usage - -The library is framework-agnostic. You provide two routes — one to redirect the user to the provider, one to handle the callback. Both are plain async functions; session management is your responsibility. - -### Step 1: Redirect to provider +## Requirements -```swift -// PerfectNIO example — route: GET /to/google -let csrf = session.data["csrf"] as? String ?? "" -let loginURL = Google.loginURL(state: csrf, sessionToken: session.token) -// redirect the user to loginURL -``` +- Swift 6.2 or later (`swift-tools-version: 6.2`, Swift 6 language mode). +- macOS 12 or later, or Linux (CI: Ubuntu 24.04 with Swift 6.2 and 6.4; macOS with Xcode 26.0.1 + and 26.6). iOS isn't a target: this is a confidential-client library for servers, and apps should + use `ASWebAuthenticationSession` without embedding a client secret. +- One dependency: Apple's [swift-crypto](https://github.com/apple/swift-crypto) + (`3.0.0..<5.0.0`), for SHA-256 (PKCE) and HMAC-SHA256 (Facebook's `appsecret_proof`). On Apple + platforms it uses CryptoKit. Networking is Foundation's `URLSession`. -Or use the instance API for custom scopes: +## Quick start ```swift -let g = Google(clientID: GoogleConfig.appid, clientSecret: GoogleConfig.secret) -let url = g.loginURL(state: csrf, sessionToken: session.token, scopes: ["profile", "email"]) -``` - -To get a Google refresh token, pass `offlineAccess: true` (see [Refreshing an access token](#refreshing-an-access-token)). - -### Step 2: Handle the callback +import Foundation +import PerfectOAuth2 -```swift -// Route: GET /auth/response/google?code=...&state=... -let code = request.queryParam("code") ?? "" -let state = request.queryParam("state") ?? "" -let csrf = session.data["csrf"] as? String ?? "" - -let profile = try await Google.processAuthResponse( - code: code, - state: state, - sessionCSRF: csrf, - sessionToken: session.token +// Once, at startup. Providers are immutable and Sendable: share one instance. +let google = Google( + configuration: OAuth2ClientConfiguration( + clientID: "your-client-id.apps.googleusercontent.com", + clientSecret: "your-client-secret", + redirectURL: URL(string: "https://example.com/auth/google/callback")! + ) ) -// Store in session -session.userid = profile.userid -session.data["loginType"] = profile.loginType -session.data["accessToken"] = profile.accessToken -session.data["firstName"] = profile.firstName ?? "" -session.data["lastName"] = profile.lastName ?? "" -session.data["picture"] = profile.picture ?? "" - -// redirect to redirectAfterAuth -``` - -### OAuthUserProfile - -`processAuthResponse` returns an `OAuthUserProfile`: +// Route 1: GET /auth/google +func startSignIn(session: inout [String: Data]) throws -> URL { + let request = try google.authorizationRequest() + // Keep the state and PKCE verifier server-side until the callback. + session["oauth.pending"] = try JSONEncoder().encode(request.pending) + return request.url // redirect the browser here +} -```swift -public struct OAuthUserProfile: Sendable { - public let userid: String - public let firstName: String? - public let lastName: String? - public let picture: String? - public let accessToken: String - public let refreshToken: String? - public let loginType: String // "google" | "github" | "facebook" | "slack" | "linkedin" | "salesforce" +// Route 2: GET /auth/google/callback?code=...&state=... +func finishSignIn(callbackURL: URL, session: inout [String: Data]) async throws -> OAuthUserProfile { + guard let data = session.removeValue(forKey: "oauth.pending") else { + throw OAuth2Error.stateMismatch + } + let pending = try JSONDecoder().decode(PendingAuthorization.self, from: data) + let profile = try await google.completeAuthorization(callbackURL: callbackURL, pending: pending) + // profile.userid is never empty. Key accounts on (profile.loginType, profile.userid). + return profile } ``` -### Manual token exchange (advanced) - -If you need access to the raw token (e.g. to call provider APIs beyond the profile): - -```swift -let provider = GitHub(clientID: GitHubConfig.appid, clientSecret: GitHubConfig.secret) -let token = try await provider.exchange(code: code, state: state, sessionToken: session.token) -let userdata = await provider.getUserData(token.accessToken) -``` +`completeAuthorization` checks the state, exchanges the code (sending the PKCE verifier and the same +`redirect_uri`), runs the provider's token checks, fetches the profile, and throws if anything is +missing. Use `completeAuthorization(code:state:pending:)` if your framework hands you the query +parameters instead of the URL. -### Refreshing an access token +`OAuthUserProfile` has `userid`, `firstName`, `lastName`, `displayName`, `picture`, `email`, +`emailVerified`, `loginType`, `token` (an `OAuth2Token`: access, refresh and id tokens, expiry, +scopes) and `raw`, the provider's profile response as a `JSONValue`. -If the provider issued a refresh token, exchange it for a new access token with `refresh` -(the RFC 6749 `refresh_token` grant): +### Refreshing ```swift -let provider = Google(clientID: GoogleConfig.appid, clientSecret: GoogleConfig.secret) -let token = try await provider.refresh(refreshToken: storedRefreshToken) +let token = try await google.refresh(refreshToken: storedRefreshToken) // token.refreshToken is the rotated refresh token if the provider sent one, // otherwise the one you passed in, so it can always be stored back. ``` -`scopes` optionally narrows the request to a subset of the originally granted scopes. -`includeClientSecret` defaults to `true`, which Google, Salesforce and LinkedIn require; pass -`false` for public clients that authenticate with `client_id` only. A rejected refresh token usually throws -`OAuth2Error` with code `.invalidGrant`; providers that answer with non-standard error codes (GitHub's -`bad_refresh_token`, Slack's `{"ok":false}`) surface as `InvalidAPIResponse` instead. +`scopes:` narrows the request to a subset of the granted scopes, and `includeClientSecret: false` +leaves the secret out for providers that reject it. Google only issues a refresh token when asked: +`google.authorizationRequest(offlineAccess: true)` adds `access_type=offline&prompt=consent`. Use it +when you need a refresh token and leave it off for routine sign-ins, so users aren't asked to consent +every time. + +### Errors + +Everything throws `OAuth2Error`: + +| Case | Meaning | +|------|---------| +| `.stateMismatch`, `.invalidState` | CSRF check failed, or the stored state is unusable | +| `.authorizationDenied(response)` | the provider redirected back with `error=` (e.g. `access_denied`) | +| `.missingAuthorizationCode` | the callback had neither `code` nor `error` | +| `.providerError(response)` | an error body from a token or API endpoint; `response.code` is the provider's code (`invalid_grant`, `bad_verification_code`, `OAuthException`...), `response.standardCode` the RFC 6749 one if it is one | +| `.httpStatus(Int)` | any other non-2xx, including redirects (never followed) | +| `.invalidResponse`, `.missingUserID` | the response wasn't the JSON expected, or had no user id | +| `.invalidIDToken`, `.restrictedDomainMismatch` | Google id_token checks | +| `.timedOut`, `.network`, `.responseTooLarge` | transport failures | +| `.insecureURL`, `.invalidConfiguration` | a non-HTTPS endpoint, or a bad redirect URL / parameter | + +Error descriptions never include secrets: provider text is truncated, stripped of control +characters, and has the client secret, code, verifier and tokens from the failed request replaced +with ``. + +## Security model + +- **State (CSRF).** `authorizationRequest()` generates 32 random bytes (`SystemRandomNumberGenerator`, + base64url). If you pass your own `state:`, it must be 32 to 512 characters. The callback's state + must be non-empty and equal to the stored one, compared in constant time, before any network + request. `AuthorizationState.generate()` and `AuthorizationState.verify(returned:expected:)` are + public if you run the flow yourself. +- **PKCE** (RFC 7636, S256), as the OAuth 2.0 Security BCP (RFC 9700) recommends for every client, + confidential ones included. It is on by default where the provider documents support (see the + table) and can be forced either way with `usePKCE:` in the configuration. Keep + `PendingAuthorization` server-side; it holds the verifier. +- **Redirect URI.** One fixed, registered `redirectURL`: absolute HTTPS (HTTP only to loopback hosts), + no fragment. It is sent unchanged on the authorization request and the exchange. 1.x appended + `?session=` to it, which leaked the session token to the provider and into logs; + 2.0 doesn't, and you shouldn't put session identifiers in it either. Use a cookie-backed session. +- **id_token trust.** An id_token is only used when it came straight from the provider's token + endpoint over TLS. OpenID Connect Core §3.1.3.7 lets a client skip the signature check in that case, + so PerfectOAuth2 does, but it checks the issuer, audience (`aud` contains your client ID, and `azp` + equals it when present or when there are several audiences), expiry and issue time (5 minutes' + leeway) and a non-empty `sub`. Google's `restrictedDomain` uses the `hd` claim of that validated + token, and the userinfo `sub` must match the token's. `OAuth2Token.idToken` is exposed raw: never + trust an id_token from anywhere else (a browser, a query string, storage) without verifying its + signature against the provider's keys. +- **Email.** `email` is only trustworthy when `emailVerified` is true. GitHub's profile email is never + marked verified. +- **Tokens on the wire.** Access tokens are sent in the `Authorization: Bearer` header, never in a URL. + Token requests are form-encoded POSTs with `client_secret` in the body. Every URL is built with + `URLComponents`, and every value is percent-encoded (including `+`, `&` and `=`). +- **HTTP limits.** Each request has a whole-request deadline (`OAuth2HTTPOptions.timeout`, default + 30 s), a response cap (`maximumResponseBytes`, default 1 MiB, checked against `Content-Length` and + while streaming), no redirects, no cookies or cache, and HTTPS only (HTTP to loopback for testing). + Salesforce's identity URL must be HTTPS on a Salesforce host before the token is sent to it. +- **No secrets in output.** `description`, `debugDescription`, `dump` and `Mirror` of + `OAuth2ClientConfiguration`, `OAuth2Token`, `OAuthUserProfile`, `PendingAuthorization` and + `OAuth2HTTPRequest` show `` for secrets, tokens, state and verifiers. + +### Response limits on Linux + +On Darwin, cancelling at the size limit stops the download. On Linux, the call also returns as soon +as the limit is passed, but corelibs Foundation can go on reading and discarding the rest of the body +in the background for a while. In testing against an endless local body, every call returned within +0.03 s and peak memory stayed under 180 MB, though the background read consumed up to 2 GB of +loopback traffic. Provider responses are small and come from the providers' own HTTPS endpoints, so +this only matters if one of them is compromised. If you need a hard bound, plug in your own +transport (for example on AsyncHTTPClient) through `OAuth2HTTPOptions(transport:)`. -Not every provider issues refresh tokens: GitHub OAuth apps and Facebook don't, and Salesforce only -does when the `refresh_token` scope is granted. Google issues one the first time a user grants -offline access, and afterwards only when consent is prompted again. `offlineAccess: true` adds both -`access_type=offline` and `prompt=consent`: - -```swift -let url = Google.loginURL(state: csrf, sessionToken: session.token, offlineAccess: true) -``` +## Providers -Use it when you need a refresh token (first sign-in, or when the stored one is lost or revoked), and -leave it off for routine sign-ins so users aren't asked to consent every time. While a Google Cloud -app's publishing status is "Testing", Google expires its refresh tokens after 7 days. +| Provider | Type | Default scopes | PKCE default | Profile endpoint | Notes | +|----------|------|----------------|--------------|------------------|-------| +| Google | `Google(configuration:restrictedDomain:)` | `openid profile` (`openid` always added) | on | OIDC `openidconnect.googleapis.com/v1/userinfo` | id_token validated on every exchange; `restrictedDomain` sends `hd` and enforces the token's `hd` claim; `authorizationRequest(offlineAccess:)` | +| GitHub | `GitHub(configuration:)` | `read:user` | on | `api.github.com/user` | GitHub added PKCE in 2025. Errors arrive as HTTP 200 with `error`. OAuth apps don't issue refresh tokens unless token expiration is enabled | +| Facebook | `Facebook(configuration:graphAPIVersion:)` | none | off | Graph `/me` | Graph `v23.0` by default; sends `appsecret_proof`. No refresh tokens | +| LinkedIn | `LinkedIn(configuration:)` | `openid profile` | off | OIDC `api.linkedin.com/v2/userinfo` | LinkedIn documents PKCE only for approved native clients. Add `email` for the address | +| Salesforce | `try Salesforce(configuration:loginHost:)` | `id` | on | identity URL from the token response | `loginHost` for sandboxes (`test.salesforce.com`) and My Domain. Add `refresh_token` for refresh tokens | +| Slack | `Slack(configuration:)` | `openid profile` | off | OIDC `slack.com/api/openid.connect.userInfo` | Sign in with Slack (OIDC). User ids are per workspace: combine with `raw["https://slack.com/team_id"]` | -## Providers +PKCE defaults are conservative: on where the provider documents S256 support for web apps with a +client secret, off where support is undocumented or limited. Providers ignore unknown parameters, so +turning it on is low-risk if your app is set up for it. -| Provider | Default scopes | Notes | -|------------|------------------------------------|-------| -| Google | `profile` | Add `restrictedDomain` to enforce G Suite domain | -| GitHub | `user` | Uses `Authorization: Bearer` header (not deprecated query param) | -| Facebook | _(none)_ | Profile fetch on Graph API v2.8; token exchange endpoint is still pinned to the older v2.3 (see Future work) | -| Slack | `identity.basic identity.avatar` | | -| LinkedIn | `openid profile` | v2 userinfo endpoint (OpenID Connect) | -| Salesforce | `id` | Token exchange returns `idURL`; `getUserData` fetches from that URL | +### Custom providers -## Error handling +`OAuth2` is the generic flow for any standards-following provider. Wrap it in a type that conforms +to `OAuth2Provider` to get `authorizationRequest`, `completeAuthorization` and `refresh`: ```swift -do { - let profile = try await Google.processAuthResponse(...) -} catch let e as OAuth2Error { - // e.code: OAuth2ErrorCode (.invalidGrant, .accessDenied, .unsupportedResponseType, ...) - // e.description: human-readable message -} catch is InvalidAPIResponse { - // provider returned unexpected JSON +struct Gitea: OAuth2Provider { + let oauth2: OAuth2 + let loginType = "gitea" + + init(configuration: OAuth2ClientConfiguration) { + oauth2 = OAuth2( + configuration: configuration, + authorizationURL: URL(string: "https://gitea.example.com/login/oauth/authorize")!, + tokenURL: URL(string: "https://gitea.example.com/login/oauth/access_token")!, + defaultScopes: ["read:user"], + usesPKCEByDefault: true + ) + } + + func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + let user = try await oauth2.getJSON( + URL(string: "https://gitea.example.com/api/v1/user")!, accessToken: token.accessToken) + return try OAuthUserProfile( + userid: user["id"]?.identifierValue, displayName: user["full_name"]?.stringValue, + loginType: loginType, token: token, raw: user) + } } ``` -`processAuthResponse` throws `OAuth2Error(code: .unsupportedResponseType)` if `state != sessionCSRF`. - -## Future work - -- **LocalAuthentication target** — the original package included a username/password auth system (account schema, email verification, SMTP). It was not resurrected because it depends on `Perfect-SMTP` and `Perfect-Mustache`. The source is on the [`legacy`](https://github.com/PerfectlySoft/Perfect-Authentication/tree/legacy) branch if that work is ever picked up. - -- **LinkedIn email scope** — add `email` to the default scopes and extract `email` from the `/v2/userinfo` response if email is needed. - -- **Salesforce sandbox** — `SalesForce` hardcodes `login.salesforce.com`. Add `SalesForceConfig.domain` to support sandbox (`test.salesforce.com`) or My Domain instances. +Override `authorizationParameters` for extra login-link parameters, and `validate(_:)` for token +checks that must pass before an exchange returns. + +## Migrating to 2.0 + +### From 1.x (and the pre-release `main`) + +2.0 is a breaking release. Every call site changes: + +| 1.x / pre-release `main` | 2.0 | +|---|---| +| `GoogleConfig.appid = ...` etc. (global `nonisolated(unsafe)` statics) | `Google(configuration: OAuth2ClientConfiguration(clientID:clientSecret:redirectURL:))`, built once and shared | +| `GoogleConfig.endpointAfterAuth` | `redirectURL` | +| `GoogleConfig.redirectAfterAuth` | removed; where to send the user after sign-in is your app's business | +| `GoogleConfig.restrictedDomain` | `Google(configuration:restrictedDomain:)` | +| `Google.loginURL(state: csrf, sessionToken:)` (static or instance) | `let request = try google.authorizationRequest()`; redirect to `request.url` and store `request.pending` | +| `Google.loginURL(..., offlineAccess: true)` | `google.authorizationRequest(offlineAccess: true)` | +| `Google.processAuthResponse(code:state:sessionCSRF:sessionToken:)` | `try await google.completeAuthorization(callbackURL:pending:)` or `completeAuthorization(code:state:pending:)` | +| `provider.exchange(code:state:sessionToken:)` / `exchange(code:state:redirectURL:)` | `try await provider.exchange(callback: AuthorizationCallback(url:), pending:)` | +| `provider.getUserData(accessToken) -> [String: Any]` | `try await provider.fetchProfile(token:) -> OAuthUserProfile` (fields typed, the rest in `raw: JSONValue`) | +| `OAuth2(clientID:clientSecret:authorizationURL:tokenURL:)` | `OAuth2(configuration:authorizationURL:tokenURL:defaultScopes:usesPKCEByDefault:)` with `URL`s | +| `getLoginLink(redirectURL:state:scopes:) -> String` | `authorizationRequest(scopes:state:additionalParameters:) -> AuthorizationRequest` | +| subclassing `OAuth2` (`open class`) | conform to `OAuth2Provider` and wrap an `OAuth2` (now `final`) | +| `OAuth2Token` class, `webToken: [String: Any]?` | `OAuth2Token` struct; `idToken: String?` (raw, see the trust model) and `expiresIn` | +| `OAuthUserProfile(userid:..., accessToken:, refreshToken:, loginType:)` | `try OAuthUserProfile(userid:..., loginType:, token:)`; `accessToken` and `refreshToken` are still readable | +| `OAuth2Error` struct with `code: OAuth2ErrorCode` | `OAuth2Error` enum; the provider's code is in `.providerError(response)` / `.authorizationDenied(response)` | +| `InvalidAPIResponse`, `APIConnectionError`, `InvalidInput`, `InvalidAuthorizationCodeError`, `AuthorizationCode` | `OAuth2Error` cases; `PendingAuthorization` | +| `Linkedin`, `SalesForce` | `LinkedIn`, `Salesforce` (the old names give a rename fix-it) | +| `refresh(refreshToken:scopes:includeClientSecret:)` | unchanged | + +Behaviour changes to check: + +- **No more `?session=` on the redirect URI.** Register the bare `redirectURL` with the provider, + and keep `PendingAuthorization` in a cookie-backed server-side session. +- **State must be 32+ characters.** Let `authorizationRequest()` generate it. +- **Empty or missing user ids throw** `OAuth2Error.missingUserID` instead of signing the user in as `""`. +- **HTTP failures throw.** 1.x turned network errors, HTTP errors and non-JSON into an empty + dictionary, so they surfaced as missing fields (or a profile with empty fields). +- **Default scopes:** GitHub `user` → `read:user` (1.x asked for write access); Google `profile` → + `openid profile`. +- **Endpoints:** Google uses its OIDC userinfo endpoint (`sub` instead of v2's `id`; the values are + the same). Slack moved from the legacy `identity.basic` flow to Sign in with Slack (OIDC): update + your Slack app's redirect URL and scopes. Facebook moved from Graph v2.3/v2.8 to v23.0. +- **PKCE** is sent to Google, GitHub and Salesforce by default. + +## Development + +```sh +swift build --build-tests -Xswiftc -warnings-as-errors +swift test +``` -- **Facebook Graph API version** — the profile-fetch endpoint (`getUserData`) uses `v2.8`, but the token-exchange endpoint (`Facebook.swift`) is still pinned to the older `v2.3`. Facebook's minimum supported version changes over time; bump both to a current version (v21+) when updating. +The tests are hermetic: provider flows run against an in-memory transport, and `URLSessionTransport` +against a local socket server on 127.0.0.1. Nothing contacts a real provider. -- **NIO session middleware integration** — the OAuth callback pattern (extract code/state, call processAuthResponse, write to session, redirect) is repetitive. A `PerfectNIOOAuth2` target in Perfect-NIO could provide pre-wired route handlers that accept a session driver and config. This is also the natural point at which this package would move from staged to actively consumed. +The pre-Swift-6 code (the Swift 3 `OAuth2` target and the `LocalAuthentication` username/password +system) is on the [`legacy`](https://github.com/PerfectlySoft/Perfect-Authentication/tree/legacy) +branch. ## License -Apache License 2.0 — see [LICENSE.md](LICENSE.md). +Apache License 2.0. See [LICENSE.md](LICENSE.md). From cfaa9a944f8d86467230c96d5d05beba6d21a1a0 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 17:11:00 -0500 Subject: [PATCH 03/14] Test server: never touch a descriptor it no longer owns Under load (12 busy loops) the suite failed 2 of 8 ThreadSanitizer runs with "connection refused" or a failed bind, and once with a dropped connection. LocalHTTPServer closed its listener twice (stop() and deinit), shut down client sockets after their handlers had closed them, and could call accept() on a closed listener whose number had been reused - each time hitting another test's socket. stop() is now idempotent and works under the lock the handlers close under, and the accept thread polls, owns the listener and closes it itself. 20 of 20 loaded runs pass (10 TSan, 10 release). Bind and socket failures now report errno instead of a fixed EADDRINUSE. Co-Authored-By: Claude Opus 5.5 --- .../Support/LocalHTTPServer.swift | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift b/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift index 8574f81..b72e698 100644 --- a/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift +++ b/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift @@ -47,7 +47,7 @@ final class LocalHTTPServer: @unchecked Sendable { #else let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) #endif - guard fd >= 0 else { throw POSIXError(.EIO) } + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } var one: Int32 = 1 setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size)) var address = sockaddr_in() @@ -63,8 +63,9 @@ final class LocalHTTPServer: @unchecked Sendable { } } guard bound == 0, listen(fd, 64) == 0 else { + let code = POSIXErrorCode(rawValue: errno) ?? .EIO close(fd) - throw POSIXError(.EADDRINUSE) + throw POSIXError(code) } var length = socklen_t(MemoryLayout.size) _ = withUnsafeMutablePointer(to: &address) { @@ -76,23 +77,33 @@ final class LocalHTTPServer: @unchecked Sendable { thread.start() } + /// Idempotent, and never touches a descriptor this server no longer owns: closing the listener + /// twice, or shutting down a client socket after its handler closed it, would hit whatever + /// socket reused that number (another test's server or connection). func stop() { - let fds: [Int32] = lock.withLock { + lock.withLock { + if stopped { return } stopped = true - defer { clients = [] } - return clients + // The accept thread notices `stopped` within one poll interval and closes the listener + // itself, so no thread can accept() on a number that has been reused. + // Handlers close their sockets under this lock, so these are still ours. + for fd in clients { shutdown(fd, Int32(SHUT_RDWR)) } } - shutdown(listener, Int32(SHUT_RDWR)) - close(listener) - for fd in fds { shutdown(fd, Int32(SHUT_RDWR)) } } deinit { stop() } private func acceptLoop() { + defer { close(listener) } while true { + if lock.withLock({ stopped }) { return } + var pfd = pollfd(fd: listener, events: Int16(POLLIN), revents: 0) + let ready = poll(&pfd, 1, 50) + if ready < 0 && errno != EINTR { return } + if ready <= 0 { continue } + if lock.withLock({ stopped }) { return } let client = accept(listener, nil, nil) - if client < 0 { return } + if client < 0 { continue } #if canImport(Darwin) var one: Int32 = 1 setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) @@ -102,15 +113,17 @@ final class LocalHTTPServer: @unchecked Sendable { clients.append(client) return true } - guard accepted else { close(client); return } + guard accepted else { close(client); return } // the deferred close(listener) follows Thread { [self] in handle(client) }.start() } } private func handle(_ fd: Int32) { defer { - lock.withLock { clients.removeAll { $0 == fd } } - close(fd) + lock.withLock { + clients.removeAll { $0 == fd } + close(fd) + } } guard let request = readRequest(fd) else { return } lock.withLock { received.append(request) } From a7bcbc892d7b9020f17802c1b59de03a3d50c4b2 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 17:23:17 -0500 Subject: [PATCH 04/14] README: Linux response-cap wording and the corelibs per-request leak Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fd942a5..9f36eee 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,10 @@ with ``. On Darwin, cancelling at the size limit stops the download. On Linux, the call also returns as soon as the limit is passed, but corelibs Foundation can go on reading and discarding the rest of the body -in the background for a while. In testing against an endless local body, every call returned within -0.03 s and peak memory stayed under 180 MB, though the background read consumed up to 2 GB of -loopback traffic. Provider responses are small and come from the providers' own HTTPS endpoints, so +in the background for a while. In testing against an endless local body, each call returned within +0.03 s of reaching the cap and peak memory stayed under 180 MB, though the background read consumed +up to 2 GB of loopback traffic. Corelibs FoundationNetworking also leaks about 76 bytes per request +(seen with LeakSanitizer), which is negligible at sign-in volumes. Provider responses are small and come from the providers' own HTTPS endpoints, so this only matters if one of them is compromised. If you need a hard bound, plug in your own transport (for example on AsyncHTTPClient) through `OAuth2HTTPOptions(transport:)`. From 9cde32ae57d43aec884ac74f69d6c3450eea4256 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 19:25:03 -0500 Subject: [PATCH 05/14] Fix round from the adversarial review: binding, domain check, gzip, tokens - Google: openid is added on every login-link path. Building the link is now a protocol requirement (makeAuthorizationRequest), dispatched dynamically, also through any OAuth2Provider; offlineAccess defaults to false. - Google restrictedDomain is enforced in fetchProfile through userinfo's hd. fetchProfile no longer reads the token's id_token, so a forged id_token on an app-built token can't pass and stored tokens with expired id_tokens still work. id_tokens are validated only on code exchange. - Google sends and checks a nonce when PKCE is off. - id_token: numeric exp and iat required, lifetime capped at 24 h, sub trimmed. - PendingAuthorization is bound to the token URL and client ID that created it, carries createdAt (max age configurable, default 10 minutes), must use the configured redirect URL and a valid RFC 7636 verifier, and is refused on reuse by the same OAuth2 instance. - URLSessionTransport sends Accept-Encoding: identity and refuses any other Content-Encoding before reading, so Darwin's automatic decompression can't get round the size cap. The timeout conversion is clamped, and an already-cancelled task never resumes the URLSession task. - Only Bearer token_type is accepted. - Error sanitizer: the client secret is always redacted, whatever its length, and every secret also in percent-encoded forms. - AuthorizationCallback and AuthorizationRequest redact code and state in description, dump and Mirror; the profile mirror shows raw keys only. - Nits: callback code capped at 4 KB; repeated error parameter is an error; provider parameters (Google hd) and duplicate names are refused in additionalParameters; loopback means only localhost, 127.0.0.1 and ::1; URLs without a host are refused; Salesforce identity host suffixes are configurable; unavailable stubs for InvalidInput, InvalidAuthorizationCodeError and AuthorizationCode(code:redirectURL:). Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectOAuth2/Authorization.swift | 89 +++++- Sources/PerfectOAuth2/IDToken.swift | 29 +- Sources/PerfectOAuth2/OAuth2.swift | 99 +++++- Sources/PerfectOAuth2/OAuth2Error.swift | 61 +++- Sources/PerfectOAuth2/OAuth2Provider.swift | 61 +++- Sources/PerfectOAuth2/OAuth2Token.swift | 8 +- Sources/PerfectOAuth2/OAuthUserProfile.swift | 3 +- Sources/PerfectOAuth2/Providers/Google.swift | 63 ++-- Sources/PerfectOAuth2/Providers/Removed.swift | 9 +- .../PerfectOAuth2/Providers/Salesforce.swift | 22 +- Sources/PerfectOAuth2/Transport.swift | 37 ++- Tests/PerfectOAuth2Tests/FlowTests.swift | 8 +- Tests/PerfectOAuth2Tests/HardeningTests.swift | 286 ++++++++++++++++++ Tests/PerfectOAuth2Tests/ProviderTests.swift | 110 +++++-- .../Support/LocalHTTPServer.swift | 9 + 15 files changed, 789 insertions(+), 105 deletions(-) create mode 100644 Tests/PerfectOAuth2Tests/HardeningTests.swift diff --git a/Sources/PerfectOAuth2/Authorization.swift b/Sources/PerfectOAuth2/Authorization.swift index 4c934c8..1a2bae9 100644 --- a/Sources/PerfectOAuth2/Authorization.swift +++ b/Sources/PerfectOAuth2/Authorization.swift @@ -51,6 +51,11 @@ public enum PKCE { randomBase64URL(byteCount: 32) } + /// True for an RFC 7636 §4.1 verifier: 43 to 128 characters of `[A-Za-z0-9-._~]`. + public static func isValidVerifier(_ verifier: String) -> Bool { + (43...128).contains(verifier.utf8.count) && verifier.utf8.allSatisfy(isUnreserved) + } + /// `BASE64URL(SHA256(ASCII(verifier)))`, RFC 7636 §4.2. public static func challenge(for verifier: String) -> String { base64URLEncode(Data(SHA256.hash(data: Data(verifier.utf8)))) @@ -59,23 +64,46 @@ public enum PKCE { /// What to keep in the server-side session between redirecting the user and handling the callback. /// -/// It's `Codable` so it can be stored as JSON. Keep it server-side: the code verifier only protects -/// the exchange if the browser never sees it. +/// It's `Codable` so it can be stored as JSON. **The JSON contains the PKCE code verifier and the +/// state in clear**: keep it server-side (a server-side session store, not a client-readable cookie), +/// under a key per provider, and delete it when the callback arrives, so each one is used once. +/// +/// It is bound to the client that created it: `exchange` refuses a pending authorization from another +/// provider or client ID, one older than `OAuth2ClientConfiguration.pendingAuthorizationMaxAge`, one +/// whose redirect URL isn't the configured one, or one already completed by this `OAuth2` instance. public struct PendingAuthorization: Sendable, Codable, Equatable, CustomStringConvertible, CustomReflectable { public let state: String /// The PKCE code verifier, or nil when PKCE is disabled for this provider. public let codeVerifier: String? /// The `redirect_uri` sent in the authorization request; sent again, unchanged, on exchange. public let redirectURL: URL + /// The OpenID Connect nonce sent in the authorization request (Google without PKCE), if any. + public let nonce: String? + /// The token endpoint and client ID of the `OAuth2` that created it. + public let tokenURL: URL + public let clientID: String + public let createdAt: Date - public init(state: String, codeVerifier: String?, redirectURL: URL) { + public init( + state: String, + codeVerifier: String?, + redirectURL: URL, + nonce: String? = nil, + tokenURL: URL, + clientID: String, + createdAt: Date = Date() + ) { self.state = state self.codeVerifier = codeVerifier self.redirectURL = redirectURL + self.nonce = nonce + self.tokenURL = tokenURL + self.clientID = clientID + self.createdAt = createdAt } public var description: String { - "PendingAuthorization(state: , codeVerifier: \(codeVerifier == nil ? "nil" : ""), redirectURL: \(redirectURL))" + "PendingAuthorization(state: , codeVerifier: \(codeVerifier == nil ? "nil" : ""), redirectURL: \(redirectURL), tokenURL: \(tokenURL), clientID: \(clientID), createdAt: \(createdAt))" } public var customMirror: Mirror { @@ -83,22 +111,32 @@ public struct PendingAuthorization: Sendable, Codable, Equatable, CustomStringCo "state": "", "codeVerifier": codeVerifier == nil ? "nil" : "", "redirectURL": redirectURL, + "nonce": nonce == nil ? "nil" : "", + "tokenURL": tokenURL, + "clientID": clientID, + "createdAt": createdAt, ], displayStyle: .struct) } } /// A login link plus what to store for the callback. -public struct AuthorizationRequest: Sendable, CustomStringConvertible { +public struct AuthorizationRequest: Sendable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { /// Redirect the user's browser here. public let url: URL /// Store this in the server-side session and pass it to `completeAuthorization`. public let pending: PendingAuthorization public var description: String { "AuthorizationRequest(url: , pending: \(pending))" } + public var debugDescription: String { description } + public var customMirror: Mirror { + Mirror(self, children: ["url": "", "pending": pending], displayStyle: .struct) + } } /// What the provider sent back to the redirect URL. -public struct AuthorizationCallback: Sendable, Equatable { +/// +/// Its description, `dump` and `Mirror` hide `code` and `state`. +public struct AuthorizationCallback: Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { public let code: String? public let state: String? public let error: OAuth2ErrorResponse? @@ -110,7 +148,8 @@ public struct AuthorizationCallback: Sendable, Equatable { } /// Reads `code`, `state`, `error`, `error_description` and `error_uri` from query items. - /// A repeated parameter makes the callback unusable (`code` or `state` comes back nil). + /// A repeated parameter makes the callback unusable: `code` or `state` comes back nil, and a + /// repeated `error` becomes an `invalid_request` error. public init(queryItems: [URLQueryItem]) { func single(_ name: String) -> String? { let values = queryItems.filter { $0.name == name } @@ -118,7 +157,10 @@ public struct AuthorizationCallback: Sendable, Equatable { } code = single("code") state = single("state") - if let errorCode = single("error") { + if queryItems.filter({ $0.name == "error" }).count > 1 { + // Ambiguous: never fall through to the code. + error = OAuth2ErrorResponse(code: "invalid_request", errorDescription: "repeated error parameter") + } else if let errorCode = single("error") { error = OAuth2ErrorResponse( code: errorCode, errorDescription: single("error_description"), @@ -133,10 +175,33 @@ public struct AuthorizationCallback: Sendable, Equatable { public init(url: URL) { self.init(queryItems: URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []) } + + public var description: String { + "AuthorizationCallback(code: \(code == nil ? "nil" : ""), state: \(state == nil ? "nil" : ""), error: \(error.map { "\($0)" } ?? "nil"))" + } + public var debugDescription: String { description } + public var customMirror: Mirror { + Mirror(self, children: [ + "code": code == nil ? "nil" : "", + "state": state == nil ? "nil" : "", + "error": error as Any, + ], displayStyle: .struct) + } } // MARK: - Encoding helpers +func isUnreserved(_ byte: UInt8) -> Bool { + switch byte { + case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), + UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "-"), UInt8(ascii: "."), + UInt8(ascii: "_"), UInt8(ascii: "~"): + return true + default: + return false + } +} + func randomBase64URL(byteCount: Int) -> String { var generator = SystemRandomNumberGenerator() var bytes = [UInt8](repeating: 0, count: byteCount) @@ -177,6 +242,11 @@ private let formValueAllowed = CharacterSet( charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" ) +/// Percent-encodes everything but unreserved characters (the encoding `formEncode` uses). +func percentEncodeAll(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? value +} + /// `application/x-www-form-urlencoded` body, keys in the given order, every reserved byte encoded. func formEncode(_ fields: [(String, String)]) -> Data { let text = fields.map { key, value in @@ -205,13 +275,14 @@ func appendingQueryItems(_ items: [URLQueryItem], to url: URL) throws -> URL { } func isLoopback(host: String?) -> Bool { + // Exactly these names; not *.localhost (resolver-dependent) or other 127/8 addresses. guard let host = host?.lowercased() else { return false } return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" - || host.hasSuffix(".localhost") } /// HTTPS, or HTTP to a loopback host (for local testing). func requireSecure(_ url: URL, _ what: String) throws { + guard url.host?.isEmpty == false else { throw OAuth2Error.insecureURL("\(what) has no host") } switch url.scheme?.lowercased() { case "https": return case "http" where isLoopback(host: url.host): return diff --git a/Sources/PerfectOAuth2/IDToken.swift b/Sources/PerfectOAuth2/IDToken.swift index 1a4c5a5..ab117af 100644 --- a/Sources/PerfectOAuth2/IDToken.swift +++ b/Sources/PerfectOAuth2/IDToken.swift @@ -15,8 +15,10 @@ struct IDTokenClaims: Sendable { _ idToken: String, issuers: Set, clientID: String, + nonce: String? = nil, now: Date = Date(), - leeway: TimeInterval = 300 + leeway: TimeInterval = 300, + maximumLifetime: TimeInterval = 86_400 ) throws -> IDTokenClaims { let parts = idToken.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == 3 else { throw OAuth2Error.invalidIDToken("not a compact JWS") } @@ -41,14 +43,29 @@ struct IDTokenClaims: Sendable { throw OAuth2Error.invalidIDToken("authorized party is not this client") } } - guard let exp = json["exp"]?.doubleValue else { throw OAuth2Error.invalidIDToken("no expiry") } - guard now.timeIntervalSince1970 < exp + leeway else { throw OAuth2Error.invalidIDToken("expired") } - if let iat = json["iat"]?.doubleValue, iat > now.timeIntervalSince1970 + leeway { - throw OAuth2Error.invalidIDToken("issued in the future") + // NumericDate (RFC 7519): JSON numbers only, not numeric strings. + func numeric(_ value: JSONValue?) -> Double? { + switch value { + case .integer(let number)?: return Double(number) + case .double(let number)? where number.isFinite: return number + default: return nil + } } - guard let sub = json["sub"]?.stringValue, !sub.isEmpty else { + guard let exp = numeric(json["exp"]) else { throw OAuth2Error.invalidIDToken("no numeric expiry") } + guard let iat = numeric(json["iat"]) else { throw OAuth2Error.invalidIDToken("no numeric issue time") } + let nowSeconds = now.timeIntervalSince1970 + guard nowSeconds < exp + leeway else { throw OAuth2Error.invalidIDToken("expired") } + guard iat <= nowSeconds + leeway else { throw OAuth2Error.invalidIDToken("issued in the future") } + guard exp - iat <= maximumLifetime else { throw OAuth2Error.invalidIDToken("lifetime too long") } + guard let sub = json["sub"]?.stringValue, + !sub.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw OAuth2Error.invalidIDToken("no subject") } + if let nonce { + guard let claimed = json["nonce"]?.stringValue, constantTimeEquals(claimed, nonce) else { + throw OAuth2Error.invalidIDToken("nonce mismatch") + } + } return IDTokenClaims(claims: json) } } diff --git a/Sources/PerfectOAuth2/OAuth2.swift b/Sources/PerfectOAuth2/OAuth2.swift index f9d4b9b..6ec875b 100644 --- a/Sources/PerfectOAuth2/OAuth2.swift +++ b/Sources/PerfectOAuth2/OAuth2.swift @@ -15,6 +15,8 @@ public struct OAuth2ClientConfiguration: Sendable, CustomStringConvertible, Cust public var scopes: [String]? /// Whether to use PKCE; nil uses the provider's default (see the README's provider table). public var usePKCE: Bool? + /// How long a `PendingAuthorization` stays usable, in seconds (default 10 minutes). + public var pendingAuthorizationMaxAge: TimeInterval public var http: OAuth2HTTPOptions public init( @@ -23,6 +25,7 @@ public struct OAuth2ClientConfiguration: Sendable, CustomStringConvertible, Cust redirectURL: URL, scopes: [String]? = nil, usePKCE: Bool? = nil, + pendingAuthorizationMaxAge: TimeInterval = 600, http: OAuth2HTTPOptions = OAuth2HTTPOptions() ) { self.clientID = clientID @@ -30,6 +33,7 @@ public struct OAuth2ClientConfiguration: Sendable, CustomStringConvertible, Cust self.redirectURL = redirectURL self.scopes = scopes self.usePKCE = usePKCE + self.pendingAuthorizationMaxAge = pendingAuthorizationMaxAge self.http = http } @@ -44,6 +48,7 @@ public struct OAuth2ClientConfiguration: Sendable, CustomStringConvertible, Cust "redirectURL": redirectURL, "scopes": scopes as Any, "usePKCE": usePKCE as Any, + "pendingAuthorizationMaxAge": pendingAuthorizationMaxAge, "http": http, ], displayStyle: .struct) } @@ -85,6 +90,7 @@ public final class OAuth2: Sendable { /// Whether authorization requests carry `code_challenge` and exchanges `code_verifier`. public let usesPKCE: Bool let transport: any OAuth2Transport + let completed = CompletedStates() public var clientID: String { configuration.clientID } @@ -111,9 +117,12 @@ public final class OAuth2: Sendable { static let reservedParameters: Set = [ "response_type", "client_id", "redirect_uri", "state", "scope", - "code_challenge", "code_challenge_method", + "code_challenge", "code_challenge_method", "nonce", ] + /// The longest authorization code accepted from a callback. + public static let maximumCodeLength = 4096 + /// Builds the login link and the values to keep for the callback. /// /// - Parameters: @@ -121,11 +130,14 @@ public final class OAuth2: Sendable { /// - state: a state you generated; nil generates one with `AuthorizationState.generate()`. It /// must be at least `AuthorizationState.minimumLength` characters. /// - additionalParameters: extra query parameters (e.g. `prompt`). They can't replace the ones - /// this method sets. + /// this method sets, and a name can't appear twice. + /// - nonce: an OpenID Connect nonce to send and keep in `pending` (checked against the id_token + /// by providers that validate one). public func authorizationRequest( scopes: [String]? = nil, state: String? = nil, - additionalParameters: [URLQueryItem] = [] + additionalParameters: [URLQueryItem] = [], + nonce: String? = nil ) throws -> AuthorizationRequest { let redirectURL = configuration.redirectURL try Self.validateRedirectURL(redirectURL) @@ -135,6 +147,10 @@ public final class OAuth2: Sendable { for item in additionalParameters where Self.reservedParameters.contains(item.name) { throw OAuth2Error.invalidConfiguration("parameter \(item.name) is set by authorizationRequest") } + let extraNames = additionalParameters.map(\.name) + guard Set(extraNames).count == extraNames.count else { + throw OAuth2Error.invalidConfiguration("a login-link parameter appears twice") + } var items = [ URLQueryItem(name: "response_type", value: "code"), @@ -153,11 +169,15 @@ public final class OAuth2: Sendable { items.append(URLQueryItem(name: "code_challenge", value: PKCE.challenge(for: codeVerifier))) items.append(URLQueryItem(name: "code_challenge_method", value: "S256")) } + if let nonce { items.append(URLQueryItem(name: "nonce", value: nonce)) } items += additionalParameters let url = try appendingQueryItems(items, to: authorizationURL) return AuthorizationRequest( url: url, - pending: PendingAuthorization(state: state, codeVerifier: verifier, redirectURL: redirectURL) + pending: PendingAuthorization( + state: state, codeVerifier: verifier, redirectURL: redirectURL, nonce: nonce, + tokenURL: tokenURL, clientID: clientID + ) ) } @@ -166,12 +186,22 @@ public final class OAuth2: Sendable { /// - Throws: `OAuth2Error.authorizationDenied` if the provider sent an error, /// `.stateMismatch`/`.invalidState` if the state check fails (before any network request), /// `.missingAuthorizationCode`, or the errors of the token request. + /// + /// The pending authorization must come from this client (same token URL and client ID), be younger + /// than `pendingAuthorizationMaxAge`, carry the configured redirect URL and a well-formed verifier + /// when PKCE is on, and not have been used before with this instance. A used state is remembered + /// (in memory, for `pendingAuthorizationMaxAge`) as soon as the code is about to be sent, so a + /// second completion with the same pending authorization fails even if the first one failed later. public func exchange(callback: AuthorizationCallback, pending: PendingAuthorization) async throws -> OAuth2Token { try AuthorizationState.verify(returned: callback.state, expected: pending.state) + try check(pending) if let error = callback.error { throw OAuth2Error.authorizationDenied(error) } guard let code = callback.code, !code.isEmpty else { throw OAuth2Error.missingAuthorizationCode } - if usesPKCE, pending.codeVerifier == nil { - throw OAuth2Error.invalidConfiguration("PKCE is enabled but the pending authorization has no code verifier") + guard code.utf8.count <= Self.maximumCodeLength else { + throw OAuth2Error.invalidCallback("code longer than \(Self.maximumCodeLength) bytes") + } + guard completed.insert(pending.state, now: Date(), maxAge: configuration.pendingAuthorizationMaxAge) else { + throw OAuth2Error.invalidPendingAuthorization("already used") } var fields: [(String, String)] = [ @@ -186,6 +216,33 @@ public final class OAuth2: Sendable { return try OAuth2Token(json: json) } + /// Checks that `pending` belongs to this client and is still usable. + func check(_ pending: PendingAuthorization, now: Date = Date()) throws { + guard pending.tokenURL == tokenURL, pending.clientID == clientID else { + throw OAuth2Error.invalidPendingAuthorization("created by a different provider or client") + } + guard pending.redirectURL == configuration.redirectURL else { + throw OAuth2Error.invalidPendingAuthorization("redirect URL differs from the configured one") + } + let age = now.timeIntervalSince(pending.createdAt) + guard age <= configuration.pendingAuthorizationMaxAge else { + throw OAuth2Error.invalidPendingAuthorization("expired") + } + guard age >= -60 else { throw OAuth2Error.invalidPendingAuthorization("created in the future") } + switch (usesPKCE, pending.codeVerifier) { + case (true, let verifier?): + guard PKCE.isValidVerifier(verifier) else { + throw OAuth2Error.invalidPendingAuthorization("malformed code verifier") + } + case (true, nil): + throw OAuth2Error.invalidPendingAuthorization("PKCE is enabled but there is no code verifier") + case (false, _?): + throw OAuth2Error.invalidPendingAuthorization("PKCE is disabled but there is a code verifier") + case (false, nil): + break + } + } + /// Obtains a new access token with a refresh token (RFC 6749 §6). /// /// Many providers (Google among them) omit `refresh_token` from the response; the returned token @@ -256,8 +313,9 @@ public final class OAuth2: Sendable { // Also checked here so a custom transport can't hand back an oversized body. guard response.body.count <= limit else { throw OAuth2Error.responseTooLarge(limit: limit) } let json = try? JSONValue(data: response.body) - let redact = (secrets + [configuration.clientSecret]).compactMap { $0 } - if let json, let error = OAuth2ErrorResponse.parse(json, redacting: redact) { + // The client secret first: it is redacted whatever its length. + let redact = ([configuration.clientSecret ?? ""] + secrets.map { $0 ?? "" }) + if let json, let error = OAuth2ErrorResponse.parse(json, redacting: redact, alwaysRedact: 1) { throw OAuth2Error.providerError(error) } guard (200..<300).contains(response.status) else { throw OAuth2Error.httpStatus(response.status) } @@ -274,3 +332,28 @@ public final class OAuth2: Sendable { } } } + +/// States already exchanged by one `OAuth2` instance, kept for the pending-authorization lifetime. +final class CompletedStates: @unchecked Sendable { + // Guarded by `lock`. + private let lock = NSLock() + private var entries: [String: Date] = [:] + private static let capacity = 100_000 + + /// Records `state`; returns false if it was already recorded and hasn't aged out. + func insert(_ state: String, now: Date, maxAge: TimeInterval) -> Bool { + lock.withLock { + if let seen = entries[state], now.timeIntervalSince(seen) <= maxAge { return false } + if entries.count >= Self.capacity { + entries = entries.filter { now.timeIntervalSince($0.value) <= maxAge } + // Still full of live entries: drop the oldest half rather than grow without bound. + if entries.count >= Self.capacity { + let keep = entries.sorted { $0.value > $1.value }.prefix(Self.capacity / 2) + entries = Dictionary(uniqueKeysWithValues: keep.map { ($0.key, $0.value) }) + } + } + entries[state] = now + return true + } + } +} diff --git a/Sources/PerfectOAuth2/OAuth2Error.swift b/Sources/PerfectOAuth2/OAuth2Error.swift index 345a199..999a3ce 100644 --- a/Sources/PerfectOAuth2/OAuth2Error.swift +++ b/Sources/PerfectOAuth2/OAuth2Error.swift @@ -4,7 +4,8 @@ import Foundation /// /// Descriptions never contain client secrets, codes, verifiers or tokens. Text taken from a provider /// (`OAuth2ErrorResponse.errorDescription`) is truncated, stripped of control characters, and has any -/// secret sent in the failed request replaced with ``. +/// secret sent in the failed request replaced with ``: the client secret always, other secrets +/// of 8 or more characters, each also in its percent-encoded forms. public enum OAuth2Error: Error, Sendable, Equatable, CustomStringConvertible { /// The `state` returned to the callback is missing or doesn't match the one stored for the session. case stateMismatch @@ -14,6 +15,11 @@ public enum OAuth2Error: Error, Sendable, Equatable, CustomStringConvertible { case authorizationDenied(OAuth2ErrorResponse) /// The callback carried neither `code` nor `error`. case missingAuthorizationCode + /// The callback is unusable (e.g. a `code` longer than 4 KB). + case invalidCallback(String) + /// The `PendingAuthorization` doesn't belong to this client, is too old, was tampered with, or was + /// already used. + case invalidPendingAuthorization(String) /// An error object returned by a token or API endpoint (RFC 6749 §5.2, or a provider's own shape). case providerError(OAuth2ErrorResponse) /// A non-2xx HTTP status without a recognisable error body. Redirects are not followed, so 3xx @@ -44,6 +50,8 @@ public enum OAuth2Error: Error, Sendable, Equatable, CustomStringConvertible { case .invalidState(let why): return "OAuth2: unusable state: \(why)" case .authorizationDenied(let response): return "OAuth2: authorization denied: \(response)" case .missingAuthorizationCode: return "OAuth2: callback has no authorization code" + case .invalidCallback(let why): return "OAuth2: invalid callback: \(why)" + case .invalidPendingAuthorization(let why): return "OAuth2: invalid pending authorization: \(why)" case .providerError(let response): return "OAuth2: provider error: \(response)" case .httpStatus(let status): return "OAuth2: unexpected HTTP status \(status)" case .invalidResponse(let why): return "OAuth2: invalid response: \(why)" @@ -74,7 +82,11 @@ public struct OAuth2ErrorResponse: Sendable, Equatable, CustomStringConvertible self.init(code: code, errorDescription: errorDescription, errorURI: errorURI, redacting: []) } - init(code: String, errorDescription: String?, errorURI: String?, redacting secrets: [String]) { + /// - Parameter secrets: values to redact. The first `alwaysRedact` of them are redacted whatever + /// their length (the client secret); the rest only from 8 characters, since shorter values would + /// mangle ordinary words. + init(code: String, errorDescription: String?, errorURI: String?, redacting secrets: [String], alwaysRedact: Int = 0) { + let secrets = Self.redactionForms(secrets, alwaysRedact: alwaysRedact) self.code = Self.sanitize(code, limit: 64, redacting: secrets) ?? "unknown" self.errorDescription = Self.sanitize(errorDescription, limit: 256, redacting: secrets) self.errorURI = Self.sanitize(errorURI, limit: 256, redacting: secrets) @@ -86,9 +98,42 @@ public struct OAuth2ErrorResponse: Sendable, Equatable, CustomStringConvertible return text } + /// Each secret plus its percent-encoded forms (upper- and lowercase hex, and form encoding with + /// `+` for space), longest first so a longer form is replaced before a substring of it. + static func redactionForms(_ secrets: [String], alwaysRedact: Int) -> [String] { + var forms = Set() + for (index, secret) in secrets.enumerated() where !secret.isEmpty { + guard index < alwaysRedact || secret.count >= 8 else { continue } + let encoded = percentEncodeAll(secret) + forms.insert(secret) + forms.insert(encoded) + forms.insert(lowercasingPercentEscapes(encoded)) + forms.insert(encoded.replacingOccurrences(of: "%20", with: "+")) + if let query = secret.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { forms.insert(query) } + } + return forms.sorted { $0.count > $1.count } + } + + private static func lowercasingPercentEscapes(_ text: String) -> String { + var result = "" + var pending = 0 + for character in text { + if character == "%" { + pending = 2 + result.append(character) + } else if pending > 0 { + pending -= 1 + result.append(contentsOf: character.lowercased()) + } else { + result.append(character) + } + } + return result + } + static func sanitize(_ text: String?, limit: Int, redacting secrets: [String]) -> String? { guard var text, !text.isEmpty else { return nil } - for secret in secrets where secret.count >= 8 { // shorter values would mangle ordinary words + for secret in secrets { text = text.replacingOccurrences(of: secret, with: "") } let scalars = text.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) } @@ -102,10 +147,10 @@ public struct OAuth2ErrorResponse: Sendable, Equatable, CustomStringConvertible /// (GitHub sends this with HTTP 200); /// - Slack: `{"ok": false, "error": "invalid_code"}`; /// - Facebook Graph: `{"error": {"message": "...", "type": "OAuthException", "code": 190}}`. - static func parse(_ json: JSONValue, redacting secrets: [String]) -> OAuth2ErrorResponse? { + static func parse(_ json: JSONValue, redacting secrets: [String], alwaysRedact: Int = 0) -> OAuth2ErrorResponse? { guard let error = json["error"] else { if json["ok"]?.boolValue == false { - return OAuth2ErrorResponse(code: "unknown", errorDescription: nil, errorURI: nil, redacting: secrets) + return OAuth2ErrorResponse(code: "unknown", errorDescription: nil, errorURI: nil, redacting: secrets, alwaysRedact: alwaysRedact) } return nil } @@ -114,7 +159,8 @@ public struct OAuth2ErrorResponse: Sendable, Equatable, CustomStringConvertible code: code, errorDescription: json["error_description"]?.stringValue, errorURI: json["error_uri"]?.stringValue, - redacting: secrets + redacting: secrets, + alwaysRedact: alwaysRedact ) } if error.objectValue != nil { @@ -123,7 +169,8 @@ public struct OAuth2ErrorResponse: Sendable, Equatable, CustomStringConvertible code: code, errorDescription: error["message"]?.stringValue, errorURI: nil, - redacting: secrets + redacting: secrets, + alwaysRedact: alwaysRedact ) } return nil diff --git a/Sources/PerfectOAuth2/OAuth2Provider.swift b/Sources/PerfectOAuth2/OAuth2Provider.swift index 5d18f68..284b4fe 100644 --- a/Sources/PerfectOAuth2/OAuth2Provider.swift +++ b/Sources/PerfectOAuth2/OAuth2Provider.swift @@ -9,18 +9,56 @@ public protocol OAuth2Provider: Sendable { var oauth2: OAuth2 { get } /// Identifies the provider in `OAuthUserProfile.loginType`. var loginType: String { get } - /// Extra query parameters for every login link (e.g. Google's `hd`). + /// Extra query parameters for every login link (e.g. Google's `hd`). Their names can't also be + /// passed in `additionalParameters`. var authorizationParameters: [URLQueryItem] { get } - /// Checks run on every token from a code exchange, before it is returned (e.g. Google's domain check). - func validate(_ token: OAuth2Token) throws - /// Fetches the user's profile. Must throw (not return an empty id) when there is no user id; - /// `OAuthUserProfile.init` does that for you. + /// Builds the login link. This is the customisation point behind every `authorizationRequest` + /// overload, so it is dispatched dynamically, also through `any OAuth2Provider`. The default + /// implementation adds `authorizationParameters` and calls `OAuth2.authorizationRequest`. + func makeAuthorizationRequest( + scopes: [String]?, + state: String?, + additionalParameters: [URLQueryItem] + ) throws -> AuthorizationRequest + /// Checks run on every token from a code exchange, before it is returned (e.g. Google's id_token + /// and domain checks). `pending` is the authorization the code was exchanged for. + func validate(_ token: OAuth2Token, pending: PendingAuthorization) throws + /// Fetches the user's profile for an access token. Must throw (not return an empty id) when there + /// is no user id; `OAuthUserProfile.init` does that for you. func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile } extension OAuth2Provider { public var authorizationParameters: [URLQueryItem] { [] } - public func validate(_ token: OAuth2Token) throws {} + public func validate(_ token: OAuth2Token, pending: PendingAuthorization) throws {} + + public func makeAuthorizationRequest( + scopes: [String]?, + state: String?, + additionalParameters: [URLQueryItem] + ) throws -> AuthorizationRequest { + try defaultAuthorizationRequest(scopes: scopes, state: state, additionalParameters: additionalParameters) + } + + /// The default login-link construction, for conformers that customise `makeAuthorizationRequest` + /// and then delegate. + public func defaultAuthorizationRequest( + scopes: [String]?, + state: String?, + additionalParameters: [URLQueryItem], + nonce: String? = nil + ) throws -> AuthorizationRequest { + let own = Set(authorizationParameters.map(\.name)) + for item in additionalParameters where own.contains(item.name) { + throw OAuth2Error.invalidConfiguration("parameter \(item.name) is set by the provider") + } + return try oauth2.authorizationRequest( + scopes: scopes, + state: state, + additionalParameters: authorizationParameters + additionalParameters, + nonce: nonce + ) + } /// Builds the login link; store `pending` in the server-side session. public func authorizationRequest( @@ -28,20 +66,15 @@ extension OAuth2Provider { state: String? = nil, additionalParameters: [URLQueryItem] = [] ) throws -> AuthorizationRequest { - try oauth2.authorizationRequest( - scopes: scopes, - state: state, - additionalParameters: authorizationParameters + additionalParameters - ) + try makeAuthorizationRequest(scopes: scopes, state: state, additionalParameters: additionalParameters) } - /// Checks state, exchanges the code, and runs `validate(_:)`. + /// Checks state and the pending authorization, exchanges the code, and runs `validate`. public func exchange(callback: AuthorizationCallback, pending: PendingAuthorization) async throws -> OAuth2Token { let token = try await oauth2.exchange(callback: callback, pending: pending) - try validate(token) + try validate(token, pending: pending) return token } - /// Checks state, exchanges the code, and fetches the profile. public func completeAuthorization( callback: AuthorizationCallback, diff --git a/Sources/PerfectOAuth2/OAuth2Token.swift b/Sources/PerfectOAuth2/OAuth2Token.swift index d920804..1501744 100644 --- a/Sources/PerfectOAuth2/OAuth2Token.swift +++ b/Sources/PerfectOAuth2/OAuth2Token.swift @@ -5,7 +5,8 @@ import Foundation /// `description`, `debugDescription`, `dump` and `Mirror` hide the access, refresh and id tokens. public struct OAuth2Token: Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { public let accessToken: String - /// `token_type`, `Bearer` when the provider omits it. + /// `token_type` as sent (case-insensitively `bearer`; anything else is refused), `Bearer` when + /// the provider omits it. public let tokenType: String public var refreshToken: String? { refreshTokenStorage } private var refreshTokenStorage: String? @@ -57,7 +58,12 @@ public struct OAuth2Token: Sendable, Equatable, CustomStringConvertible, CustomD let scope = json["scope"]?.stringValue.map { $0.split(whereSeparator: { $0 == " " || $0 == "," }).map(String.init) } + // Only Bearer tokens are supported (RFC 6750): a MAC or DPoP token sent as Bearer would fail + // or be misused. A missing or empty token_type is treated as Bearer. let tokenType = json["token_type"]?.stringValue + if let tokenType, !tokenType.isEmpty, tokenType.lowercased() != "bearer" { + throw OAuth2Error.invalidResponse("unsupported token_type") + } self.init( accessToken: accessToken, tokenType: (tokenType?.isEmpty == false ? tokenType : nil) ?? "Bearer", diff --git a/Sources/PerfectOAuth2/OAuthUserProfile.swift b/Sources/PerfectOAuth2/OAuthUserProfile.swift index 7d5c805..438b4b2 100644 --- a/Sources/PerfectOAuth2/OAuthUserProfile.swift +++ b/Sources/PerfectOAuth2/OAuthUserProfile.swift @@ -75,7 +75,8 @@ public struct OAuthUserProfile: Sendable, Equatable, CustomStringConvertible, Cu "emailVerified": emailVerified, "loginType": loginType, "token": token, - "raw": raw, + // Keys only: a provider response could carry a token-like value. + "raw": raw.objectValue.map { "" } ?? "", ], displayStyle: .struct) } } diff --git a/Sources/PerfectOAuth2/Providers/Google.swift b/Sources/PerfectOAuth2/Providers/Google.swift index d298d18..cb13e83 100644 --- a/Sources/PerfectOAuth2/Providers/Google.swift +++ b/Sources/PerfectOAuth2/Providers/Google.swift @@ -4,14 +4,18 @@ import Foundation /// /// - Endpoints: `accounts.google.com/o/oauth2/v2/auth`, `oauth2.googleapis.com/token`, and the OIDC /// userinfo endpoint `openidconnect.googleapis.com/v1/userinfo`. -/// - Default scopes `openid profile`; `openid` is always added, so every exchange returns an id_token. -/// - PKCE on by default. -/// - Every id_token from the exchange is checked: issuer, audience (this client ID), expiry and -/// subject. With `restrictedDomain`, its `hd` claim must match (see the README's trust model). +/// - Default scopes `openid profile`. `openid` is always added, however the login link is built, so +/// every exchange returns an id_token. +/// - PKCE on by default. With PKCE off, a `nonce` is sent and checked against the id_token instead. +/// - On every code exchange the id_token is checked: issuer, audience (this client ID), expiry, issue +/// time, subject, the nonce if one was sent, and with `restrictedDomain` its `hd` claim. +/// - `fetchProfile(token:)` accepts any access token and trusts only the userinfo response, which +/// Google returns for that token: with `restrictedDomain`, userinfo's `hd` must match. It never +/// uses the token's id_token, which may not have come from this library's exchange. public final class Google: OAuth2Provider { public let oauth2: OAuth2 public let loginType = "google" - /// Only accept accounts in this Google Workspace domain (the id_token's `hd` claim). + /// Only accept accounts in this Google Workspace domain (the `hd` claim and userinfo field). public let restrictedDomain: String? static let issuers: Set = ["https://accounts.google.com", "accounts.google.com"] @@ -19,8 +23,8 @@ public final class Google: OAuth2Provider { public init(configuration: OAuth2ClientConfiguration, restrictedDomain: String? = nil) { var configuration = configuration - if let scopes = configuration.scopes, !scopes.contains("openid") { - configuration.scopes = ["openid"] + scopes + if let scopes = configuration.scopes { + configuration.scopes = Self.withOpenID(scopes) } self.oauth2 = OAuth2( configuration: configuration, @@ -32,10 +36,27 @@ public final class Google: OAuth2Provider { self.restrictedDomain = restrictedDomain?.lowercased() } + static func withOpenID(_ scopes: [String]) -> [String] { + scopes.contains("openid") ? scopes : ["openid"] + scopes + } + public var authorizationParameters: [URLQueryItem] { restrictedDomain.map { [URLQueryItem(name: "hd", value: $0)] } ?? [] } + public func makeAuthorizationRequest( + scopes: [String]?, + state: String?, + additionalParameters: [URLQueryItem] + ) throws -> AuthorizationRequest { + try defaultAuthorizationRequest( + scopes: scopes.map(Self.withOpenID), + state: state, + additionalParameters: additionalParameters, + nonce: oauth2.usesPKCE ? nil : AuthorizationState.generate() + ) + } + /// Builds the login link. /// /// - Parameter offlineAccess: request a refresh token. Adds `access_type=offline` and @@ -45,40 +66,38 @@ public final class Google: OAuth2Provider { public func authorizationRequest( scopes: [String]? = nil, state: String? = nil, - offlineAccess: Bool, + offlineAccess: Bool = false, additionalParameters: [URLQueryItem] = [] ) throws -> AuthorizationRequest { var extra = additionalParameters if offlineAccess { extra += [URLQueryItem(name: "access_type", value: "offline"), URLQueryItem(name: "prompt", value: "consent")] } - let scopes = scopes.map { $0.contains("openid") ? $0 : ["openid"] + $0 } - return try authorizationRequest(scopes: scopes, state: state, additionalParameters: extra) + return try makeAuthorizationRequest(scopes: scopes, state: state, additionalParameters: extra) } - public func validate(_ token: OAuth2Token) throws { - _ = try validatedClaims(token) - } - - func validatedClaims(_ token: OAuth2Token) throws -> IDTokenClaims? { + /// Validates the id_token from a code exchange (see the type's documentation). + public func validate(_ token: OAuth2Token, pending: PendingAuthorization) throws { guard let idToken = token.idToken else { - if restrictedDomain != nil { throw OAuth2Error.invalidIDToken("no id_token; request the openid scope") } - return nil + throw OAuth2Error.invalidIDToken("no id_token; request the openid scope") } - let claims = try IDTokenClaims.validate(idToken, issuers: Self.issuers, clientID: oauth2.clientID) + let claims = try IDTokenClaims.validate( + idToken, issuers: Self.issuers, clientID: oauth2.clientID, nonce: pending.nonce + ) if let restrictedDomain { guard claims.claims["hd"]?.stringValue?.lowercased() == restrictedDomain else { throw OAuth2Error.restrictedDomainMismatch } } - return claims } + /// Fetches the userinfo for `token.accessToken` (any access token; see the type's documentation). public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { - let claims = try validatedClaims(token) let info = try await oauth2.getJSON(Self.userInfoURL, accessToken: token.accessToken) - if let claims, let sub = info["sub"]?.stringValue, sub != claims.subject { - throw OAuth2Error.invalidResponse("userinfo subject differs from the id_token subject") + if let restrictedDomain { + guard info["hd"]?.stringValue?.lowercased() == restrictedDomain else { + throw OAuth2Error.restrictedDomainMismatch + } } return try OAuthUserProfile( userid: info["sub"]?.stringValue, diff --git a/Sources/PerfectOAuth2/Providers/Removed.swift b/Sources/PerfectOAuth2/Providers/Removed.swift index 5a1bbf2..479d3d4 100644 --- a/Sources/PerfectOAuth2/Providers/Removed.swift +++ b/Sources/PerfectOAuth2/Providers/Removed.swift @@ -21,7 +21,14 @@ public typealias Linkedin = LinkedIn public typealias SalesForce = Salesforce @available(*, unavailable, message: "use PendingAuthorization and completeAuthorization(callback:pending:)") -public struct AuthorizationCode {} +public struct AuthorizationCode { + @available(*, unavailable, message: "use PendingAuthorization and completeAuthorization(callback:pending:)") + public init(code: String, redirectURL: String) { fatalError("unavailable") } +} +@available(*, unavailable, message: "errors are now OAuth2Error cases (.invalidConfiguration, .invalidCallback)") +public struct InvalidInput {} +@available(*, unavailable, message: "errors are now OAuth2Error cases (.providerError, .missingAuthorizationCode)") +public struct InvalidAuthorizationCodeError {} @available(*, unavailable, message: "errors are now OAuth2Error cases") public struct InvalidAPIResponse {} @available(*, unavailable, message: "errors are now OAuth2Error cases (.network, .timedOut)") diff --git a/Sources/PerfectOAuth2/Providers/Salesforce.swift b/Sources/PerfectOAuth2/Providers/Salesforce.swift index 6919ce7..a9487c4 100644 --- a/Sources/PerfectOAuth2/Providers/Salesforce.swift +++ b/Sources/PerfectOAuth2/Providers/Salesforce.swift @@ -6,16 +6,26 @@ import Foundation /// comes from the identity URL (`id`) in the token response. /// - Default scope `id`. Add `refresh_token` to get a refresh token. /// - PKCE on by default (Salesforce supports S256, and newer connected apps require it). -/// - The identity URL must be HTTPS on a Salesforce host (`*.salesforce.com`, -/// `*.salesforce.mil`, or the login host) before the access token is sent to it. +/// - The identity URL must be HTTPS on the login host or a host ending in one of +/// `identityHostSuffixes` (default `.salesforce.com` and `.salesforce.mil`) before the access token +/// is sent to it. Other Salesforce clouds (Government Cloud Plus, Alibaba-hosted China instances) +/// use other domains that haven't been verified here; add their suffixes if you use them. public final class Salesforce: OAuth2Provider { public let oauth2: OAuth2 public let loginType = "salesforce" public let loginHost: String + public let identityHostSuffixes: [String] + + public static let defaultIdentityHostSuffixes = [".salesforce.com", ".salesforce.mil"] /// - Parameter loginHost: `login.salesforce.com` (default), `test.salesforce.com` for sandboxes, /// or a My Domain host such as `example.my.salesforce.com`. - public init(configuration: OAuth2ClientConfiguration, loginHost: String = "login.salesforce.com") throws { + /// - Parameter identityHostSuffixes: host suffixes (with a leading dot) allowed for the identity URL. + public init( + configuration: OAuth2ClientConfiguration, + loginHost: String = "login.salesforce.com", + identityHostSuffixes: [String] = Salesforce.defaultIdentityHostSuffixes + ) throws { let host = loginHost.lowercased() guard !host.isEmpty, host.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "-") }), let authorizationURL = URL(string: "https://\(host)/services/oauth2/authorize"), @@ -23,6 +33,10 @@ public final class Salesforce: OAuth2Provider { throw OAuth2Error.invalidConfiguration("loginHost must be a host name") } self.loginHost = host + guard identityHostSuffixes.allSatisfy({ $0.hasPrefix(".") && $0.count > 2 }) else { + throw OAuth2Error.invalidConfiguration("identity host suffixes must start with a dot") + } + self.identityHostSuffixes = identityHostSuffixes.map { $0.lowercased() } oauth2 = OAuth2( configuration: configuration, authorizationURL: authorizationURL, @@ -35,7 +49,7 @@ public final class Salesforce: OAuth2Provider { func isSalesforceHost(_ host: String?) -> Bool { guard let host = host?.lowercased() else { return false } if host == loginHost { return true } - return [".salesforce.com", ".salesforce.mil"].contains { host.hasSuffix($0) } + return identityHostSuffixes.contains { host.hasSuffix($0) } } public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { diff --git a/Sources/PerfectOAuth2/Transport.swift b/Sources/PerfectOAuth2/Transport.swift index 12fdd18..f081bb8 100644 --- a/Sources/PerfectOAuth2/Transport.swift +++ b/Sources/PerfectOAuth2/Transport.swift @@ -58,10 +58,13 @@ public protocol OAuth2Transport: Sendable { /// - No cookies, cache or credential storage. /// - Redirects are not followed: the 3xx comes back as `OAuth2Error.httpStatus`. /// - `timeout` is a deadline for the whole request, not just the gap between bytes. +/// - `Accept-Encoding: identity` is sent and any other `Content-Encoding` is refused before the body +/// is read (Darwin would otherwise decompress before the size check). /// - A `Content-Length` over `maximumResponseBytes` is refused before the body is read; otherwise the -/// task is cancelled as soon as the received body passes the limit. On Darwin that stops the -/// download. On Linux, corelibs Foundation may keep reading after cancellation until the deadline, -/// discarding what it reads (see the README's "Response limits on Linux"). +/// call returns as soon as the received body passes the limit and the task is cancelled. On Darwin +/// that stops the download. On Linux, corelibs Foundation keeps reading and discarding in the +/// background after cancellation, badly so on Swift 6.2 (see the README's "Response limits on +/// Linux"). Only a custom `OAuth2Transport` gives hard memory and traffic bounds. public struct URLSessionTransport: OAuth2Transport { public let timeout: TimeInterval public let maximumResponseBytes: Int @@ -79,6 +82,9 @@ public struct URLSessionTransport: OAuth2Transport { for (name, value) in request.headers { urlRequest.setValue(value, forHTTPHeaderField: name) } + // Darwin decompresses gzip/deflate/br before the delegate sees the bytes, so a compressed body + // would get round the size cap as a memory bound. Ask for identity, and refuse anything else. + urlRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding") let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = timeout @@ -95,7 +101,9 @@ public struct URLSessionTransport: OAuth2Transport { delegate.task = task let deadline = Task { [timeout] in - try await Task.sleep(nanoseconds: UInt64(max(timeout, 0) * 1_000_000_000)) + // Clamped: UInt64(.infinity) or a huge value would trap. + let seconds = timeout.isNaN ? 0 : min(max(timeout, 0), 31_536_000) + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) delegate.fail(.timedOut) } defer { @@ -104,10 +112,11 @@ public struct URLSessionTransport: OAuth2Transport { DispatchQueue.global().async { session.invalidateAndCancel() } } + try Task.checkCancellation() return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - delegate.start(continuation) - task.resume() + // Not resumed if already cancelled or timed out: no request goes out. + if delegate.start(continuation) { task.resume() } } } onCancel: { delegate.fail(.network("cancelled")) @@ -138,7 +147,9 @@ private final class CappedDataDelegate: NSObject, URLSessionDataDelegate, @unche set { lock.withLock { _task = newValue } } } - func start(_ continuation: CheckedContinuation) { + /// Stores the continuation; returns false (after resuming it with the failure) if the request + /// already failed (cancelled or timed out) and shouldn't start. + func start(_ continuation: CheckedContinuation) -> Bool { let failure: OAuth2Error? = lock.withLock { if let pendingFailure, !finished { finished = true @@ -147,7 +158,11 @@ private final class CappedDataDelegate: NSObject, URLSessionDataDelegate, @unche self.continuation = continuation return nil } - if let failure { continuation.resume(throwing: failure) } + if let failure { + continuation.resume(throwing: failure) + return false + } + return true } /// Ends the request with `error` (timeout, cancellation, size limit) unless it already finished. @@ -178,6 +193,12 @@ private final class CappedDataDelegate: NSObject, URLSessionDataDelegate, @unche completionHandler(.cancel) return } + if let encoding = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Encoding")? + .trimmingCharacters(in: .whitespaces).lowercased(), !encoding.isEmpty, encoding != "identity" { + fail(.invalidResponse("compressed response refused (Content-Encoding: \(encoding.prefix(32)))")) + completionHandler(.cancel) + return + } lock.withLock { status = (response as? HTTPURLResponse)?.statusCode ?? 0 } completionHandler(.allow) } diff --git a/Tests/PerfectOAuth2Tests/FlowTests.swift b/Tests/PerfectOAuth2Tests/FlowTests.swift index 65bbd7a..58a004c 100644 --- a/Tests/PerfectOAuth2Tests/FlowTests.swift +++ b/Tests/PerfectOAuth2Tests/FlowTests.swift @@ -117,7 +117,7 @@ struct FlowTests { try await client.exchange(callback: AuthorizationCallback(code: "c", state: state), pending: request.pending) } } - let emptyPending = PendingAuthorization(state: "", codeVerifier: nil, redirectURL: URL(string: "https://app.test/cb")!) + let emptyPending = PendingAuthorization(state: "", codeVerifier: request.pending.codeVerifier, redirectURL: request.pending.redirectURL, tokenURL: client.tokenURL, clientID: client.clientID) await #expect(throws: OAuth2Error.self) { try await client.exchange(callback: AuthorizationCallback(code: "c", state: ""), pending: emptyPending) } @@ -134,7 +134,7 @@ struct FlowTests { await #expect(throws: OAuth2Error.missingAuthorizationCode) { try await client.exchange(callback: AuthorizationCallback(code: "", state: validState), pending: request.pending) } - let noVerifier = PendingAuthorization(state: validState, codeVerifier: nil, redirectURL: request.pending.redirectURL) + let noVerifier = PendingAuthorization(state: validState, codeVerifier: nil, redirectURL: request.pending.redirectURL, tokenURL: client.tokenURL, clientID: client.clientID) await #expect(throws: OAuth2Error.self) { try await client.exchange(callback: AuthorizationCallback(code: "c", state: validState), pending: noVerifier) } @@ -152,7 +152,7 @@ struct ErrorMappingTests { private func exchange(status: Int, body: String) async throws -> OAuth2Token { let transport = StubTransport().on(tokenURL, status: status, body) let client = generic(transport, pkce: false) - let pending = PendingAuthorization(state: validState, codeVerifier: nil, redirectURL: URL(string: "https://app.test/auth/callback")!) + let pending = PendingAuthorization(state: validState, codeVerifier: nil, redirectURL: URL(string: "https://app.test/auth/callback")!, tokenURL: client.tokenURL, clientID: client.clientID) return try await client.exchange(callback: AuthorizationCallback(code: "the-code-123", state: validState), pending: pending) } @@ -288,7 +288,7 @@ struct RedactionTests { let configuration = OAuth2ClientConfiguration(clientID: "id", clientSecret: "TOP-SECRET", redirectURL: URL(string: "https://app.test/cb")!) let token = OAuth2Token(accessToken: "ACCESS-TOK", refreshToken: "REFRESH-TOK", idToken: "ID-TOK") let profile = try OAuthUserProfile(userid: "42", loginType: "x", token: token) - let pending = PendingAuthorization(state: "STATE-VALUE-0123456789-0123456789", codeVerifier: "VERIFIER-VALUE", redirectURL: URL(string: "https://app.test/cb")!) + let pending = PendingAuthorization(state: "STATE-VALUE-0123456789-0123456789", codeVerifier: "VERIFIER-VALUE", redirectURL: URL(string: "https://app.test/cb")!, nonce: "NONCE-VALUE", tokenURL: URL(string: "https://a.test/t")!, clientID: "id") let google = Google(configuration: configuration) let request = OAuth2HTTPRequest(method: .post, url: URL(string: "https://a.test/t?x=1")!, headers: ["Authorization": "Bearer ACCESS-TOK"], body: Data("client_secret=TOP-SECRET".utf8)) let all = [configuration, token, profile, pending, google, google.oauth2, request] as [Any] diff --git a/Tests/PerfectOAuth2Tests/HardeningTests.swift b/Tests/PerfectOAuth2Tests/HardeningTests.swift new file mode 100644 index 0000000..d4a2553 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/HardeningTests.swift @@ -0,0 +1,286 @@ +import Foundation +import Testing +@testable import PerfectOAuth2 + +private let tokenURL = "https://auth.test/token" + +private func client(_ transport: StubTransport, pkce: Bool? = nil, secret: String? = "s3cr3t/+=&", maxAge: TimeInterval = 600) -> OAuth2 { + var configuration = testConfiguration(transport: transport, usePKCE: pkce, secret: secret) + configuration.pendingAuthorizationMaxAge = maxAge + return OAuth2(configuration: configuration, authorizationURL: URL(string: "https://auth.test/authorize")!, tokenURL: URL(string: tokenURL)!) +} + +private func callback(_ request: AuthorizationRequest, code: String = "CODE") -> AuthorizationCallback { + AuthorizationCallback(code: code, state: request.pending.state) +} + +private func copy(_ p: PendingAuthorization, verifier: String?? = .none, redirect: URL? = nil, tokenURL: URL? = nil, clientID: String? = nil, createdAt: Date? = nil) -> PendingAuthorization { + PendingAuthorization( + state: p.state, codeVerifier: verifier ?? p.codeVerifier, redirectURL: redirect ?? p.redirectURL, nonce: p.nonce, + tokenURL: tokenURL ?? p.tokenURL, clientID: clientID ?? p.clientID, createdAt: createdAt ?? p.createdAt + ) +} + +@Suite("Pending authorization binding") +struct PendingBindingTests { + @Test func crossProviderPendingIsRefused() async throws { + // The reviewer's repro: a GitHub pending authorization completing a Facebook exchange. + let transport = StubTransport() + .on("https://github.com/login/oauth/access_token", #"{"access_token":"AT"}"#) + .on("https://graph.facebook.com/v23.0/oauth/access_token", #"{"access_token":"FBAT"}"#) + let github = GitHub(configuration: testConfiguration(transport: transport)) + let facebook = Facebook(configuration: testConfiguration(transport: transport, usePKCE: true)) + let request = try github.authorizationRequest() + await #expect(throws: OAuth2Error.invalidPendingAuthorization("created by a different provider or client")) { + try await facebook.exchange(callback: callback(request), pending: request.pending) + } + let otherClient = OAuth2( + configuration: OAuth2ClientConfiguration(clientID: "other", clientSecret: "s", redirectURL: request.pending.redirectURL, http: OAuth2HTTPOptions(transport: transport)), + authorizationURL: github.oauth2.authorizationURL, tokenURL: github.oauth2.tokenURL + ) + await #expect(throws: OAuth2Error.invalidPendingAuthorization("created by a different provider or client")) { + try await otherClient.exchange(callback: callback(request), pending: request.pending) + } + #expect(transport.requests.isEmpty) + } + + @Test func tamperedRedirectIsRefused() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) + let oauth = client(transport) + let request = try oauth.authorizationRequest() + for redirect in ["http://evil.test/steal#frag", "https://app.test/auth/callback?x=1", "https://evil.test/auth/callback"] { + await #expect(throws: OAuth2Error.invalidPendingAuthorization("redirect URL differs from the configured one")) { + try await oauth.exchange(callback: callback(request), pending: copy(request.pending, redirect: URL(string: redirect)!)) + } + } + #expect(transport.requests.isEmpty) + } + + @Test func junkOrMismatchedVerifierIsRefused() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) + let oauth = client(transport) + let request = try oauth.authorizationRequest() + for verifier in ["a b", "short", String(repeating: "a", count: 129), String(repeating: "a", count: 42) + "+"] { + await #expect(throws: OAuth2Error.invalidPendingAuthorization("malformed code verifier")) { + try await oauth.exchange(callback: callback(request), pending: copy(request.pending, verifier: .some(verifier))) + } + } + await #expect(throws: OAuth2Error.invalidPendingAuthorization("PKCE is enabled but there is no code verifier")) { + try await oauth.exchange(callback: callback(request), pending: copy(request.pending, verifier: .some(nil))) + } + let noPKCE = client(transport, pkce: false) + await #expect(throws: OAuth2Error.invalidPendingAuthorization("PKCE is disabled but there is a code verifier")) { + try await noPKCE.exchange(callback: callback(request), pending: request.pending) + } + #expect(transport.requests.isEmpty) + #expect(PKCE.isValidVerifier(String(repeating: "a", count: 43))) + #expect(PKCE.isValidVerifier(String(repeating: "-._~", count: 32))) + } + + @Test func expiryAndFutureCreation() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) + let oauth = client(transport, maxAge: 60) + let request = try oauth.authorizationRequest() + await #expect(throws: OAuth2Error.invalidPendingAuthorization("expired")) { + try await oauth.exchange(callback: callback(request), pending: copy(request.pending, createdAt: Date().addingTimeInterval(-61))) + } + await #expect(throws: OAuth2Error.invalidPendingAuthorization("created in the future")) { + try await oauth.exchange(callback: callback(request), pending: copy(request.pending, createdAt: Date().addingTimeInterval(3600))) + } + _ = try await oauth.exchange(callback: callback(request), pending: copy(request.pending, createdAt: Date().addingTimeInterval(-59))) + } + + @Test func reuseIsRefused() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) + let oauth = client(transport) + let request = try oauth.authorizationRequest() + _ = try await oauth.exchange(callback: callback(request, code: "c1"), pending: request.pending) + await #expect(throws: OAuth2Error.invalidPendingAuthorization("already used")) { + try await oauth.exchange(callback: callback(request, code: "c2"), pending: request.pending) + } + #expect(transport.requests.count == 1) + // A failed exchange consumes it too: the code was already sent. + let failing = client(StubTransport().on(tokenURL, status: 400, #"{"error":"invalid_grant"}"#)) + let second = try failing.authorizationRequest() + await #expect(throws: OAuth2Error.self) { try await failing.exchange(callback: callback(second), pending: second.pending) } + await #expect(throws: OAuth2Error.invalidPendingAuthorization("already used")) { + try await failing.exchange(callback: callback(second), pending: second.pending) + } + } + + @Test func completedStatesAgeOut() { + let states = CompletedStates() + let start = Date() + #expect(states.insert("a", now: start, maxAge: 10)) + #expect(!states.insert("a", now: start.addingTimeInterval(5), maxAge: 10)) + #expect(states.insert("a", now: start.addingTimeInterval(11), maxAge: 10)) + } + + @Test func oversizedCodeIsRefused() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) + let oauth = client(transport, pkce: false) + let request = try oauth.authorizationRequest() + await #expect(throws: OAuth2Error.invalidCallback("code longer than 4096 bytes")) { + try await oauth.exchange(callback: callback(request, code: String(repeating: "C", count: 4097)), pending: request.pending) + } + #expect(transport.requests.isEmpty) + } +} + +@Suite("Token type and sanitizer") +struct TokenTypeSanitizerTests { + @Test func onlyBearerIsAccepted() async throws { + for (body, ok) in [(#"{"access_token":"a","token_type":"mac"}"#, false), (#"{"access_token":"a","token_type":"DPoP"}"#, false), + (#"{"access_token":"a","token_type":"BEARER"}"#, true), (#"{"access_token":"a","token_type":"bearer"}"#, true), + (#"{"access_token":"a"}"#, true)] { + let oauth = client(StubTransport().on(tokenURL, body)) + if ok { + _ = try await oauth.refresh(refreshToken: "r") + } else { + await #expect(throws: OAuth2Error.invalidResponse("unsupported token_type")) { try await oauth.refresh(refreshToken: "r") } + } + } + } + + @Test func shortAndEncodedSecretsAreRedacted() async { + // The reviewer's repro: a 7-character client secret echoed raw, and an encoded secret. + let body = #"{"error":"invalid_client","error_description":"secret abc1234 / abc1234 enc s3cr3t%2F%2B%3D%26 lower s3cr3t%2f%2b%3d%26 refresh R-TOKEN-1234567 R-TOKEN%2D1234567"}"# + for secret in ["abc1234", "s3cr3t/+=&"] { + let oauth = client(StubTransport().on(tokenURL, status: 400, body), secret: secret) + let error = await #expect(throws: OAuth2Error.self) { try await oauth.refresh(refreshToken: "R-TOKEN-1234567") } + let text = String(describing: error) + #expect(!text.contains(secret)) + #expect(!text.contains(percentEncodeAll(secret))) + #expect(!text.lowercased().contains(percentEncodeAll(secret).lowercased()) || !percentEncodeAll(secret).contains("%")) + #expect(!text.contains("R-TOKEN-1234567")) + } + // Ordinary words survive short non-secret values. + let words = OAuth2ErrorResponse(code: "x", errorDescription: "the code was revoked", errorURI: nil, redacting: ["", "revoked"], alwaysRedact: 1) + #expect(words.errorDescription == "the code was revoked") + } +} + +@Suite("Redaction and parsing nits") +struct NitTests { + @Test func repeatedErrorParameterIsAnError() { + let callback = AuthorizationCallback(url: URL(string: "https://app.test/cb?error=a&error=b&code=c&state=s")!) + #expect(callback.error?.code == "invalid_request") + } + + @Test func profileMirrorShowsRawKeysOnly() throws { + let profile = try OAuthUserProfile(userid: "u", loginType: "t", token: OAuth2Token(accessToken: "a"), + raw: .object(["access_token": .string("RAW-ACCESS-IN-RAW")])) + var text = "" + dump(profile, to: &text) + #expect(!text.contains("RAW-ACCESS-IN-RAW")) + #expect(text.contains("access_token")) + } + + @Test func callbackAndRequestRedaction() throws { + let request = try client(StubTransport()).authorizationRequest() + let callback = AuthorizationCallback(code: "AUTH-CODE-VALUE", state: request.pending.state) + for value in [callback as Any, request as Any] { + var text = "\(value) \(String(reflecting: value))" + dump(value, to: &text) + for secret in ["AUTH-CODE-VALUE", request.pending.state, request.pending.codeVerifier!, "code_challenge"] { + #expect(!text.contains(secret), "\(secret) leaked") + } + } + } + + @Test func loopbackIsExact() { + for (url, ok) in [("http://localhost/a", true), ("http://127.0.0.1:8080/a", true), ("http://[::1]:8080/a", true), ("http://LOCALHOST/a", true), + ("http://evil.localhost/a", false), ("http://localhost.evil.test/a", false), ("http://127.0.0.2/a", false), + ("http://0.0.0.0/a", false), ("http://127.0.0.1.nip.io/a", false), ("https://x.test/a", true), + ("https:x.test", false)] { + #expect(((try? requireSecure(URL(string: url)!, "x")) != nil) == ok, "\(url)") + } + } + + @Test func salesforceIdentitySuffixesAreConfigurable() throws { + let configuration = testConfiguration(transport: StubTransport()) + let salesforce = try Salesforce(configuration: configuration, identityHostSuffixes: [".example-cloud.test"]) + #expect(salesforce.isSalesforceHost("id.example-cloud.test")) + #expect(!salesforce.isSalesforceHost("login.salesforce.com.evil.test")) + #expect(salesforce.isSalesforceHost("login.salesforce.com")) // the login host is always allowed + #expect(throws: OAuth2Error.self) { try Salesforce(configuration: configuration, identityHostSuffixes: ["salesforce.com"]) } + #expect(try Salesforce(configuration: configuration).identityHostSuffixes == [".salesforce.com", ".salesforce.mil"]) + } + + @Test func idTokenShapes() { + let now = Int(Date().timeIntervalSince1970) + let claims: [String: Any] = ["iss": "https://accounts.google.com", "aud": "c", "sub": "1", "exp": now + 600, "iat": now] + let header = base64URLEncode(Data(#"{"alg":"none"}"#.utf8)) + let body = base64URLEncode(try! JSONSerialization.data(withJSONObject: claims)) + // alg is irrelevant: the signature isn't checked and the token is only trusted from the token + // endpoint. Segment count still is. + #expect((try? IDTokenClaims.validate("\(header).\(body).", issuers: Google.issuers, clientID: "c")) != nil) + for token in ["\(header).\(body)", "\(header).\(body).x.y", "a.b.c.d.e"] { + #expect(throws: OAuth2Error.invalidIDToken("not a compact JWS")) { + try IDTokenClaims.validate(token, issuers: Google.issuers, clientID: "c") + } + } + } +} + +@Suite("URLSessionTransport hardening", .serialized) +struct TransportHardeningTests { + private func oauth(_ server: LocalHTTPServer, timeout: TimeInterval = 10) -> OAuth2 { + OAuth2( + configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: "s", redirectURL: URL(string: "https://app.test/cb")!, usePKCE: false, + http: OAuth2HTTPOptions(timeout: timeout)), + authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(server.baseURL)/token")! + ) + } + + @Test func compressedResponsesAreRefusedAndIdentityRequested() async throws { + // A real gzip stream of 1 MiB of spaces (about 1 KB compressed); never decompressed. + let gzip = try #require(Data(base64Encoded: gzippedSpacesBase64)) + for encoding in ["gzip", "br", "deflate", " GZIP "] { + let server = try LocalHTTPServer { _ in + .raw(head: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: \(encoding)\r\nContent-Length: \(gzip.count)\r\nConnection: close\r\n\r\n", chunk: gzip, count: 1) + } + defer { server.stop() } + let error = await #expect(throws: OAuth2Error.self) { try await oauth(server).refresh(refreshToken: "r") } + guard case .invalidResponse(let why)? = error else { Issue.record("\(String(describing: error))"); continue } + #expect(why.hasPrefix("compressed response refused")) + let head = try #require(server.requests.first?.head.lowercased()) + #expect(head.contains("accept-encoding: identity")) + } + let identity = try LocalHTTPServer { _ in + .respond(status: 200, headers: ["Content-Encoding": "identity"], body: Data(#"{"access_token":"AT"}"#.utf8)) + } + defer { identity.stop() } + #expect(try await oauth(identity).refresh(refreshToken: "r").accessToken == "AT") + } + + @Test func infiniteTimeoutDoesNotTrap() async throws { + let server = try LocalHTTPServer { _ in .respond(status: 200, headers: [:], body: Data(#"{"access_token":"AT"}"#.utf8)) } + defer { server.stop() } + for timeout in [TimeInterval.infinity, .greatestFiniteMagnitude, .nan] { + _ = await Result(catching: { try await oauth(server, timeout: timeout).refresh(refreshToken: "r") }) + } + } + + @Test func alreadyCancelledTaskSendsNothing() async throws { + let server = try LocalHTTPServer { _ in .respond(status: 200, headers: [:], body: Data(#"{"access_token":"AT"}"#.utf8)) } + defer { server.stop() } + let client = oauth(server) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await client.refresh(refreshToken: "r") + } + if case .success = await task.result { Issue.record("cancelled request succeeded") } + try await Task.sleep(nanoseconds: 300_000_000) + #expect(server.requests.isEmpty) + } +} + +extension Result where Failure == any Error { + init(catching body: () async throws -> Success) async { + do { self = .success(try await body()) } catch { self = .failure(error) } + } +} + +/// gzip of 1 MiB of spaces (generated with Python's gzip module). +private let gzippedSpacesBase64 = "H4sIADCW3moC/+3BMQEAAADCoCrrn9IQvkABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwGpwID8QAAEAA=" diff --git a/Tests/PerfectOAuth2Tests/ProviderTests.swift b/Tests/PerfectOAuth2Tests/ProviderTests.swift index 350b79f..d29d87b 100644 --- a/Tests/PerfectOAuth2Tests/ProviderTests.swift +++ b/Tests/PerfectOAuth2Tests/ProviderTests.swift @@ -41,6 +41,9 @@ private let googleUserInfo = "https://openidconnect.googleapis.com/v1/userinfo" @Suite("Google") struct GoogleTests { + private let token = #"{"access_token":"AT","id_token":"\#(googleIDToken())"}"# + private let info = #"{"sub":"1234567890","hd":"corp.test"}"# + @Test func loginLink() throws { let google = Google(configuration: testConfiguration(transport: StubTransport()), restrictedDomain: "Corp.Test&prompt=none") let request = try google.authorizationRequest() @@ -51,18 +54,44 @@ struct GoogleTests { #expect(query["hd"] == "corp.test&prompt=none") // one encoded value, not an injected prompt #expect(query["prompt"] == nil) #expect(query["code_challenge_method"] == "S256") + #expect(query["nonce"] == nil) // PKCE on: no nonce #expect(query["access_type"] == nil) } - @Test func offlineAccessAndOpenIDAlwaysRequested() throws { - let google = Google(configuration: testConfiguration(transport: StubTransport(), scopes: ["email"])) + @Test func openIDIsAddedOnEveryPath() throws { + let google = Google(configuration: testConfiguration(transport: StubTransport())) + let requests = [ + try google.authorizationRequest(scopes: ["email"]), + try google.authorizationRequest(scopes: ["email"], offlineAccess: false), + try google.authorizationRequest(scopes: ["email"], state: nil, additionalParameters: []), + try (google as any OAuth2Provider).authorizationRequest(scopes: ["email"], state: nil, additionalParameters: []), + try (google as any OAuth2Provider).authorizationRequest(scopes: ["email"]), + try google.makeAuthorizationRequest(scopes: ["email"], state: nil, additionalParameters: []), + ] + for request in requests { + #expect(queryParameters(request.url)?["scope"] == "openid email") + } + let configured = Google(configuration: testConfiguration(transport: StubTransport(), scopes: ["email"])) + #expect(queryParameters(try configured.authorizationRequest().url)?["scope"] == "openid email") + } + + @Test func offlineAccess() throws { + let google = Google(configuration: testConfiguration(transport: StubTransport())) let query = try #require(queryParameters(try google.authorizationRequest(offlineAccess: true).url)) #expect(query["access_type"] == "offline") #expect(query["prompt"] == "consent") - #expect(query["scope"] == "openid email") - let custom = try #require(queryParameters(try google.authorizationRequest(scopes: ["profile"], offlineAccess: false).url)) - #expect(custom["scope"] == "openid profile") - #expect(custom["access_type"] == nil) + #expect(query["scope"] == "openid profile") + // A duplicated prompt (offlineAccess plus an explicit one) is refused rather than sent twice. + #expect(throws: OAuth2Error.self) { + try google.authorizationRequest(offlineAccess: true, additionalParameters: [URLQueryItem(name: "prompt", value: "none")]) + } + } + + @Test func hdIsReservedAgainstAdditionalParameters() { + let google = Google(configuration: testConfiguration(transport: StubTransport()), restrictedDomain: "corp.test") + #expect(throws: OAuth2Error.invalidConfiguration("parameter hd is set by the provider")) { + try google.authorizationRequest(additionalParameters: [URLQueryItem(name: "hd", value: "evil.test")]) + } } @Test func signInUsesBearerHeaderAndValidatedIDToken() async throws { @@ -88,14 +117,14 @@ struct GoogleTests { // 1.x returned a profile with userid "" here. for body in [#"{}"#, #"{"sub":""}"#, #"{"id":"legacy-field"}"#] { await #expect(throws: OAuth2Error.missingUserID) { - _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, #"{"access_token":"AT"}"#), profile: (googleUserInfo, 200, body)) + _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, token), profile: (googleUserInfo, 200, body)) } } await #expect(throws: OAuth2Error.httpStatus(500)) { - _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, #"{"access_token":"AT"}"#), profile: (googleUserInfo, 500, "oops")) + _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, token), profile: (googleUserInfo, 500, "oops")) } await #expect(throws: OAuth2Error.providerError(OAuth2ErrorResponse(code: "invalid_token"))) { - _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, #"{"access_token":"AT"}"#), profile: (googleUserInfo, 401, #"{"error":"invalid_token"}"#)) + _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, token), profile: (googleUserInfo, 401, #"{"error":"invalid_token"}"#)) } } @@ -105,9 +134,15 @@ struct GoogleTests { (googleIDToken(["aud": "someone-else"]), .invalidIDToken("audience is not this client")), (googleIDToken(["aud": ["client id&x", "other"]]), .invalidIDToken("authorized party is not this client")), (googleIDToken(["exp": now - 600]), .invalidIDToken("expired")), - (googleIDToken(removing: ["exp"]), .invalidIDToken("no expiry")), + (googleIDToken(removing: ["exp"]), .invalidIDToken("no numeric expiry")), + (googleIDToken(["exp": String(Int(now) + 600)]), .invalidIDToken("no numeric expiry")), + (googleIDToken(removing: ["iat"]), .invalidIDToken("no numeric issue time")), + (googleIDToken(["iat": String(Int(now))]), .invalidIDToken("no numeric issue time")), (googleIDToken(["iat": now + 3600]), .invalidIDToken("issued in the future")), + (googleIDToken(["exp": now + 3_153_600_000]), .invalidIDToken("lifetime too long")), (googleIDToken(["sub": ""]), .invalidIDToken("no subject")), + (googleIDToken(["sub": " "]), .invalidIDToken("no subject")), + (googleIDToken(["sub": 123]), .invalidIDToken("no subject")), (googleIDToken(["hd": "other.test"]), .restrictedDomainMismatch), (googleIDToken(removing: ["hd"]), .restrictedDomainMismatch), ("not.a-jwt", .invalidIDToken("not a compact JWS")), @@ -118,23 +153,23 @@ struct GoogleTests { _ = try await signIn( { Google(configuration: $0, restrictedDomain: "corp.test") }, token: (googleToken, #"{"access_token":"AT","id_token":"\#(idToken)"}"#), - profile: (googleUserInfo, 200, #"{"sub":"1234567890"}"#) + profile: (googleUserInfo, 200, info) ) } } - // Restricted domain without an id_token at all. + // Every exchange needs an id_token now that openid is always requested. await #expect(throws: OAuth2Error.invalidIDToken("no id_token; request the openid scope")) { - _ = try await signIn({ Google(configuration: $0, restrictedDomain: "corp.test") }, token: (googleToken, #"{"access_token":"AT"}"#), profile: nil) + _ = try await signIn({ Google(configuration: $0) }, token: (googleToken, #"{"access_token":"AT"}"#), profile: nil) } // Accepted: azp matches with multiple audiences, issuer without scheme, small clock skew. _ = try await signIn( { Google(configuration: $0, restrictedDomain: "CORP.test") }, token: (googleToken, #"{"access_token":"AT","id_token":"\#(googleIDToken(["aud": ["client id&x", "other"], "azp": "client id&x", "iss": "accounts.google.com", "exp": now - 60]))"}"#), - profile: (googleUserInfo, 200, #"{"sub":"1234567890"}"#) + profile: (googleUserInfo, 200, info) ) } - @Test func base64URLIDTokenPayloadDecodes() async throws { + @Test func base64URLIDTokenPayloadDecodes() throws { // A payload whose base64url form contains '-' and '_' (1.x used standard base64 and failed). let token = googleIDToken(["name": "??>>~~ÿ"]) #expect(token.split(separator: ".")[1].contains { $0 == "-" || $0 == "_" }) @@ -142,11 +177,46 @@ struct GoogleTests { #expect(claims.claims["hd"]?.stringValue == "corp.test") } - @Test func subjectMismatchBetweenIDTokenAndUserInfo() async { - await #expect(throws: OAuth2Error.invalidResponse("userinfo subject differs from the id_token subject")) { - _ = try await signIn({ Google(configuration: $0) }, - token: (googleToken, #"{"access_token":"AT","id_token":"\#(googleIDToken())"}"#), - profile: (googleUserInfo, 200, #"{"sub":"someone-else"}"#)) + @Test func restrictedDomainIsCheckedThroughUserInfo() async throws { + // The reviewer's repro: an app-built token with a forged (unsigned) id_token claiming + // hd=corp.test, plus a real access token whose userinfo has no hd. + let transport = StubTransport().on(googleUserInfo, #"{"sub":"attacker-sub","email":"a@gmail.com","email_verified":true}"#) + let google = Google(configuration: testConfiguration(transport: transport), restrictedDomain: "corp.test") + let forged = OAuth2Token(accessToken: "attacker-access-token", idToken: googleIDToken(["sub": "attacker-sub", "hd": "corp.test"])) + await #expect(throws: OAuth2Error.restrictedDomainMismatch) { try await google.fetchProfile(token: forged) } + transport.on(googleUserInfo, #"{"sub":"x","hd":"other.test"}"#) + await #expect(throws: OAuth2Error.restrictedDomainMismatch) { try await google.fetchProfile(token: forged) } + transport.on(googleUserInfo, #"{"sub":"x","hd":"CORP.test"}"#) + #expect(try await google.fetchProfile(token: forged).userid == "x") + } + + @Test func storedTokenWithExpiredIDTokenStillFetchesProfile() async throws { + let transport = StubTransport().on(googleUserInfo, #"{"sub":"123"}"#) + let google = Google(configuration: testConfiguration(transport: transport)) + let stored = OAuth2Token(accessToken: "AT", idToken: googleIDToken(["exp": now - 7200, "iat": now - 10800])) + #expect(try await google.fetchProfile(token: stored).userid == "123") + } + + @Test func nonceWhenPKCEIsOff() async throws { + let transport = StubTransport().on(googleUserInfo, info) + let google = Google(configuration: testConfiguration(transport: transport, usePKCE: false)) + let request = try google.authorizationRequest() + let query = try #require(queryParameters(request.url)) + let nonce = try #require(request.pending.nonce) + #expect(query["nonce"] == nonce) + #expect(query["code_challenge"] == nil) + let callback = AuthorizationCallback(code: "CODE", state: request.pending.state) + for (claims, ok) in [(["nonce": nonce], true), (["nonce": "other"], false), ([:], false)] as [([String: Any], Bool)] { + let fresh = Google(configuration: testConfiguration(transport: transport, usePKCE: false)) + let pending = PendingAuthorization(state: request.pending.state, codeVerifier: nil, redirectURL: request.pending.redirectURL, nonce: nonce, tokenURL: fresh.oauth2.tokenURL, clientID: fresh.oauth2.clientID) + transport.on(googleToken, #"{"access_token":"AT","id_token":"\#(googleIDToken(claims))"}"#) + if ok { + _ = try await fresh.completeAuthorization(callback: callback, pending: pending) + } else { + await #expect(throws: OAuth2Error.invalidIDToken("nonce mismatch")) { + try await fresh.completeAuthorization(callback: callback, pending: pending) + } + } } } } diff --git a/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift b/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift index b72e698..91bf8b7 100644 --- a/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift +++ b/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift @@ -19,6 +19,8 @@ final class LocalHTTPServer: @unchecked Sendable { case drip(declared: Int, interval: TimeInterval) /// Accepts and never answers. case silent + /// Writes `head` verbatim, then `chunk` `count` times (nil: until the client goes away). + case raw(head: String, chunk: Data, count: Int?) } struct Received: Sendable { @@ -142,6 +144,13 @@ final class LocalHTTPServer: @unchecked Sendable { Thread.sleep(forTimeInterval: interval) if !write(fd, Data(" ".utf8)) { return } } + case .raw(let head, let chunk, let count): + guard write(fd, Data(head.utf8)) else { return } + var sent = 0 + while count.map({ sent < $0 }) ?? true { + if !write(fd, chunk) { return } + sent += 1 + } case .silent: var byte: UInt8 = 0 _ = recv(fd, &byte, 1, 0) // returns when the client closes or the server stops From 0d6194b19fb62ccd5070ee156a63e9702752a713 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 19:36:38 -0500 Subject: [PATCH 06/14] URLSessionTransport: keep the deadline off the cooperative pool On Linux with Swift 6.2, corelibs URLSession creation, resume() and cancel() block for a long time while earlier cancelled transfers drain. Done on the cooperative pool, that also starved the deadline Task: with a 5 s timeout, calls took 31 s and 75 s, 10 capped calls moved 107 GB over loopback and RSS reached 1.8 GB. Session setup now runs on a Dispatch thread and the deadline is a Dispatch timer. Afterwards (linux/arm64 6.2, 50 calls): every call ends by its 5 s deadline, 44.9 s total, 37.9 GB, 487 MB peak RSS. Swift 6.4: 0.19 s, 365 MB, 123 MB RSS. Corelibs still drains in the background; that's why the next commit adds a different default transport. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectOAuth2/Transport.swift | 78 ++++++++++++++++++--------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/Sources/PerfectOAuth2/Transport.swift b/Sources/PerfectOAuth2/Transport.swift index f081bb8..bb4f398 100644 --- a/Sources/PerfectOAuth2/Transport.swift +++ b/Sources/PerfectOAuth2/Transport.swift @@ -86,37 +86,44 @@ public struct URLSessionTransport: OAuth2Transport { // would get round the size cap as a memory bound. Ask for identity, and refuse anything else. urlRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding") - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = timeout - configuration.timeoutIntervalForResource = timeout - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.urlCache = nil - configuration.httpCookieStorage = nil - configuration.httpShouldSetCookies = false - configuration.urlCredentialStorage = nil - let delegate = CappedDataDelegate(limit: maximumResponseBytes) - let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) - let task = session.dataTask(with: urlRequest) - delegate.task = task - - let deadline = Task { [timeout] in - // Clamped: UInt64(.infinity) or a huge value would trap. - let seconds = timeout.isNaN ? 0 : min(max(timeout, 0), 31_536_000) - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - delegate.fail(.timedOut) - } + // Neither the deadline nor the session setup runs on Swift's cooperative pool. On Linux, + // corelibs' URLSession creation, resume() and cancel() can block for a long time while it + // drains earlier cancelled transfers; on the cooperative pool that starved the deadline + // itself (a 5 s timeout fired after 75 s on Swift 6.2). A Dispatch timer and a Dispatch + // thread for setup keep the caller's deadline honest. + let seconds = timeout.isNaN ? 0 : min(max(timeout, 0), 31_536_000) // clamped: no trap on .infinity + let deadline = DispatchWorkItem { delegate.fail(.timedOut) } + DispatchQueue.global().asyncAfter(deadline: .now() + seconds, execute: deadline) defer { deadline.cancel() - // Off the cooperative pool for the same reason as `cancelOffThread`. - DispatchQueue.global().async { session.invalidateAndCancel() } + delegate.invalidate() } try Task.checkCancellation() + let timeout = self.timeout + let finalRequest = urlRequest return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // Not resumed if already cancelled or timed out: no request goes out. - if delegate.start(continuation) { task.resume() } + // Not started if already cancelled or timed out: no request goes out. + guard delegate.start(continuation) else { return } + DispatchQueue.global().async { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCredentialStorage = nil + let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + let task = session.dataTask(with: finalRequest) + if delegate.attach(session: session, task: task) { + task.resume() + } else { + session.invalidateAndCancel() // finished (timed out or cancelled) during setup + } + } } } onCancel: { delegate.fail(.network("cancelled")) @@ -139,12 +146,31 @@ private final class CappedDataDelegate: NSObject, URLSessionDataDelegate, @unche private var status = 0 private var data = Data() private var _task: URLSessionTask? + private var session: URLSession? + private var invalidated = false init(limit: Int) { self.limit = limit } - var task: URLSessionTask? { - get { lock.withLock { _task } } - set { lock.withLock { _task = newValue } } + /// Records the session and task; returns false if the request already finished or the caller + /// has returned, in which case the task must not be resumed. + func attach(session: URLSession, task: URLSessionTask) -> Bool { + lock.withLock { + guard !finished, !invalidated else { return false } + self.session = session + _task = task + return true + } + } + + /// Called when the caller returns: invalidates the session off the calling thread (or makes a + /// later `attach` refuse, if setup hasn't finished). + func invalidate() { + let session: URLSession? = lock.withLock { + invalidated = true + defer { self.session = nil } + return self.session + } + if let session { DispatchQueue.global().async { session.invalidateAndCancel() } } } /// Stores the continuation; returns false (after resuming it with the failure) if the request From 0881a57a378798704bef24781d9cf006a8a4baf3 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 20:03:50 -0500 Subject: [PATCH 07/14] Default transport: NIOTransport, a bounded HTTP/1.1 client on SwiftNIO Corelibs URLSession can't be held to a size or time bound on Linux: after a capped request is cancelled it keeps reading in the background. With Swift 6.2, 50 capped calls against an endless body took 44.9 s and moved 37.9 GB (arm64), and 127 s and 102 GB (emulated amd64). Following Perfect-SMTP 6.0.0's NIOMTASTSHTTPFetcher, the default is now NIOTransport: - one connection per request on MultiThreadedEventLoopGroup.singleton (injectable); GET and form-encoded POST; - HTTPS only (plain HTTP only to localhost, 127.0.0.1 and ::1); SNI and full verification by default, plus a DNS subjectAltName check on the verified certificate (swift-asn1 parser over NIOSSL's public peerCertificate), so an IP-only certificate can't stand in for a name; - nothing written until the handshake and the name check pass; - the cap enforced while reading, chunked or not, and an oversized Content-Length refused up front; Accept-Encoding: identity and any other Content-Encoding refused; no redirects; - an event-loop deadline and caller cancellation that end the request at any stage (connect, handshake, write, read) and stop any later write; - every promise completed and the channel closed on every path. Measured over 50 back-to-back capped calls: 0.24 s on macOS, 0.34-0.44 s on Swift 6.2 arm64, 0.32 s on 6.4 arm64, 1.25 s on 6.4 amd64 (emulated), descriptors unchanged, peak RSS 59-140 MB. URLSessionTransport stays as an opt-in (for proxies, which NIOTransport ignores); its infinite-timeout trap on Linux corelibs is clamped away. Dependencies: swift-nio from 2.86.0, swift-nio-ssl from 2.31.0, swift-asn1 from 1.2.0 (Perfect-SMTP 6.0.0's floors), swift-crypto unchanged. Tests: a SwiftNIO TLS server with a throwaway test CA (named, IP-only, wrong-name and wildcard leaves, and an untrusted CA) covering every 3xx, endless and endless-chunked bodies, a huge Content-Length, compressed bodies, slow-loris and silent servers, a stalled handshake, cancellation, refused connections, certificate refusals that never reach the server, and descriptor and connection leaks. The loopback transport suites run against both transports. README documents the transports, the proxy caveat and the measurements. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 24 +- README.md | 222 +++++++++--- .../PerfectOAuth2/CertificateDNSNames.swift | 124 +++++++ Sources/PerfectOAuth2/NIOTransport.swift | 325 ++++++++++++++++++ Sources/PerfectOAuth2/OAuth2.swift | 5 +- Sources/PerfectOAuth2/Transport.swift | 18 +- Tests/PerfectOAuth2Tests/HardeningTests.swift | 20 +- .../NIOTransportTests.swift | 280 +++++++++++++++ .../Support/NIOTestServer.swift | 199 +++++++++++ .../Support/TestCertificates.swift | 145 ++++++++ Tests/PerfectOAuth2Tests/TransportTests.swift | 64 ++-- 11 files changed, 1334 insertions(+), 92 deletions(-) create mode 100644 Sources/PerfectOAuth2/CertificateDNSNames.swift create mode 100644 Sources/PerfectOAuth2/NIOTransport.swift create mode 100644 Tests/PerfectOAuth2Tests/NIOTransportTests.swift create mode 100644 Tests/PerfectOAuth2Tests/Support/NIOTestServer.swift create mode 100644 Tests/PerfectOAuth2Tests/Support/TestCertificates.swift diff --git a/Package.swift b/Package.swift index 9796c1e..1195998 100644 --- a/Package.swift +++ b/Package.swift @@ -11,17 +11,37 @@ let package = Package( // SHA-256 for PKCE (RFC 7636 S256) and HMAC-SHA256 for Facebook's appsecret_proof. // The range overlaps Perfect-SMTP (4.0.0..<5.0.0) and Perfect-NIO (3.0.0..<5.0.0). .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"5.0.0"), + // The default transport, NIOTransport: an HTTP/1.1 client with hard size and time bounds on + // every platform (corelibs URLSession can't be bounded on Linux). Same floors as + // Perfect-SMTP 6.0.0: nio 2.86.0, nio-ssl 2.31.0 (public `peerCertificate`, read by the + // DNS-subjectAltName check), swift-asn1 1.2.0 (the subjectAltName parser). + .package(url: "https://github.com/apple/swift-nio.git", from: "2.86.0"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.31.0"), + .package(url: "https://github.com/apple/swift-asn1.git", from: "1.2.0"), ], targets: [ .target( name: "PerfectOAuth2", - dependencies: [.product(name: "Crypto", package: "swift-crypto")], + dependencies: [ + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOTLS", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), + .product(name: "SwiftASN1", package: "swift-asn1"), + ], path: "Sources/PerfectOAuth2", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "PerfectOAuth2Tests", - dependencies: ["PerfectOAuth2"], + dependencies: [ + "PerfectOAuth2", + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), + ], path: "Tests/PerfectOAuth2Tests", swiftSettings: [.swiftLanguageMode(.v6)] ), diff --git a/README.md b/README.md index 9f36eee..1900745 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,13 @@ library product you import is `PerfectOAuth2`. - macOS 12 or later, or Linux (CI: Ubuntu 24.04 with Swift 6.2 and 6.4; macOS with Xcode 26.0.1 and 26.6). iOS isn't a target: this is a confidential-client library for servers, and apps should use `ASWebAuthenticationSession` without embedding a client secret. -- One dependency: Apple's [swift-crypto](https://github.com/apple/swift-crypto) - (`3.0.0..<5.0.0`), for SHA-256 (PKCE) and HMAC-SHA256 (Facebook's `appsecret_proof`). On Apple - platforms it uses CryptoKit. Networking is Foundation's `URLSession`. +- Apple/SSWG dependencies only: + - [swift-crypto](https://github.com/apple/swift-crypto) `3.0.0..<5.0.0`, for SHA-256 (PKCE) and + HMAC-SHA256 (Facebook's `appsecret_proof`); on Apple platforms it uses CryptoKit; + - [swift-nio](https://github.com/apple/swift-nio) from 2.86.0, + [swift-nio-ssl](https://github.com/apple/swift-nio-ssl) from 2.31.0 and + [swift-asn1](https://github.com/apple/swift-asn1) from 1.2.0, for the default HTTP transport + (see [Transports](#transports)). The floors match Perfect-SMTP 6.0.0. ## Quick start @@ -50,14 +54,15 @@ let google = Google( // Route 1: GET /auth/google func startSignIn(session: inout [String: Data]) throws -> URL { let request = try google.authorizationRequest() - // Keep the state and PKCE verifier server-side until the callback. - session["oauth.pending"] = try JSONEncoder().encode(request.pending) + // Keep the state and PKCE verifier server-side until the callback, one key per provider. + session["oauth.pending.google"] = try JSONEncoder().encode(request.pending) return request.url // redirect the browser here } // Route 2: GET /auth/google/callback?code=...&state=... func finishSignIn(callbackURL: URL, session: inout [String: Data]) async throws -> OAuthUserProfile { - guard let data = session.removeValue(forKey: "oauth.pending") else { + // Remove it before completing, so it can only be used once. + guard let data = session.removeValue(forKey: "oauth.pending.google") else { throw OAuth2Error.stateMismatch } let pending = try JSONDecoder().decode(PendingAuthorization.self, from: data) @@ -67,9 +72,9 @@ func finishSignIn(callbackURL: URL, session: inout [String: Data]) async throws } ``` -`completeAuthorization` checks the state, exchanges the code (sending the PKCE verifier and the same -`redirect_uri`), runs the provider's token checks, fetches the profile, and throws if anything is -missing. Use `completeAuthorization(code:state:pending:)` if your framework hands you the query +`completeAuthorization` checks the state and the pending authorization, exchanges the code (sending +the PKCE verifier and the same `redirect_uri`), runs the provider's token checks, fetches the profile, +and throws if anything is missing. Use `completeAuthorization(code:state:pending:)` if your framework hands you the query parameters instead of the URL. `OAuthUserProfile` has `userid`, `firstName`, `lastName`, `displayName`, `picture`, `email`, @@ -98,17 +103,18 @@ Everything throws `OAuth2Error`: |------|---------| | `.stateMismatch`, `.invalidState` | CSRF check failed, or the stored state is unusable | | `.authorizationDenied(response)` | the provider redirected back with `error=` (e.g. `access_denied`) | -| `.missingAuthorizationCode` | the callback had neither `code` nor `error` | +| `.missingAuthorizationCode`, `.invalidCallback` | the callback had neither `code` nor `error`, or a `code` over 4 KB | +| `.invalidPendingAuthorization` | the `PendingAuthorization` is from another provider or client, expired, tampered with, or already used | | `.providerError(response)` | an error body from a token or API endpoint; `response.code` is the provider's code (`invalid_grant`, `bad_verification_code`, `OAuthException`...), `response.standardCode` the RFC 6749 one if it is one | | `.httpStatus(Int)` | any other non-2xx, including redirects (never followed) | -| `.invalidResponse`, `.missingUserID` | the response wasn't the JSON expected, or had no user id | -| `.invalidIDToken`, `.restrictedDomainMismatch` | Google id_token checks | +| `.invalidResponse`, `.missingUserID` | the response wasn't the JSON expected (including a non-Bearer `token_type` or a compressed body), or had no user id | +| `.invalidIDToken`, `.restrictedDomainMismatch` | Google id_token and domain checks | | `.timedOut`, `.network`, `.responseTooLarge` | transport failures | | `.insecureURL`, `.invalidConfiguration` | a non-HTTPS endpoint, or a bad redirect URL / parameter | Error descriptions never include secrets: provider text is truncated, stripped of control -characters, and has the client secret, code, verifier and tokens from the failed request replaced -with ``. +characters, and has the client secret (always) and the code, verifier and tokens from the failed +request (from 8 characters) replaced with ``, in raw and percent-encoded forms. ## Security model @@ -119,20 +125,33 @@ with ``. public if you run the flow yourself. - **PKCE** (RFC 7636, S256), as the OAuth 2.0 Security BCP (RFC 9700) recommends for every client, confidential ones included. It is on by default where the provider documents support (see the - table) and can be forced either way with `usePKCE:` in the configuration. Keep - `PendingAuthorization` server-side; it holds the verifier. -- **Redirect URI.** One fixed, registered `redirectURL`: absolute HTTPS (HTTP only to loopback hosts), - no fragment. It is sent unchanged on the authorization request and the exchange. 1.x appended + table) and can be forced either way with `usePKCE:` in the configuration. Google sends and checks an + OpenID Connect `nonce` instead when PKCE is turned off. +- **Pending authorizations.** `PendingAuthorization` holds the state, the PKCE verifier and the + nonce, and its JSON contains them in clear: keep it in a server-side session store, not a + client-readable cookie. Use one session key per provider and delete it when the callback arrives + (as the quick start does). `exchange` also refuses one that was created by another provider or + client ID, is older than `pendingAuthorizationMaxAge` (default 10 minutes), carries a redirect URL + other than the configured one or a malformed verifier, or has already been completed by the same + provider instance (remembered in memory for the max age). That last check can't span processes, so + deleting it from the session is what makes it single-use across a cluster. +- **Redirect URI.** One fixed, registered `redirectURL` per provider: absolute HTTPS (HTTP only to + `localhost`, `127.0.0.1` and `::1`), no fragment. It is sent unchanged on the authorization request and the exchange. 1.x appended `?session=` to it, which leaked the session token to the provider and into logs; 2.0 doesn't, and you shouldn't put session identifiers in it either. Use a cookie-backed session. -- **id_token trust.** An id_token is only used when it came straight from the provider's token - endpoint over TLS. OpenID Connect Core §3.1.3.7 lets a client skip the signature check in that case, - so PerfectOAuth2 does, but it checks the issuer, audience (`aud` contains your client ID, and `azp` - equals it when present or when there are several audiences), expiry and issue time (5 minutes' - leeway) and a non-empty `sub`. Google's `restrictedDomain` uses the `hd` claim of that validated - token, and the userinfo `sub` must match the token's. `OAuth2Token.idToken` is exposed raw: never - trust an id_token from anywhere else (a browser, a query string, storage) without verifying its - signature against the provider's keys. +- **id_token trust.** An id_token is only used during a code exchange, when it came straight from + the provider's token endpoint over TLS. OpenID Connect Core §3.1.3.7 lets a client skip the + signature check in that case, so PerfectOAuth2 does, but it checks the issuer, audience (`aud` + contains your client ID, and `azp` equals it when present or when there are several audiences), + numeric expiry and issue time (5 minutes' leeway, at most 24 hours apart), a non-empty `sub`, and + the nonce when one was sent. With `restrictedDomain`, the token's `hd` claim must match. + `OAuth2Token.idToken` is exposed raw: never trust an id_token from anywhere else (a browser, a query + string, storage) without verifying its signature against the provider's keys. +- **Profiles come from the provider's API, not from tokens.** `fetchProfile(token:)` accepts any + access token, including one your app stored or received from elsewhere, and trusts only the + provider's userinfo response for it. It never reads the token's id_token. Google's + `restrictedDomain` is checked there too, against userinfo's `hd`, so a token from another Google + client or a forged id_token can't get round it. - **Email.** `email` is only trustworthy when `emailVerified` is true. GitHub's profile email is never marked verified. - **Tokens on the wire.** Access tokens are sent in the `Authorization: Bearer` header, never in a URL. @@ -140,32 +159,137 @@ with ``. `URLComponents`, and every value is percent-encoded (including `+`, `&` and `=`). - **HTTP limits.** Each request has a whole-request deadline (`OAuth2HTTPOptions.timeout`, default 30 s), a response cap (`maximumResponseBytes`, default 1 MiB, checked against `Content-Length` and - while streaming), no redirects, no cookies or cache, and HTTPS only (HTTP to loopback for testing). - Salesforce's identity URL must be HTTPS on a Salesforce host before the token is sent to it. + while reading), no redirects, no cookies or cache, `Accept-Encoding: identity` with any other + `Content-Encoding` refused before the body is read, and HTTPS only (HTTP to loopback for testing). + With the default `NIOTransport` these are hard bounds on every platform: reading stops and the + connection closes the moment one is hit. Only `token_type` Bearer is accepted. +- **TLS.** The default transport verifies the certificate chain against the system trust store and + the host name (with SNI), and for a host name also requires a matching DNS subjectAltName, so a + certificate that only names an IP address is refused (NIOSSL alone accepts an IP SAN equal to the + dialed address). Nothing is written to the connection until the handshake and that check pass, so + a server whose certificate is refused never sees the request or the client secret in it. Salesforce's + identity URL must be HTTPS on the login host or an allowed suffix (`identityHostSuffixes`) before + the token is sent to it. - **No secrets in output.** `description`, `debugDescription`, `dump` and `Mirror` of - `OAuth2ClientConfiguration`, `OAuth2Token`, `OAuthUserProfile`, `PendingAuthorization` and - `OAuth2HTTPRequest` show `` for secrets, tokens, state and verifiers. + `OAuth2ClientConfiguration`, `OAuth2Token`, `OAuthUserProfile`, `PendingAuthorization`, + `AuthorizationRequest`, `AuthorizationCallback` and `OAuth2HTTPRequest` show `` for + secrets, tokens, codes, state, nonces and verifiers. (`Codable` encoding of `PendingAuthorization` + is for storage and isn't redacted.) + +## Transports + +- **`NIOTransport` (default).** A small HTTP/1.1 client on SwiftNIO, one connection per request, + on `MultiThreadedEventLoopGroup.singleton` (nothing to shut down; pass `group:` to use your own). + Hard size, time and redirect bounds as above, on every platform. Use + `NIOTransport(tlsConfiguration:)` to trust a private CA. **It ignores proxy settings**: URLSession + honours the system and environment proxies, this client doesn't. +- **`URLSessionTransport` (opt-in).** Foundation's URLSession, with the same deadline, cap, identity + encoding and no-redirect rules. Use it if you need a proxy. It is bounded on macOS, but **not on + Linux**: corelibs Foundation keeps reading and discarding a cancelled response in the background, + and on Swift 6.2 that is severe. +- **Your own `OAuth2Transport`**, set with `OAuth2HTTPOptions(transport:)`: for example one on + AsyncHTTPClient (below), or one that routes through your proxy. -### Response limits on Linux +```swift +let google = Google(configuration: OAuth2ClientConfiguration( + clientID: "your-client-id", clientSecret: "your-client-secret", + redirectURL: URL(string: "https://example.com/auth/google/callback")!, + http: OAuth2HTTPOptions(transport: URLSessionTransport(timeout: 30)) // opt in to URLSession +)) +``` + +50 back-to-back calls against a local server that sends an endless body, each capped at 16 KiB +(5 s deadline): + +| Transport | Platform | Wall time | Read over loopback | Peak RSS | Notes | +|-----------|----------|-----------|--------------------|----------|-------| +| `NIOTransport` | macOS 27 | 0.24 s | 45 MB | 59 MB | descriptors unchanged | +| `NIOTransport` | Swift 6.2, linux/arm64 | 0.44 s | 310 MB | 140 MB | descriptors unchanged | +| `NIOTransport` | Swift 6.2, linux/amd64 (emulated) | NIO_62_AMD64 | | | | +| `NIOTransport` | Swift 6.4, linux/arm64 | NIO_64_ARM64 | | | | +| `NIOTransport` | Swift 6.4, linux/amd64 (emulated) | NIO_64_AMD64 | | | | +| `URLSessionTransport` | Swift 6.2, linux/arm64 | 44.9 s | 37.9 GB | 487 MB | 6 calls ended only at the 5 s deadline; 35 more descriptors open afterwards | +| `URLSessionTransport` | Swift 6.2, linux/amd64 (emulated) | 127 s | 102 GB | 920 MB | 16 calls ended only at the deadline; 43 more descriptors open afterwards | +| `URLSessionTransport` | Swift 6.4, linux/arm64 | 0.19 s | 365 MB | 123 MB | | + +("Read over loopback" is what the server managed to send; on the NIO rows most of it is kernel +socket buffers. The NIO rows used TLS from a local test CA, the URLSession rows plain HTTP to a +socket server.) The endpoints are the providers' own fixed TLS hosts, so this needs a compromised or +impersonated provider to trigger, but the default no longer depends on that. Corelibs +FoundationNetworking also leaks about 76 bytes per URLSession request (seen with LeakSanitizer). + +A transport on [AsyncHTTPClient](https://github.com/swift-server/async-http-client), checked by +building and running it against local endless, slow, gzip and redirecting servers: + +```swift +import AsyncHTTPClient +import Foundation +import NIOCore +import PerfectOAuth2 -On Darwin, cancelling at the size limit stops the download. On Linux, the call also returns as soon -as the limit is passed, but corelibs Foundation can go on reading and discarding the rest of the body -in the background for a while. In testing against an endless local body, each call returned within -0.03 s of reaching the cap and peak memory stayed under 180 MB, though the background read consumed -up to 2 GB of loopback traffic. Corelibs FoundationNetworking also leaks about 76 bytes per request -(seen with LeakSanitizer), which is negligible at sign-in volumes. Provider responses are small and come from the providers' own HTTPS endpoints, so -this only matters if one of them is compromised. If you need a hard bound, plug in your own -transport (for example on AsyncHTTPClient) through `OAuth2HTTPOptions(transport:)`. +/// Whole-request deadline, streamed size cap, no redirects, no compression. +struct AsyncHTTPClientTransport: OAuth2Transport { + let client: HTTPClient // configured with redirectConfiguration: .disallow + var timeout: TimeInterval = 30 + var maximumResponseBytes = 1 << 20 + + func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse { + // execute(timeout:) only bounds the response head, so race the whole exchange, body + // included, against the deadline. + try await withThrowingTaskGroup(of: OAuth2HTTPResponse.self) { group in + group.addTask { try await perform(request) } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw OAuth2Error.timedOut + } + defer { group.cancelAll() } + guard let first = try await group.next() else { throw OAuth2Error.timedOut } + return first + } + } + + private func perform(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse { + var outgoing = HTTPClientRequest(url: request.url.absoluteString) + outgoing.method = request.method == .post ? .POST : .GET + for (name, value) in request.headers { outgoing.headers.replaceOrAdd(name: name, value: value) } + outgoing.headers.replaceOrAdd(name: "Accept-Encoding", value: "identity") + if let body = request.body { outgoing.body = .bytes(ByteBuffer(bytes: body)) } + do { + let response = try await client.execute(outgoing, timeout: .seconds(Int64(timeout.rounded(.up)))) + if let encoding = response.headers.first(name: "Content-Encoding"), encoding.lowercased() != "identity" { + throw OAuth2Error.invalidResponse("compressed response refused") + } + let body = try await response.body.collect(upTo: maximumResponseBytes) + return OAuth2HTTPResponse(status: Int(response.status.code), body: Data(buffer: body)) + } catch is NIOTooManyBytesError { + throw OAuth2Error.responseTooLarge(limit: maximumResponseBytes) + } catch let error as HTTPClientError where error == .deadlineExceeded { + throw OAuth2Error.timedOut + } + } +} + +// One client for the process, with redirects off (decompression is off by default). +let httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: HTTPClient.Configuration(redirectConfiguration: .disallow) +) +let google = Google(configuration: OAuth2ClientConfiguration( + clientID: "your-client-id", clientSecret: "your-client-secret", + redirectURL: URL(string: "https://example.com/auth/google/callback")!, + http: OAuth2HTTPOptions(transport: AsyncHTTPClientTransport(client: httpClient)) +)) +``` ## Providers | Provider | Type | Default scopes | PKCE default | Profile endpoint | Notes | |----------|------|----------------|--------------|------------------|-------| -| Google | `Google(configuration:restrictedDomain:)` | `openid profile` (`openid` always added) | on | OIDC `openidconnect.googleapis.com/v1/userinfo` | id_token validated on every exchange; `restrictedDomain` sends `hd` and enforces the token's `hd` claim; `authorizationRequest(offlineAccess:)` | +| Google | `Google(configuration:restrictedDomain:)` | `openid profile` (`openid` always added) | on (nonce when off) | OIDC `openidconnect.googleapis.com/v1/userinfo` | id_token validated on every exchange; `restrictedDomain` sends `hd` and enforces both the token's `hd` claim and userinfo's `hd`; `authorizationRequest(offlineAccess:)` | | GitHub | `GitHub(configuration:)` | `read:user` | on | `api.github.com/user` | GitHub added PKCE in 2025. Errors arrive as HTTP 200 with `error`. OAuth apps don't issue refresh tokens unless token expiration is enabled | | Facebook | `Facebook(configuration:graphAPIVersion:)` | none | off | Graph `/me` | Graph `v23.0` by default; sends `appsecret_proof`. No refresh tokens | | LinkedIn | `LinkedIn(configuration:)` | `openid profile` | off | OIDC `api.linkedin.com/v2/userinfo` | LinkedIn documents PKCE only for approved native clients. Add `email` for the address | -| Salesforce | `try Salesforce(configuration:loginHost:)` | `id` | on | identity URL from the token response | `loginHost` for sandboxes (`test.salesforce.com`) and My Domain. Add `refresh_token` for refresh tokens | +| Salesforce | `try Salesforce(configuration:loginHost:identityHostSuffixes:)` | `id` | on | identity URL from the token response | `loginHost` for sandboxes (`test.salesforce.com`) and My Domain. Identity hosts default to `.salesforce.com`/`.salesforce.mil`; other clouds (Government Cloud Plus, China) aren't verified, add their suffixes. Add `refresh_token` for refresh tokens | | Slack | `Slack(configuration:)` | `openid profile` | off | OIDC `slack.com/api/openid.connect.userInfo` | Sign in with Slack (OIDC). User ids are per workspace: combine with `raw["https://slack.com/team_id"]` | PKCE defaults are conservative: on where the provider documents S256 support for web apps with a @@ -202,8 +326,10 @@ struct Gitea: OAuth2Provider { } ``` -Override `authorizationParameters` for extra login-link parameters, and `validate(_:)` for token -checks that must pass before an exchange returns. +Override `authorizationParameters` for extra login-link parameters (their names are then reserved), +`makeAuthorizationRequest(scopes:state:additionalParameters:)` to change how every login link is +built (it's dispatched dynamically, also through `any OAuth2Provider`), and +`validate(_:pending:)` for token checks that must pass before an exchange returns. ## Migrating to 2.0 @@ -220,7 +346,7 @@ checks that must pass before an exchange returns. | `Google.loginURL(state: csrf, sessionToken:)` (static or instance) | `let request = try google.authorizationRequest()`; redirect to `request.url` and store `request.pending` | | `Google.loginURL(..., offlineAccess: true)` | `google.authorizationRequest(offlineAccess: true)` | | `Google.processAuthResponse(code:state:sessionCSRF:sessionToken:)` | `try await google.completeAuthorization(callbackURL:pending:)` or `completeAuthorization(code:state:pending:)` | -| `provider.exchange(code:state:sessionToken:)` / `exchange(code:state:redirectURL:)` | `try await provider.exchange(callback: AuthorizationCallback(url:), pending:)` | +| `provider.exchange(code:state:sessionToken:)` / `exchange(code:state:redirectURL:)` / `exchange(authorizationCode: AuthorizationCode(code:redirectURL:))` | `try await provider.exchange(callback: AuthorizationCallback(url:), pending:)` | | `provider.getUserData(accessToken) -> [String: Any]` | `try await provider.fetchProfile(token:) -> OAuthUserProfile` (fields typed, the rest in `raw: JSONValue`) | | `OAuth2(clientID:clientSecret:authorizationURL:tokenURL:)` | `OAuth2(configuration:authorizationURL:tokenURL:defaultScopes:usesPKCEByDefault:)` with `URL`s | | `getLoginLink(redirectURL:state:scopes:) -> String` | `authorizationRequest(scopes:state:additionalParameters:) -> AuthorizationRequest` | @@ -237,6 +363,9 @@ Behaviour changes to check: - **No more `?session=` on the redirect URI.** Register the bare `redirectURL` with the provider, and keep `PendingAuthorization` in a cookie-backed server-side session. - **State must be 32+ characters.** Let `authorizationRequest()` generate it. +- **Pending authorizations expire** after 10 minutes (`pendingAuthorizationMaxAge`) and only work + with the provider, client ID and redirect URL that created them. +- **Only Bearer tokens** are accepted, and compressed responses are refused. - **Empty or missing user ids throw** `OAuth2Error.missingUserID` instead of signing the user in as `""`. - **HTTP failures throw.** 1.x turned network errors, HTTP errors and non-JSON into an empty dictionary, so they surfaced as missing fields (or a profile with empty fields). @@ -254,8 +383,9 @@ swift build --build-tests -Xswiftc -warnings-as-errors swift test ``` -The tests are hermetic: provider flows run against an in-memory transport, and `URLSessionTransport` -against a local socket server on 127.0.0.1. Nothing contacts a real provider. +The tests are hermetic: provider flows run against an in-memory transport, both built-in transports +against local servers on 127.0.0.1 (a socket server over HTTP, and a SwiftNIO server with TLS from a +throwaway test CA for the certificate checks). Nothing contacts a real provider. The pre-Swift-6 code (the Swift 3 `OAuth2` target and the `LocalAuthentication` username/password system) is on the [`legacy`](https://github.com/PerfectlySoft/Perfect-Authentication/tree/legacy) diff --git a/Sources/PerfectOAuth2/CertificateDNSNames.swift b/Sources/PerfectOAuth2/CertificateDNSNames.swift new file mode 100644 index 0000000..3cc3c1c --- /dev/null +++ b/Sources/PerfectOAuth2/CertificateDNSNames.swift @@ -0,0 +1,124 @@ +// +// CertificateDNSNames.swift +// PerfectOAuth2 +// +// Adapted from Perfect-SMTP 6.0.0. NIOSSL's own hostname check also accepts +// an iPAddress subjectAltName equal to the dialed address, so a certificate +// issued for an IP can stand in for a host name. NIOTransport therefore +// requires a dNSName that matches the host. This runs after the handshake, +// on the certificate NIOSSL has already verified, through public API only: +// NIOSSL's `peerCertificate` and `toDERBytes()`, then a small +// subjectAltName parser on swift-asn1. +// + +import NIOCore +import NIOSSL +import SwiftASN1 + +/// The certificate verified as a chain but didn't name the host as +/// required. +enum CertificateNameError: Error, Sendable, Equatable { + /// No DNS subjectAltName matches `host`. + case noDNSName(host: String) + /// The handshake completed without a peer certificate to check. + case noPeerCertificate + /// The certificate's DER couldn't be parsed far enough to read its + /// subjectAltName extension. + case malformedCertificate +} + +enum CertificateDNSNames { + /// The subjectAltName extension, RFC 5280 §4.2.1.6. + private static let subjectAltNameOID: [UInt] = [2, 5, 29, 17] + + /// Every dNSName in the certificate's subjectAltName extension(s), as + /// written. Throws `CertificateNameError.malformedCertificate` if the + /// DER doesn't have the X.509 shape up to and including the + /// extensions. A certificate without a subjectAltName returns `[]`. + static func dnsNames(fromDER der: [UInt8]) throws -> [String] { + do { + let certificate = try children(of: DER.parse(der), universal: 0x10) + guard let tbsNode = certificate.first else { throw CertificateNameError.malformedCertificate } + let tbs = try children(of: tbsNode, universal: 0x10) + // extensions [3] EXPLICIT Extensions (RFC 5280 §4.1). + guard let wrapper = tbs.first(where: { $0.identifier.tagClass == .contextSpecific && $0.identifier.tagNumber == 3 }) else { + return [] + } + let wrapped = try children(of: wrapper) + guard wrapped.count == 1 else { throw CertificateNameError.malformedCertificate } + var names: [String] = [] + for extensionNode in try children(of: wrapped[0], universal: 0x10) { + // Extension ::= SEQUENCE { extnID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING } + let parts = try children(of: extensionNode, universal: 0x10) + guard (2...3).contains(parts.count) else { throw CertificateNameError.malformedCertificate } + let oid = try ASN1ObjectIdentifier(derEncoded: parts[0]) + guard oid.oidComponents == subjectAltNameOID else { continue } + let value = try primitive(parts[parts.count - 1], universal: 0x04) + // GeneralNames ::= SEQUENCE OF GeneralName; dNSName [2] IMPLICIT IA5String. + for general in try children(of: DER.parse(value), universal: 0x10) + where general.identifier.tagClass == .contextSpecific && general.identifier.tagNumber == 2 { + guard case .primitive(let bytes) = general.content else { throw CertificateNameError.malformedCertificate } + // IA5String: ASCII only. Anything else can't name a host. + guard !bytes.isEmpty, bytes.allSatisfy({ (0x21...0x7E).contains($0) }) else { continue } + names.append(String(decoding: bytes, as: UTF8.self)) + } + } + return names + } catch let error as CertificateNameError { + throw error + } catch { + throw CertificateNameError.malformedCertificate + } + } + + /// Throws `CertificateNameError.noDNSName` unless one of the + /// certificate's dNSNames matches `host`: equal ignoring ASCII case, or + /// a `*.` wildcard covering exactly one leading label (RFC 6125 §6.4.3). + static func requireMatch(certificateDER der: [UInt8], host: String) throws { + let names = try dnsNames(fromDER: der) + guard names.contains(where: { matches(pattern: $0, host: host) }) else { + throw CertificateNameError.noDNSName(host: host) + } + } + + /// Runs `requireMatch` on the certificate the TLS handler in + /// `pipeline` received. Call once the handshake has completed. + static func requireMatch(in pipeline: ChannelPipeline.SynchronousOperations, host: String) throws { + let handler = try pipeline.handler(type: NIOSSLClientHandler.self) + guard let certificate = handler.peerCertificate else { throw CertificateNameError.noPeerCertificate } + try requireMatch(certificateDER: certificate.toDERBytes(), host: host) + } + + /// Exact match ignoring ASCII case, or `*.suffix` covering exactly one non-empty leading label. + static func matches(pattern: String, host: String) -> Bool { + let pattern = normalize(pattern), host = normalize(host) + guard pattern.hasPrefix("*.") else { return pattern == host } + let suffix = "." + pattern.dropFirst(2) + guard suffix.count > 1, host.count > suffix.count, host.hasSuffix(suffix) else { return false } + let label = host.dropLast(suffix.count) + return !label.isEmpty && !label.contains(".") + } + + private static func normalize(_ name: String) -> String { + var lower = name.lowercased() + if lower.hasSuffix(".") { lower.removeLast() } + return lower + } + + private static func children(of node: ASN1Node, universal tag: UInt? = nil) throws -> [ASN1Node] { + if let tag { + guard node.identifier.tagClass == .universal, node.identifier.tagNumber == tag else { + throw CertificateNameError.malformedCertificate + } + } + guard case .constructed(let collection) = node.content else { throw CertificateNameError.malformedCertificate } + return Array(collection) + } + + private static func primitive(_ node: ASN1Node, universal tag: UInt) throws -> [UInt8] { + guard node.identifier.tagClass == .universal, node.identifier.tagNumber == tag, + case .primitive(let bytes) = node.content + else { throw CertificateNameError.malformedCertificate } + return Array(bytes) + } +} diff --git a/Sources/PerfectOAuth2/NIOTransport.swift b/Sources/PerfectOAuth2/NIOTransport.swift new file mode 100644 index 0000000..3bacb38 --- /dev/null +++ b/Sources/PerfectOAuth2/NIOTransport.swift @@ -0,0 +1,325 @@ +import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix +import NIOSSL +import NIOTLS + +/// The default transport: a small HTTP/1.1 client on SwiftNIO, one connection per request. +/// +/// It exists because Foundation's URLSession can't be held to a size or time bound on Linux: +/// corelibs keeps reading a cancelled response in the background (see the README). This client stops +/// reading, and closes the connection, the moment a limit is hit, on every platform. +/// +/// - **HTTPS only** (plain HTTP only to `localhost`, `127.0.0.1` and `::1`). Full certificate +/// verification against the system trust store, SNI, and NIOSSL's hostname check. For a host name, +/// the certificate must also carry a matching DNS subjectAltName (exact or a one-label `*.` +/// wildcard): NIOSSL alone accepts an IP-address SAN equal to the dialed address, so an IP-only +/// certificate could stand in for a name. +/// - **Nothing is written** until the TLS handshake and that name check have passed, so a server +/// whose certificate is refused never receives the request (or the client secret in it). +/// - `timeout` bounds the whole request, connect to last byte (`OAuth2Error.timedOut`). +/// - At most `maximumResponseBytes` of body are read, chunked or not; a larger `Content-Length` is +/// refused before the body (`OAuth2Error.responseTooLarge`). +/// - `Accept-Encoding: identity` is sent and any other `Content-Encoding` is refused. +/// - Redirects are never followed; a 3xx comes back as is. +/// - **No proxy support:** unlike URLSession it ignores system and environment proxy settings. If +/// you need a proxy, use `URLSessionTransport` or your own `OAuth2Transport`. +public struct NIOTransport: OAuth2Transport { + public let timeout: TimeInterval + public let maximumResponseBytes: Int + private let group: any EventLoopGroup + private let tlsConfiguration: TLSConfiguration + /// Test hook: where to connect for a host and port instead of resolving the name. + private let addressOverride: (@Sendable (String, Int) -> SocketAddress?)? + + /// - Parameters: + /// - group: the event loops to use. The default, `MultiThreadedEventLoopGroup.singleton`, needs + /// no shutdown. + /// - tlsConfiguration: replaces the default `.makeClientConfiguration()` (full verification, + /// system trust store), e.g. to trust a private CA. + public init( + group: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton, + timeout: TimeInterval = 30, + maximumResponseBytes: Int = 1 << 20, + tlsConfiguration: TLSConfiguration = .makeClientConfiguration() + ) { + self.init(group: group, timeout: timeout, maximumResponseBytes: maximumResponseBytes, + tlsConfiguration: tlsConfiguration, addressOverride: nil) + } + + init( + group: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton, + timeout: TimeInterval = 30, + maximumResponseBytes: Int = 1 << 20, + tlsConfiguration: TLSConfiguration = .makeClientConfiguration(), + addressOverride: (@Sendable (String, Int) -> SocketAddress?)? + ) { + self.group = group + self.timeout = timeout.isNaN ? 0 : min(max(timeout, 0), 86_400) + self.maximumResponseBytes = max(0, maximumResponseBytes) + self.tlsConfiguration = tlsConfiguration + self.addressOverride = addressOverride + } + + public func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse { + let url = request.url + try requireSecure(url, "request URL") + guard let rawHost = url.host else { throw OAuth2Error.insecureURL("request URL has no host") } + let host = rawHost.hasPrefix("[") && rawHost.hasSuffix("]") ? String(rawHost.dropFirst().dropLast()) : rawHost + let useTLS = url.scheme?.lowercased() == "https" + let port = url.port ?? (useTLS ? 443 : 80) + let isLiteral = (try? SocketAddress(ipAddress: host, port: 0)) != nil + let basePath = url.path.isEmpty ? "/" : url.path + let target = url.query.map { basePath + "?" + $0 } ?? basePath + let hostField = (host.contains(":") ? "[\(host)]" : host) + (url.port.map { ":\($0)" } ?? "") + + var headers = HTTPHeaders() + headers.add(name: "Host", value: hostField) + for (name, value) in request.headers.sorted(by: { $0.key < $1.key }) + where !["host", "accept-encoding", "connection", "content-length", "transfer-encoding"].contains(name.lowercased()) { + headers.add(name: name, value: value) + } + headers.add(name: "Accept-Encoding", value: "identity") + headers.add(name: "Connection", value: "close") + if let body = request.body { headers.add(name: "Content-Length", value: String(body.count)) } + let head = HTTPRequestHead( + version: .http1_1, method: request.method == .post ? .POST : .GET, uri: target, headers: headers + ) + let body = request.body + + let sslContext: NIOSSLContext? + do { + sslContext = useTLS ? try NIOSSLContext(configuration: tlsConfiguration) : nil + } catch { + throw OAuth2Error.invalidConfiguration("TLS configuration: \(type(of: error))") + } + let limit = maximumResponseBytes + let loop = group.next() + let response = loop.makePromise(of: OAuth2HTTPResponse.self) + let ready = loop.makePromise(of: Void.self) + let connected = loop.makePromise(of: (any Channel).self) + let serverName = isLiteral ? nil : host + let override = addressOverride?(host, port) + let seconds = timeout + + // Everything that can end the request early (deadline, caller cancellation) and every stage + // that could still send something consults this state on the event loop, so once the request + // is aborted nothing more is written and the channel is closed. + let state = NIOLoopBoundBox.makeBoxSendingValue(RequestState(), as: RequestState.self, eventLoop: loop) + let abort: @Sendable (OAuth2Error) -> Void = { error in + loop.execute { + if state.value.aborted == nil { state.value.aborted = error } + connected.fail(error) + ready.fail(error) + response.fail(error) + state.value.channel?.close(promise: nil) + } + } + let deadline = loop.scheduleTask(in: .nanoseconds(Int64(seconds * 1_000_000_000))) { abort(.timedOut) } + defer { + deadline.cancel() + // Every promise is completed on every path (a no-op if it already was). + connected.fail(OAuth2Error.network("request finished")) + ready.fail(OAuth2Error.network("request finished")) + response.fail(OAuth2Error.network("request finished")) + } + + let bootstrap = ClientBootstrap(group: loop) + .connectTimeout(.nanoseconds(Int64(seconds * 1_000_000_000))) + .channelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + if let sslContext { + let tls = try NIOSSLClientHandler(context: sslContext, serverHostname: serverName) + try channel.pipeline.syncOperations.addHandler(tls) + try channel.pipeline.syncOperations.addHandler(TLSReadyGate(host: serverName, ready: ready)) + } + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandler(CappedResponseCollector(limit: limit, promise: response)) + } + } + + return try await withTaskCancellationHandler { + // Connect through a promise that an abort can fail, so a deadline or cancellation during + // a slow connect returns at once. A channel that connects after an abort is closed. + let connecting = override.map { bootstrap.connect(to: $0) } ?? bootstrap.connect(host: host, port: port) + connecting.whenComplete { result in + switch result { + case .success(let channel): + loop.execute { + if state.value.aborted != nil { + channel.close(promise: nil) + } else { + state.value.channel = channel + } + connected.succeed(channel) + } + case .failure(let error): + connected.fail(error) + } + } + let channel: any Channel + do { + channel = try await connected.futureResult.get() + } catch { + throw Self.map(await Self.aborted(state, loop) ?? error) + } + do { + try await loop.submit { + if let aborted = state.value.aborted { throw aborted } + }.get() + if useTLS { + try await ready.futureResult.get() + } + // Write only if nothing aborted the request after the handshake. + try await loop.submit { + if let aborted = state.value.aborted { throw aborted } + channel.write(HTTPClientRequestPart.head(head), promise: nil) + if let body { channel.write(HTTPClientRequestPart.body(.byteBuffer(ByteBuffer(bytes: body))), promise: nil) } + channel.writeAndFlush(HTTPClientRequestPart.end(nil), promise: nil) + }.get() + let result = try await response.futureResult.get() + channel.close(promise: nil) + return result + } catch { + response.fail(error) + channel.close(promise: nil) + throw Self.map(await Self.aborted(state, loop) ?? error) + } + } onCancel: { + abort(.network("cancelled")) + } + } + + /// The reason the request was aborted (deadline or cancellation), if it was. + private static func aborted(_ state: NIOLoopBoundBox, _ loop: any EventLoop) async -> OAuth2Error? { + try? await loop.submit { state.value.aborted }.get() + } + + static func map(_ error: any Error) -> OAuth2Error { + switch error { + case let error as OAuth2Error: return error + case let error as CertificateNameError: + switch error { + case .noDNSName: return .network("TLS: certificate does not name the host") + case .noPeerCertificate: return .network("TLS: no peer certificate") + case .malformedCertificate: return .network("TLS: malformed certificate") + } + case is NIOSSLError, is BoringSSLError, is NIOSSLExtraError: return .network("TLS: \(type(of: error))") + case is ChannelError, is NIOConnectionError, is IOError: return .network("connection failed") + case is CancellationError: return .timedOut + default: return .network(String(describing: type(of: error))) + } + } +} + +/// Per-request state, confined to the request's event loop. +private struct RequestState: Sendable { + var channel: (any Channel)? + var aborted: OAuth2Error? +} + +/// Completes `ready` once the TLS handshake is done and, for a host name, the verified certificate +/// carries a matching DNS subjectAltName; fails it (and closes) otherwise. The request is written +/// only after `ready` succeeds. Event-loop confined. +private final class TLSReadyGate: ChannelInboundHandler { + typealias InboundIn = NIOAny + private let host: String? + private let ready: EventLoopPromise + + init(host: String?, ready: EventLoopPromise) { + self.host = host + self.ready = ready + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if let tlsEvent = event as? TLSUserEvent, case .handshakeCompleted = tlsEvent { + do { + if let host { try CertificateDNSNames.requireMatch(in: context.pipeline.syncOperations, host: host) } + ready.succeed(()) + } catch { + ready.fail(error) + context.fireErrorCaught(error) + context.close(promise: nil) + return + } + } + context.fireUserInboundEventTriggered(event) + } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + ready.fail(error) + context.fireErrorCaught(error) + } + + func channelInactive(context: ChannelHandlerContext) { + ready.fail(OAuth2Error.network("connection closed during the TLS handshake")) + context.fireChannelInactive() + } + + func handlerRemoved(context: ChannelHandlerContext) { + ready.fail(OAuth2Error.network("connection closed during the TLS handshake")) + } +} + +/// Collects one HTTP response, failing (and closing) as soon as the body would pass `limit`, or on a +/// compressed body. Event-loop confined. +private final class CappedResponseCollector: ChannelInboundHandler { + typealias InboundIn = HTTPClientResponsePart + + private let limit: Int + private let promise: EventLoopPromise + private var status: Int? + private var body: [UInt8] = [] + private var done = false + + init(limit: Int, promise: EventLoopPromise) { + self.limit = limit + self.promise = promise + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + guard !done else { return } + switch Self.unwrapInboundIn(data) { + case .head(let head): + guard head.status.code >= 200 else { return } // 1xx interim responses + if let length = head.headers.first(name: "Content-Length").flatMap(Int.init), length > limit { + finish(context, .failure(OAuth2Error.responseTooLarge(limit: limit))) + return + } + if let encoding = head.headers.first(name: "Content-Encoding")?.trimmingCharacters(in: .whitespaces).lowercased(), + !encoding.isEmpty, encoding != "identity" { + finish(context, .failure(OAuth2Error.invalidResponse("compressed response refused (Content-Encoding: \(encoding.prefix(32)))"))) + return + } + status = Int(head.status.code) + case .body(var buffer): + guard status != nil else { return } + if body.count + buffer.readableBytes > limit { + finish(context, .failure(OAuth2Error.responseTooLarge(limit: limit))) + return + } + body.append(contentsOf: buffer.readBytes(length: buffer.readableBytes) ?? []) + case .end: + guard let status else { return } + finish(context, .success(OAuth2HTTPResponse(status: status, body: Data(body)))) + } + } + + func channelInactive(context: ChannelHandlerContext) { + finish(context, .failure(OAuth2Error.network("connection closed before the response was complete"))) + context.fireChannelInactive() + } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + finish(context, .failure(error)) + } + + private func finish(_ context: ChannelHandlerContext, _ result: Result) { + guard !done else { return } + done = true + body = [] + promise.completeWith(result) + context.close(promise: nil) + } +} diff --git a/Sources/PerfectOAuth2/OAuth2.swift b/Sources/PerfectOAuth2/OAuth2.swift index 6ec875b..a8272bb 100644 --- a/Sources/PerfectOAuth2/OAuth2.swift +++ b/Sources/PerfectOAuth2/OAuth2.swift @@ -60,7 +60,8 @@ public struct OAuth2HTTPOptions: Sendable { public var timeout: TimeInterval /// Largest response body accepted. Token and profile responses are a few KB. public var maximumResponseBytes: Int - /// Replaces the default `URLSessionTransport(timeout:maximumResponseBytes:)`. + /// Replaces the default `NIOTransport(timeout:maximumResponseBytes:)`. `URLSessionTransport` is + /// the opt-in alternative (it honours system proxies but isn't memory-bounded on Linux). public var transport: (any OAuth2Transport)? public var userAgent: String @@ -109,7 +110,7 @@ public final class OAuth2: Sendable { self.tokenURL = tokenURL self.defaultScopes = defaultScopes self.usesPKCE = configuration.usePKCE ?? usesPKCEByDefault - self.transport = configuration.http.transport ?? URLSessionTransport( + self.transport = configuration.http.transport ?? NIOTransport( timeout: configuration.http.timeout, maximumResponseBytes: configuration.http.maximumResponseBytes ) diff --git a/Sources/PerfectOAuth2/Transport.swift b/Sources/PerfectOAuth2/Transport.swift index bb4f398..bff8b17 100644 --- a/Sources/PerfectOAuth2/Transport.swift +++ b/Sources/PerfectOAuth2/Transport.swift @@ -44,8 +44,8 @@ public struct OAuth2HTTPResponse: Sendable { } } -/// Sends PerfectOAuth2's HTTP requests. The default is `URLSessionTransport`; supply your own (for -/// example one on AsyncHTTPClient) through `OAuth2HTTPOptions.transport`. +/// Sends PerfectOAuth2's HTTP requests. The default is `NIOTransport`; `URLSessionTransport` is an +/// opt-in, and you can supply your own through `OAuth2HTTPOptions.transport`. /// /// Implementations should not follow redirects, should enforce a deadline, and should stop reading /// a body past a size limit, throwing the matching `OAuth2Error`s. @@ -53,7 +53,12 @@ public protocol OAuth2Transport: Sendable { func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse } -/// The default transport: Foundation's URLSession, one ephemeral session per request. +/// An opt-in transport on Foundation's URLSession, one ephemeral session per request. Use it when you +/// need system proxy settings, which `NIOTransport` (the default) ignores. +/// +/// **Not memory- or traffic-bounded on Linux**, especially with Swift 6.2: after a capped or timed-out +/// request is cancelled, corelibs Foundation keeps reading and discarding the rest of the body in the +/// background (see the README for measurements). On macOS it is bounded, given the gzip refusal below. /// /// - No cookies, cache or credential storage. /// - Redirects are not followed: the 3xx comes back as `OAuth2Error.httpStatus`. @@ -69,9 +74,10 @@ public struct URLSessionTransport: OAuth2Transport { public let timeout: TimeInterval public let maximumResponseBytes: Int + /// `timeout` is clamped to 1 ms ... 1 day: corelibs URLSession traps on an infinite timeout. public init(timeout: TimeInterval = 30, maximumResponseBytes: Int = 1 << 20) { - self.timeout = timeout - self.maximumResponseBytes = maximumResponseBytes + self.timeout = timeout.isNaN ? 0.001 : min(max(timeout, 0.001), 86_400) + self.maximumResponseBytes = max(0, maximumResponseBytes) } public func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse { @@ -92,7 +98,7 @@ public struct URLSessionTransport: OAuth2Transport { // drains earlier cancelled transfers; on the cooperative pool that starved the deadline // itself (a 5 s timeout fired after 75 s on Swift 6.2). A Dispatch timer and a Dispatch // thread for setup keep the caller's deadline honest. - let seconds = timeout.isNaN ? 0 : min(max(timeout, 0), 31_536_000) // clamped: no trap on .infinity + let seconds = timeout // clamped in init let deadline = DispatchWorkItem { delegate.fail(.timedOut) } DispatchQueue.global().asyncAfter(deadline: .now() + seconds, execute: deadline) defer { diff --git a/Tests/PerfectOAuth2Tests/HardeningTests.swift b/Tests/PerfectOAuth2Tests/HardeningTests.swift index d4a2553..92c1b7d 100644 --- a/Tests/PerfectOAuth2Tests/HardeningTests.swift +++ b/Tests/PerfectOAuth2Tests/HardeningTests.swift @@ -223,17 +223,17 @@ struct NitTests { } } -@Suite("URLSessionTransport hardening", .serialized) +@Suite("Transport hardening", .serialized) struct TransportHardeningTests { - private func oauth(_ server: LocalHTTPServer, timeout: TimeInterval = 10) -> OAuth2 { + private func oauth(_ kind: TransportKind, _ server: LocalHTTPServer, timeout: TimeInterval = 10) -> OAuth2 { OAuth2( configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: "s", redirectURL: URL(string: "https://app.test/cb")!, usePKCE: false, - http: OAuth2HTTPOptions(timeout: timeout)), + http: OAuth2HTTPOptions(timeout: timeout, transport: kind.make(timeout: timeout, limit: 1 << 20))), authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(server.baseURL)/token")! ) } - @Test func compressedResponsesAreRefusedAndIdentityRequested() async throws { + @Test(arguments: TransportKind.allCases) func compressedResponsesAreRefusedAndIdentityRequested(kind: TransportKind) async throws { // A real gzip stream of 1 MiB of spaces (about 1 KB compressed); never decompressed. let gzip = try #require(Data(base64Encoded: gzippedSpacesBase64)) for encoding in ["gzip", "br", "deflate", " GZIP "] { @@ -241,7 +241,7 @@ struct TransportHardeningTests { .raw(head: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: \(encoding)\r\nContent-Length: \(gzip.count)\r\nConnection: close\r\n\r\n", chunk: gzip, count: 1) } defer { server.stop() } - let error = await #expect(throws: OAuth2Error.self) { try await oauth(server).refresh(refreshToken: "r") } + let error = await #expect(throws: OAuth2Error.self) { try await oauth(kind, server).refresh(refreshToken: "r") } guard case .invalidResponse(let why)? = error else { Issue.record("\(String(describing: error))"); continue } #expect(why.hasPrefix("compressed response refused")) let head = try #require(server.requests.first?.head.lowercased()) @@ -251,21 +251,21 @@ struct TransportHardeningTests { .respond(status: 200, headers: ["Content-Encoding": "identity"], body: Data(#"{"access_token":"AT"}"#.utf8)) } defer { identity.stop() } - #expect(try await oauth(identity).refresh(refreshToken: "r").accessToken == "AT") + #expect(try await oauth(kind, identity).refresh(refreshToken: "r").accessToken == "AT") } - @Test func infiniteTimeoutDoesNotTrap() async throws { + @Test(arguments: TransportKind.allCases) func infiniteTimeoutDoesNotTrap(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in .respond(status: 200, headers: [:], body: Data(#"{"access_token":"AT"}"#.utf8)) } defer { server.stop() } for timeout in [TimeInterval.infinity, .greatestFiniteMagnitude, .nan] { - _ = await Result(catching: { try await oauth(server, timeout: timeout).refresh(refreshToken: "r") }) + _ = await Result(catching: { try await oauth(kind, server, timeout: timeout).refresh(refreshToken: "r") }) } } - @Test func alreadyCancelledTaskSendsNothing() async throws { + @Test(arguments: TransportKind.allCases) func alreadyCancelledTaskSendsNothing(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in .respond(status: 200, headers: [:], body: Data(#"{"access_token":"AT"}"#.utf8)) } defer { server.stop() } - let client = oauth(server) + let client = oauth(kind, server) let task = Task { withUnsafeCurrentTask { $0?.cancel() } return try await client.refresh(refreshToken: "r") diff --git a/Tests/PerfectOAuth2Tests/NIOTransportTests.swift b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift new file mode 100644 index 0000000..eeedeba --- /dev/null +++ b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift @@ -0,0 +1,280 @@ +import Foundation +import NIOCore +import NIOPosix +import NIOSSL +import Testing +@testable import PerfectOAuth2 +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +/// A transport that trusts the test CA and connects every name to the test server on 127.0.0.1. +func testTransport(_ server: NIOTestServer, timeout: TimeInterval = 10, limit: Int = 1 << 20, trust: String = TestCertificates.caPEM) throws -> NIOTransport { + var tls = TLSConfiguration.makeClientConfiguration() + tls.trustRoots = .certificates(try NIOSSLCertificate.fromPEMBytes(Array(trust.utf8))) + let port = server.port + return NIOTransport(timeout: timeout, maximumResponseBytes: limit, tlsConfiguration: tls, + addressOverride: { _, _ in try? SocketAddress(ipAddress: "127.0.0.1", port: port) }) +} + +func openFileDescriptors() -> Int { + #if os(Linux) + return (try? FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd").count) ?? -1 + #else + return (try? FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count) ?? -1 + #endif +} + +private func get(_ transport: NIOTransport, _ url: String) async throws -> OAuth2HTTPResponse { + try await transport.send(OAuth2HTTPRequest(method: .get, url: URL(string: url)!)) +} + +private func json(_ body: String, status: Int = 200, headers: [String: String] = [:]) -> NIOTestServer.Behavior { + .respond(status: status, headers: headers.merging(["Content-Type": "application/json"]) { a, _ in a }, body: Data(body.utf8)) +} + +/// Waits (up to 5 s) for the server to see every connection closed. +private func settle(_ server: NIOTestServer) async { + for _ in 0..<100 where server.openConnections > 0 { try? await Task.sleep(nanoseconds: 50_000_000) } +} + +@Suite("NIOTransport", .serialized) +struct NIOTransportTests { + @Test func postOverTLSWithSNIAndHostHeader() async throws { + let server = try NIOTestServer { _ in json(#"{"access_token":"AT"}"#) } + defer { server.stop() } + let oauth = OAuth2( + configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: "secret-value-123", redirectURL: URL(string: "https://app.test/cb")!, + usePKCE: false, http: OAuth2HTTPOptions(transport: try testTransport(server))), + authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(server.baseURL)/oauth/token?x=1")! + ) + let token = try await oauth.refresh(refreshToken: "refresh-value") + #expect(token.accessToken == "AT") + let request = try #require(server.requests.first) + #expect(request.head.hasPrefix("POST /oauth/token?x=1 HTTP/1.1")) + let head = request.head.lowercased() + #expect(head.contains("host: auth.test:\(server.port)")) + #expect(head.contains("accept-encoding: identity")) + #expect(head.contains("content-type: application/x-www-form-urlencoded")) + #expect(head.contains("connection: close")) + #expect(formFields(request.body)["refresh_token"] == "refresh-value") + await settle(server) + #expect(server.openConnections == 0) + } + + @Test func plainHTTPOnlyToLoopback() async throws { + let server = try NIOTestServer(certificate: nil) { _ in json("{}") } + defer { server.stop() } + let transport = NIOTransport(timeout: 5) + #expect(try await get(transport, "http://127.0.0.1:\(server.port)/x").status == 200) + await #expect(throws: OAuth2Error.insecureURL("request URL must use https")) { + try await get(transport, "http://auth.test:\(server.port)/x") + } + } + + @Test(arguments: [301, 302, 303, 307, 308]) + func redirectsAreReturnedNotFollowed(code: Int) async throws { + let target = try NIOTestServer { _ in json(#"{"access_token":"stolen"}"#) } + defer { target.stop() } + let server = try NIOTestServer { _ in .respond(status: code, headers: ["Location": "\(target.baseURL)/steal"], body: Data()) } + defer { server.stop() } + let response = try await get(try testTransport(server), "\(server.baseURL)/token") + #expect(response.status == code) + #expect(target.requests.isEmpty) + } + + @Test func endlessBodiesStopAtTheCap() async throws { + for behavior in [NIOTestServer.Behavior.endless, .endlessChunked] { + let server = try NIOTestServer { _ in behavior } + defer { server.stop() } + let start = Date() + await #expect(throws: OAuth2Error.responseTooLarge(limit: 64 * 1024)) { + try await get(try testTransport(server, limit: 64 * 1024), "\(server.baseURL)/t") + } + #expect(Date().timeIntervalSince(start) < 2) + await settle(server) + #expect(server.openConnections == 0) + // The connection was closed at the cap: the server stops being able to write. + let sent = server.totalBytesSent + try await Task.sleep(nanoseconds: 300_000_000) + #expect(server.totalBytesSent - sent < 1 << 20) + } + } + + @Test func hugeContentLengthIsRefusedUpFront() async throws { + let server = try NIOTestServer { _ in .rawHead("HTTP/1.1 200 OK\r\nContent-Length: 99999999999\r\n\r\n") } + defer { server.stop() } + await #expect(throws: OAuth2Error.responseTooLarge(limit: 1 << 20)) { + try await get(try testTransport(server), "\(server.baseURL)/t") + } + } + + @Test func compressedBodiesAreRefused() async throws { + for encoding in ["gzip", "br", "deflate", " GZIP "] { + let server = try NIOTestServer { _ in .respond(status: 200, headers: ["Content-Encoding": encoding], body: Data(repeating: 0x1f, count: 64)) } + defer { server.stop() } + let error = await #expect(throws: OAuth2Error.self) { try await get(try testTransport(server), "\(server.baseURL)/t") } + guard case .invalidResponse(let why)? = error else { Issue.record("\(String(describing: error))"); continue } + #expect(why.hasPrefix("compressed response refused")) + } + } + + @Test func slowLorisAndSilentServersHitTheDeadline() async throws { + for behavior in [NIOTestServer.Behavior.drip(declared: 1000, interval: 0.2), .silent] { + let server = try NIOTestServer { _ in behavior } + defer { server.stop() } + let start = Date() + await #expect(throws: OAuth2Error.timedOut) { try await get(try testTransport(server, timeout: 1.5), "\(server.baseURL)/t") } + let elapsed = Date().timeIntervalSince(start) + #expect(elapsed >= 1.4 && elapsed < 4) + await settle(server) + #expect(server.openConnections == 0) + } + } + + @Test func deadlineDuringTheHandshakeSendsNothing() async throws { + // A plain TCP server that never speaks TLS: the handshake stalls until the deadline. + let server = try NIOTestServer(certificate: nil) { _ in .silent } + defer { server.stop() } + await #expect(throws: OAuth2Error.timedOut) { try await get(try testTransport(server, timeout: 1), "\(server.baseURL)/t") } + #expect(server.requests.isEmpty) + } + + @Test func cancellationEndsTheRequest() async throws { + let server = try NIOTestServer { _ in .silent } + defer { server.stop() } + let transport = try testTransport(server, timeout: 30) + let task = Task { try await get(transport, "\(server.baseURL)/t") } + try await Task.sleep(nanoseconds: 300_000_000) + let start = Date() + task.cancel() + let result = await task.result + #expect(Date().timeIntervalSince(start) < 2) + guard case .failure(OAuth2Error.network("cancelled")) = result else { Issue.record("\(result)"); return } + } + + @Test func connectionRefused() async throws { + let server = try NIOTestServer { _ in .silent } + let port = server.port + server.stop() + let transport = NIOTransport(timeout: 5, addressOverride: { _, _ in try? SocketAddress(ipAddress: "127.0.0.1", port: port) }) + await #expect(throws: OAuth2Error.network("connection failed")) { try await get(transport, "https://auth.test:\(port)/t") } + } +} + +@Suite("NIOTransport certificates", .serialized) +struct NIOTransportCertificateTests { + /// Every refusal happens before the request is written: the server never sees it. + private func expectRefused(_ certificate: NIOTestServer.Certificate, host: String = "auth.test", trust: String = TestCertificates.caPEM, message: String) async throws { + let server = try NIOTestServer(certificate: certificate) { _ in json(#"{"access_token":"AT"}"#) } + defer { server.stop() } + let error = await #expect(throws: OAuth2Error.self) { + try await get(try testTransport(server, trust: trust), "https://\(host):\(server.port)/token") + } + guard case .network(let why)? = error else { Issue.record("\(String(describing: error))"); return } + #expect(why.hasPrefix(message), "\(why)") + #expect(server.requests.isEmpty) + await settle(server) + #expect(server.openConnections == 0) + } + + @Test func ipOnlyCertificateIsRefusedForAName() async throws { + // NIOSSL alone accepts an IP SAN equal to the dialed address (127.0.0.1); the DNS-SAN check doesn't. + try await expectRefused(.ipOnly, message: "TLS") + } + + @Test func wrongNameIsRefused() async throws { + try await expectRefused(.wrongName, message: "TLS") + } + + @Test func untrustedCAIsRefused() async throws { + try await expectRefused(.untrusted, message: "TLS") + } + + @Test func wildcardCoversExactlyOneLabel() async throws { + let server = try NIOTestServer(certificate: .wildcard) { _ in json("{}") } + defer { server.stop() } + #expect(try await get(try testTransport(server), "https://login.wild.test:\(server.port)/t").status == 200) + try await expectRefused(.wildcard, host: "a.b.wild.test", message: "TLS") + try await expectRefused(.wildcard, host: "wild.test", message: "TLS") + } + + @Test func ipLiteralUsesNIOSSLsIPCheck() async throws { + let server = try NIOTestServer(certificate: .ipOnly) { _ in json("{}") } + defer { server.stop() } + var tls = TLSConfiguration.makeClientConfiguration() + tls.trustRoots = .certificates(try NIOSSLCertificate.fromPEMBytes(Array(TestCertificates.caPEM.utf8))) + let transport = NIOTransport(timeout: 5, tlsConfiguration: tls) + #expect(try await get(transport, "https://127.0.0.1:\(server.port)/t").status == 200) + } + + @Test func certificateNameParsing() throws { + func der(_ pem: String) throws -> [UInt8] { try NIOSSLCertificate.fromPEMBytes(Array(pem.utf8))[0].toDERBytes() } + #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.namedCertificatePEM)) == ["auth.test"]) + #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.ipOnlyCertificatePEM)) == []) + #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.wildcardCertificatePEM)) == ["*.wild.test"]) + #expect(throws: CertificateNameError.self) { try CertificateDNSNames.dnsNames(fromDER: [0x30, 0x03, 0x02, 0x01]) } + for (pattern, host, ok) in [("auth.test", "AUTH.test", true), ("auth.test", "auth.test.", true), ("*.wild.test", "a.wild.test", true), + ("*.wild.test", "wild.test", false), ("*.wild.test", "a.b.wild.test", false), ("*.", "a", false), + ("a*.wild.test", "ab.wild.test", false)] { + #expect(CertificateDNSNames.matches(pattern: pattern, host: host) == ok, "\(pattern) \(host)") + } + } +} + +@Suite("NIOTransport resource use", .serialized) +struct NIOTransportResourceTests { + /// Back-to-back capped calls against an endless body: each returns at the cap, connections are + /// closed, and descriptors don't accumulate. With MEASURE_CALLS set, prints the numbers. + @Test func cappedCallsDoNotLeak() async throws { + let calls = Int(ProcessInfo.processInfo.environment["MEASURE_CALLS"] ?? "") ?? 20 + let server = try NIOTestServer { _ in .endless } + defer { server.stop() } + let transport = try testTransport(server, timeout: 5, limit: 16 * 1024) + _ = try? await get(transport, "\(server.baseURL)/warm") + await settle(server) + let fdBefore = openFileDescriptors() + let start = Date() + var slowest = 0.0 + for _ in 0.. %d, open server connections %d, max RSS %d MB", + calls, elapsed, slowest, sentAtReturn >> 20, (server.totalBytesSent - sentAtReturn) >> 20, fdBefore, fdAfter, server.openConnections, rssMB)) + } + #expect(server.openConnections == 0) + #expect(fdAfter <= fdBefore + 2) + #expect(slowest < 2) + } + + @Test func failingCallsDoNotLeak() async throws { + let server = try NIOTestServer(certificate: .wrongName) { _ in json("{}") } + defer { server.stop() } + let transport = try testTransport(server, timeout: 5) + _ = try? await get(transport, "\(server.baseURL)/warm") + let fdBefore = openFileDescriptors() + for _ in 0..<50 { _ = try? await get(transport, "\(server.baseURL)/t") } + await settle(server) + #expect(server.openConnections == 0) + #expect(server.requests.isEmpty) + #expect(openFileDescriptors() <= fdBefore + 2) + } +} diff --git a/Tests/PerfectOAuth2Tests/Support/NIOTestServer.swift b/Tests/PerfectOAuth2Tests/Support/NIOTestServer.swift new file mode 100644 index 0000000..5cf5724 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/Support/NIOTestServer.swift @@ -0,0 +1,199 @@ +import Foundation +import NIOCore +import NIOPosix +import NIOSSL + +/// A local HTTP/1.1 server on SwiftNIO, with TLS from the test CA, for exercising `NIOTransport`. +/// Same behaviours as `LocalHTTPServer`, plus an endless chunked stream. +final class NIOTestServer: @unchecked Sendable { + enum Behavior: Sendable { + case respond(status: Int, headers: [String: String], body: Data) + /// Headers without a length, then spaces until the client goes away. + case endless + /// `Transfer-Encoding: chunked`, then 64 KiB chunks until the client goes away. + case endlessChunked + /// `Content-Length: declared`, then one byte every `interval` seconds. + case drip(declared: Int, interval: TimeInterval) + /// Writes `head` verbatim (e.g. a huge Content-Length), then nothing. + case rawHead(String) + /// Accepts and never answers. + case silent + } + + struct Received: Sendable { + let head: String + let body: Data + } + + enum Certificate: Sendable { case named, ipOnly, wrongName, wildcard, untrusted } + + private let channel: any Channel + private let stats: Stats + + var port: Int { channel.localAddress?.port ?? 0 } + var baseURL: String { "https://auth.test:\(port)" } + var requests: [Received] { stats.lock.withLock { stats.received } } + var totalBytesSent: Int { stats.lock.withLock { stats.bytesSent } } + var connectionCount: Int { stats.lock.withLock { stats.connections } } + var openConnections: Int { stats.lock.withLock { stats.open } } + + /// - Parameter certificate: nil for plain HTTP. + init(certificate: Certificate? = .named, _ behavior: @escaping @Sendable (Received) -> Behavior) throws { + let context: NIOSSLContext? = try certificate.map { certificate in + let (cert, key) = Self.pem(for: certificate) + var configuration = TLSConfiguration.makeServerConfiguration( + certificateChain: try NIOSSLCertificate.fromPEMBytes(Array(cert.utf8)).map { .certificate($0) }, + privateKey: .privateKey(try NIOSSLPrivateKey(bytes: Array(key.utf8), format: .pem)) + ) + configuration.minimumTLSVersion = .tlsv12 + return try NIOSSLContext(configuration: configuration) + } + let stats = Stats() + channel = try ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { child in + child.eventLoop.makeCompletedFuture { + if let context { + try child.pipeline.syncOperations.addHandler(NIOSSLServerHandler(context: context)) + } + try child.pipeline.syncOperations.addHandler(RequestHandler(behavior: behavior, stats: stats)) + } + } + .bind(host: "127.0.0.1", port: 0).wait() + self.stats = stats + } + + func stop() { + try? channel.close().wait() + } + + static func pem(for certificate: Certificate) -> (String, String) { + switch certificate { + case .named: (TestCertificates.namedCertificatePEM, TestCertificates.namedKeyPEM) + case .ipOnly: (TestCertificates.ipOnlyCertificatePEM, TestCertificates.ipOnlyKeyPEM) + case .wrongName: (TestCertificates.wrongNameCertificatePEM, TestCertificates.wrongNameKeyPEM) + case .wildcard: (TestCertificates.wildcardCertificatePEM, TestCertificates.wildcardKeyPEM) + case .untrusted: (TestCertificates.untrustedCertificatePEM, TestCertificates.untrustedKeyPEM) + } + } + + final class Stats: @unchecked Sendable { + let lock = NSLock() + var received: [Received] = [] + var bytesSent = 0 + var connections = 0 + var open = 0 + } +} + +/// Reads one request, then answers per the behaviour. Event-loop confined. +private final class RequestHandler: ChannelInboundHandler { + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + private let behavior: @Sendable (NIOTestServer.Received) -> NIOTestServer.Behavior + private let stats: NIOTestServer.Stats + private var buffer = ByteBuffer() + private var answered = false + private var active = true + + init(behavior: @escaping @Sendable (NIOTestServer.Received) -> NIOTestServer.Behavior, stats: NIOTestServer.Stats) { + self.behavior = behavior + self.stats = stats + } + + func channelActive(context: ChannelHandlerContext) { + stats.lock.withLock { + stats.connections += 1 + stats.open += 1 + } + context.fireChannelActive() + } + + func channelInactive(context: ChannelHandlerContext) { + active = false + stats.lock.withLock { stats.open -= 1 } + context.fireChannelInactive() + } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + context.close(promise: nil) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + var incoming = Self.unwrapInboundIn(data) + buffer.writeBuffer(&incoming) + guard !answered else { return } + let bytes = Array(buffer.readableBytesView) + guard let split = Self.find(Array("\r\n\r\n".utf8), in: bytes) else { return } + let head = String(decoding: bytes[..= length else { return } + answered = true + let request = NIOTestServer.Received(head: head, body: Data(bytes[bodyStart..<(bodyStart + length)])) + stats.lock.withLock { stats.received.append(request) } + respond(context, behavior(request)) + } + + private func respond(_ context: ChannelHandlerContext, _ behavior: NIOTestServer.Behavior) { + switch behavior { + case .respond(let status, let headers, let body): + var head = "HTTP/1.1 \(status) Status\r\nContent-Length: \(body.count)\r\nConnection: close\r\n" + for (name, value) in headers { head += "\(name): \(value)\r\n" } + send(context, Array((head + "\r\n").utf8) + Array(body), then: { context.close(promise: nil) }) + case .endless: + send(context, Array("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n".utf8)) { + self.pump(context, Array(repeating: UInt8(ascii: " "), count: 64 * 1024)) + } + case .endlessChunked: + let chunk = Array("10000\r\n".utf8) + Array(repeating: UInt8(ascii: " "), count: 0x10000) + Array("\r\n".utf8) + send(context, Array("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".utf8)) { + self.pump(context, chunk) + } + case .drip(let declared, let interval): + send(context, Array("HTTP/1.1 200 OK\r\nContent-Length: \(declared)\r\nConnection: close\r\n\r\n".utf8)) { + self.drip(context, remaining: declared, interval: interval) + } + case .rawHead(let head): + send(context, Array(head.utf8)) + case .silent: + break + } + } + + private func send(_ context: ChannelHandlerContext, _ bytes: [UInt8], then next: @escaping () -> Void = {}) { + guard active else { return } + let count = bytes.count + let stats = self.stats + context.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(bytes: bytes))).assumeIsolated().whenComplete { result in + if case .success = result { + stats.lock.withLock { stats.bytesSent += count } + // Hop through the loop: a write that completes synchronously would otherwise recurse + // (an endless pump overflowed the stack). + context.eventLoop.assumeIsolated().execute { next() } + } + } + } + + private func pump(_ context: ChannelHandlerContext, _ chunk: [UInt8]) { + send(context, chunk) { self.pump(context, chunk) } + } + + private func drip(_ context: ChannelHandlerContext, remaining: Int, interval: TimeInterval) { + guard remaining > 0, active else { return } + context.eventLoop.assumeIsolated().scheduleTask(in: .milliseconds(Int64(interval * 1000))) { + self.send(context, [UInt8(ascii: " ")]) { self.drip(context, remaining: remaining - 1, interval: interval) } + } + } + + private static func find(_ needle: [UInt8], in haystack: [UInt8]) -> Int? { + guard haystack.count >= needle.count else { return nil } + for index in 0...(haystack.count - needle.count) where Array(haystack[index..<(index + needle.count)]) == needle { + return index + } + return nil + } +} diff --git a/Tests/PerfectOAuth2Tests/Support/TestCertificates.swift b/Tests/PerfectOAuth2Tests/Support/TestCertificates.swift new file mode 100644 index 0000000..e7653d6 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/Support/TestCertificates.swift @@ -0,0 +1,145 @@ +// +// TestCertificates.swift +// PerfectOAuth2Tests +// +// A throwaway local CA, an untrusted second CA, and leaf certificates for TLS tests on +// 127.0.0.1, valid for 100 years. Generated with OpenSSL 3.6 for the 2.0.0 release; the keys +// protect nothing. +// +// - `named`: subjectAltName DNS:auth.test +// - `ipOnly`: subjectAltName IP:127.0.0.1 only +// - `wrongName`: subjectAltName DNS:other.test +// - `wildcard`: subjectAltName DNS:*.wild.test +// - `untrusted`: DNS:auth.test, issued by the untrusted CA +// + +enum TestCertificates { + static let caPEM = """ +-----BEGIN CERTIFICATE----- +MIIBpzCCAU2gAwIBAgIUQIvd6/iSBiDAGuu2ns6BuyYMBzMwCgYIKoZIzj0EAwIw +IDEeMBwGA1UEAwwVUGVyZmVjdE9BdXRoMiBUZXN0IENBMCAXDTI2MTAyNjAwNDA1 +MloYDzIxMjYxMDAyMDA0MDUyWjAgMR4wHAYDVQQDDBVQZXJmZWN0T0F1dGgyIFRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ77AGvIOq2m4pTCvi085IR +NY1/VGxgaPPtq6/loB0SbX3r4dieGIR4U+TY6EZ6TKgNzqDFlGY/5Rlx0GIqMSnD +o2MwYTAdBgNVHQ4EFgQU+hc6I5iMRki7JnPsWHiAjlgRWTgwHwYDVR0jBBgwFoAU ++hc6I5iMRki7JnPsWHiAjlgRWTgwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E +BAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIgRvGKY4xZoqb/j+O6xZOTI871rUl58SkH +CN8GKSZji64CIQCV8fBwigxaS8lv0QDk464nqR+g539ETjHbXoFhWBTWWw== +-----END CERTIFICATE----- +""" + + static let namedCertificatePEM = """ +-----BEGIN CERTIFICATE----- +MIIBrTCCAVKgAwIBAgIURysn1zojNJTdakpyM8uDgnxBX4YwCgYIKoZIzj0EAwIw +IDEeMBwGA1UEAwwVUGVyZmVjdE9BdXRoMiBUZXN0IENBMCAXDTI2MTAyNjAwNDA1 +MloYDzIxMjYxMDAyMDA0MDUyWjAQMQ4wDAYDVQQDDAVuYW1lZDBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABNTDE2nmCiB83PIKAaRomPN0AaK9MqJrwYPv9yei73RE +Uv0yiM3/sUYmbwzOWEHlT82G8UxSbKTpgowYJiN9QxujeDB2MBQGA1UdEQQNMAuC +CWF1dGgudGVzdDAJBgNVHRMEAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0GA1Ud +DgQWBBSE5QdNLbxjpz9oMUJg33dqtsg03zAfBgNVHSMEGDAWgBT6FzojmIxGSLsm +c+xYeICOWBFZODAKBggqhkjOPQQDAgNJADBGAiEA1s44JA/EOMYdXMJdf+9M3LSb +fnxULtSlDE4NfjkIeJ0CIQDaM6uswnhYSl9FFhZLmJ5l+fuypzKlWw5IJ2r0lLcm +Lg== +-----END CERTIFICATE----- +""" + + static let namedKeyPEM = """ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwfS0BJ4iJ5p63I30 +P9/inUFnD36nBRg1UEL3rV7vqAqhRANCAATUwxNp5gogfNzyCgGkaJjzdAGivTKi +a8GD7/cnou90RFL9MojN/7FGJm8MzlhB5U/NhvFMUmyk6YKMGCYjfUMb +-----END PRIVATE KEY----- +""" + + static let ipOnlyCertificatePEM = """ +-----BEGIN CERTIFICATE----- +MIIBpzCCAU6gAwIBAgIURysn1zojNJTdakpyM8uDgnxBX4cwCgYIKoZIzj0EAwIw +IDEeMBwGA1UEAwwVUGVyZmVjdE9BdXRoMiBUZXN0IENBMCAXDTI2MTAyNjAwNDA1 +MloYDzIxMjYxMDAyMDA0MDUyWjARMQ8wDQYDVQQDDAZpcG9ubHkwWTATBgcqhkjO +PQIBBggqhkjOPQMBBwNCAATFpNV1dXT1f6nimU6nYesfVF5Cb0xamNboT1oZsYhB +0rPSC4SG+jUxV6fLqVqp1qLS5Y7rJNPN06+hAXvQ61k0o3MwcTAPBgNVHREECDAG +hwR/AAABMAkGA1UdEwQCMAAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYE +FEsVck4DMh7bQhLrFrWiJwILVx5EMB8GA1UdIwQYMBaAFPoXOiOYjEZIuyZz7Fh4 +gI5YEVk4MAoGCCqGSM49BAMCA0cAMEQCIQCGrMSJVq7WOQMz5Zx5od6nOUl7scGm +SjYSWyCGyb36BQIfchw0XptzV5Gjxmidrg27MIRLnrugBtudQ3t/KIAU7g== +-----END CERTIFICATE----- +""" + + static let ipOnlyKeyPEM = """ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgk5ijlwtB8rPCc/38 +qwsQcaK7LBYB/BMXdkVKSQ7enVyhRANCAATFpNV1dXT1f6nimU6nYesfVF5Cb0xa +mNboT1oZsYhB0rPSC4SG+jUxV6fLqVqp1qLS5Y7rJNPN06+hAXvQ61k0 +-----END PRIVATE KEY----- +""" + + static let wrongNameCertificatePEM = """ +-----BEGIN CERTIFICATE----- +MIIBsDCCAVegAwIBAgIURysn1zojNJTdakpyM8uDgnxBX4gwCgYIKoZIzj0EAwIw +IDEeMBwGA1UEAwwVUGVyZmVjdE9BdXRoMiBUZXN0IENBMCAXDTI2MTAyNjAwNDA1 +MloYDzIxMjYxMDAyMDA0MDUyWjAUMRIwEAYDVQQDDAl3cm9uZ25hbWUwWTATBgcq +hkjOPQIBBggqhkjOPQMBBwNCAASQT2Q+LoKk/ufDeMG3gMxcOMir7oin18UQ72Xq +KJbxSdpI1pNMMrIkYQ0A0sqRLTv6vcONUPLRwIFsdTSJAD9bo3kwdzAVBgNVHREE +DjAMggpvdGhlci50ZXN0MAkGA1UdEwQCMAAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw +HQYDVR0OBBYEFPrvZD14chUZAytPDiZIDdgQh+l1MB8GA1UdIwQYMBaAFPoXOiOY +jEZIuyZz7Fh4gI5YEVk4MAoGCCqGSM49BAMCA0cAMEQCIFmPY5zcvYVgfcY9OUun +ghV3r0o7nmOMojtfJoLFg2tyAiAWkDxDoRPHJ/OEQwBdEtmcDidUU+lGKgDZDXwX +0AQ90g== +-----END CERTIFICATE----- +""" + + static let wrongNameKeyPEM = """ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgy1vr13eBnQYLC8W9 +GlJEf7sVss/fgeJ073jgNmODhlOhRANCAASQT2Q+LoKk/ufDeMG3gMxcOMir7oin +18UQ72XqKJbxSdpI1pNMMrIkYQ0A0sqRLTv6vcONUPLRwIFsdTSJAD9b +-----END PRIVATE KEY----- +""" + + static let wildcardCertificatePEM = """ +-----BEGIN CERTIFICATE----- +MIIBsDCCAVegAwIBAgIURysn1zojNJTdakpyM8uDgnxBX4kwCgYIKoZIzj0EAwIw +IDEeMBwGA1UEAwwVUGVyZmVjdE9BdXRoMiBUZXN0IENBMCAXDTI2MTAyNjAwNDA1 +MloYDzIxMjYxMDAyMDA0MDUyWjATMREwDwYDVQQDDAh3aWxkY2FyZDBZMBMGByqG +SM49AgEGCCqGSM49AwEHA0IABCQ/Hjg96nZ/0lszmLLJtIuiHa37MQppDrXXHmj3 +/9gRk0acZ2PxuQWe+XfPNZSx9X79BgAIFfzTsZfUul618Y+jejB4MBYGA1UdEQQP +MA2CCyoud2lsZC50ZXN0MAkGA1UdEwQCMAAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw +HQYDVR0OBBYEFBBFSD8rbOn7wQqLdGZP+4X+L+EpMB8GA1UdIwQYMBaAFPoXOiOY +jEZIuyZz7Fh4gI5YEVk4MAoGCCqGSM49BAMCA0cAMEQCIDuYlc39D/8SIoFihjes +PUWuJd64RcDETudygIgrwDjmAiA0cYhXbCpmutm5ytlmwsH8L6SFx65pb8zvMWDc +9jsD4Q== +-----END CERTIFICATE----- +""" + + static let wildcardKeyPEM = """ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgRppwoa06qj/DsoJc +oMc4sKC9G9cvZ4gSxqkh+tM5gg6hRANCAAQkPx44Pep2f9JbM5iyybSLoh2t+zEK +aQ611x5o9//YEZNGnGdj8bkFnvl3zzWUsfV+/QYACBX807GX1LpetfGP +-----END PRIVATE KEY----- +""" + + static let untrustedCertificatePEM = """ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIUKFHk2GxXaKwZqwgmBPq+GE/AgnkwCgYIKoZIzj0EAwIw +JTEjMCEGA1UEAwwaUGVyZmVjdE9BdXRoMiBVbnRydXN0ZWQgQ0EwIBcNMjYxMDI2 +MDA0MDUyWhgPMjEyNjEwMDIwMDQwNTJaMBQxEjAQBgNVBAMMCXVudHJ1c3RlZDBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABA7Vjv0QkUe5I+VwnQKFoPTgJQIsf+Xt +mTBk5OthuMyyjgpiWbs0fJCwRHMCvvTY+oL9my9BI0s71CXSJfz+SUajeDB2MBQG +A1UdEQQNMAuCCWF1dGgudGVzdDAJBgNVHRMEAjAAMBMGA1UdJQQMMAoGCCsGAQUF +BwMBMB0GA1UdDgQWBBRksY1YgccYXknMVIkrvVBaG7fzkjAfBgNVHSMEGDAWgBR6 +9WgrNBZJlNp1gHVg8RF6v2k8fTAKBggqhkjOPQQDAgNIADBFAiEAkuqczrLXh/pv +p++7vW5aVV45TfPfkVyHs7PSYXwcFyQCIApvylCt8vraXecyeQVvPz7OZseqdeHI +sCO4Mop/uUHa +-----END CERTIFICATE----- +""" + + static let untrustedKeyPEM = """ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgc9CeQJChbdQFA+rv +7SXVmA/KngXQdvXZyUeRZo3lgNmhRANCAAQO1Y79EJFHuSPlcJ0ChaD04CUCLH/l +7ZkwZOTrYbjMso4KYlm7NHyQsERzAr702PqC/ZsvQSNLO9Ql0iX8/klG +-----END PRIVATE KEY----- +""" + +} diff --git a/Tests/PerfectOAuth2Tests/TransportTests.swift b/Tests/PerfectOAuth2Tests/TransportTests.swift index aa68929..f34de04 100644 --- a/Tests/PerfectOAuth2Tests/TransportTests.swift +++ b/Tests/PerfectOAuth2Tests/TransportTests.swift @@ -2,14 +2,26 @@ import Foundation import Testing @testable import PerfectOAuth2 -/// `URLSessionTransport` against a real socket on 127.0.0.1. -@Suite("URLSessionTransport", .serialized) +/// Both built-in transports, over plain HTTP to a real socket on 127.0.0.1. +enum TransportKind: String, CaseIterable, Sendable, CustomTestStringConvertible { + case nio, urlSession + var testDescription: String { rawValue } + + func make(timeout: TimeInterval, limit: Int) -> any OAuth2Transport { + switch self { + case .nio: NIOTransport(timeout: timeout, maximumResponseBytes: limit) + case .urlSession: URLSessionTransport(timeout: timeout, maximumResponseBytes: limit) + } + } +} + +@Suite("Transports over loopback HTTP", .serialized) struct TransportTests { - private func client(_ server: LocalHTTPServer, timeout: TimeInterval = 10, limit: Int = 1 << 20) -> OAuth2 { + private func client(_ kind: TransportKind, _ server: LocalHTTPServer, timeout: TimeInterval = 10, limit: Int = 1 << 20) -> OAuth2 { OAuth2( configuration: OAuth2ClientConfiguration( clientID: "id", clientSecret: "secret-value-123", redirectURL: URL(string: "https://app.test/cb")!, - usePKCE: false, http: OAuth2HTTPOptions(timeout: timeout, maximumResponseBytes: limit) + usePKCE: false, http: OAuth2HTTPOptions(timeout: timeout, maximumResponseBytes: limit, transport: kind.make(timeout: timeout, limit: limit)) ), authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(server.baseURL)/token")! @@ -20,10 +32,10 @@ struct TransportTests { .respond(status: status, headers: headers.merging(["Content-Type": "application/json"]) { a, _ in a }, body: Data(body.utf8)) } - @Test func postsFormAndParsesJSON() async throws { + @Test(arguments: TransportKind.allCases) func postsFormAndParsesJSON(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"AT","expires_in":10}"#) } defer { server.stop() } - let token = try await client(server).refresh(refreshToken: "refresh-value") + let token = try await client(kind, server).refresh(refreshToken: "refresh-value") #expect(token.accessToken == "AT") let request = try #require(server.requests.first) #expect(request.head.hasPrefix("POST /token HTTP/1.1")) @@ -32,89 +44,89 @@ struct TransportTests { #expect(formFields(request.body)["refresh_token"] == "refresh-value") } - @Test func bearerHeaderOnGet() async throws { + @Test(arguments: TransportKind.allCases) func bearerHeaderOnGet(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in Self.json(200, #"{"sub":"1"}"#) } defer { server.stop() } - let json = try await client(server).getJSON(URL(string: "\(server.baseURL)/me")!, accessToken: "AT-123") + let json = try await client(kind, server).getJSON(URL(string: "\(server.baseURL)/me")!, accessToken: "AT-123") #expect(json["sub"]?.stringValue == "1") let head = try #require(server.requests.first?.head) #expect(head.hasPrefix("GET /me HTTP/1.1")) #expect(head.contains("Authorization: Bearer AT-123") || head.contains("authorization: Bearer AT-123")) } - @Test func statusAndBodyErrors() async throws { + @Test(arguments: TransportKind.allCases) func statusAndBodyErrors(kind: TransportKind) async throws { let server = try LocalHTTPServer { request in if request.head.hasPrefix("POST /token?") { return Self.json(400, #"{"error":"invalid_grant"}"#) } return Self.json(503, "down") } defer { server.stop() } - await #expect(throws: OAuth2Error.httpStatus(503)) { try await client(server).refresh(refreshToken: "r") } + await #expect(throws: OAuth2Error.httpStatus(503)) { try await client(kind, server).refresh(refreshToken: "r") } } - @Test func redirectsAreNotFollowed() async throws { + @Test(arguments: TransportKind.allCases) func redirectsAreNotFollowed(kind: TransportKind) async throws { let target = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"stolen"}"#) } defer { target.stop() } let server = try LocalHTTPServer { _ in .respond(status: 307, headers: ["Location": "\(target.baseURL)/steal"], body: Data()) } defer { server.stop() } - await #expect(throws: OAuth2Error.httpStatus(307)) { try await client(server).refresh(refreshToken: "r") } + await #expect(throws: OAuth2Error.httpStatus(307)) { try await client(kind, server).refresh(refreshToken: "r") } #expect(target.requests.isEmpty) } - @Test func declaredOversizeIsRefused() async throws { + @Test(arguments: TransportKind.allCases) func declaredOversizeIsRefused(kind: TransportKind) async throws { let big = String(repeating: " ", count: 5000) + #"{"access_token":"AT"}"# let server = try LocalHTTPServer { _ in Self.json(200, big) } defer { server.stop() } await #expect(throws: OAuth2Error.responseTooLarge(limit: 4096)) { - try await client(server, limit: 4096).refresh(refreshToken: "r") + try await client(kind, server, limit: 4096).refresh(refreshToken: "r") } } - @Test func endlessBodyStopsAtTheLimit() async throws { + @Test(arguments: TransportKind.allCases) func endlessBodyStopsAtTheLimit(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in .endless } defer { server.stop() } let start = Date() await #expect(throws: OAuth2Error.responseTooLarge(limit: 64 * 1024)) { - try await client(server, timeout: 20, limit: 64 * 1024).refresh(refreshToken: "r") + try await client(kind, server, timeout: 20, limit: 64 * 1024).refresh(refreshToken: "r") } // The call returns as soon as the limit is passed, well before the deadline. (On Linux, // corelibs Foundation may go on reading and discarding in the background; see the README.) #expect(Date().timeIntervalSince(start) < 5) } - @Test func slowDripHitsTheWholeRequestDeadline() async throws { + @Test(arguments: TransportKind.allCases) func slowDripHitsTheWholeRequestDeadline(kind: TransportKind) async throws { // One byte every 0.2 s never trips URLSession's idle timeout; the deadline must. let server = try LocalHTTPServer { _ in .drip(declared: 1000, interval: 0.2) } defer { server.stop() } let start = Date() - await #expect(throws: OAuth2Error.timedOut) { try await client(server, timeout: 1.5).refresh(refreshToken: "r") } + await #expect(throws: OAuth2Error.timedOut) { try await client(kind, server, timeout: 1.5).refresh(refreshToken: "r") } let elapsed = Date().timeIntervalSince(start) #expect(elapsed >= 1.4 && elapsed < 5) } - @Test func silentServerTimesOut() async throws { + @Test(arguments: TransportKind.allCases) func silentServerTimesOut(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in .silent } defer { server.stop() } - await #expect(throws: OAuth2Error.timedOut) { try await client(server, timeout: 1).refresh(refreshToken: "r") } + await #expect(throws: OAuth2Error.timedOut) { try await client(kind, server, timeout: 1).refresh(refreshToken: "r") } } - @Test func connectionRefusedIsANetworkError() async throws { + @Test(arguments: TransportKind.allCases) func connectionRefusedIsANetworkError(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in .silent } let url = server.baseURL server.stop() let client = OAuth2( - configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: nil, redirectURL: URL(string: "https://app.test/cb")!, http: OAuth2HTTPOptions(timeout: 5)), + configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: nil, redirectURL: URL(string: "https://app.test/cb")!, http: OAuth2HTTPOptions(timeout: 5, transport: kind.make(timeout: 5, limit: 1 << 20))), authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(url)/token")! ) let error = await #expect(throws: OAuth2Error.self) { try await client.refresh(refreshToken: "r") } if case .network? = error {} else { Issue.record("expected .network, got \(String(describing: error))") } } - @Test func cancellationEndsTheRequest() async throws { + @Test(arguments: TransportKind.allCases) func cancellationEndsTheRequest(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in .silent } defer { server.stop() } - let oauth = client(server, timeout: 30) + let oauth = client(kind, server, timeout: 30) let task = Task { try await oauth.refresh(refreshToken: "r") } try await Task.sleep(nanoseconds: 300_000_000) let start = Date() @@ -124,10 +136,10 @@ struct TransportTests { if case .success = result { Issue.record("cancelled request succeeded") } } - @Test func concurrentRequests() async throws { + @Test(arguments: TransportKind.allCases) func concurrentRequests(kind: TransportKind) async throws { let server = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"AT"}"#) } defer { server.stop() } - let oauth = client(server) + let oauth = client(kind, server) try await withThrowingTaskGroup(of: String.self) { group in for _ in 0..<20 { group.addTask { try await oauth.refresh(refreshToken: "r").accessToken } } for try await token in group { #expect(token == "AT") } From acae05c4860e342278984b12bbef649f35bb3ecc Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 20:31:40 -0500 Subject: [PATCH 08/14] Tests: serialize the socket suites; leak checks on the server side On linux/amd64 (emulated) Swift 6.4, a URLSession endless-body test running beside the NIO suites left corelibs draining in the background and starved two NIO calls into their deadline. All socket suites now run one at a time under a serialized parent suite. The process-wide descriptor count was noisy for the same reason (parallel suites, and URLSession leaking descriptors on Linux), so the leak checks assert that the server saw every connection opened and closed; descriptor counts are still printed in MEASURE runs, which run the suite alone. Co-Authored-By: Claude Opus 5.5 --- Tests/PerfectOAuth2Tests/HardeningTests.swift | 88 ++-- .../NIOTransportTests.swift | 413 +++++++++--------- .../Support/TransportSuites.swift | 7 + Tests/PerfectOAuth2Tests/TransportTests.swift | 226 +++++----- 4 files changed, 376 insertions(+), 358 deletions(-) create mode 100644 Tests/PerfectOAuth2Tests/Support/TransportSuites.swift diff --git a/Tests/PerfectOAuth2Tests/HardeningTests.swift b/Tests/PerfectOAuth2Tests/HardeningTests.swift index 92c1b7d..b8164cc 100644 --- a/Tests/PerfectOAuth2Tests/HardeningTests.swift +++ b/Tests/PerfectOAuth2Tests/HardeningTests.swift @@ -223,56 +223,58 @@ struct NitTests { } } -@Suite("Transport hardening", .serialized) -struct TransportHardeningTests { - private func oauth(_ kind: TransportKind, _ server: LocalHTTPServer, timeout: TimeInterval = 10) -> OAuth2 { - OAuth2( - configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: "s", redirectURL: URL(string: "https://app.test/cb")!, usePKCE: false, - http: OAuth2HTTPOptions(timeout: timeout, transport: kind.make(timeout: timeout, limit: 1 << 20))), - authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(server.baseURL)/token")! - ) - } +extension TransportSuites { + @Suite("Transport hardening", .serialized) + struct TransportHardeningTests { + private func oauth(_ kind: TransportKind, _ server: LocalHTTPServer, timeout: TimeInterval = 10) -> OAuth2 { + OAuth2( + configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: "s", redirectURL: URL(string: "https://app.test/cb")!, usePKCE: false, + http: OAuth2HTTPOptions(timeout: timeout, transport: kind.make(timeout: timeout, limit: 1 << 20))), + authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(server.baseURL)/token")! + ) + } - @Test(arguments: TransportKind.allCases) func compressedResponsesAreRefusedAndIdentityRequested(kind: TransportKind) async throws { - // A real gzip stream of 1 MiB of spaces (about 1 KB compressed); never decompressed. - let gzip = try #require(Data(base64Encoded: gzippedSpacesBase64)) - for encoding in ["gzip", "br", "deflate", " GZIP "] { - let server = try LocalHTTPServer { _ in - .raw(head: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: \(encoding)\r\nContent-Length: \(gzip.count)\r\nConnection: close\r\n\r\n", chunk: gzip, count: 1) + @Test(arguments: TransportKind.allCases) func compressedResponsesAreRefusedAndIdentityRequested(kind: TransportKind) async throws { + // A real gzip stream of 1 MiB of spaces (about 1 KB compressed); never decompressed. + let gzip = try #require(Data(base64Encoded: gzippedSpacesBase64)) + for encoding in ["gzip", "br", "deflate", " GZIP "] { + let server = try LocalHTTPServer { _ in + .raw(head: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: \(encoding)\r\nContent-Length: \(gzip.count)\r\nConnection: close\r\n\r\n", chunk: gzip, count: 1) + } + defer { server.stop() } + let error = await #expect(throws: OAuth2Error.self) { try await oauth(kind, server).refresh(refreshToken: "r") } + guard case .invalidResponse(let why)? = error else { Issue.record("\(String(describing: error))"); continue } + #expect(why.hasPrefix("compressed response refused")) + let head = try #require(server.requests.first?.head.lowercased()) + #expect(head.contains("accept-encoding: identity")) } - defer { server.stop() } - let error = await #expect(throws: OAuth2Error.self) { try await oauth(kind, server).refresh(refreshToken: "r") } - guard case .invalidResponse(let why)? = error else { Issue.record("\(String(describing: error))"); continue } - #expect(why.hasPrefix("compressed response refused")) - let head = try #require(server.requests.first?.head.lowercased()) - #expect(head.contains("accept-encoding: identity")) - } - let identity = try LocalHTTPServer { _ in - .respond(status: 200, headers: ["Content-Encoding": "identity"], body: Data(#"{"access_token":"AT"}"#.utf8)) + let identity = try LocalHTTPServer { _ in + .respond(status: 200, headers: ["Content-Encoding": "identity"], body: Data(#"{"access_token":"AT"}"#.utf8)) + } + defer { identity.stop() } + #expect(try await oauth(kind, identity).refresh(refreshToken: "r").accessToken == "AT") } - defer { identity.stop() } - #expect(try await oauth(kind, identity).refresh(refreshToken: "r").accessToken == "AT") - } - @Test(arguments: TransportKind.allCases) func infiniteTimeoutDoesNotTrap(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in .respond(status: 200, headers: [:], body: Data(#"{"access_token":"AT"}"#.utf8)) } - defer { server.stop() } - for timeout in [TimeInterval.infinity, .greatestFiniteMagnitude, .nan] { - _ = await Result(catching: { try await oauth(kind, server, timeout: timeout).refresh(refreshToken: "r") }) + @Test(arguments: TransportKind.allCases) func infiniteTimeoutDoesNotTrap(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in .respond(status: 200, headers: [:], body: Data(#"{"access_token":"AT"}"#.utf8)) } + defer { server.stop() } + for timeout in [TimeInterval.infinity, .greatestFiniteMagnitude, .nan] { + _ = await Result(catching: { try await oauth(kind, server, timeout: timeout).refresh(refreshToken: "r") }) + } } - } - @Test(arguments: TransportKind.allCases) func alreadyCancelledTaskSendsNothing(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in .respond(status: 200, headers: [:], body: Data(#"{"access_token":"AT"}"#.utf8)) } - defer { server.stop() } - let client = oauth(kind, server) - let task = Task { - withUnsafeCurrentTask { $0?.cancel() } - return try await client.refresh(refreshToken: "r") + @Test(arguments: TransportKind.allCases) func alreadyCancelledTaskSendsNothing(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in .respond(status: 200, headers: [:], body: Data(#"{"access_token":"AT"}"#.utf8)) } + defer { server.stop() } + let client = oauth(kind, server) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await client.refresh(refreshToken: "r") + } + if case .success = await task.result { Issue.record("cancelled request succeeded") } + try await Task.sleep(nanoseconds: 300_000_000) + #expect(server.requests.isEmpty) } - if case .success = await task.result { Issue.record("cancelled request succeeded") } - try await Task.sleep(nanoseconds: 300_000_000) - #expect(server.requests.isEmpty) } } diff --git a/Tests/PerfectOAuth2Tests/NIOTransportTests.swift b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift index eeedeba..5e5f264 100644 --- a/Tests/PerfectOAuth2Tests/NIOTransportTests.swift +++ b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift @@ -40,241 +40,248 @@ private func settle(_ server: NIOTestServer) async { for _ in 0..<100 where server.openConnections > 0 { try? await Task.sleep(nanoseconds: 50_000_000) } } -@Suite("NIOTransport", .serialized) -struct NIOTransportTests { - @Test func postOverTLSWithSNIAndHostHeader() async throws { - let server = try NIOTestServer { _ in json(#"{"access_token":"AT"}"#) } - defer { server.stop() } - let oauth = OAuth2( - configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: "secret-value-123", redirectURL: URL(string: "https://app.test/cb")!, - usePKCE: false, http: OAuth2HTTPOptions(transport: try testTransport(server))), - authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(server.baseURL)/oauth/token?x=1")! - ) - let token = try await oauth.refresh(refreshToken: "refresh-value") - #expect(token.accessToken == "AT") - let request = try #require(server.requests.first) - #expect(request.head.hasPrefix("POST /oauth/token?x=1 HTTP/1.1")) - let head = request.head.lowercased() - #expect(head.contains("host: auth.test:\(server.port)")) - #expect(head.contains("accept-encoding: identity")) - #expect(head.contains("content-type: application/x-www-form-urlencoded")) - #expect(head.contains("connection: close")) - #expect(formFields(request.body)["refresh_token"] == "refresh-value") - await settle(server) - #expect(server.openConnections == 0) - } +extension TransportSuites { + @Suite("NIOTransport", .serialized) + struct NIOTransportTests { + @Test func postOverTLSWithSNIAndHostHeader() async throws { + let server = try NIOTestServer { _ in json(#"{"access_token":"AT"}"#) } + defer { server.stop() } + let oauth = OAuth2( + configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: "secret-value-123", redirectURL: URL(string: "https://app.test/cb")!, + usePKCE: false, http: OAuth2HTTPOptions(transport: try testTransport(server))), + authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(server.baseURL)/oauth/token?x=1")! + ) + let token = try await oauth.refresh(refreshToken: "refresh-value") + #expect(token.accessToken == "AT") + let request = try #require(server.requests.first) + #expect(request.head.hasPrefix("POST /oauth/token?x=1 HTTP/1.1")) + let head = request.head.lowercased() + #expect(head.contains("host: auth.test:\(server.port)")) + #expect(head.contains("accept-encoding: identity")) + #expect(head.contains("content-type: application/x-www-form-urlencoded")) + #expect(head.contains("connection: close")) + #expect(formFields(request.body)["refresh_token"] == "refresh-value") + await settle(server) + #expect(server.openConnections == 0) + } - @Test func plainHTTPOnlyToLoopback() async throws { - let server = try NIOTestServer(certificate: nil) { _ in json("{}") } - defer { server.stop() } - let transport = NIOTransport(timeout: 5) - #expect(try await get(transport, "http://127.0.0.1:\(server.port)/x").status == 200) - await #expect(throws: OAuth2Error.insecureURL("request URL must use https")) { - try await get(transport, "http://auth.test:\(server.port)/x") + @Test func plainHTTPOnlyToLoopback() async throws { + let server = try NIOTestServer(certificate: nil) { _ in json("{}") } + defer { server.stop() } + let transport = NIOTransport(timeout: 5) + #expect(try await get(transport, "http://127.0.0.1:\(server.port)/x").status == 200) + await #expect(throws: OAuth2Error.insecureURL("request URL must use https")) { + try await get(transport, "http://auth.test:\(server.port)/x") + } } - } - @Test(arguments: [301, 302, 303, 307, 308]) - func redirectsAreReturnedNotFollowed(code: Int) async throws { - let target = try NIOTestServer { _ in json(#"{"access_token":"stolen"}"#) } - defer { target.stop() } - let server = try NIOTestServer { _ in .respond(status: code, headers: ["Location": "\(target.baseURL)/steal"], body: Data()) } - defer { server.stop() } - let response = try await get(try testTransport(server), "\(server.baseURL)/token") - #expect(response.status == code) - #expect(target.requests.isEmpty) - } + @Test(arguments: [301, 302, 303, 307, 308]) + func redirectsAreReturnedNotFollowed(code: Int) async throws { + let target = try NIOTestServer { _ in json(#"{"access_token":"stolen"}"#) } + defer { target.stop() } + let server = try NIOTestServer { _ in .respond(status: code, headers: ["Location": "\(target.baseURL)/steal"], body: Data()) } + defer { server.stop() } + let response = try await get(try testTransport(server), "\(server.baseURL)/token") + #expect(response.status == code) + #expect(target.requests.isEmpty) + } + + @Test func endlessBodiesStopAtTheCap() async throws { + for behavior in [NIOTestServer.Behavior.endless, .endlessChunked] { + let server = try NIOTestServer { _ in behavior } + defer { server.stop() } + let start = Date() + await #expect(throws: OAuth2Error.responseTooLarge(limit: 64 * 1024)) { + try await get(try testTransport(server, limit: 64 * 1024), "\(server.baseURL)/t") + } + #expect(Date().timeIntervalSince(start) < 2) + await settle(server) + #expect(server.openConnections == 0) + // The connection was closed at the cap: the server stops being able to write. + let sent = server.totalBytesSent + try await Task.sleep(nanoseconds: 300_000_000) + #expect(server.totalBytesSent - sent < 1 << 20) + } + } - @Test func endlessBodiesStopAtTheCap() async throws { - for behavior in [NIOTestServer.Behavior.endless, .endlessChunked] { - let server = try NIOTestServer { _ in behavior } + @Test func hugeContentLengthIsRefusedUpFront() async throws { + let server = try NIOTestServer { _ in .rawHead("HTTP/1.1 200 OK\r\nContent-Length: 99999999999\r\n\r\n") } defer { server.stop() } - let start = Date() - await #expect(throws: OAuth2Error.responseTooLarge(limit: 64 * 1024)) { - try await get(try testTransport(server, limit: 64 * 1024), "\(server.baseURL)/t") + await #expect(throws: OAuth2Error.responseTooLarge(limit: 1 << 20)) { + try await get(try testTransport(server), "\(server.baseURL)/t") } - #expect(Date().timeIntervalSince(start) < 2) - await settle(server) - #expect(server.openConnections == 0) - // The connection was closed at the cap: the server stops being able to write. - let sent = server.totalBytesSent - try await Task.sleep(nanoseconds: 300_000_000) - #expect(server.totalBytesSent - sent < 1 << 20) } - } - @Test func hugeContentLengthIsRefusedUpFront() async throws { - let server = try NIOTestServer { _ in .rawHead("HTTP/1.1 200 OK\r\nContent-Length: 99999999999\r\n\r\n") } - defer { server.stop() } - await #expect(throws: OAuth2Error.responseTooLarge(limit: 1 << 20)) { - try await get(try testTransport(server), "\(server.baseURL)/t") + @Test func compressedBodiesAreRefused() async throws { + for encoding in ["gzip", "br", "deflate", " GZIP "] { + let server = try NIOTestServer { _ in .respond(status: 200, headers: ["Content-Encoding": encoding], body: Data(repeating: 0x1f, count: 64)) } + defer { server.stop() } + let error = await #expect(throws: OAuth2Error.self) { try await get(try testTransport(server), "\(server.baseURL)/t") } + guard case .invalidResponse(let why)? = error else { Issue.record("\(String(describing: error))"); continue } + #expect(why.hasPrefix("compressed response refused")) + } } - } - @Test func compressedBodiesAreRefused() async throws { - for encoding in ["gzip", "br", "deflate", " GZIP "] { - let server = try NIOTestServer { _ in .respond(status: 200, headers: ["Content-Encoding": encoding], body: Data(repeating: 0x1f, count: 64)) } + @Test func slowLorisAndSilentServersHitTheDeadline() async throws { + for behavior in [NIOTestServer.Behavior.drip(declared: 1000, interval: 0.2), .silent] { + let server = try NIOTestServer { _ in behavior } + defer { server.stop() } + let start = Date() + await #expect(throws: OAuth2Error.timedOut) { try await get(try testTransport(server, timeout: 1.5), "\(server.baseURL)/t") } + let elapsed = Date().timeIntervalSince(start) + #expect(elapsed >= 1.4 && elapsed < 4) + await settle(server) + #expect(server.openConnections == 0) + } + } + + @Test func deadlineDuringTheHandshakeSendsNothing() async throws { + // A plain TCP server that never speaks TLS: the handshake stalls until the deadline. + let server = try NIOTestServer(certificate: nil) { _ in .silent } defer { server.stop() } - let error = await #expect(throws: OAuth2Error.self) { try await get(try testTransport(server), "\(server.baseURL)/t") } - guard case .invalidResponse(let why)? = error else { Issue.record("\(String(describing: error))"); continue } - #expect(why.hasPrefix("compressed response refused")) + await #expect(throws: OAuth2Error.timedOut) { try await get(try testTransport(server, timeout: 1), "\(server.baseURL)/t") } + #expect(server.requests.isEmpty) } - } - @Test func slowLorisAndSilentServersHitTheDeadline() async throws { - for behavior in [NIOTestServer.Behavior.drip(declared: 1000, interval: 0.2), .silent] { - let server = try NIOTestServer { _ in behavior } + @Test func cancellationEndsTheRequest() async throws { + let server = try NIOTestServer { _ in .silent } defer { server.stop() } + let transport = try testTransport(server, timeout: 30) + let task = Task { try await get(transport, "\(server.baseURL)/t") } + try await Task.sleep(nanoseconds: 300_000_000) let start = Date() - await #expect(throws: OAuth2Error.timedOut) { try await get(try testTransport(server, timeout: 1.5), "\(server.baseURL)/t") } - let elapsed = Date().timeIntervalSince(start) - #expect(elapsed >= 1.4 && elapsed < 4) - await settle(server) - #expect(server.openConnections == 0) + task.cancel() + let result = await task.result + #expect(Date().timeIntervalSince(start) < 2) + guard case .failure(OAuth2Error.network("cancelled")) = result else { Issue.record("\(result)"); return } } - } - - @Test func deadlineDuringTheHandshakeSendsNothing() async throws { - // A plain TCP server that never speaks TLS: the handshake stalls until the deadline. - let server = try NIOTestServer(certificate: nil) { _ in .silent } - defer { server.stop() } - await #expect(throws: OAuth2Error.timedOut) { try await get(try testTransport(server, timeout: 1), "\(server.baseURL)/t") } - #expect(server.requests.isEmpty) - } - @Test func cancellationEndsTheRequest() async throws { - let server = try NIOTestServer { _ in .silent } - defer { server.stop() } - let transport = try testTransport(server, timeout: 30) - let task = Task { try await get(transport, "\(server.baseURL)/t") } - try await Task.sleep(nanoseconds: 300_000_000) - let start = Date() - task.cancel() - let result = await task.result - #expect(Date().timeIntervalSince(start) < 2) - guard case .failure(OAuth2Error.network("cancelled")) = result else { Issue.record("\(result)"); return } - } - - @Test func connectionRefused() async throws { - let server = try NIOTestServer { _ in .silent } - let port = server.port - server.stop() - let transport = NIOTransport(timeout: 5, addressOverride: { _, _ in try? SocketAddress(ipAddress: "127.0.0.1", port: port) }) - await #expect(throws: OAuth2Error.network("connection failed")) { try await get(transport, "https://auth.test:\(port)/t") } + @Test func connectionRefused() async throws { + let server = try NIOTestServer { _ in .silent } + let port = server.port + server.stop() + let transport = NIOTransport(timeout: 5, addressOverride: { _, _ in try? SocketAddress(ipAddress: "127.0.0.1", port: port) }) + await #expect(throws: OAuth2Error.network("connection failed")) { try await get(transport, "https://auth.test:\(port)/t") } + } } } -@Suite("NIOTransport certificates", .serialized) -struct NIOTransportCertificateTests { - /// Every refusal happens before the request is written: the server never sees it. - private func expectRefused(_ certificate: NIOTestServer.Certificate, host: String = "auth.test", trust: String = TestCertificates.caPEM, message: String) async throws { - let server = try NIOTestServer(certificate: certificate) { _ in json(#"{"access_token":"AT"}"#) } - defer { server.stop() } - let error = await #expect(throws: OAuth2Error.self) { - try await get(try testTransport(server, trust: trust), "https://\(host):\(server.port)/token") +extension TransportSuites { + @Suite("NIOTransport certificates", .serialized) + struct NIOTransportCertificateTests { + /// Every refusal happens before the request is written: the server never sees it. + private func expectRefused(_ certificate: NIOTestServer.Certificate, host: String = "auth.test", trust: String = TestCertificates.caPEM, message: String) async throws { + let server = try NIOTestServer(certificate: certificate) { _ in json(#"{"access_token":"AT"}"#) } + defer { server.stop() } + let error = await #expect(throws: OAuth2Error.self) { + try await get(try testTransport(server, trust: trust), "https://\(host):\(server.port)/token") + } + guard case .network(let why)? = error else { Issue.record("\(String(describing: error))"); return } + #expect(why.hasPrefix(message), "\(why)") + #expect(server.requests.isEmpty) + await settle(server) + #expect(server.openConnections == 0) } - guard case .network(let why)? = error else { Issue.record("\(String(describing: error))"); return } - #expect(why.hasPrefix(message), "\(why)") - #expect(server.requests.isEmpty) - await settle(server) - #expect(server.openConnections == 0) - } - @Test func ipOnlyCertificateIsRefusedForAName() async throws { - // NIOSSL alone accepts an IP SAN equal to the dialed address (127.0.0.1); the DNS-SAN check doesn't. - try await expectRefused(.ipOnly, message: "TLS") - } + @Test func ipOnlyCertificateIsRefusedForAName() async throws { + // NIOSSL alone accepts an IP SAN equal to the dialed address (127.0.0.1); the DNS-SAN check doesn't. + try await expectRefused(.ipOnly, message: "TLS") + } - @Test func wrongNameIsRefused() async throws { - try await expectRefused(.wrongName, message: "TLS") - } + @Test func wrongNameIsRefused() async throws { + try await expectRefused(.wrongName, message: "TLS") + } - @Test func untrustedCAIsRefused() async throws { - try await expectRefused(.untrusted, message: "TLS") - } + @Test func untrustedCAIsRefused() async throws { + try await expectRefused(.untrusted, message: "TLS") + } - @Test func wildcardCoversExactlyOneLabel() async throws { - let server = try NIOTestServer(certificate: .wildcard) { _ in json("{}") } - defer { server.stop() } - #expect(try await get(try testTransport(server), "https://login.wild.test:\(server.port)/t").status == 200) - try await expectRefused(.wildcard, host: "a.b.wild.test", message: "TLS") - try await expectRefused(.wildcard, host: "wild.test", message: "TLS") - } + @Test func wildcardCoversExactlyOneLabel() async throws { + let server = try NIOTestServer(certificate: .wildcard) { _ in json("{}") } + defer { server.stop() } + #expect(try await get(try testTransport(server), "https://login.wild.test:\(server.port)/t").status == 200) + try await expectRefused(.wildcard, host: "a.b.wild.test", message: "TLS") + try await expectRefused(.wildcard, host: "wild.test", message: "TLS") + } - @Test func ipLiteralUsesNIOSSLsIPCheck() async throws { - let server = try NIOTestServer(certificate: .ipOnly) { _ in json("{}") } - defer { server.stop() } - var tls = TLSConfiguration.makeClientConfiguration() - tls.trustRoots = .certificates(try NIOSSLCertificate.fromPEMBytes(Array(TestCertificates.caPEM.utf8))) - let transport = NIOTransport(timeout: 5, tlsConfiguration: tls) - #expect(try await get(transport, "https://127.0.0.1:\(server.port)/t").status == 200) - } + @Test func ipLiteralUsesNIOSSLsIPCheck() async throws { + let server = try NIOTestServer(certificate: .ipOnly) { _ in json("{}") } + defer { server.stop() } + var tls = TLSConfiguration.makeClientConfiguration() + tls.trustRoots = .certificates(try NIOSSLCertificate.fromPEMBytes(Array(TestCertificates.caPEM.utf8))) + let transport = NIOTransport(timeout: 5, tlsConfiguration: tls) + #expect(try await get(transport, "https://127.0.0.1:\(server.port)/t").status == 200) + } - @Test func certificateNameParsing() throws { - func der(_ pem: String) throws -> [UInt8] { try NIOSSLCertificate.fromPEMBytes(Array(pem.utf8))[0].toDERBytes() } - #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.namedCertificatePEM)) == ["auth.test"]) - #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.ipOnlyCertificatePEM)) == []) - #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.wildcardCertificatePEM)) == ["*.wild.test"]) - #expect(throws: CertificateNameError.self) { try CertificateDNSNames.dnsNames(fromDER: [0x30, 0x03, 0x02, 0x01]) } - for (pattern, host, ok) in [("auth.test", "AUTH.test", true), ("auth.test", "auth.test.", true), ("*.wild.test", "a.wild.test", true), - ("*.wild.test", "wild.test", false), ("*.wild.test", "a.b.wild.test", false), ("*.", "a", false), - ("a*.wild.test", "ab.wild.test", false)] { - #expect(CertificateDNSNames.matches(pattern: pattern, host: host) == ok, "\(pattern) \(host)") + @Test func certificateNameParsing() throws { + func der(_ pem: String) throws -> [UInt8] { try NIOSSLCertificate.fromPEMBytes(Array(pem.utf8))[0].toDERBytes() } + #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.namedCertificatePEM)) == ["auth.test"]) + #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.ipOnlyCertificatePEM)) == []) + #expect(try CertificateDNSNames.dnsNames(fromDER: der(TestCertificates.wildcardCertificatePEM)) == ["*.wild.test"]) + #expect(throws: CertificateNameError.self) { try CertificateDNSNames.dnsNames(fromDER: [0x30, 0x03, 0x02, 0x01]) } + for (pattern, host, ok) in [("auth.test", "AUTH.test", true), ("auth.test", "auth.test.", true), ("*.wild.test", "a.wild.test", true), + ("*.wild.test", "wild.test", false), ("*.wild.test", "a.b.wild.test", false), ("*.", "a", false), + ("a*.wild.test", "ab.wild.test", false)] { + #expect(CertificateDNSNames.matches(pattern: pattern, host: host) == ok, "\(pattern) \(host)") + } } } } -@Suite("NIOTransport resource use", .serialized) -struct NIOTransportResourceTests { - /// Back-to-back capped calls against an endless body: each returns at the cap, connections are - /// closed, and descriptors don't accumulate. With MEASURE_CALLS set, prints the numbers. - @Test func cappedCallsDoNotLeak() async throws { - let calls = Int(ProcessInfo.processInfo.environment["MEASURE_CALLS"] ?? "") ?? 20 - let server = try NIOTestServer { _ in .endless } - defer { server.stop() } - let transport = try testTransport(server, timeout: 5, limit: 16 * 1024) - _ = try? await get(transport, "\(server.baseURL)/warm") - await settle(server) - let fdBefore = openFileDescriptors() - let start = Date() - var slowest = 0.0 - for _ in 0.. %d, open server connections %d, max RSS %d MB", - calls, elapsed, slowest, sentAtReturn >> 20, (server.totalBytesSent - sentAtReturn) >> 20, fdBefore, fdAfter, server.openConnections, rssMB)) +extension TransportSuites { + @Suite("NIOTransport resource use", .serialized) + struct NIOTransportResourceTests { + /// Back-to-back capped calls against an endless body: each returns at the cap, connections are + /// closed, and descriptors don't accumulate. With MEASURE_CALLS set, prints the numbers. + @Test func cappedCallsDoNotLeak() async throws { + let calls = Int(ProcessInfo.processInfo.environment["MEASURE_CALLS"] ?? "") ?? 20 + let server = try NIOTestServer { _ in .endless } + defer { server.stop() } + let transport = try testTransport(server, timeout: 5, limit: 16 * 1024) + _ = try? await get(transport, "\(server.baseURL)/warm") + await settle(server) + let fdBefore = openFileDescriptors() + let start = Date() + var slowest = 0.0 + for _ in 0.. %d, open server connections %d, max RSS %d MB", + calls, elapsed, slowest, sentAtReturn >> 20, (server.totalBytesSent - sentAtReturn) >> 20, fdBefore, fdAfter, server.openConnections, rssMB)) + } + // Every connection was closed by the client (the endless server never closes on its own). + // The process-wide descriptor count is only reported in MEASURE runs: other suites run in + // parallel and open descriptors of their own. + #expect(server.openConnections == 0) + #expect(server.connectionCount == calls + 1) + #expect(slowest < 2) } - #expect(server.openConnections == 0) - #expect(fdAfter <= fdBefore + 2) - #expect(slowest < 2) - } - @Test func failingCallsDoNotLeak() async throws { - let server = try NIOTestServer(certificate: .wrongName) { _ in json("{}") } - defer { server.stop() } - let transport = try testTransport(server, timeout: 5) - _ = try? await get(transport, "\(server.baseURL)/warm") - let fdBefore = openFileDescriptors() - for _ in 0..<50 { _ = try? await get(transport, "\(server.baseURL)/t") } - await settle(server) - #expect(server.openConnections == 0) - #expect(server.requests.isEmpty) - #expect(openFileDescriptors() <= fdBefore + 2) + @Test func failingCallsDoNotLeak() async throws { + let server = try NIOTestServer(certificate: .wrongName) { _ in json("{}") } + defer { server.stop() } + let transport = try testTransport(server, timeout: 5) + for _ in 0..<50 { _ = try? await get(transport, "\(server.baseURL)/t") } + await settle(server) + #expect(server.connectionCount == 50) + #expect(server.openConnections == 0) + #expect(server.requests.isEmpty) + } } } diff --git a/Tests/PerfectOAuth2Tests/Support/TransportSuites.swift b/Tests/PerfectOAuth2Tests/Support/TransportSuites.swift new file mode 100644 index 0000000..d3e824e --- /dev/null +++ b/Tests/PerfectOAuth2Tests/Support/TransportSuites.swift @@ -0,0 +1,7 @@ +import Testing + +/// Every suite that opens real sockets runs under this parent, one at a time: on Linux a URLSession +/// test leaves corelibs draining a cancelled body in the background, which on an emulated CPU can +/// starve whatever runs next to it. +@Suite("Transports", .serialized) +struct TransportSuites {} diff --git a/Tests/PerfectOAuth2Tests/TransportTests.swift b/Tests/PerfectOAuth2Tests/TransportTests.swift index f34de04..959581e 100644 --- a/Tests/PerfectOAuth2Tests/TransportTests.swift +++ b/Tests/PerfectOAuth2Tests/TransportTests.swift @@ -15,134 +15,136 @@ enum TransportKind: String, CaseIterable, Sendable, CustomTestStringConvertible } } -@Suite("Transports over loopback HTTP", .serialized) -struct TransportTests { - private func client(_ kind: TransportKind, _ server: LocalHTTPServer, timeout: TimeInterval = 10, limit: Int = 1 << 20) -> OAuth2 { - OAuth2( - configuration: OAuth2ClientConfiguration( - clientID: "id", clientSecret: "secret-value-123", redirectURL: URL(string: "https://app.test/cb")!, - usePKCE: false, http: OAuth2HTTPOptions(timeout: timeout, maximumResponseBytes: limit, transport: kind.make(timeout: timeout, limit: limit)) - ), - authorizationURL: URL(string: "https://auth.test/a")!, - tokenURL: URL(string: "\(server.baseURL)/token")! - ) - } +extension TransportSuites { + @Suite("Transports over loopback HTTP", .serialized) + struct TransportTests { + private func client(_ kind: TransportKind, _ server: LocalHTTPServer, timeout: TimeInterval = 10, limit: Int = 1 << 20) -> OAuth2 { + OAuth2( + configuration: OAuth2ClientConfiguration( + clientID: "id", clientSecret: "secret-value-123", redirectURL: URL(string: "https://app.test/cb")!, + usePKCE: false, http: OAuth2HTTPOptions(timeout: timeout, maximumResponseBytes: limit, transport: kind.make(timeout: timeout, limit: limit)) + ), + authorizationURL: URL(string: "https://auth.test/a")!, + tokenURL: URL(string: "\(server.baseURL)/token")! + ) + } - private static func json(_ status: Int, _ body: String, headers: [String: String] = [:]) -> LocalHTTPServer.Behavior { - .respond(status: status, headers: headers.merging(["Content-Type": "application/json"]) { a, _ in a }, body: Data(body.utf8)) - } + private static func json(_ status: Int, _ body: String, headers: [String: String] = [:]) -> LocalHTTPServer.Behavior { + .respond(status: status, headers: headers.merging(["Content-Type": "application/json"]) { a, _ in a }, body: Data(body.utf8)) + } - @Test(arguments: TransportKind.allCases) func postsFormAndParsesJSON(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"AT","expires_in":10}"#) } - defer { server.stop() } - let token = try await client(kind, server).refresh(refreshToken: "refresh-value") - #expect(token.accessToken == "AT") - let request = try #require(server.requests.first) - #expect(request.head.hasPrefix("POST /token HTTP/1.1")) - #expect(request.head.lowercased().contains("content-type: application/x-www-form-urlencoded")) - #expect(request.head.lowercased().contains("user-agent: perfectoauth2/2.0")) - #expect(formFields(request.body)["refresh_token"] == "refresh-value") - } + @Test(arguments: TransportKind.allCases) func postsFormAndParsesJSON(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"AT","expires_in":10}"#) } + defer { server.stop() } + let token = try await client(kind, server).refresh(refreshToken: "refresh-value") + #expect(token.accessToken == "AT") + let request = try #require(server.requests.first) + #expect(request.head.hasPrefix("POST /token HTTP/1.1")) + #expect(request.head.lowercased().contains("content-type: application/x-www-form-urlencoded")) + #expect(request.head.lowercased().contains("user-agent: perfectoauth2/2.0")) + #expect(formFields(request.body)["refresh_token"] == "refresh-value") + } - @Test(arguments: TransportKind.allCases) func bearerHeaderOnGet(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in Self.json(200, #"{"sub":"1"}"#) } - defer { server.stop() } - let json = try await client(kind, server).getJSON(URL(string: "\(server.baseURL)/me")!, accessToken: "AT-123") - #expect(json["sub"]?.stringValue == "1") - let head = try #require(server.requests.first?.head) - #expect(head.hasPrefix("GET /me HTTP/1.1")) - #expect(head.contains("Authorization: Bearer AT-123") || head.contains("authorization: Bearer AT-123")) - } + @Test(arguments: TransportKind.allCases) func bearerHeaderOnGet(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in Self.json(200, #"{"sub":"1"}"#) } + defer { server.stop() } + let json = try await client(kind, server).getJSON(URL(string: "\(server.baseURL)/me")!, accessToken: "AT-123") + #expect(json["sub"]?.stringValue == "1") + let head = try #require(server.requests.first?.head) + #expect(head.hasPrefix("GET /me HTTP/1.1")) + #expect(head.contains("Authorization: Bearer AT-123") || head.contains("authorization: Bearer AT-123")) + } - @Test(arguments: TransportKind.allCases) func statusAndBodyErrors(kind: TransportKind) async throws { - let server = try LocalHTTPServer { request in - if request.head.hasPrefix("POST /token?") { return Self.json(400, #"{"error":"invalid_grant"}"#) } - return Self.json(503, "down") + @Test(arguments: TransportKind.allCases) func statusAndBodyErrors(kind: TransportKind) async throws { + let server = try LocalHTTPServer { request in + if request.head.hasPrefix("POST /token?") { return Self.json(400, #"{"error":"invalid_grant"}"#) } + return Self.json(503, "down") + } + defer { server.stop() } + await #expect(throws: OAuth2Error.httpStatus(503)) { try await client(kind, server).refresh(refreshToken: "r") } } - defer { server.stop() } - await #expect(throws: OAuth2Error.httpStatus(503)) { try await client(kind, server).refresh(refreshToken: "r") } - } - @Test(arguments: TransportKind.allCases) func redirectsAreNotFollowed(kind: TransportKind) async throws { - let target = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"stolen"}"#) } - defer { target.stop() } - let server = try LocalHTTPServer { _ in - .respond(status: 307, headers: ["Location": "\(target.baseURL)/steal"], body: Data()) + @Test(arguments: TransportKind.allCases) func redirectsAreNotFollowed(kind: TransportKind) async throws { + let target = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"stolen"}"#) } + defer { target.stop() } + let server = try LocalHTTPServer { _ in + .respond(status: 307, headers: ["Location": "\(target.baseURL)/steal"], body: Data()) + } + defer { server.stop() } + await #expect(throws: OAuth2Error.httpStatus(307)) { try await client(kind, server).refresh(refreshToken: "r") } + #expect(target.requests.isEmpty) } - defer { server.stop() } - await #expect(throws: OAuth2Error.httpStatus(307)) { try await client(kind, server).refresh(refreshToken: "r") } - #expect(target.requests.isEmpty) - } - @Test(arguments: TransportKind.allCases) func declaredOversizeIsRefused(kind: TransportKind) async throws { - let big = String(repeating: " ", count: 5000) + #"{"access_token":"AT"}"# - let server = try LocalHTTPServer { _ in Self.json(200, big) } - defer { server.stop() } - await #expect(throws: OAuth2Error.responseTooLarge(limit: 4096)) { - try await client(kind, server, limit: 4096).refresh(refreshToken: "r") + @Test(arguments: TransportKind.allCases) func declaredOversizeIsRefused(kind: TransportKind) async throws { + let big = String(repeating: " ", count: 5000) + #"{"access_token":"AT"}"# + let server = try LocalHTTPServer { _ in Self.json(200, big) } + defer { server.stop() } + await #expect(throws: OAuth2Error.responseTooLarge(limit: 4096)) { + try await client(kind, server, limit: 4096).refresh(refreshToken: "r") + } } - } - @Test(arguments: TransportKind.allCases) func endlessBodyStopsAtTheLimit(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in .endless } - defer { server.stop() } - let start = Date() - await #expect(throws: OAuth2Error.responseTooLarge(limit: 64 * 1024)) { - try await client(kind, server, timeout: 20, limit: 64 * 1024).refresh(refreshToken: "r") + @Test(arguments: TransportKind.allCases) func endlessBodyStopsAtTheLimit(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in .endless } + defer { server.stop() } + let start = Date() + await #expect(throws: OAuth2Error.responseTooLarge(limit: 64 * 1024)) { + try await client(kind, server, timeout: 20, limit: 64 * 1024).refresh(refreshToken: "r") + } + // The call returns as soon as the limit is passed, well before the deadline. (On Linux, + // corelibs Foundation may go on reading and discarding in the background; see the README.) + #expect(Date().timeIntervalSince(start) < 5) } - // The call returns as soon as the limit is passed, well before the deadline. (On Linux, - // corelibs Foundation may go on reading and discarding in the background; see the README.) - #expect(Date().timeIntervalSince(start) < 5) - } - @Test(arguments: TransportKind.allCases) func slowDripHitsTheWholeRequestDeadline(kind: TransportKind) async throws { - // One byte every 0.2 s never trips URLSession's idle timeout; the deadline must. - let server = try LocalHTTPServer { _ in .drip(declared: 1000, interval: 0.2) } - defer { server.stop() } - let start = Date() - await #expect(throws: OAuth2Error.timedOut) { try await client(kind, server, timeout: 1.5).refresh(refreshToken: "r") } - let elapsed = Date().timeIntervalSince(start) - #expect(elapsed >= 1.4 && elapsed < 5) - } + @Test(arguments: TransportKind.allCases) func slowDripHitsTheWholeRequestDeadline(kind: TransportKind) async throws { + // One byte every 0.2 s never trips URLSession's idle timeout; the deadline must. + let server = try LocalHTTPServer { _ in .drip(declared: 1000, interval: 0.2) } + defer { server.stop() } + let start = Date() + await #expect(throws: OAuth2Error.timedOut) { try await client(kind, server, timeout: 1.5).refresh(refreshToken: "r") } + let elapsed = Date().timeIntervalSince(start) + #expect(elapsed >= 1.4 && elapsed < 5) + } - @Test(arguments: TransportKind.allCases) func silentServerTimesOut(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in .silent } - defer { server.stop() } - await #expect(throws: OAuth2Error.timedOut) { try await client(kind, server, timeout: 1).refresh(refreshToken: "r") } - } + @Test(arguments: TransportKind.allCases) func silentServerTimesOut(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in .silent } + defer { server.stop() } + await #expect(throws: OAuth2Error.timedOut) { try await client(kind, server, timeout: 1).refresh(refreshToken: "r") } + } - @Test(arguments: TransportKind.allCases) func connectionRefusedIsANetworkError(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in .silent } - let url = server.baseURL - server.stop() - let client = OAuth2( - configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: nil, redirectURL: URL(string: "https://app.test/cb")!, http: OAuth2HTTPOptions(timeout: 5, transport: kind.make(timeout: 5, limit: 1 << 20))), - authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(url)/token")! - ) - let error = await #expect(throws: OAuth2Error.self) { try await client.refresh(refreshToken: "r") } - if case .network? = error {} else { Issue.record("expected .network, got \(String(describing: error))") } - } + @Test(arguments: TransportKind.allCases) func connectionRefusedIsANetworkError(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in .silent } + let url = server.baseURL + server.stop() + let client = OAuth2( + configuration: OAuth2ClientConfiguration(clientID: "id", clientSecret: nil, redirectURL: URL(string: "https://app.test/cb")!, http: OAuth2HTTPOptions(timeout: 5, transport: kind.make(timeout: 5, limit: 1 << 20))), + authorizationURL: URL(string: "https://auth.test/a")!, tokenURL: URL(string: "\(url)/token")! + ) + let error = await #expect(throws: OAuth2Error.self) { try await client.refresh(refreshToken: "r") } + if case .network? = error {} else { Issue.record("expected .network, got \(String(describing: error))") } + } - @Test(arguments: TransportKind.allCases) func cancellationEndsTheRequest(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in .silent } - defer { server.stop() } - let oauth = client(kind, server, timeout: 30) - let task = Task { try await oauth.refresh(refreshToken: "r") } - try await Task.sleep(nanoseconds: 300_000_000) - let start = Date() - task.cancel() - let result = await task.result - #expect(Date().timeIntervalSince(start) < 3) - if case .success = result { Issue.record("cancelled request succeeded") } - } + @Test(arguments: TransportKind.allCases) func cancellationEndsTheRequest(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in .silent } + defer { server.stop() } + let oauth = client(kind, server, timeout: 30) + let task = Task { try await oauth.refresh(refreshToken: "r") } + try await Task.sleep(nanoseconds: 300_000_000) + let start = Date() + task.cancel() + let result = await task.result + #expect(Date().timeIntervalSince(start) < 3) + if case .success = result { Issue.record("cancelled request succeeded") } + } - @Test(arguments: TransportKind.allCases) func concurrentRequests(kind: TransportKind) async throws { - let server = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"AT"}"#) } - defer { server.stop() } - let oauth = client(kind, server) - try await withThrowingTaskGroup(of: String.self) { group in - for _ in 0..<20 { group.addTask { try await oauth.refresh(refreshToken: "r").accessToken } } - for try await token in group { #expect(token == "AT") } + @Test(arguments: TransportKind.allCases) func concurrentRequests(kind: TransportKind) async throws { + let server = try LocalHTTPServer { _ in Self.json(200, #"{"access_token":"AT"}"#) } + defer { server.stop() } + let oauth = client(kind, server) + try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0..<20 { group.addTask { try await oauth.refresh(refreshToken: "r").accessToken } } + for try await token in group { #expect(token == "AT") } + } } } } From 0cc9449711c763cdbb04b62486de3c8cd4c1635f Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 22:16:17 -0500 Subject: [PATCH 09/14] NIOTransport: stop reading and skip the TLS close_notify wait on close Closing a NIOSSL channel starts a clean TLS shutdown: it sends close_notify and keeps reading (and discarding) until the peer's close_notify or NIOSSL's shutdownTimeout, 5 s by default. Against an endless body every capped call kept a connection reading at full speed for up to 5 s. On an emulated linux/amd64 Swift 6.4 release build, 50 capped calls took up to 17.7 s, the server pushed 31 GB (6 GB more after the calls had returned), and some calls ran into their own deadline. The transport now turns autoRead off before closing (on completion, cap, deadline and cancellation) and sets the TLS shutdownTimeout to 1 ms: a client that has its response, or has given up, needs no clean TLS shutdown. Same test: 0.11 s, about 136 MB, four runs out of four. Also: the test TLS server runs on its own event-loop thread (sharing the client's loops let its endless pump compete with the client), malformed HTTP (e.g. endless headers, which NIO's parser caps) maps to invalidResponse, and the wall-clock bound in the capped-calls test is dropped (each call is already shown to end at the cap). Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectOAuth2/NIOTransport.swift | 19 +++++++++++++++++-- .../NIOTransportTests.swift | 4 +++- .../Support/NIOTestServer.swift | 7 ++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Sources/PerfectOAuth2/NIOTransport.swift b/Sources/PerfectOAuth2/NIOTransport.swift index 3bacb38..b28b915 100644 --- a/Sources/PerfectOAuth2/NIOTransport.swift +++ b/Sources/PerfectOAuth2/NIOTransport.swift @@ -23,6 +23,8 @@ import NIOTLS /// refused before the body (`OAuth2Error.responseTooLarge`). /// - `Accept-Encoding: identity` is sent and any other `Content-Encoding` is refused. /// - Redirects are never followed; a 3xx comes back as is. +/// - Once a request is done (or capped, or timed out) it stops reading and closes without waiting +/// for the server's TLS close_notify (`tlsConfiguration.shutdownTimeout` is overridden). /// - **No proxy support:** unlike URLSession it ignores system and environment proxy settings. If /// you need a proxy, use `URLSessionTransport` or your own `OAuth2Transport`. public struct NIOTransport: OAuth2Transport { @@ -88,9 +90,15 @@ public struct NIOTransport: OAuth2Transport { ) let body = request.body + // When we close (done, capped, timed out), don't wait for the peer's close_notify: NIOSSL's + // clean shutdown keeps reading, and discarding, whatever the peer sends for up to + // `shutdownTimeout` (5 s by default). Against an endless body that was 5 s of full-speed + // reading per capped call. + var tls = tlsConfiguration + tls.shutdownTimeout = .milliseconds(1) let sslContext: NIOSSLContext? do { - sslContext = useTLS ? try NIOSSLContext(configuration: tlsConfiguration) : nil + sslContext = useTLS ? try NIOSSLContext(configuration: tls) : nil } catch { throw OAuth2Error.invalidConfiguration("TLS configuration: \(type(of: error))") } @@ -113,7 +121,10 @@ public struct NIOTransport: OAuth2Transport { connected.fail(error) ready.fail(error) response.fail(error) - state.value.channel?.close(promise: nil) + if let channel = state.value.channel { + try? channel.syncOptions?.setOption(.autoRead, value: false) + channel.close(promise: nil) + } } } let deadline = loop.scheduleTask(in: .nanoseconds(Int64(seconds * 1_000_000_000))) { abort(.timedOut) } @@ -207,6 +218,7 @@ public struct NIOTransport: OAuth2Transport { } case is NIOSSLError, is BoringSSLError, is NIOSSLExtraError: return .network("TLS: \(type(of: error))") case is ChannelError, is NIOConnectionError, is IOError: return .network("connection failed") + case is HTTPParserError: return .invalidResponse("malformed HTTP response") case is CancellationError: return .timedOut default: return .network(String(describing: type(of: error))) } @@ -320,6 +332,9 @@ private final class CappedResponseCollector: ChannelInboundHandler { done = true body = [] promise.completeWith(result) + // Stop reading before closing, so nothing more is pulled off the socket while the close + // (and any TLS shutdown) completes. + try? context.channel.syncOptions?.setOption(.autoRead, value: false) context.close(promise: nil) } } diff --git a/Tests/PerfectOAuth2Tests/NIOTransportTests.swift b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift index 5e5f264..5cc44be 100644 --- a/Tests/PerfectOAuth2Tests/NIOTransportTests.swift +++ b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift @@ -270,7 +270,9 @@ extension TransportSuites { // parallel and open descriptors of their own. #expect(server.openConnections == 0) #expect(server.connectionCount == calls + 1) - #expect(slowest < 2) + // No wall-clock bound: each call is already shown to end at the cap (responseTooLarge, not + // timedOut). On Linux with Swift 6.2, corelibs can still be draining an earlier URLSession + // test's body in the background and slow the machine down. } @Test func failingCallsDoNotLeak() async throws { diff --git a/Tests/PerfectOAuth2Tests/Support/NIOTestServer.swift b/Tests/PerfectOAuth2Tests/Support/NIOTestServer.swift index 5cf5724..6e81787 100644 --- a/Tests/PerfectOAuth2Tests/Support/NIOTestServer.swift +++ b/Tests/PerfectOAuth2Tests/Support/NIOTestServer.swift @@ -29,6 +29,10 @@ final class NIOTestServer: @unchecked Sendable { private let channel: any Channel private let stats: Stats + /// Its own thread: sharing the client's event loops let an endless pump starve the client's + /// reads on an emulated (amd64-on-arm64) machine. A real provider is remote, so this is closer + /// to production too. + private let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) var port: Int { channel.localAddress?.port ?? 0 } var baseURL: String { "https://auth.test:\(port)" } @@ -49,7 +53,7 @@ final class NIOTestServer: @unchecked Sendable { return try NIOSSLContext(configuration: configuration) } let stats = Stats() - channel = try ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + channel = try ServerBootstrap(group: group) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) .childChannelInitializer { child in child.eventLoop.makeCompletedFuture { @@ -65,6 +69,7 @@ final class NIOTestServer: @unchecked Sendable { func stop() { try? channel.close().wait() + try? group.syncShutdownGracefully() } static func pem(for certificate: Certificate) -> (String, String) { From 58420783912af26ddc6b0eb197d516f22c139586 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 22:45:14 -0500 Subject: [PATCH 10/14] Mirrors: don't reflect NIO internals from dump(provider) ThreadSanitizer caught dump(google.oauth2) reading NIO socket state on the test thread while an event loop closed the socket: the default Mirror of OAuth2 walked into NIOTransport's EventLoopGroup. OAuth2, NIOTransport and OAuth2HTTPOptions now have custom mirrors that show settings and the transport's type only (the client secret stays redacted). Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectOAuth2/NIOTransport.swift | 12 ++++++++++- Sources/PerfectOAuth2/OAuth2.swift | 26 ++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Sources/PerfectOAuth2/NIOTransport.swift b/Sources/PerfectOAuth2/NIOTransport.swift index b28b915..9cd0bde 100644 --- a/Sources/PerfectOAuth2/NIOTransport.swift +++ b/Sources/PerfectOAuth2/NIOTransport.swift @@ -27,7 +27,7 @@ import NIOTLS /// for the server's TLS close_notify (`tlsConfiguration.shutdownTimeout` is overridden). /// - **No proxy support:** unlike URLSession it ignores system and environment proxy settings. If /// you need a proxy, use `URLSessionTransport` or your own `OAuth2Transport`. -public struct NIOTransport: OAuth2Transport { +public struct NIOTransport: OAuth2Transport, CustomReflectable { public let timeout: TimeInterval public let maximumResponseBytes: Int private let group: any EventLoopGroup @@ -64,6 +64,16 @@ public struct NIOTransport: OAuth2Transport { self.addressOverride = addressOverride } + /// Doesn't descend into the event-loop group: reflecting live NIO internals from another thread + /// is a data race (found by ThreadSanitizer through `dump(provider)`). + public var customMirror: Mirror { + Mirror(self, children: [ + "timeout": timeout, + "maximumResponseBytes": maximumResponseBytes, + "group": String(describing: type(of: group)), + ], displayStyle: .struct) + } + public func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse { let url = request.url try requireSecure(url, "request URL") diff --git a/Sources/PerfectOAuth2/OAuth2.swift b/Sources/PerfectOAuth2/OAuth2.swift index a8272bb..ffb33ff 100644 --- a/Sources/PerfectOAuth2/OAuth2.swift +++ b/Sources/PerfectOAuth2/OAuth2.swift @@ -55,7 +55,7 @@ public struct OAuth2ClientConfiguration: Sendable, CustomStringConvertible, Cust } /// HTTP limits for token and profile requests. -public struct OAuth2HTTPOptions: Sendable { +public struct OAuth2HTTPOptions: Sendable, CustomReflectable { /// Deadline for each whole request, in seconds. public var timeout: TimeInterval /// Largest response body accepted. Token and profile responses are a few KB. @@ -76,6 +76,15 @@ public struct OAuth2HTTPOptions: Sendable { self.transport = transport self.userAgent = userAgent } + + public var customMirror: Mirror { + Mirror(self, children: [ + "timeout": timeout, + "maximumResponseBytes": maximumResponseBytes, + "transport": transport.map { String(describing: type(of: $0)) } ?? "default (NIOTransport)", + "userAgent": userAgent, + ], displayStyle: .struct) + } } /// The authorization-code flow (RFC 6749 §4.1) with PKCE (RFC 7636) for one provider's endpoints. @@ -83,7 +92,7 @@ public struct OAuth2HTTPOptions: Sendable { /// Use it directly for any standards-following provider, or wrap it in an `OAuth2Provider` that adds /// a profile fetch. It is `final` and immutable, so it is checked `Sendable`; customise providers by /// composition (`OAuth2Provider`), not subclassing. -public final class OAuth2: Sendable { +public final class OAuth2: Sendable, CustomReflectable { public let configuration: OAuth2ClientConfiguration public let authorizationURL: URL public let tokenURL: URL @@ -95,6 +104,19 @@ public final class OAuth2: Sendable { public var clientID: String { configuration.clientID } + /// The configuration (secret redacted), endpoints and the transport's type, but not the + /// transport's or the replay record's internals. + public var customMirror: Mirror { + Mirror(self, children: [ + "configuration": configuration, + "authorizationURL": authorizationURL, + "tokenURL": tokenURL, + "defaultScopes": defaultScopes, + "usesPKCE": usesPKCE, + "transport": String(describing: type(of: transport)), + ], displayStyle: .class) + } + /// - Parameters: /// - defaultScopes: used when `configuration.scopes` is nil. /// - usesPKCEByDefault: used when `configuration.usePKCE` is nil. From 32485ef72bc44cebf5ae943cc08322805cdbf95d Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 23:05:56 -0500 Subject: [PATCH 11/14] README: NIOTransport default, URLSession opt-in, proxy note, measurements Documents the new dependency set (swift-crypto plus swift-nio, swift-nio-ssl and swift-asn1 at Perfect-SMTP 6.0.0's floors), the Transports section (NIOTransport default and its bounds, the TLS name check, no proxy support; URLSessionTransport opt-in and why it isn't bounded on Linux; custom transports, with an AsyncHTTPClient example checked by building and running it locally), the 50-call measurements for both transports, the pending-authorization binding and single use, the fetchProfile trust model, and the new migration rows. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1900745..e1d5770 100644 --- a/README.md +++ b/README.md @@ -203,18 +203,18 @@ let google = Google(configuration: OAuth2ClientConfiguration( | Transport | Platform | Wall time | Read over loopback | Peak RSS | Notes | |-----------|----------|-----------|--------------------|----------|-------| -| `NIOTransport` | macOS 27 | 0.24 s | 45 MB | 59 MB | descriptors unchanged | -| `NIOTransport` | Swift 6.2, linux/arm64 | 0.44 s | 310 MB | 140 MB | descriptors unchanged | -| `NIOTransport` | Swift 6.2, linux/amd64 (emulated) | NIO_62_AMD64 | | | | -| `NIOTransport` | Swift 6.4, linux/arm64 | NIO_64_ARM64 | | | | -| `NIOTransport` | Swift 6.4, linux/amd64 (emulated) | NIO_64_AMD64 | | | | +| `NIOTransport` | macOS 27 | 0.18 s | 44 MB | 44 MB | descriptors unchanged | +| `NIOTransport` | Swift 6.2, linux/arm64 | 0.19 s | 136 MB | 141 MB | descriptors unchanged | +| `NIOTransport` | Swift 6.2, linux/amd64 (emulated) | 0.25 s | 71 MB | 87 MB | descriptors unchanged | +| `NIOTransport` | Swift 6.4, linux/arm64 | 0.20 s | 154 MB | 125 MB | descriptors unchanged | +| `NIOTransport` | Swift 6.4, linux/amd64 (emulated) | 0.24 s | 69 MB | 82 MB | descriptors unchanged | | `URLSessionTransport` | Swift 6.2, linux/arm64 | 44.9 s | 37.9 GB | 487 MB | 6 calls ended only at the 5 s deadline; 35 more descriptors open afterwards | | `URLSessionTransport` | Swift 6.2, linux/amd64 (emulated) | 127 s | 102 GB | 920 MB | 16 calls ended only at the deadline; 43 more descriptors open afterwards | | `URLSessionTransport` | Swift 6.4, linux/arm64 | 0.19 s | 365 MB | 123 MB | | -("Read over loopback" is what the server managed to send; on the NIO rows most of it is kernel -socket buffers. The NIO rows used TLS from a local test CA, the URLSession rows plain HTTP to a -socket server.) The endpoints are the providers' own fixed TLS hosts, so this needs a compromised or +("Read over loopback" is what the server managed to write before the connection closed, mostly +into kernel socket buffers on the NIO rows; peak RSS is the whole test process. The NIO rows used +TLS from a local test CA, the URLSession rows plain HTTP to a socket server.) The endpoints are the providers' own fixed TLS hosts, so this needs a compromised or impersonated provider to trigger, but the default no longer depends on that. Corelibs FoundationNetworking also leaks about 76 bytes per URLSession request (seen with LeakSanitizer). From 3a5c2cf0a6d05d8a46fdecf3a79e560ae12e348a Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Mon, 26 Oct 2026 00:34:18 -0500 Subject: [PATCH 12/14] Re-review fixes: NIOTransport request target, framing, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NIOTransport built the request target from url.path, which is percent-decoded and drops a trailing slash: /api/ went out as /api, /%3F/? as /??, and /a%20b/c%2Fd?q=%20x or /ünï were never sent (reported as a malformed response). The target is now URLComponents' percentEncodedPath (empty -> /) plus ? and percentEncodedQuery. A request the HTTP encoder or header validator refuses is reported as invalidConfiguration("HTTP request rejected ..."), not as a malformed response. Tested for each reported path. - Any Transfer-Encoding other than plain chunked (e.g. "gzip, chunked") is refused, and every Content-Encoding header and list element is checked, in both transports. - New OAuth2Error.cancelled for a cancelled calling task (was .network("cancelled"), or .timedOut from a CancellationError). - Pre-release conformers implementing validate(_ token:) are run again: it stays a requirement (through _OAuth2LegacyTokenValidation, deprecated in its documentation) with a default no-op, and exchange calls it before validate(_:pending:). Tested with a conformer implementing only the old hook. - Docs: the pending-authorization checks are consistency checks, not tamper detection (store it server-side); the reuse record is best-effort under flood; URLSessionTransport isn't reliably bounded on macOS against gzip either; fetchProfile doesn't prove the token was issued to your app (token substitution; use exchange tokens or check the audience, e.g. with Google's tokeninfo); the state -> pending -> error= order in exchange. Co-Authored-By: Claude Opus 5.5 --- README.md | 50 +++++++++---- Sources/PerfectOAuth2/Authorization.swift | 2 + Sources/PerfectOAuth2/NIOTransport.swift | 42 ++++++++--- Sources/PerfectOAuth2/OAuth2.swift | 11 +++ Sources/PerfectOAuth2/OAuth2Error.swift | 9 ++- Sources/PerfectOAuth2/OAuth2Provider.swift | 21 +++++- Sources/PerfectOAuth2/Transport.swift | 17 +++-- Tests/PerfectOAuth2Tests/HardeningTests.swift | 35 +++++++++ .../NIOTransportTests.swift | 73 ++++++++++++++++++- 9 files changed, 224 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index e1d5770..340c5a1 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,18 @@ Everything throws `OAuth2Error`: | `.stateMismatch`, `.invalidState` | CSRF check failed, or the stored state is unusable | | `.authorizationDenied(response)` | the provider redirected back with `error=` (e.g. `access_denied`) | | `.missingAuthorizationCode`, `.invalidCallback` | the callback had neither `code` nor `error`, or a `code` over 4 KB | -| `.invalidPendingAuthorization` | the `PendingAuthorization` is from another provider or client, expired, tampered with, or already used | +| `.invalidPendingAuthorization` | the `PendingAuthorization` was created by another provider or client, is expired, is inconsistent with the configuration (redirect URL, PKCE, verifier), or was already used by this instance | | `.providerError(response)` | an error body from a token or API endpoint; `response.code` is the provider's code (`invalid_grant`, `bad_verification_code`, `OAuthException`...), `response.standardCode` the RFC 6749 one if it is one | | `.httpStatus(Int)` | any other non-2xx, including redirects (never followed) | | `.invalidResponse`, `.missingUserID` | the response wasn't the JSON expected (including a non-Bearer `token_type` or a compressed body), or had no user id | | `.invalidIDToken`, `.restrictedDomainMismatch` | Google id_token and domain checks | -| `.timedOut`, `.network`, `.responseTooLarge` | transport failures | +| `.timedOut`, `.cancelled`, `.network`, `.responseTooLarge` | transport failures, or the calling task was cancelled | | `.insecureURL`, `.invalidConfiguration` | a non-HTTPS endpoint, or a bad redirect URL / parameter | +`exchange` and `completeAuthorization` check the state first, then the pending authorization, and +only then report an `error=` callback, so a provider error arriving with an expired pending +authorization throws `.invalidPendingAuthorization` rather than `.authorizationDenied`. + Error descriptions never include secrets: provider text is truncated, stripped of control characters, and has the client secret (always) and the code, verifier and tokens from the failed request (from 8 characters) replaced with ``, in raw and percent-encoded forms. @@ -133,8 +137,15 @@ request (from 8 characters) replaced with ``, in raw and percent-encod (as the quick start does). `exchange` also refuses one that was created by another provider or client ID, is older than `pendingAuthorizationMaxAge` (default 10 minutes), carries a redirect URL other than the configured one or a malformed verifier, or has already been completed by the same - provider instance (remembered in memory for the max age). That last check can't span processes, so - deleting it from the session is what makes it single-use across a cluster. + provider instance (remembered in memory for the max age). + - These are consistency checks against honest mistakes (a pending from another provider, a stale + one), **not tamper detection**: the value isn't signed, so a hand-built pending with the right + fields passes, and so does one with its nonce removed. Its integrity rests on keeping it + server-side, where the client can't change it. + - The reuse check is best-effort: it is per process, and under a flood (100,000 live entries) it + keeps only the newest half, so an older state could be accepted again within the max age. A + replay would still need the victim's server-side pending authorization. Deleting it from the + session on completion is what makes it single-use, across a cluster too. - **Redirect URI.** One fixed, registered `redirectURL` per provider: absolute HTTPS (HTTP only to `localhost`, `127.0.0.1` and `::1`), no fragment. It is sent unchanged on the authorization request and the exchange. 1.x appended `?session=` to it, which leaked the session token to the provider and into logs; @@ -150,8 +161,12 @@ request (from 8 characters) replaced with ``, in raw and percent-encod - **Profiles come from the provider's API, not from tokens.** `fetchProfile(token:)` accepts any access token, including one your app stored or received from elsewhere, and trusts only the provider's userinfo response for it. It never reads the token's id_token. Google's - `restrictedDomain` is checked there too, against userinfo's `hd`, so a token from another Google - client or a forged id_token can't get round it. + `restrictedDomain` is checked there too, against userinfo's `hd`, so a forged id_token can't get + round it. But **`fetchProfile` doesn't prove the token was issued to your app**: the access token's + audience isn't checked, so a token another app obtained for the same user (token substitution) + would be accepted. Use it with tokens from your own `exchange`/`completeAuthorization`, or verify + the audience yourself first (for Google, `https://oauth2.googleapis.com/tokeninfo` returns the + token's `aud`). - **Email.** `email` is only trustworthy when `emailVerified` is true. GitHub's profile email is never marked verified. - **Tokens on the wire.** Access tokens are sent in the `Authorization: Bearer` header, never in a URL. @@ -160,16 +175,16 @@ request (from 8 characters) replaced with ``, in raw and percent-encod - **HTTP limits.** Each request has a whole-request deadline (`OAuth2HTTPOptions.timeout`, default 30 s), a response cap (`maximumResponseBytes`, default 1 MiB, checked against `Content-Length` and while reading), no redirects, no cookies or cache, `Accept-Encoding: identity` with any other - `Content-Encoding` refused before the body is read, and HTTPS only (HTTP to loopback for testing). - With the default `NIOTransport` these are hard bounds on every platform: reading stops and the - connection closes the moment one is hit. Only `token_type` Bearer is accepted. + `Content-Encoding` (and any `Transfer-Encoding` other than plain `chunked`) refused when the + response head arrives, and HTTPS only (HTTP to loopback for testing). With the default + `NIOTransport` these are hard bounds on every platform: reading stops and the connection closes the + moment one is hit. Only `token_type` Bearer is accepted. Salesforce's identity URL must be HTTPS on + the login host or an allowed suffix (`identityHostSuffixes`) before the token is sent to it. - **TLS.** The default transport verifies the certificate chain against the system trust store and the host name (with SNI), and for a host name also requires a matching DNS subjectAltName, so a certificate that only names an IP address is refused (NIOSSL alone accepts an IP SAN equal to the dialed address). Nothing is written to the connection until the handshake and that check pass, so - a server whose certificate is refused never sees the request or the client secret in it. Salesforce's - identity URL must be HTTPS on the login host or an allowed suffix (`identityHostSuffixes`) before - the token is sent to it. + a server whose certificate is refused never sees the request or the client secret in it. - **No secrets in output.** `description`, `debugDescription`, `dump` and `Mirror` of `OAuth2ClientConfiguration`, `OAuth2Token`, `OAuthUserProfile`, `PendingAuthorization`, `AuthorizationRequest`, `AuthorizationCallback` and `OAuth2HTTPRequest` show `` for @@ -184,9 +199,10 @@ request (from 8 characters) replaced with ``, in raw and percent-encod `NIOTransport(tlsConfiguration:)` to trust a private CA. **It ignores proxy settings**: URLSession honours the system and environment proxies, this client doesn't. - **`URLSessionTransport` (opt-in).** Foundation's URLSession, with the same deadline, cap, identity - encoding and no-redirect rules. Use it if you need a proxy. It is bounded on macOS, but **not on - Linux**: corelibs Foundation keeps reading and discarding a cancelled response in the background, - and on Swift 6.2 that is severe. + encoding and no-redirect rules. Use it if you need a proxy. **It isn't reliably bounded**: on Linux + corelibs Foundation keeps reading and discarding a cancelled response in the background (severe on + Swift 6.2), and on macOS Darwin can decompress part of a gzip body before the refusal takes effect + (233–368 MB in 3 of 9 runs against a 255 KB gzip bomb). `NIOTransport` is the bounded one. - **Your own `OAuth2Transport`**, set with `OAuth2HTTPOptions(transport:)`: for example one on AsyncHTTPClient (below), or one that routes through your proxy. @@ -329,7 +345,8 @@ struct Gitea: OAuth2Provider { Override `authorizationParameters` for extra login-link parameters (their names are then reserved), `makeAuthorizationRequest(scopes:state:additionalParameters:)` to change how every login link is built (it's dispatched dynamically, also through `any OAuth2Provider`), and -`validate(_:pending:)` for token checks that must pass before an exchange returns. +`validate(_:pending:)` for token checks that must pass before an exchange returns. (The +pre-release `validate(_:)` hook is deprecated but still called, before `validate(_:pending:)`.) ## Migrating to 2.0 @@ -351,6 +368,7 @@ built (it's dispatched dynamically, also through `any OAuth2Provider`), and | `OAuth2(clientID:clientSecret:authorizationURL:tokenURL:)` | `OAuth2(configuration:authorizationURL:tokenURL:defaultScopes:usesPKCEByDefault:)` with `URL`s | | `getLoginLink(redirectURL:state:scopes:) -> String` | `authorizationRequest(scopes:state:additionalParameters:) -> AuthorizationRequest` | | subclassing `OAuth2` (`open class`) | conform to `OAuth2Provider` and wrap an `OAuth2` (now `final`) | +| pre-release `OAuth2Provider.validate(_ token:)` | `validate(_:pending:)`; the old method is deprecated but still called by `exchange`, before the new one | | `OAuth2Token` class, `webToken: [String: Any]?` | `OAuth2Token` struct; `idToken: String?` (raw, see the trust model) and `expiresIn` | | `OAuthUserProfile(userid:..., accessToken:, refreshToken:, loginType:)` | `try OAuthUserProfile(userid:..., loginType:, token:)`; `accessToken` and `refreshToken` are still readable | | `OAuth2Error` struct with `code: OAuth2ErrorCode` | `OAuth2Error` enum; the provider's code is in `.providerError(response)` / `.authorizationDenied(response)` | diff --git a/Sources/PerfectOAuth2/Authorization.swift b/Sources/PerfectOAuth2/Authorization.swift index 1a2bae9..0f39858 100644 --- a/Sources/PerfectOAuth2/Authorization.swift +++ b/Sources/PerfectOAuth2/Authorization.swift @@ -71,6 +71,8 @@ public enum PKCE { /// It is bound to the client that created it: `exchange` refuses a pending authorization from another /// provider or client ID, one older than `OAuth2ClientConfiguration.pendingAuthorizationMaxAge`, one /// whose redirect URL isn't the configured one, or one already completed by this `OAuth2` instance. +/// These are consistency checks, not tamper detection: it isn't signed, so a hand-built value with the +/// right fields passes. Keep it where the client can't modify it. public struct PendingAuthorization: Sendable, Codable, Equatable, CustomStringConvertible, CustomReflectable { public let state: String /// The PKCE code verifier, or nil when PKCE is disabled for this provider. diff --git a/Sources/PerfectOAuth2/NIOTransport.swift b/Sources/PerfectOAuth2/NIOTransport.swift index 9cd0bde..86531d2 100644 --- a/Sources/PerfectOAuth2/NIOTransport.swift +++ b/Sources/PerfectOAuth2/NIOTransport.swift @@ -21,7 +21,8 @@ import NIOTLS /// - `timeout` bounds the whole request, connect to last byte (`OAuth2Error.timedOut`). /// - At most `maximumResponseBytes` of body are read, chunked or not; a larger `Content-Length` is /// refused before the body (`OAuth2Error.responseTooLarge`). -/// - `Accept-Encoding: identity` is sent and any other `Content-Encoding` is refused. +/// - `Accept-Encoding: identity` is sent and any other `Content-Encoding` is refused, as is any +/// `Transfer-Encoding` other than plain `chunked`. /// - Redirects are never followed; a 3xx comes back as is. /// - Once a request is done (or capped, or timed out) it stops reading and closes without waiting /// for the server's TLS close_notify (`tlsConfiguration.shutdownTimeout` is overridden). @@ -82,8 +83,14 @@ public struct NIOTransport: OAuth2Transport, CustomReflectable { let useTLS = url.scheme?.lowercased() == "https" let port = url.port ?? (useTLS ? 443 : 80) let isLiteral = (try? SocketAddress(ipAddress: host, port: 0)) != nil - let basePath = url.path.isEmpty ? "/" : url.path - let target = url.query.map { basePath + "?" + $0 } ?? basePath + // The request target, exactly as encoded in the URL. (`url.path` is percent-decoded and drops a + // trailing slash: `/api/` went out as `/api`, `/%3F/` as `/?`, and `/a%20b` as an invalid + // request line.) + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw OAuth2Error.invalidConfiguration("unparseable request URL") + } + let encodedPath = components.percentEncodedPath.isEmpty ? "/" : components.percentEncodedPath + let target = components.percentEncodedQuery.map { encodedPath + "?" + $0 } ?? encodedPath let hostField = (host.contains(":") ? "[\(host)]" : host) + (url.port.map { ":\($0)" } ?? "") var headers = HTTPHeaders() @@ -192,12 +199,22 @@ public struct NIOTransport: OAuth2Transport, CustomReflectable { if useTLS { try await ready.futureResult.get() } - // Write only if nothing aborted the request after the handshake. + // Write only if nothing aborted the request after the handshake. A write the HTTP encoder + // or header validator refuses is our request's fault, not the server's: report it as + // such rather than as a malformed response. try await loop.submit { if let aborted = state.value.aborted { throw aborted } + let written = loop.makePromise(of: Void.self) + written.futureResult.whenFailure { error in + guard state.value.aborted == nil else { return } + let rejected = OAuth2Error.invalidConfiguration("HTTP request rejected (\(type(of: error)))") + state.value.aborted = rejected + response.fail(rejected) + channel.close(promise: nil) + } channel.write(HTTPClientRequestPart.head(head), promise: nil) if let body { channel.write(HTTPClientRequestPart.body(.byteBuffer(ByteBuffer(bytes: body))), promise: nil) } - channel.writeAndFlush(HTTPClientRequestPart.end(nil), promise: nil) + channel.writeAndFlush(HTTPClientRequestPart.end(nil), promise: written) }.get() let result = try await response.futureResult.get() channel.close(promise: nil) @@ -208,7 +225,7 @@ public struct NIOTransport: OAuth2Transport, CustomReflectable { throw Self.map(await Self.aborted(state, loop) ?? error) } } onCancel: { - abort(.network("cancelled")) + abort(.cancelled) } } @@ -229,7 +246,7 @@ public struct NIOTransport: OAuth2Transport, CustomReflectable { case is NIOSSLError, is BoringSSLError, is NIOSSLExtraError: return .network("TLS: \(type(of: error))") case is ChannelError, is NIOConnectionError, is IOError: return .network("connection failed") case is HTTPParserError: return .invalidResponse("malformed HTTP response") - case is CancellationError: return .timedOut + case is CancellationError: return .cancelled default: return .network(String(describing: type(of: error))) } } @@ -309,11 +326,18 @@ private final class CappedResponseCollector: ChannelInboundHandler { finish(context, .failure(OAuth2Error.responseTooLarge(limit: limit))) return } - if let encoding = head.headers.first(name: "Content-Encoding")?.trimmingCharacters(in: .whitespaces).lowercased(), - !encoding.isEmpty, encoding != "identity" { + // Every Content-Encoding header and list element, not only the first. + let encodings = head.headers[canonicalForm: "Content-Encoding"].map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + if let encoding = encodings.first(where: { !$0.isEmpty && $0 != "identity" }) { finish(context, .failure(OAuth2Error.invalidResponse("compressed response refused (Content-Encoding: \(encoding.prefix(32)))"))) return } + // Only plain chunked framing: `gzip, chunked` would hand the collector a compressed body. + let transferCodings = head.headers[canonicalForm: "Transfer-Encoding"].map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + if !transferCodings.isEmpty, transferCodings != ["chunked"] { + finish(context, .failure(OAuth2Error.invalidResponse("unsupported Transfer-Encoding (\(transferCodings.joined(separator: ", ").prefix(32)))"))) + return + } status = Int(head.status.code) case .body(var buffer): guard status != nil else { return } diff --git a/Sources/PerfectOAuth2/OAuth2.swift b/Sources/PerfectOAuth2/OAuth2.swift index ffb33ff..24e370b 100644 --- a/Sources/PerfectOAuth2/OAuth2.swift +++ b/Sources/PerfectOAuth2/OAuth2.swift @@ -206,6 +206,12 @@ public final class OAuth2: Sendable, CustomReflectable { /// Checks the callback's `state` against `pending.state`, then exchanges the code. /// + /// Order: the state is verified first, then the pending authorization, and only then is an + /// `error=` callback reported. So a provider error that arrives with an expired or foreign pending + /// authorization throws `.invalidPendingAuthorization`, not `.authorizationDenied`: the state + /// (and the pending authorization it belongs to) must check out before anything in the callback + /// is believed. + /// /// - Throws: `OAuth2Error.authorizationDenied` if the provider sent an error, /// `.stateMismatch`/`.invalidState` if the state check fails (before any network request), /// `.missingAuthorizationCode`, or the errors of the token request. @@ -357,6 +363,11 @@ public final class OAuth2: Sendable, CustomReflectable { } /// States already exchanged by one `OAuth2` instance, kept for the pending-authorization lifetime. +/// +/// Best-effort: it is per process, and at `capacity` live entries it keeps only the newest half, so +/// under a flood an older state can be accepted again within the max age. A replay still needs the +/// victim's server-side pending authorization; deleting it from the session on completion is the +/// real single-use guarantee. final class CompletedStates: @unchecked Sendable { // Guarded by `lock`. private let lock = NSLock() diff --git a/Sources/PerfectOAuth2/OAuth2Error.swift b/Sources/PerfectOAuth2/OAuth2Error.swift index 999a3ce..b9aa8c3 100644 --- a/Sources/PerfectOAuth2/OAuth2Error.swift +++ b/Sources/PerfectOAuth2/OAuth2Error.swift @@ -17,8 +17,10 @@ public enum OAuth2Error: Error, Sendable, Equatable, CustomStringConvertible { case missingAuthorizationCode /// The callback is unusable (e.g. a `code` longer than 4 KB). case invalidCallback(String) - /// The `PendingAuthorization` doesn't belong to this client, is too old, was tampered with, or was - /// already used. + /// The `PendingAuthorization` was created by another provider or client, is older than the max age, + /// is inconsistent with the configuration (redirect URL, PKCE, verifier format), or was already + /// used by this instance. These are consistency checks, not tamper detection: the value isn't + /// signed, so it must be stored server-side where the client can't change it. case invalidPendingAuthorization(String) /// An error object returned by a token or API endpoint (RFC 6749 §5.2, or a provider's own shape). case providerError(OAuth2ErrorResponse) @@ -31,6 +33,8 @@ public enum OAuth2Error: Error, Sendable, Equatable, CustomStringConvertible { case responseTooLarge(limit: Int) /// The request didn't complete within `OAuth2HTTPOptions.timeout`. case timedOut + /// The calling task was cancelled. + case cancelled /// A network-level failure (DNS, TLS, connection reset...). case network(String) /// The provider's profile had no user identifier. Never treat a sign-in without one as a login. @@ -57,6 +61,7 @@ public enum OAuth2Error: Error, Sendable, Equatable, CustomStringConvertible { case .invalidResponse(let why): return "OAuth2: invalid response: \(why)" case .responseTooLarge(let limit): return "OAuth2: response larger than \(limit) bytes" case .timedOut: return "OAuth2: request timed out" + case .cancelled: return "OAuth2: request cancelled" case .network(let why): return "OAuth2: network error: \(why)" case .missingUserID: return "OAuth2: provider returned no user id" case .invalidIDToken(let why): return "OAuth2: invalid id_token: \(why)" diff --git a/Sources/PerfectOAuth2/OAuth2Provider.swift b/Sources/PerfectOAuth2/OAuth2Provider.swift index 284b4fe..02c297a 100644 --- a/Sources/PerfectOAuth2/OAuth2Provider.swift +++ b/Sources/PerfectOAuth2/OAuth2Provider.swift @@ -5,7 +5,22 @@ import Foundation /// Conform your own types to add providers this package doesn't ship. Implement `oauth2`, /// `loginType` and `fetchProfile(token:)`; override `authorizationParameters` and `validate(_:)` if /// you need extra login-link parameters or token checks. -public protocol OAuth2Provider: Sendable { +/// The pre-release `validate(_ token:)` hook, kept so conformers written against it still have it +/// run. **Deprecated:** implement `OAuth2Provider.validate(_:pending:)` instead. +/// +/// It is a separate, underscored protocol rather than a `@available(*, deprecated)` requirement: Swift +/// doesn't warn the types that implement a deprecated requirement, only callers, and the one caller is +/// `exchange` itself. +public protocol _OAuth2LegacyTokenValidation { + /// Deprecated: implement `validate(_:pending:)`. Still called by `exchange`, before it. + func validate(_ token: OAuth2Token) throws +} + +extension _OAuth2LegacyTokenValidation { + public func validate(_ token: OAuth2Token) throws {} +} + +public protocol OAuth2Provider: Sendable, _OAuth2LegacyTokenValidation { var oauth2: OAuth2 { get } /// Identifies the provider in `OAuthUserProfile.loginType`. var loginType: String { get } @@ -69,9 +84,11 @@ extension OAuth2Provider { try makeAuthorizationRequest(scopes: scopes, state: state, additionalParameters: additionalParameters) } - /// Checks state and the pending authorization, exchanges the code, and runs `validate`. + /// Checks state and the pending authorization, exchanges the code, and runs the deprecated + /// `validate(_:)` and then `validate(_:pending:)`. public func exchange(callback: AuthorizationCallback, pending: PendingAuthorization) async throws -> OAuth2Token { let token = try await oauth2.exchange(callback: callback, pending: pending) + try validate(token) try validate(token, pending: pending) return token } diff --git a/Sources/PerfectOAuth2/Transport.swift b/Sources/PerfectOAuth2/Transport.swift index bff8b17..904ffdf 100644 --- a/Sources/PerfectOAuth2/Transport.swift +++ b/Sources/PerfectOAuth2/Transport.swift @@ -58,13 +58,16 @@ public protocol OAuth2Transport: Sendable { /// /// **Not memory- or traffic-bounded on Linux**, especially with Swift 6.2: after a capped or timed-out /// request is cancelled, corelibs Foundation keeps reading and discarding the rest of the body in the -/// background (see the README for measurements). On macOS it is bounded, given the gzip refusal below. +/// background (see the README for measurements). On macOS it is bounded except against compressed +/// bodies: Darwin may decompress part of one before the refusal below takes effect. Only +/// `NIOTransport` (or your own transport) gives hard bounds. /// /// - No cookies, cache or credential storage. /// - Redirects are not followed: the 3xx comes back as `OAuth2Error.httpStatus`. /// - `timeout` is a deadline for the whole request, not just the gap between bytes. -/// - `Accept-Encoding: identity` is sent and any other `Content-Encoding` is refused before the body -/// is read (Darwin would otherwise decompress before the size check). +/// - `Accept-Encoding: identity` is sent and any other `Content-Encoding` is refused once the response +/// head arrives. That is best-effort on Darwin: it can decompress part of the body first (seen at +/// 233–368 MB in 3 of 9 runs against a gzip bomb). /// - A `Content-Length` over `maximumResponseBytes` is refused before the body is read; otherwise the /// call returns as soon as the received body passes the limit and the task is cancelled. On Darwin /// that stops the download. On Linux, corelibs Foundation keeps reading and discarding in the @@ -132,7 +135,7 @@ public struct URLSessionTransport: OAuth2Transport { } } } onCancel: { - delegate.fail(.network("cancelled")) + delegate.fail(.cancelled) } } } @@ -225,8 +228,10 @@ private final class CappedDataDelegate: NSObject, URLSessionDataDelegate, @unche completionHandler(.cancel) return } - if let encoding = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Encoding")? - .trimmingCharacters(in: .whitespaces).lowercased(), !encoding.isEmpty, encoding != "identity" { + // Repeated headers arrive joined with commas: check every element. + let encodings = ((response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Encoding") ?? "") + .split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + if let encoding = encodings.first(where: { !$0.isEmpty && $0 != "identity" }) { fail(.invalidResponse("compressed response refused (Content-Encoding: \(encoding.prefix(32)))")) completionHandler(.cancel) return diff --git a/Tests/PerfectOAuth2Tests/HardeningTests.swift b/Tests/PerfectOAuth2Tests/HardeningTests.swift index b8164cc..cbcf2b7 100644 --- a/Tests/PerfectOAuth2Tests/HardeningTests.swift +++ b/Tests/PerfectOAuth2Tests/HardeningTests.swift @@ -286,3 +286,38 @@ extension Result where Failure == any Error { /// gzip of 1 MiB of spaces (generated with Python's gzip module). private let gzippedSpacesBase64 = "H4sIADCW3moC/+3BMQEAAADCoCrrn9IQvkABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwGpwID8QAAEAA=" + +@Suite("Provider hook compatibility and ordering") +struct HookCompatibilityTests { + /// A conformer written against the pre-release API: it implements only `validate(_:)`. + struct LegacyProvider: OAuth2Provider { + let oauth2: OAuth2 + let loginType = "legacy" + func validate(_ token: OAuth2Token) throws { throw OAuth2Error.invalidResponse("legacy validate ran") } + func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + try OAuthUserProfile(userid: "u", loginType: loginType, token: token) + } + } + + @Test func legacyValidateStillRuns() async throws { + let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) + let provider = LegacyProvider(oauth2: client(transport)) + let request = try provider.authorizationRequest() + await #expect(throws: OAuth2Error.invalidResponse("legacy validate ran")) { + try await provider.completeAuthorization(callback: callback(request), pending: request.pending) + } + } + + @Test func pendingIsCheckedBeforeAnErrorCallback() async throws { + let oauth = client(StubTransport(), maxAge: 60) + let request = try oauth.authorizationRequest() + let expired = copy(request.pending, createdAt: Date().addingTimeInterval(-120)) + let denied = AuthorizationCallback(code: nil, state: request.pending.state, error: OAuth2ErrorResponse(code: "access_denied")) + await #expect(throws: OAuth2Error.invalidPendingAuthorization("expired")) { + try await oauth.exchange(callback: denied, pending: expired) + } + await #expect(throws: OAuth2Error.authorizationDenied(OAuth2ErrorResponse(code: "access_denied"))) { + try await oauth.exchange(callback: denied, pending: request.pending) + } + } +} diff --git a/Tests/PerfectOAuth2Tests/NIOTransportTests.swift b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift index 5cc44be..f893ccf 100644 --- a/Tests/PerfectOAuth2Tests/NIOTransportTests.swift +++ b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift @@ -153,7 +153,7 @@ extension TransportSuites { task.cancel() let result = await task.result #expect(Date().timeIntervalSince(start) < 2) - guard case .failure(OAuth2Error.network("cancelled")) = result else { Issue.record("\(result)"); return } + guard case .failure(OAuth2Error.cancelled) = result else { Issue.record("\(result)"); return } } @Test func connectionRefused() async throws { @@ -287,3 +287,74 @@ extension TransportSuites { } } } + +extension TransportSuites { + @Suite("NIOTransport request targets and framing", .serialized) + struct NIOTransportTargetTests { + /// The request line the server saw for `path`. + private func requestLine(_ path: String) async throws -> (String?, Result) { + let server = try NIOTestServer(certificate: nil) { _ in .respond(status: 200, headers: [:], body: Data("{}".utf8)) } + defer { server.stop() } + let transport = NIOTransport(timeout: 5) + let result: Result + do { + result = .success(try await transport.send(OAuth2HTTPRequest(method: .get, url: URL(string: "http://127.0.0.1:\(server.port)\(path)")!))) + } catch { + result = .failure(error) + } + return (server.requests.first?.head.split(separator: "\r\n").first.map(String.init), result) + } + + @Test func pathsAreSentAsEncoded() async throws { + // Reported by the re-review: `url.path` decoded these and dropped the trailing slash. + let cases: [(String, String)] = [ + ("/api/", "GET /api/ HTTP/1.1"), + ("/%3F/?", "GET /%3F/? HTTP/1.1"), + ("/a%20b/c%2Fd?q=%20x&y=1", "GET /a%20b/c%2Fd?q=%20x&y=1 HTTP/1.1"), + ("/x%0D%0AX-Injected:%201", "GET /x%0D%0AX-Injected:%201 HTTP/1.1"), + ("/a/../b", "GET /a/../b HTTP/1.1"), + ("", "GET / HTTP/1.1"), + ("?x=1", "GET /?x=1 HTTP/1.1"), + ] + for (path, expected) in cases { + let (line, result) = try await requestLine(path) + #expect(line == expected, "\(path)") + if case .failure(let error) = result { Issue.record("\(path): \(error)") } + } + // A non-ASCII path is percent-encoded by URL(string:), and sent as such. + let (line, _) = try await requestLine("/ünï") + #expect(line == "GET /%C3%BCn%C3%AF HTTP/1.1") + } + + @Test func requestTheEncoderRefusesIsReportedAsOurs() async throws { + let server = try NIOTestServer(certificate: nil) { _ in .respond(status: 200, headers: [:], body: Data("{}".utf8)) } + defer { server.stop() } + let request = OAuth2HTTPRequest(method: .get, url: URL(string: "http://127.0.0.1:\(server.port)/")!, headers: ["X-Bad": "a\r\nX-Injected: 1"]) + let error = await #expect(throws: OAuth2Error.self) { try await NIOTransport(timeout: 5).send(request) } + guard case .invalidConfiguration(let why)? = error else { Issue.record("\(String(describing: error))"); return } + #expect(why.hasPrefix("HTTP request rejected")) + #expect(server.requests.isEmpty) + } + + @Test func onlyPlainChunkedAndIdentityAreAccepted() async throws { + for head in [ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Encoding: identity\r\nContent-Encoding: gzip\r\nContent-Length: 2\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Encoding: identity, br\r\nContent-Length: 2\r\n\r\n{}", + ] { + let server = try NIOTestServer(certificate: nil) { _ in .rawHead(head) } + defer { server.stop() } + let error = await #expect(throws: OAuth2Error.self) { + try await NIOTransport(timeout: 3).send(OAuth2HTTPRequest(method: .get, url: URL(string: "http://127.0.0.1:\(server.port)/")!)) + } + guard case .invalidResponse(let why)? = error else { Issue.record("\(head): \(String(describing: error))"); continue } + #expect(why.hasPrefix("unsupported Transfer-Encoding") || why.hasPrefix("compressed response refused"), "\(why)") + } + let chunked = try NIOTestServer(certificate: nil) { _ in .rawHead("HTTP/1.1 200 OK\r\nTransfer-Encoding: Chunked\r\nConnection: close\r\n\r\n2\r\n{}\r\n0\r\n\r\n") } + defer { chunked.stop() } + let response = try await NIOTransport(timeout: 3).send(OAuth2HTTPRequest(method: .get, url: URL(string: "http://127.0.0.1:\(chunked.port)/")!)) + #expect(String(decoding: response.body, as: UTF8.self) == "{}") + } + } +} From 7d74e0ef617f8ff52de4c4647705f5b82c43b463 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Mon, 26 Oct 2026 00:47:00 -0500 Subject: [PATCH 13/14] Replay record: evict by insertion order The reviewer's flood probe (250k live inserts at one instant) found that eviction, which sorted by timestamp, kept an arbitrary half when timestamps tied, so even the most recent state could be accepted again. Entries now carry an insertion sequence and eviction keeps the newest half. Still best-effort, as documented; tested. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectOAuth2/OAuth2.swift | 21 +++++++++++-------- Tests/PerfectOAuth2Tests/HardeningTests.swift | 10 +++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Sources/PerfectOAuth2/OAuth2.swift b/Sources/PerfectOAuth2/OAuth2.swift index 24e370b..7a02a41 100644 --- a/Sources/PerfectOAuth2/OAuth2.swift +++ b/Sources/PerfectOAuth2/OAuth2.swift @@ -369,24 +369,27 @@ public final class OAuth2: Sendable, CustomReflectable { /// victim's server-side pending authorization; deleting it from the session on completion is the /// real single-use guarantee. final class CompletedStates: @unchecked Sendable { - // Guarded by `lock`. + // Guarded by `lock`. `sequence` orders entries by insertion, so eviction keeps the newest ones + // even when many share a timestamp. private let lock = NSLock() - private var entries: [String: Date] = [:] - private static let capacity = 100_000 + private var entries: [String: (seen: Date, sequence: UInt64)] = [:] + private var sequence: UInt64 = 0 + static let capacity = 100_000 /// Records `state`; returns false if it was already recorded and hasn't aged out. func insert(_ state: String, now: Date, maxAge: TimeInterval) -> Bool { lock.withLock { - if let seen = entries[state], now.timeIntervalSince(seen) <= maxAge { return false } + if let entry = entries[state], now.timeIntervalSince(entry.seen) <= maxAge { return false } if entries.count >= Self.capacity { - entries = entries.filter { now.timeIntervalSince($0.value) <= maxAge } - // Still full of live entries: drop the oldest half rather than grow without bound. + entries = entries.filter { now.timeIntervalSince($0.value.seen) <= maxAge } + // Still full of live entries: drop the older half rather than grow without bound. if entries.count >= Self.capacity { - let keep = entries.sorted { $0.value > $1.value }.prefix(Self.capacity / 2) - entries = Dictionary(uniqueKeysWithValues: keep.map { ($0.key, $0.value) }) + let cutoff = sequence - UInt64(Self.capacity / 2) + entries = entries.filter { $0.value.sequence > cutoff } } } - entries[state] = now + sequence += 1 + entries[state] = (now, sequence) return true } } diff --git a/Tests/PerfectOAuth2Tests/HardeningTests.swift b/Tests/PerfectOAuth2Tests/HardeningTests.swift index cbcf2b7..92395e5 100644 --- a/Tests/PerfectOAuth2Tests/HardeningTests.swift +++ b/Tests/PerfectOAuth2Tests/HardeningTests.swift @@ -108,6 +108,16 @@ struct PendingBindingTests { } } + @Test func floodKeepsTheNewestHalf() { + let states = CompletedStates() + let now = Date() + let total = CompletedStates.capacity + 10 + for index in 0.. Date: Mon, 26 Oct 2026 01:10:26 -0500 Subject: [PATCH 14/14] Remove the legacy validate(_:) shim validate(_:) and OAuth2Provider never existed on main or in any tag, only on this unreleased branch, so _OAuth2LegacyTokenValidation protected nobody. It also exposed a no-op validate(_:) on every built-in provider (try google.validate(token) compiled and did nothing). The protocol, its default implementation, the call in exchange, the migration line and its test are removed; validate(_:pending:) is the only validation hook. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +--- Sources/PerfectOAuth2/OAuth2Provider.swift | 21 ++--------------- Tests/PerfectOAuth2Tests/HardeningTests.swift | 23 ++----------------- 3 files changed, 5 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 340c5a1..a9d83ee 100644 --- a/README.md +++ b/README.md @@ -345,8 +345,7 @@ struct Gitea: OAuth2Provider { Override `authorizationParameters` for extra login-link parameters (their names are then reserved), `makeAuthorizationRequest(scopes:state:additionalParameters:)` to change how every login link is built (it's dispatched dynamically, also through `any OAuth2Provider`), and -`validate(_:pending:)` for token checks that must pass before an exchange returns. (The -pre-release `validate(_:)` hook is deprecated but still called, before `validate(_:pending:)`.) +`validate(_:pending:)` for token checks that must pass before an exchange returns. ## Migrating to 2.0 @@ -368,7 +367,6 @@ pre-release `validate(_:)` hook is deprecated but still called, before `validate | `OAuth2(clientID:clientSecret:authorizationURL:tokenURL:)` | `OAuth2(configuration:authorizationURL:tokenURL:defaultScopes:usesPKCEByDefault:)` with `URL`s | | `getLoginLink(redirectURL:state:scopes:) -> String` | `authorizationRequest(scopes:state:additionalParameters:) -> AuthorizationRequest` | | subclassing `OAuth2` (`open class`) | conform to `OAuth2Provider` and wrap an `OAuth2` (now `final`) | -| pre-release `OAuth2Provider.validate(_ token:)` | `validate(_:pending:)`; the old method is deprecated but still called by `exchange`, before the new one | | `OAuth2Token` class, `webToken: [String: Any]?` | `OAuth2Token` struct; `idToken: String?` (raw, see the trust model) and `expiresIn` | | `OAuthUserProfile(userid:..., accessToken:, refreshToken:, loginType:)` | `try OAuthUserProfile(userid:..., loginType:, token:)`; `accessToken` and `refreshToken` are still readable | | `OAuth2Error` struct with `code: OAuth2ErrorCode` | `OAuth2Error` enum; the provider's code is in `.providerError(response)` / `.authorizationDenied(response)` | diff --git a/Sources/PerfectOAuth2/OAuth2Provider.swift b/Sources/PerfectOAuth2/OAuth2Provider.swift index 02c297a..8f681f1 100644 --- a/Sources/PerfectOAuth2/OAuth2Provider.swift +++ b/Sources/PerfectOAuth2/OAuth2Provider.swift @@ -5,22 +5,7 @@ import Foundation /// Conform your own types to add providers this package doesn't ship. Implement `oauth2`, /// `loginType` and `fetchProfile(token:)`; override `authorizationParameters` and `validate(_:)` if /// you need extra login-link parameters or token checks. -/// The pre-release `validate(_ token:)` hook, kept so conformers written against it still have it -/// run. **Deprecated:** implement `OAuth2Provider.validate(_:pending:)` instead. -/// -/// It is a separate, underscored protocol rather than a `@available(*, deprecated)` requirement: Swift -/// doesn't warn the types that implement a deprecated requirement, only callers, and the one caller is -/// `exchange` itself. -public protocol _OAuth2LegacyTokenValidation { - /// Deprecated: implement `validate(_:pending:)`. Still called by `exchange`, before it. - func validate(_ token: OAuth2Token) throws -} - -extension _OAuth2LegacyTokenValidation { - public func validate(_ token: OAuth2Token) throws {} -} - -public protocol OAuth2Provider: Sendable, _OAuth2LegacyTokenValidation { +public protocol OAuth2Provider: Sendable { var oauth2: OAuth2 { get } /// Identifies the provider in `OAuthUserProfile.loginType`. var loginType: String { get } @@ -84,11 +69,9 @@ extension OAuth2Provider { try makeAuthorizationRequest(scopes: scopes, state: state, additionalParameters: additionalParameters) } - /// Checks state and the pending authorization, exchanges the code, and runs the deprecated - /// `validate(_:)` and then `validate(_:pending:)`. + /// Checks state and the pending authorization, exchanges the code, and runs `validate(_:pending:)`. public func exchange(callback: AuthorizationCallback, pending: PendingAuthorization) async throws -> OAuth2Token { let token = try await oauth2.exchange(callback: callback, pending: pending) - try validate(token) try validate(token, pending: pending) return token } diff --git a/Tests/PerfectOAuth2Tests/HardeningTests.swift b/Tests/PerfectOAuth2Tests/HardeningTests.swift index 92395e5..a9c6a30 100644 --- a/Tests/PerfectOAuth2Tests/HardeningTests.swift +++ b/Tests/PerfectOAuth2Tests/HardeningTests.swift @@ -297,27 +297,8 @@ extension Result where Failure == any Error { /// gzip of 1 MiB of spaces (generated with Python's gzip module). private let gzippedSpacesBase64 = "H4sIADCW3moC/+3BMQEAAADCoCrrn9IQvkABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwGpwID8QAAEAA=" -@Suite("Provider hook compatibility and ordering") -struct HookCompatibilityTests { - /// A conformer written against the pre-release API: it implements only `validate(_:)`. - struct LegacyProvider: OAuth2Provider { - let oauth2: OAuth2 - let loginType = "legacy" - func validate(_ token: OAuth2Token) throws { throw OAuth2Error.invalidResponse("legacy validate ran") } - func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { - try OAuthUserProfile(userid: "u", loginType: loginType, token: token) - } - } - - @Test func legacyValidateStillRuns() async throws { - let transport = StubTransport().on(tokenURL, #"{"access_token":"AT"}"#) - let provider = LegacyProvider(oauth2: client(transport)) - let request = try provider.authorizationRequest() - await #expect(throws: OAuth2Error.invalidResponse("legacy validate ran")) { - try await provider.completeAuthorization(callback: callback(request), pending: request.pending) - } - } - +@Suite("Callback ordering") +struct CallbackOrderingTests { @Test func pendingIsCheckedBeforeAnErrorCallback() async throws { let oauth = client(StubTransport(), maxAge: 60) let request = try oauth.authorizationRequest()