Skip to content

Perfect-Authentication 2.0.0: instance config, PKCE, bounded NIO transport, security fixes - #12

Merged
taplin merged 14 commits into
mainfrom
release-2.0.0
Oct 26, 2026
Merged

taplin merged 14 commits into
mainfrom
release-2.0.0

Conversation

@taplin

@taplin taplin commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

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 main first)

  • Empty user id: a failed or id-less profile fetch returned userid: "", so an app keying accounts on it could sign unrelated users into one account. It now throws missingUserID.
  • Swallowed errors: network failures, HTTP errors and non-JSON replies came back as [:] (an HTTP 500 carrying an access_token counted as success). Errors are now typed OAuth2Error cases.
  • CSRF state: an empty state matched 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.
  • PKCE (RFC 7636, S256) via swift-crypto's SHA-256, on by default for Google, GitHub and Salesforce; a Google nonce when PKCE is off.
  • Tokens out of URLs: Google, Facebook and Slack put access tokens in query strings; every provider now uses Authorization: Bearer. Facebook also sends appsecret_proof.
  • URL injection: hd= and the session suffix were interpolated unencoded; every URL is built with URLComponents, and the session token is no longer sent to the provider.
  • Google id_token: decoded as base64url (standard base64 failed on many real tokens); iss, aud/azp, exp, iat and sub are validated; restrictedDomain is enforced on exchange and through userinfo hd in fetchProfile.
  • Pending authorization: bound to the provider, the configured redirect URI and a 10-minute lifetime, verifier validated, reuse refused per instance. Only Bearer tokens accepted.
  • Secrets redacted from descriptions, dump, Mirror and provider error text.

Transport

  • New default 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.
  • URLSessionTransport stays 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.
  • Pluggable OAuth2Transport for custom clients (README has an AsyncHTTPClient example).

API changes

  • OAuth2ClientConfiguration (instance config) replaces the 25 nonisolated(unsafe) global *Config statics; the static loginURL / processAuthResponse helpers are removed.
  • OAuth2 is final and checked Sendable; custom providers conform to the new OAuth2Provider protocol.
  • Flow: authorizationRequest(...) returns the login URL plus a Codable PendingAuthorization to keep server-side; completeAuthorization(...) finishes it.
  • JSONValue replaces [String: Any]; OAuth2Token and OAuthUserProfile are structs; OAuth2Error is an enum. Linkedin → LinkedIn, SalesForce → Salesforce.
  • Provider endpoints moved to current APIs (Google and LinkedIn OIDC userinfo, Sign in with Slack, Facebook Graph v23.0, configurable Salesforce login host); GitHub's default scope is 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

  • 120 tests, hermetic (stub transport and local TLS servers; no provider is contacted). Xcode 27 and Xcode 26.3, debug and release, -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).
  • Linux swift:6.2-noble and swift:6.4-noble on arm64 and amd64, --network none, including TSan.
  • Three adversarial review rounds: CSRF, PKCE and pending-authorization tampering, crafted id_tokens, error sanitizing, redaction, and NIOTransport (no write before certificate verification, truncation, caps, slow-loris, every 3xx, gzip, a 1500-case response fuzz, leak checks); final verdict push.

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 hd for restrictedDomain.

🤖 Generated with Claude Code

taplin and others added 14 commits October 25, 2026 16:31
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>
@taplin
taplin merged commit 1b2f9d2 into main Oct 26, 2026
16 checks passed
@taplin
taplin deleted the release-2.0.0 branch October 26, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant