aporat/apwebauthentication
A Swift package for in-app OAuth 1.0a and OAuth 2.0 authentication on iOS. Presents a WKWebView-backed sign-in flow, validates the redirect, parses the callback, and persists credentials to the Keychain.
Features
- OAuth 1.0a (RFC 5849) and OAuth 2.0 request signing via
Alamofireinterceptors - Hosted sign-in UI (
WebAuthViewController) with normal and Safari-style chrome - Strict component-based redirect-URL matching and OAuth
stateCSRF validation - Automatic refresh-token grant on 401 with single-flight queueing
- Keychain-backed credential storage (
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) - Configurable tint colors and per-account browser/user-agent modes
- Cookie-based ("session") flows for providers that don't speak OAuth (Pinterest Web, TikTok Web)
Requirements
- iOS 18+
- Swift 6 (strict concurrency)
Installation
Add the package to Package.swift:
dependencies: [
.package(url: "https://github.com/aporat/APWebAuthentication.git", from: "1.0.0")
]Or in Xcode: File → Add Package Dependencies… and paste the URL.
Quick start: OAuth 2.0 with state validation
import APWebAuthentication
import UIKit
@MainActor
final class GitHubLogin: NSObject, APWebAuthenticationPresentationContextProviding {
weak var anchor: UIViewController?
func signIn() async throws {
// 1. Build the authorization URL with a freshly generated state.
let state = WebAuthRedirectHandler.generateState()
var components = URLComponents(string: "https://github.com/login/oauth/authorize")!
components.queryItems = [
URLQueryItem(name: "client_id", value: "YOUR_CLIENT_ID"),
URLQueryItem(name: "redirect_uri", value: "myapp://github-callback"),
URLQueryItem(name: "scope", value: "user repo"),
URLQueryItem(name: "state", value: state)
]
let authURL = components.url!
let callbackURL = URL(string: "myapp://github-callback")!
// 2. Configure the session and validate the state on the callback.
let session = APWebAuthSession(accountType: AccountStore.github)
session.presentationContextProvider = self
session.appearanceStyle = .safari
let vc = WebAuthViewController(authURL: authURL, redirectURL: callbackURL)
vc.expectedState = state // <- CSRF guard
session.loginViewController = vc
// 3. Start. Throws on cancel, network failure, error= callback, or state mismatch.
let (resultURL, _) = try await session.start()
// 4. Pull the `code` out of the redirect; exchange it for tokens on your server.
let code = resultURL.parameters["code"] ?? ""
try await exchangeCodeForTokens(code)
}
func presentationAnchor(for session: APWebAuthSession) -> UIViewController? { anchor }
}Storing credentials
Auth1Authentication and Auth2Authentication persist all secrets to the Keychain. Set properties, call save(), retrieve later with load().
let auth = Auth2Authentication()
auth.accountIdentifier = "github"
auth.clientId = "..."
auth.clientSecret = "..."
auth.accessToken = "ya29..."
auth.refreshToken = "1//0..."
await auth.save() // writes to Keychain
// Later, in a new session:
let restored = Auth2Authentication()
restored.accountIdentifier = "github"
await restored.load()
print(restored.isAuthorized) // truedelete() removes the Keychain entry for that account. No data ever lands in Documents/.
Authenticated network requests
Use the OAuth interceptors with Alamofire:
import Alamofire
// OAuth 2.0 — bearer token, auto-refresh on 401
let interceptor = OAuth2Interceptor(
auth: auth,
tokenLocation: .authorizationHeader,
refreshTokenURL: "https://github.com/login/oauth/access_token"
)
let session = Session(interceptor: interceptor)
let user: GitHubUser = try await session
.request("https://api.github.com/user")
.serializingDecodable(GitHubUser.self)
.valueWhen a request returns 401 and refreshTokenURL is set, the interceptor exchanges the refresh token, retries every in-flight request once, and only clears the stored tokens on a definitive 400/401 from the token endpoint (transient errors keep the session intact).
OAuth 1.0a works the same way:
let auth1 = Auth1Authentication()
auth1.consumerKey = "..."; auth1.consumerSecret = "..."
auth1.token = "..."; auth1.secret = "..."
let session = Session(interceptor: OAuth1Interceptor(auth: auth1))OAuth1Interceptor follows RFC 5849 §3.4.1: form bodies, query items, and fragment params are all included in the signature base — duplicate keys (e.g. scope=a&scope=b) survive.
Customizing the sign-in UI
Tint colors are configured once at app launch and apply to every presented session:
APWebAuthSession.setTintColor(.systemBlue)
APWebAuthSession.setBarTintColor(.systemBackground)Both fall back to UIColor(named: "TintColor") / "BarTintColor" from your asset catalog when no override is supplied, and to system defaults when neither exists.
Per-session knobs:
session.appearanceStyle = .safari // or .normal
session.statusBarStyle = .lightContent
auth.browserMode = .iosChrome // user-agent profile
auth.customUserAgent = "MyApp/2.0"Intercepting tokens from a web flow
WebTokenInterceptorViewController loads a URL, injects an XMLHttpRequest hook, and surfaces every matching request via a delegate — useful for providers that emit tokens in JavaScript rather than as a redirect.
let config = WebTokenInterceptorConfiguration(
url: URL(string: "https://example.com/login")!,
targetURL: URL(string: "https://example.com/api/token")!,
isInteractive: true
)
let vc = WebTokenInterceptorViewController(configuration: config)
vc.delegate = self
present(UINavigationController(rootViewController: vc), animated: true)
try await vc.start()extension MyCoordinator: WebTokenInterceptorDelegate {
func webTokenInterceptor(
_ controller: WebTokenInterceptorViewController,
didIntercept request: InterceptedRequest
) async {
if let auth = request.requestHeaders["Authorization"] {
// Stash the bearer token, etc.
}
}
}Built-in providers
AccountStore ships configured AccountType entries for X (Twitter), Reddit, Pinterest, GitHub, Tumblr, Twitch, TikTok, Foursquare, 500px, and Instagram. Each provider has its own API client and user model under the matching subdirectory (X/, Reddit/, etc.) — see Sources/.
Adding a new provider is a matter of declaring an AccountType, attaching an OAuth1Interceptor or OAuth2Interceptor, and writing a thin API client over Alamofire.Session.
Errors
All authentication paths surface APWebAuthenticationError:
do {
let (url, cookies) = try await session.start()
} catch APWebAuthenticationError.canceled {
// User dismissed the web view.
} catch let error as APWebAuthenticationError {
print(error.errorTitle, error.errorDescription ?? "")
}Useful classifiers on the error: isRetryable, isLoginError, requiresUserAction, isCancelledError.
Architecture
| Type | Role | | --- | --- | | APWebAuthSession | Orchestrates the sign-in flow; presents the web VC; returns the callback URL + cookies. | | WebAuthViewController | WKWebView-backed sign-in screen. Owns the redirect handler. | | WebAuthRedirectHandler | Strict scheme/host/port/path matching; CSRF state validation; response parsing. | | Authentication / Auth1Authentication / Auth2Authentication | Credential models with Keychain persistence. | | OAuth1Interceptor / OAuth2Interceptor | Alamofire interceptors that sign requests and refresh tokens. | | SessionAuthentication | Cookie-based auth (for providers without OAuth). Cookies live in the Keychain too. | | WebTokenInterceptorViewController | Captures tokens from in-page XHR calls. | | KeychainStore | Low-level Keychain wrapper used by every credential type. |
Testing
xcodebuild -scheme APWebAuthentication \
-destination 'platform=iOS Simulator,name=iPhone 17' testSome Keychain-touching tests skip when run from a SwiftPM test host without an application-identifier entitlement — that's the only environment where this happens; real apps embedding the library exercise the full code path normally.
License
MIT — see LICENSE.
Package Metadata
Repository: aporat/apwebauthentication
Default branch: main
README: README.md