Repository navigation
Perfect-Authentication 2.0.0: instance config, PKCE, bounded NIO transport, security fixes - #12
Merged
Merged
Conversation
Security fixes: - A profile without a user id now throws OAuth2Error.missingUserID; 1.x returned userid "" when the profile fetch failed or lacked an id. - HTTP errors are no longer swallowed: typed OAuth2Error cases for provider error bodies (RFC 6749, GitHub, Slack ok:false, Facebook Graph), HTTP status, non-JSON, timeouts, network failures and oversized responses. - URLSessionTransport: ephemeral session per request, whole-request deadline, response size cap (Content-Length pre-check plus streaming cap), redirects not followed, HTTPS required (HTTP only to loopback). - State: stored value must be 32-512 characters, returned value non-empty, constant-time compare; AuthorizationState.generate() uses SystemRandomNumberGenerator. - PKCE (RFC 7636 S256) via swift-crypto's SHA256; on by default for Google, GitHub and Salesforce, off for Facebook, LinkedIn and Slack. - Access tokens go in the Authorization header for every provider (Google, Facebook and Slack put them in the query string). Facebook also sends appsecret_proof. - Every URL is built with URLComponents and fully encoded (hd=, redirect_uri); the session token is no longer appended to the redirect URI. - id_token payloads are decoded as base64url; Google checks iss, aud/azp, exp, iat and sub, and restrictedDomain requires the validated hd claim. - Secrets are redacted from description, dump and Mirror output, and from provider error text. API: - OAuth2ClientConfiguration passed to each provider's init replaces the nonisolated(unsafe) global *Config statics; the static helpers are removed. - OAuth2 is final and checked Sendable; providers are final classes conforming to the new OAuth2Provider protocol (the extension point for custom providers). - JSONValue replaces [String: Any]; OAuth2Token and OAuthUserProfile are structs. Linkedin -> LinkedIn, SalesForce -> Salesforce. - Provider endpoints updated: Google OIDC userinfo, Slack Sign in with Slack (OIDC), Facebook Graph v23.0 (configurable), Salesforce login host configurable; GitHub default scope read:user. Tests are hermetic: a stub transport and a local socket HTTP server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI: Linux Swift 6.2/6.4 and macOS Xcode 26.0.1/26.6, debug and release, warnings as errors, contents: read, 30-minute timeouts, no Swift backtracer on Linux. No iOS job: this is a server-side confidential-client library. README: install from 2.0.0, requirements, a quick start checked by building it in a scratch package, the security model (state, PKCE, redirect URI, id_token trust, HTTP limits and the Linux caveat), the provider table, custom providers, and migration from 1.x and the pre-release main API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under load (12 busy loops) the suite failed 2 of 8 ThreadSanitizer runs with "connection refused" or a failed bind, and once with a dropped connection. LocalHTTPServer closed its listener twice (stop() and deinit), shut down client sockets after their handlers had closed them, and could call accept() on a closed listener whose number had been reused - each time hitting another test's socket. stop() is now idempotent and works under the lock the handlers close under, and the accept thread polls, owns the listener and closes it itself. 20 of 20 loaded runs pass (10 TSan, 10 release). Bind and socket failures now report errno instead of a fixed EADDRINUSE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…okens - Google: openid is added on every login-link path. Building the link is now a protocol requirement (makeAuthorizationRequest), dispatched dynamically, also through any OAuth2Provider; offlineAccess defaults to false. - Google restrictedDomain is enforced in fetchProfile through userinfo's hd. fetchProfile no longer reads the token's id_token, so a forged id_token on an app-built token can't pass and stored tokens with expired id_tokens still work. id_tokens are validated only on code exchange. - Google sends and checks a nonce when PKCE is off. - id_token: numeric exp and iat required, lifetime capped at 24 h, sub trimmed. - PendingAuthorization is bound to the token URL and client ID that created it, carries createdAt (max age configurable, default 10 minutes), must use the configured redirect URL and a valid RFC 7636 verifier, and is refused on reuse by the same OAuth2 instance. - URLSessionTransport sends Accept-Encoding: identity and refuses any other Content-Encoding before reading, so Darwin's automatic decompression can't get round the size cap. The timeout conversion is clamped, and an already-cancelled task never resumes the URLSession task. - Only Bearer token_type is accepted. - Error sanitizer: the client secret is always redacted, whatever its length, and every secret also in percent-encoded forms. - AuthorizationCallback and AuthorizationRequest redact code and state in description, dump and Mirror; the profile mirror shows raw keys only. - Nits: callback code capped at 4 KB; repeated error parameter is an error; provider parameters (Google hd) and duplicate names are refused in additionalParameters; loopback means only localhost, 127.0.0.1 and ::1; URLs without a host are refused; Salesforce identity host suffixes are configurable; unavailable stubs for InvalidInput, InvalidAuthorizationCodeError and AuthorizationCode(code:redirectURL:). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux with Swift 6.2, corelibs URLSession creation, resume() and cancel() block for a long time while earlier cancelled transfers drain. Done on the cooperative pool, that also starved the deadline Task: with a 5 s timeout, calls took 31 s and 75 s, 10 capped calls moved 107 GB over loopback and RSS reached 1.8 GB. Session setup now runs on a Dispatch thread and the deadline is a Dispatch timer. Afterwards (linux/arm64 6.2, 50 calls): every call ends by its 5 s deadline, 44.9 s total, 37.9 GB, 487 MB peak RSS. Swift 6.4: 0.19 s, 365 MB, 123 MB RSS. Corelibs still drains in the background; that's why the next commit adds a different default transport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Corelibs URLSession can't be held to a size or time bound on Linux: after a capped request is cancelled it keeps reading in the background. With Swift 6.2, 50 capped calls against an endless body took 44.9 s and moved 37.9 GB (arm64), and 127 s and 102 GB (emulated amd64). Following Perfect-SMTP 6.0.0's NIOMTASTSHTTPFetcher, the default is now NIOTransport: - one connection per request on MultiThreadedEventLoopGroup.singleton (injectable); GET and form-encoded POST; - HTTPS only (plain HTTP only to localhost, 127.0.0.1 and ::1); SNI and full verification by default, plus a DNS subjectAltName check on the verified certificate (swift-asn1 parser over NIOSSL's public peerCertificate), so an IP-only certificate can't stand in for a name; - nothing written until the handshake and the name check pass; - the cap enforced while reading, chunked or not, and an oversized Content-Length refused up front; Accept-Encoding: identity and any other Content-Encoding refused; no redirects; - an event-loop deadline and caller cancellation that end the request at any stage (connect, handshake, write, read) and stop any later write; - every promise completed and the channel closed on every path. Measured over 50 back-to-back capped calls: 0.24 s on macOS, 0.34-0.44 s on Swift 6.2 arm64, 0.32 s on 6.4 arm64, 1.25 s on 6.4 amd64 (emulated), descriptors unchanged, peak RSS 59-140 MB. URLSessionTransport stays as an opt-in (for proxies, which NIOTransport ignores); its infinite-timeout trap on Linux corelibs is clamped away. Dependencies: swift-nio from 2.86.0, swift-nio-ssl from 2.31.0, swift-asn1 from 1.2.0 (Perfect-SMTP 6.0.0's floors), swift-crypto unchanged. Tests: a SwiftNIO TLS server with a throwaway test CA (named, IP-only, wrong-name and wildcard leaves, and an untrusted CA) covering every 3xx, endless and endless-chunked bodies, a huge Content-Length, compressed bodies, slow-loris and silent servers, a stalled handshake, cancellation, refused connections, certificate refusals that never reach the server, and descriptor and connection leaks. The loopback transport suites run against both transports. README documents the transports, the proxy caveat and the measurements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On linux/amd64 (emulated) Swift 6.4, a URLSession endless-body test running beside the NIO suites left corelibs draining in the background and starved two NIO calls into their deadline. All socket suites now run one at a time under a serialized parent suite. The process-wide descriptor count was noisy for the same reason (parallel suites, and URLSession leaking descriptors on Linux), so the leak checks assert that the server saw every connection opened and closed; descriptor counts are still printed in MEASURE runs, which run the suite alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing a NIOSSL channel starts a clean TLS shutdown: it sends close_notify and keeps reading (and discarding) until the peer's close_notify or NIOSSL's shutdownTimeout, 5 s by default. Against an endless body every capped call kept a connection reading at full speed for up to 5 s. On an emulated linux/amd64 Swift 6.4 release build, 50 capped calls took up to 17.7 s, the server pushed 31 GB (6 GB more after the calls had returned), and some calls ran into their own deadline. The transport now turns autoRead off before closing (on completion, cap, deadline and cancellation) and sets the TLS shutdownTimeout to 1 ms: a client that has its response, or has given up, needs no clean TLS shutdown. Same test: 0.11 s, about 136 MB, four runs out of four. Also: the test TLS server runs on its own event-loop thread (sharing the client's loops let its endless pump compete with the client), malformed HTTP (e.g. endless headers, which NIO's parser caps) maps to invalidResponse, and the wall-clock bound in the capped-calls test is dropped (each call is already shown to end at the cap). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ThreadSanitizer caught dump(google.oauth2) reading NIO socket state on the test thread while an event loop closed the socket: the default Mirror of OAuth2 walked into NIOTransport's EventLoopGroup. OAuth2, NIOTransport and OAuth2HTTPOptions now have custom mirrors that show settings and the transport's type only (the client secret stays redacted). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ents Documents the new dependency set (swift-crypto plus swift-nio, swift-nio-ssl and swift-asn1 at Perfect-SMTP 6.0.0's floors), the Transports section (NIOTransport default and its bounds, the TLS name check, no proxy support; URLSessionTransport opt-in and why it isn't bounded on Linux; custom transports, with an AsyncHTTPClient example checked by building and running it locally), the 50-call measurements for both transports, the pending-authorization binding and single use, the fetchProfile trust model, and the new migration rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- NIOTransport built the request target from url.path, which is
percent-decoded and drops a trailing slash: /api/ went out as /api,
/%3F/? as /??, and /a%20b/c%2Fd?q=%20x or /ünï were never sent (reported
as a malformed response). The target is now URLComponents'
percentEncodedPath (empty -> /) plus ? and percentEncodedQuery. A request
the HTTP encoder or header validator refuses is reported as
invalidConfiguration("HTTP request rejected ..."), not as a malformed
response. Tested for each reported path.
- Any Transfer-Encoding other than plain chunked (e.g. "gzip, chunked") is
refused, and every Content-Encoding header and list element is checked,
in both transports.
- New OAuth2Error.cancelled for a cancelled calling task (was
.network("cancelled"), or .timedOut from a CancellationError).
- Pre-release conformers implementing validate(_ token:) are run again: it
stays a requirement (through _OAuth2LegacyTokenValidation, deprecated in
its documentation) with a default no-op, and exchange calls it before
validate(_:pending:). Tested with a conformer implementing only the old
hook.
- Docs: the pending-authorization checks are consistency checks, not
tamper detection (store it server-side); the reuse record is best-effort
under flood; URLSessionTransport isn't reliably bounded on macOS against
gzip either; fetchProfile doesn't prove the token was issued to your app
(token substitution; use exchange tokens or check the audience, e.g. with
Google's tokeninfo); the state -> pending -> error= order in exchange.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reviewer's flood probe (250k live inserts at one instant) found that eviction, which sorted by timestamp, kept an arbitrary half when timestamps tied, so even the most recent state could be accepted again. Entries now carry an insertion sequence and eviction keeps the newest half. Still best-effort, as documented; tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validate(_:) and OAuth2Provider never existed on main or in any tag, only on this unreleased branch, so _OAuth2LegacyTokenValidation protected nobody. It also exposed a no-op validate(_:) on every built-in provider (try google.validate(token) compiled and did nothing). The protocol, its default implementation, the call in exchange, the migration line and its test are removed; validate(_:pending:) is the only validation hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Prepares Perfect-Authentication (
PerfectOAuth2) 2.0.0, the first tagged release of the Swift 6 rewrite (#9, #10, #11), replacing 1.2.0 (2018). This is a breaking release: the API is redesigned around instance configuration and an explicit, verifiable sign-in flow, and the security issues found in the pre-release code are fixed.Security fixes (each reproduced on
mainfirst)userid: "", so an app keying accounts on it could sign unrelated users into one account. It now throwsmissingUserID.[:](an HTTP 500 carrying anaccess_tokencounted as success). Errors are now typedOAuth2Errorcases.statematched an empty session value. State must be 32–512 characters, is compared in constant time, and is checked before any network call;AuthorizationState.generate()provides it.noncewhen PKCE is off.Authorization: Bearer. Facebook also sendsappsecret_proof.hd=and the session suffix were interpolated unencoded; every URL is built withURLComponents, and the session token is no longer sent to the provider.restrictedDomainis enforced on exchange and through userinfohdinfetchProfile.dump, Mirror and provider error text.Transport
NIOTransport, a bounded HTTP/1.1 client on SwiftNIO (same pattern as Perfect-SMTP 6.0.0's MTA-STS fetcher): nothing is written until the TLS handshake and a DNS-SAN check pass (an IP-only certificate is refused), the 1 MiB response cap is enforced while reading, compressed responses are refused, redirects are never followed, and the whole request has a deadline. 50 capped calls take 0.2–0.9 s with file descriptors flat on macOS and Linux.URLSessionTransportstays as an opt-in (it honours system proxies) but is documented as not reliably bounded: Swift 6.2 corelibs keeps reading after cancel on Linux, and Darwin decompresses gzip before the size check.OAuth2Transportfor custom clients (README has an AsyncHTTPClient example).API changes
OAuth2ClientConfiguration(instance config) replaces the 25nonisolated(unsafe)global*Configstatics; the staticloginURL/processAuthResponsehelpers are removed.OAuth2isfinaland checkedSendable; custom providers conform to the newOAuth2Providerprotocol.authorizationRequest(...)returns the login URL plus aCodablePendingAuthorizationto keep server-side;completeAuthorization(...)finishes it.JSONValuereplaces[String: Any];OAuth2TokenandOAuthUserProfileare structs;OAuth2Erroris an enum.Linkedin→LinkedIn,SalesForce→Salesforce.read:user.Dependencies
swift-crypto
"3.0.0"..<"5.0.0", swift-nio from 2.86.0, swift-nio-ssl from 2.31.0, swift-asn1 from 1.2.0 (the same floors as Perfect-SMTP 6.0.0); floors verified by pinning them exactly together. Co-resolves with Perfect-SMTP 6.0.0 and Perfect-NIO 0.1.0.CI and docs
CI copies Perfect-CRUD's workflow without the iOS job: Linux Swift 6.2/6.4, macOS Xcode 26.0.1/26.6, debug and release, warnings as errors. README rewritten for 2.0.0 with the security model, transport notes, provider table and a 1.x migration table.
Verification
-warnings-as-errors; ASan and TSan clean (one intermittent TSan report inside NIO's continuation bridge, the pattern Perfect-SMTP documented as a false positive).swift:6.2-nobleandswift:6.4-nobleon arm64 and amd64,--network none, including TSan.Not yet done: no live sign-in against a real provider. Endpoint paths, field names and PKCE support come from provider documentation. Worth a real sign-in per provider before tagging, especially Google's userinfo
hdforrestrictedDomain.🤖 Generated with Claude Code