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/.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..1195998 100644 --- a/Package.swift +++ b/Package.swift @@ -7,15 +7,41 @@ 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"), + // 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"), + .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 648b3c1..a9d83ee 100644 --- a/README.md +++ b/README.md @@ -1,221 +1,412 @@ # 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 -``` +## Requirements -## Configuration +- 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. +- 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. -Set config before your server starts. All properties are `nonisolated(unsafe) static var` to satisfy Swift 6 global state rules. +## Quick start ```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 +import Foundation +import PerfectOAuth2 -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. +// 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")! + ) +) -### Step 1: Redirect to provider +// 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, one key per provider. + session["oauth.pending.google"] = try JSONEncoder().encode(request.pending) + return request.url // redirect the browser here +} -```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 +// Route 2: GET /auth/google/callback?code=...&state=... +func finishSignIn(callbackURL: URL, session: inout [String: Data]) async throws -> OAuthUserProfile { + // 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) + let profile = try await google.completeAuthorization(callbackURL: callbackURL, pending: pending) + // profile.userid is never empty. Key accounts on (profile.loginType, profile.userid). + return profile +} ``` -Or use the instance API for custom scopes: +`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. -```swift -let g = Google(clientID: GoogleConfig.appid, clientSecret: GoogleConfig.secret) -let url = g.loginURL(state: csrf, sessionToken: session.token, scopes: ["profile", "email"]) -``` +`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`. -To get a Google refresh token, pass `offlineAccess: true` (see [Refreshing an access token](#refreshing-an-access-token)). - -### Step 2: Handle the callback +### Refreshing ```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 -) - -// 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 +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. ``` -### OAuthUserProfile - -`processAuthResponse` returns an `OAuthUserProfile`: +`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`, `.invalidCallback` | the callback had neither `code` nor `error`, or a `code` over 4 KB | +| `.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`, `.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. + +## 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. 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). + - 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; + 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 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 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. + 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 reading), no redirects, no cookies or cache, `Accept-Encoding: identity` with any other + `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. +- **No secrets in output.** `description`, `debugDescription`, `dump` and `Mirror` of + `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 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. ```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" -} +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 +)) ``` -### Manual token exchange (advanced) - -If you need access to the raw token (e.g. to call provider APIs beyond the profile): +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.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 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). + +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 -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) -``` - -### Refreshing an access token +import AsyncHTTPClient +import Foundation +import NIOCore +import PerfectOAuth2 -If the provider issued a refresh token, exchange it for a new access token with `refresh` -(the RFC 6749 `refresh_token` grant): +/// 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 + } + } +} -```swift -let provider = Google(clientID: GoogleConfig.appid, clientSecret: GoogleConfig.secret) -let token = try await provider.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. +// 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)) +)) ``` -`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. - -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 (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: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"]` | -## 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 (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 + +### 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:)` / `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` | +| 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. +- **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). +- **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, 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. -- **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). diff --git a/Sources/PerfectOAuth2/Authorization.swift b/Sources/PerfectOAuth2/Authorization.swift new file mode 100644 index 0000000..0f39858 --- /dev/null +++ b/Sources/PerfectOAuth2/Authorization.swift @@ -0,0 +1,293 @@ +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) + } + + /// 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)))) + } +} + +/// 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. **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. +/// 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. + 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, + 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), tokenURL: \(tokenURL), clientID: \(clientID), createdAt: \(createdAt))" + } + + public var customMirror: Mirror { + Mirror(self, children: [ + "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, 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. +/// +/// 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? + + 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, 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 } + return values.count == 1 ? values[0].value : nil + } + code = single("code") + state = single("state") + 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"), + 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 ?? []) + } + + 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) + 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.. 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 + 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 { + // 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]" +} + +/// 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 + 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/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/IDToken.swift b/Sources/PerfectOAuth2/IDToken.swift new file mode 100644 index 0000000..ab117af --- /dev/null +++ b/Sources/PerfectOAuth2/IDToken.swift @@ -0,0 +1,71 @@ +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, + nonce: String? = nil, + now: Date = Date(), + 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") } + 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") + } + } + // 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 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/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/NIOTransport.swift b/Sources/PerfectOAuth2/NIOTransport.swift new file mode 100644 index 0000000..86531d2 --- /dev/null +++ b/Sources/PerfectOAuth2/NIOTransport.swift @@ -0,0 +1,374 @@ +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, 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). +/// - **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, CustomReflectable { + 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 + } + + /// 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") + 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 + // 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() + 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 + + // 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: tls) : 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) + 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) } + 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. 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: written) + }.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(.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 HTTPParserError: return .invalidResponse("malformed HTTP response") + case is CancellationError: return .cancelled + 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 + } + // 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 } + 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) + // 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/Sources/PerfectOAuth2/OAuth2.swift b/Sources/PerfectOAuth2/OAuth2.swift index c7bff59..7a02a41 100644 --- a/Sources/PerfectOAuth2/OAuth2.swift +++ b/Sources/PerfectOAuth2/OAuth2.swift @@ -1,100 +1,396 @@ 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? + /// How long a `PendingAuthorization` stays usable, in seconds (default 10 minutes). + public var pendingAuthorizationMaxAge: TimeInterval + 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, + pendingAuthorizationMaxAge: TimeInterval = 600, + http: OAuth2HTTPOptions = OAuth2HTTPOptions() + ) { + self.clientID = clientID + self.clientSecret = clientSecret + self.redirectURL = redirectURL + self.scopes = scopes + self.usePKCE = usePKCE + self.pendingAuthorizationMaxAge = pendingAuthorizationMaxAge + 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, + "pendingAuthorizationMaxAge": pendingAuthorizationMaxAge, + "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, CustomReflectable { + /// 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 `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 - 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 + } + + 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. +/// +/// 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, CustomReflectable { + 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 + let completed = CompletedStates() + + 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. + 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 ?? NIOTransport( + 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", "nonce", + ] - 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, + /// 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: + /// - 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, 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] = [], + nonce: String? = nil + ) 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") + } + 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"), + 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")) + } + 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, nonce: nonce, + tokenURL: tokenURL, clientID: clientID + ) + ) + } + + /// 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. + /// + /// 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 } + 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") } - return token + + 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) } - open func exchange(code: String, state: String, redirectURL: String) async throws -> OAuth2Token { - return try await exchange(authorizationCode: AuthorizationCode(code: code, redirectURL: redirectURL)) + /// 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 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 !scopes.isEmpty { fields.append(("scope", scopes.joined(separator: " "))) } + if includeClientSecret, let secret = configuration.clientSecret { + fields.append(("client_secret", secret)) } - if includeClientSecret { - postBody["client_secret"] = clientSecret + 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) + // 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) } - 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 + 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)") } - guard let token = OAuth2Token(json: data) else { - if let error = OAuth2Error(json: data) { - throw error + } +} + +/// 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`. `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: (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 entry = entries[state], now.timeIntervalSince(entry.seen) <= maxAge { return false } + if entries.count >= Self.capacity { + 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 cutoff = sequence - UInt64(Self.capacity / 2) + entries = entries.filter { $0.value.sequence > cutoff } + } } - throw InvalidAPIResponse() + sequence += 1 + entries[state] = (now, sequence) + return true } - return token } } diff --git a/Sources/PerfectOAuth2/OAuth2Error.swift b/Sources/PerfectOAuth2/OAuth2Error.swift index dc85df7..b9aa8c3 100644 --- a/Sources/PerfectOAuth2/OAuth2Error.swift +++ b/Sources/PerfectOAuth2/OAuth2Error.swift @@ -1,31 +1,188 @@ -public struct OAuth2Error: Error, Sendable { - public let code: OAuth2ErrorCode - public let description: String - public let uri: String +import Foundation - public init(code: OAuth2ErrorCode, description: String? = nil, uri: String? = nil) { - self.code = code - self.description = description ?? code.rawValue - self.uri = uri ?? "" +/// 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 ``: 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 + /// 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 + /// The callback is unusable (e.g. a `code` longer than 4 KB). + case invalidCallback(String) + /// 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) + /// 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 + /// 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. + 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 .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)" + 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)" + 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: []) + } + + /// - 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) + } + + public var description: String { + var text = code + if let errorDescription { text += " (\(errorDescription))" } + return text } - 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 - ) + /// 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 } } - 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"]) + 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 { + 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], 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, alwaysRedact: alwaysRedact) + } + return nil + } + if let code = error.stringValue { + return OAuth2ErrorResponse( + code: code, + errorDescription: json["error_description"]?.stringValue, + errorURI: json["error_uri"]?.stringValue, + redacting: secrets, + alwaysRedact: alwaysRedact + ) + } + 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, + alwaysRedact: alwaysRedact + ) + } + 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 +195,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..8f681f1 --- /dev/null +++ b/Sources/PerfectOAuth2/OAuth2Provider.swift @@ -0,0 +1,109 @@ +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`). Their names can't also be + /// passed in `additionalParameters`. + var authorizationParameters: [URLQueryItem] { get } + /// 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, 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( + scopes: [String]? = nil, + state: String? = nil, + additionalParameters: [URLQueryItem] = [] + ) throws -> AuthorizationRequest { + try makeAuthorizationRequest(scopes: scopes, state: state, additionalParameters: additionalParameters) + } + + /// 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, pending: pending) + 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..1501744 100644 --- a/Sources/PerfectOAuth2/OAuth2Token.swift +++ b/Sources/PerfectOAuth2/OAuth2Token.swift @@ -1,68 +1,112 @@ 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` 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? + /// `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) + } + // 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, - 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..438b4b2 100644 --- a/Sources/PerfectOAuth2/OAuthUserProfile.swift +++ b/Sources/PerfectOAuth2/OAuthUserProfile.swift @@ -1,27 +1,82 @@ -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, + // Keys only: a provider response could carry a token-like value. + "raw": raw.objectValue.map { "" } ?? "", + ], 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..cb13e83 100644 --- a/Sources/PerfectOAuth2/Providers/Google.swift +++ b/Sources/PerfectOAuth2/Providers/Google.swift @@ -1,112 +1,115 @@ 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, 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 `hd` claim and userinfo field). + public let restrictedDomain: String? + + static let issuers: Set = ["https://accounts.google.com", "accounts.google.com"] + static let userInfoURL = URL(string: "https://openidconnect.googleapis.com/v1/userinfo")! -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" + public init(configuration: OAuth2ClientConfiguration, restrictedDomain: String? = nil) { + var configuration = configuration + if let scopes = configuration.scopes { + configuration.scopes = Self.withOpenID(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() } - 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 + static func withOpenID(_ scopes: [String]) -> [String] { + scopes.contains("openid") ? scopes : ["openid"] + scopes } - /// 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. + 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 /// `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 = false, + additionalParameters: [URLQueryItem] = [] + ) throws -> AuthorizationRequest { + var extra = additionalParameters if offlineAccess { - url += "&access_type=offline&prompt=consent" + extra += [URLQueryItem(name: "access_type", value: "offline"), URLQueryItem(name: "prompt", value: "consent")] } - return url + return try makeAuthorizationRequest(scopes: scopes, state: state, additionalParameters: extra) } - 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) + /// 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 { + throw OAuth2Error.invalidIDToken("no id_token; request the openid scope") + } + 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 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) - } - - public static func loginURL(state: String, sessionToken: String, scopes: [String] = ["profile"]) -> String { - loginURL(state: state, sessionToken: sessionToken, scopes: scopes, offlineAccess: false) - } - - 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) - } - - 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" + /// Fetches the userinfo for `token.accessToken` (any access token; see the type's documentation). + public func fetchProfile(token: OAuth2Token) async throws -> OAuthUserProfile { + let info = try await oauth2.getJSON(Self.userInfoURL, accessToken: token.accessToken) + if let restrictedDomain { + guard info["hd"]?.stringValue?.lowercased() == restrictedDomain else { + throw OAuth2Error.restrictedDomainMismatch + } + } + 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..479d3d4 --- /dev/null +++ b/Sources/PerfectOAuth2/Providers/Removed.swift @@ -0,0 +1,35 @@ +// 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: "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)") +public struct APIConnectionError {} diff --git a/Sources/PerfectOAuth2/Providers/Salesforce.swift b/Sources/PerfectOAuth2/Providers/Salesforce.swift index 69a31a8..a9487c4 100644 --- a/Sources/PerfectOAuth2/Providers/Salesforce.swift +++ b/Sources/PerfectOAuth2/Providers/Salesforce.swift @@ -1,68 +1,76 @@ 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() {} -} - -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" - ) - } +/// 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 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] - // 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 static let defaultIdentityHostSuffixes = [".salesforce.com", ".salesforce.mil"] - 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) + /// - Parameter loginHost: `login.salesforce.com` (default), `test.salesforce.com` for sandboxes, + /// or a My Domain host such as `example.my.salesforce.com`. + /// - 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"), + let tokenURL = URL(string: "https://\(host)/services/oauth2/token") else { + 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, + tokenURL: tokenURL, + defaultScopes: ["id"], + usesPKCEByDefault: true + ) } - 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 identityHostSuffixes.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..904ffdf --- /dev/null +++ b/Sources/PerfectOAuth2/Transport.swift @@ -0,0 +1,288 @@ +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 `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. +public protocol OAuth2Transport: Sendable { + func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse +} + +/// 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 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 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 +/// 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 + + /// `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.isNaN ? 0.001 : min(max(timeout, 0.001), 86_400) + self.maximumResponseBytes = max(0, 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) + } + // 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 delegate = CappedDataDelegate(limit: maximumResponseBytes) + // 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 // clamped in init + let deadline = DispatchWorkItem { delegate.fail(.timedOut) } + DispatchQueue.global().asyncAfter(deadline: .now() + seconds, execute: deadline) + defer { + deadline.cancel() + delegate.invalidate() + } + + try Task.checkCancellation() + let timeout = self.timeout + let finalRequest = urlRequest + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // 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(.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? + private var session: URLSession? + private var invalidated = false + + init(limit: Int) { self.limit = limit } + + /// 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 + /// 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 + return pendingFailure + } + self.continuation = continuation + return nil + } + if let failure { + continuation.resume(throwing: failure) + return false + } + return true + } + + /// 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 + } + // 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 + } + 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..58a004c --- /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: 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) + } + #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, tokenURL: client.tokenURL, clientID: client.clientID) + 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")!, tokenURL: client.tokenURL, clientID: client.clientID) + 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")!, 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] + 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/HardeningTests.swift b/Tests/PerfectOAuth2Tests/HardeningTests.swift new file mode 100644 index 0000000..a9c6a30 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/HardeningTests.swift @@ -0,0 +1,314 @@ +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 floodKeepsTheNewestHalf() { + let states = CompletedStates() + let now = Date() + let total = CompletedStates.capacity + 10 + for index in 0.. 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) + } + 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)) + } + 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 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) + } + } +} + +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=" + +@Suite("Callback ordering") +struct CallbackOrderingTests { + @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 new file mode 100644 index 0000000..f893ccf --- /dev/null +++ b/Tests/PerfectOAuth2Tests/NIOTransportTests.swift @@ -0,0 +1,360 @@ +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) } +} + +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(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.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") } + } + } +} + +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) + } + + @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)") + } + } + } +} + +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) + // 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 { + 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) + } + } +} + +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) == "{}") + } + } +} 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..d29d87b --- /dev/null +++ b/Tests/PerfectOAuth2Tests/ProviderTests.swift @@ -0,0 +1,418 @@ +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 { + 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() + 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["nonce"] == nil) // PKCE on: no nonce + #expect(query["access_type"] == nil) + } + + @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 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 { + 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, token), profile: (googleUserInfo, 200, body)) + } + } + await #expect(throws: OAuth2Error.httpStatus(500)) { + _ = 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, token), 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 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")), + ("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, info) + ) + } + } + // 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) }, 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, info) + ) + } + + @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 == "_" }) + let claims = try IDTokenClaims.validate(token, issuers: Google.issuers, clientID: "client id&x") + #expect(claims.claims["hd"]?.stringValue == "corp.test") + } + + @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) + } + } + } + } +} + +@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..91bf8b7 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/Support/LocalHTTPServer.swift @@ -0,0 +1,201 @@ +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 + /// Writes `head` verbatim, then `chunk` `count` times (nil: until the client goes away). + case raw(head: String, chunk: Data, count: Int?) + } + + 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(POSIXErrorCode(rawValue: errno) ?? .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 { + let code = POSIXErrorCode(rawValue: errno) ?? .EIO + close(fd) + throw POSIXError(code) + } + 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() + } + + /// 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() { + lock.withLock { + if stopped { return } + stopped = true + // 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)) } + } + } + + 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 { continue } + #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 } // 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) + } + } + 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[.. 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: group) + .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() + try? group.syncShutdownGracefully() + } + + 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/StubTransport.swift b/Tests/PerfectOAuth2Tests/Support/StubTransport.swift new file mode 100644 index 0000000..a01f0e4 --- /dev/null +++ b/Tests/PerfectOAuth2Tests/Support/StubTransport.swift @@ -0,0 +1,90 @@ +import Foundation +@testable import PerfectOAuth2 + +/// An in-memory transport: canned responses keyed by "host/path", every request recorded. +/// Unknown URLs throw, so nothing can reach a network. +final class StubTransport: OAuth2Transport, @unchecked Sendable { + // Guarded by `lock`. + private let lock = NSLock() + private var routes: [String: OAuth2HTTPResponse] = [:] + private var recorded: [OAuth2HTTPRequest] = [] + + init() {} + + @discardableResult + func on(_ url: String, status: Int = 200, _ body: String) -> 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/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/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 new file mode 100644 index 0000000..959581e --- /dev/null +++ b/Tests/PerfectOAuth2Tests/TransportTests.swift @@ -0,0 +1,150 @@ +import Foundation +import Testing +@testable import PerfectOAuth2 + +/// 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) + } + } +} + +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)) + } + + @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 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") } + } + + @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) + } + + @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") + } + // 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 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 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") } + } + } + } +}