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 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") }
+ }
+ }
+ }
+}