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.
Naming: the repo is Perfect-Authentication, the package is PerfectAuthentication, and the
library product you import is PerfectOAuth2.
.package(url: "https://github.com/PerfectlySoft/Perfect-Authentication.git", from: "2.0.0"),
// target dependency
.product(name: "PerfectOAuth2", package: "Perfect-Authentication"),- 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
ASWebAuthenticationSessionwithout embedding a client secret. - Apple/SSWG dependencies only:
- swift-crypto
3.0.0..<5.0.0, for SHA-256 (PKCE) and HMAC-SHA256 (Facebook'sappsecret_proof); on Apple platforms it uses CryptoKit; - swift-nio from 2.86.0, swift-nio-ssl from 2.31.0 and swift-asn1 from 1.2.0, for the default HTTP transport (see Transports). The floors match Perfect-SMTP 6.0.0.
- swift-crypto
import Foundation
import PerfectOAuth2
// 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")!
)
)
// 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
}
// 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
}completeAuthorization checks the state and the pending authorization, exchanges the code (sending
the PKCE verifier and the same redirect_uri), runs the provider's token checks, fetches the profile,
and throws if anything is missing. Use completeAuthorization(code:state:pending:) if your framework hands you the query
parameters instead of the URL.
OAuthUserProfile has userid, firstName, lastName, displayName, picture, email,
emailVerified, loginType, token (an OAuth2Token: access, refresh and id tokens, expiry,
scopes) and raw, the provider's profile response as a JSONValue.
let token = try await google.refresh(refreshToken: storedRefreshToken)
// token.refreshToken is the rotated refresh token if the provider sent one,
// otherwise the one you passed in, so it can always be stored back.scopes: 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.
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 <redacted>, in raw and percent-encoded forms.
- State (CSRF).
authorizationRequest()generates 32 random bytes (SystemRandomNumberGenerator, base64url). If you pass your ownstate:, 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()andAuthorizationState.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 Connectnonceinstead when PKCE is turned off. - Pending authorizations.
PendingAuthorizationholds 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).exchangealso refuses one that was created by another provider or client ID, is older thanpendingAuthorizationMaxAge(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
redirectURLper provider: absolute HTTPS (HTTP only tolocalhost,127.0.0.1and::1), no fragment. It is sent unchanged on the authorization request and the exchange. 1.x appended?session=<session token>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 (
audcontains your client ID, andazpequals it when present or when there are several audiences), numeric expiry and issue time (5 minutes' leeway, at most 24 hours apart), a non-emptysub, and the nonce when one was sent. WithrestrictedDomain, the token'shdclaim must match.OAuth2Token.idTokenis 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'srestrictedDomainis checked there too, against userinfo'shd, so a forged id_token can't get round it. ButfetchProfiledoesn'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 ownexchange/completeAuthorization, or verify the audience yourself first (for Google,https://oauth2.googleapis.com/tokeninforeturns the token'saud). - Email.
emailis only trustworthy whenemailVerifiedis true. GitHub's profile email is never marked verified. - Tokens on the wire. Access tokens are sent in the
Authorization: Bearerheader, never in a URL. Token requests are form-encoded POSTs withclient_secretin the body. Every URL is built withURLComponents, 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 againstContent-Lengthand while reading), no redirects, no cookies or cache,Accept-Encoding: identitywith any otherContent-Encoding(and anyTransfer-Encodingother than plainchunked) refused when the response head arrives, and HTTPS only (HTTP to loopback for testing). With the defaultNIOTransportthese are hard bounds on every platform: reading stops and the connection closes the moment one is hit. Onlytoken_typeBearer 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,dumpandMirrorofOAuth2ClientConfiguration,OAuth2Token,OAuthUserProfile,PendingAuthorization,AuthorizationRequest,AuthorizationCallbackandOAuth2HTTPRequestshow<redacted>for secrets, tokens, codes, state, nonces and verifiers. (Codableencoding ofPendingAuthorizationis for storage and isn't redacted.)
NIOTransport(default). A small HTTP/1.1 client on SwiftNIO, one connection per request, onMultiThreadedEventLoopGroup.singleton(nothing to shut down; passgroup:to use your own). Hard size, time and redirect bounds as above, on every platform. UseNIOTransport(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).NIOTransportis the bounded one.- Your own
OAuth2Transport, set withOAuth2HTTPOptions(transport:): for example one on AsyncHTTPClient (below), or one that routes through your proxy.
let google = Google(configuration: OAuth2ClientConfiguration(
clientID: "your-client-id", clientSecret: "your-client-secret",
redirectURL: URL(string: "https://example.com/auth/google/callback")!,
http: OAuth2HTTPOptions(transport: URLSessionTransport(timeout: 30)) // opt in to URLSession
))50 back-to-back calls against a local server that sends an endless body, each capped at 16 KiB (5 s deadline):
| Transport | Platform | Wall time | Read over loopback | Peak RSS | Notes |
|---|---|---|---|---|---|
NIOTransport |
macOS 27 | 0.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, checked by building and running it against local endless, slow, gzip and redirecting servers:
import AsyncHTTPClient
import Foundation
import NIOCore
import PerfectOAuth2
/// Whole-request deadline, streamed size cap, no redirects, no compression.
struct AsyncHTTPClientTransport: OAuth2Transport {
let client: HTTPClient // configured with redirectConfiguration: .disallow
var timeout: TimeInterval = 30
var maximumResponseBytes = 1 << 20
func send(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse {
// execute(timeout:) only bounds the response head, so race the whole exchange, body
// included, against the deadline.
try await withThrowingTaskGroup(of: OAuth2HTTPResponse.self) { group in
group.addTask { try await perform(request) }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw OAuth2Error.timedOut
}
defer { group.cancelAll() }
guard let first = try await group.next() else { throw OAuth2Error.timedOut }
return first
}
}
private func perform(_ request: OAuth2HTTPRequest) async throws -> OAuth2HTTPResponse {
var outgoing = HTTPClientRequest(url: request.url.absoluteString)
outgoing.method = request.method == .post ? .POST : .GET
for (name, value) in request.headers { outgoing.headers.replaceOrAdd(name: name, value: value) }
outgoing.headers.replaceOrAdd(name: "Accept-Encoding", value: "identity")
if let body = request.body { outgoing.body = .bytes(ByteBuffer(bytes: body)) }
do {
let response = try await client.execute(outgoing, timeout: .seconds(Int64(timeout.rounded(.up))))
if let encoding = response.headers.first(name: "Content-Encoding"), encoding.lowercased() != "identity" {
throw OAuth2Error.invalidResponse("compressed response refused")
}
let body = try await response.body.collect(upTo: maximumResponseBytes)
return OAuth2HTTPResponse(status: Int(response.status.code), body: Data(buffer: body))
} catch is NIOTooManyBytesError {
throw OAuth2Error.responseTooLarge(limit: maximumResponseBytes)
} catch let error as HTTPClientError where error == .deadlineExceeded {
throw OAuth2Error.timedOut
}
}
}
// One client for the process, with redirects off (decompression is off by default).
let httpClient = HTTPClient(
eventLoopGroupProvider: .singleton,
configuration: HTTPClient.Configuration(redirectConfiguration: .disallow)
)
let google = Google(configuration: OAuth2ClientConfiguration(
clientID: "your-client-id", clientSecret: "your-client-secret",
redirectURL: URL(string: "https://example.com/auth/google/callback")!,
http: OAuth2HTTPOptions(transport: AsyncHTTPClientTransport(client: httpClient))
))| Provider | Type | Default scopes | PKCE default | Profile endpoint | Notes |
|---|---|---|---|---|---|
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(configuration:graphAPIVersion:) |
none | off | Graph /me |
Graph v23.0 by default; sends appsecret_proof. No refresh tokens |
|
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"] |
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.
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:
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)
}
}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.
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 URLs |
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 bareredirectURLwith the provider, and keepPendingAuthorizationin 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.missingUserIDinstead 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); Googleprofile→openid profile. - Endpoints: Google uses its OIDC userinfo endpoint (
subinstead of v2'sid; the values are the same). Slack moved from the legacyidentity.basicflow 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.
swift build --build-tests -Xswiftc -warnings-as-errors
swift testThe tests are hermetic: provider flows run against an in-memory transport, both built-in transports against local servers on 127.0.0.1 (a socket server over HTTP, and a SwiftNIO server with TLS from a throwaway test CA for the certificate checks). Nothing contacts a real provider.
The pre-Swift-6 code (the Swift 3 OAuth2 target and the LocalAuthentication username/password
system) is on the legacy
branch.
Apache License 2.0. See LICENSE.md.