oauth #1

open
opened by radmakr.com targeting main from oauth
Changed files
+330 -7
Documentation
CoreATProtocol.docc
Sources
+9 -1
Package.swift
···
targets: ["CoreATProtocol"]
),
],
+
dependencies: [
+
.package(url: "https://github.com/ChimeHQ/OAuthenticator", branch: "main"),
+
.package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"),
+
],
targets: [
.target(
-
name: "CoreATProtocol"
+
name: "CoreATProtocol",
+
dependencies: [
+
"OAuthenticator",
+
.product(name: "JWTKit", package: "jwt-kit"),
+
],
),
.testTarget(
name: "CoreATProtocolTests",
+6 -1
Sources/CoreATProtocol/APEnvironment.swift
···
// Created by Thomas Rademaker on 10/10/25.
//
+
import OAuthenticator
+
@APActor
public class APEnvironment {
public static var current: APEnvironment = APEnvironment()
···
public var host: String?
public var accessToken: String?
public var refreshToken: String?
+
public var login: Login?
+
public var dpopProofGenerator: DPoPSigner.JWTGenerator?
+
public var resourceServerNonce: String?
public var atProtocoldelegate: CoreATProtocolDelegate?
public let routerDelegate = APRouterDelegate()
+
public let resourceDPoPSigner = DPoPSigner()
private init() {}
···
// self.userAgent = userAgent
// }
}
-
+28
Sources/CoreATProtocol/CoreATProtocol.swift
···
// The Swift Programming Language
// https://docs.swift.org/swift-book
+
@_exported import OAuthenticator
+
public protocol CoreATProtocolDelegate: AnyObject {}
@APActor
···
public func update(hostURL: String?) {
APEnvironment.current.host = hostURL
}
+
+
@APActor
+
public func applyAuthenticationContext(login: Login, generator: @escaping DPoPSigner.JWTGenerator, resourceNonce: String? = nil) {
+
APEnvironment.current.login = login
+
APEnvironment.current.accessToken = login.accessToken.value
+
APEnvironment.current.refreshToken = login.refreshToken?.value
+
APEnvironment.current.dpopProofGenerator = generator
+
APEnvironment.current.resourceServerNonce = resourceNonce
+
APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce
+
}
+
+
@APActor
+
public func clearAuthenticationContext() {
+
APEnvironment.current.login = nil
+
APEnvironment.current.dpopProofGenerator = nil
+
APEnvironment.current.resourceServerNonce = nil
+
APEnvironment.current.accessToken = nil
+
APEnvironment.current.refreshToken = nil
+
APEnvironment.current.resourceDPoPSigner.nonce = nil
+
}
+
+
@APActor
+
public func updateResourceDPoPNonce(_ nonce: String?) {
+
APEnvironment.current.resourceServerNonce = nonce
+
APEnvironment.current.resourceDPoPSigner.nonce = nonce
+
}
+83
Sources/CoreATProtocol/DPoPJWTGenerator.swift
···
+
import Foundation
+
import JWTKit
+
import OAuthenticator
+
+
public enum DPoPKeyMaterialError: Error, Equatable {
+
case publicKeyUnavailable
+
case invalidCoordinate
+
}
+
+
public actor DPoPJWTGenerator {
+
private let privateKey: ES256PrivateKey
+
private let keys: JWTKeyCollection
+
private let jwkHeader: [String: JWTHeaderField]
+
+
public init(privateKey: ES256PrivateKey) async throws {
+
self.privateKey = privateKey
+
self.keys = JWTKeyCollection()
+
self.jwkHeader = try Self.makeJWKHeader(from: privateKey)
+
await self.keys.add(ecdsa: privateKey)
+
}
+
+
public func jwtGenerator() -> DPoPSigner.JWTGenerator {
+
{ params in
+
try await self.makeJWT(for: params)
+
}
+
}
+
+
public func makeJWT(for params: DPoPSigner.JWTParameters) async throws -> String {
+
var header = JWTHeader()
+
header.typ = params.keyType
+
header.alg = header.alg ?? "ES256"
+
header.jwk = jwkHeader
+
+
let issuedAt = Date()
+
let payload = DPoPPayload(
+
htm: params.httpMethod,
+
htu: params.requestEndpoint,
+
iat: IssuedAtClaim(value: issuedAt),
+
exp: ExpirationClaim(value: issuedAt.addingTimeInterval(60)),
+
jti: IDClaim(value: UUID().uuidString),
+
nonce: params.nonce,
+
iss: params.issuingServer.map { IssuerClaim(value: $0) },
+
ath: params.tokenHash
+
)
+
+
return try await keys.sign(payload, header: header)
+
}
+
+
private static func makeJWKHeader(from key: ES256PrivateKey) throws -> [String: JWTHeaderField] {
+
guard let parameters = key.publicKey.parameters else {
+
throw DPoPKeyMaterialError.publicKeyUnavailable
+
}
+
+
guard
+
let xData = Data(base64Encoded: parameters.x),
+
let yData = Data(base64Encoded: parameters.y)
+
else {
+
throw DPoPKeyMaterialError.invalidCoordinate
+
}
+
+
return [
+
"kty": .string("EC"),
+
"crv": .string("P-256"),
+
"x": .string(xData.base64URLEncodedString()),
+
"y": .string(yData.base64URLEncodedString())
+
]
+
}
+
}
+
+
struct DPoPPayload: JWTPayload {
+
let htm: String
+
let htu: String
+
let iat: IssuedAtClaim
+
let exp: ExpirationClaim
+
let jti: IDClaim
+
let nonce: String?
+
let iss: IssuerClaim?
+
let ath: String?
+
+
func verify(using key: some JWTAlgorithm) throws {
+
try exp.verifyNotExpired(currentDate: Date())
+
}
+
}
+11
Sources/CoreATProtocol/Extensions/Data+Base64URL.swift
···
+
import Foundation
+
+
extension Data {
+
/// Returns a URL-safe Base64 representation without padding.
+
func base64URLEncodedString() -> String {
+
base64EncodedString()
+
.replacingOccurrences(of: "+", with: "-")
+
.replacingOccurrences(of: "/", with: "_")
+
.replacingOccurrences(of: "=", with: "")
+
}
+
}
+35 -4
Sources/CoreATProtocol/Networking.swift
···
//
import Foundation
+
import CryptoKit
+
@preconcurrency import OAuthenticator
extension JSONDecoder {
public static var atDecoder: JSONDecoder {
···
return differenceInMinutes >= timeLimit
}
-
@APActor
+
@MainActor
public class APRouterDelegate: NetworkRouterDelegate {
private var shouldRefreshToken = false
public func intercept(_ request: inout URLRequest) async {
-
if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken {
+
if let generator = await APEnvironment.current.dpopProofGenerator,
+
let login = await APEnvironment.current.login {
+
let token = login.accessToken.value
+
let tokenHash = tokenHash(for: token)
+
let signer = await APEnvironment.current.resourceDPoPSigner
+
signer.nonce = await APEnvironment.current.resourceServerNonce
+
+
do {
+
try await signer.authenticateRequest(
+
&request,
+
isolation: MainActor.shared,
+
using: generator,
+
token: token,
+
tokenHash: tokenHash,
+
issuer: login.issuingServer
+
)
+
} catch {
+
// If DPoP signing fails, fall back to providing the token directly.
+
request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization")
+
}
+
+
return
+
}
+
+
if let refreshToken = await APEnvironment.current.refreshToken, shouldRefreshToken {
shouldRefreshToken = false
request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization")
-
} else if let accessToken = APEnvironment.current.accessToken {
+
} else if let accessToken = await APEnvironment.current.accessToken {
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
}
···
message.error == AtErrorType.expiredToken.rawValue {
return try await getNewToken()
}
-
+
return false
}
+
+
private func tokenHash(for token: String) -> String {
+
let digest = SHA256.hash(data: Data(token.utf8))
+
return Data(digest).base64URLEncodedString()
+
}
}
+1 -1
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
···
import Foundation
-
@APActor
+
@MainActor
public protocol NetworkRouterDelegate: AnyObject {
func intercept(_ request: inout URLRequest) async
func shouldRetry(error: Error, attempts: Int) async throws -> Bool
+157
Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md
···
+
# Build a Bluesky Login Flow
+
+
Learn how an iOS app can depend on ``CoreATProtocol`` and guide a user through the AT Protocol OAuth flow using Bluesky as the authorization server.
+
+
## Add the package to your app
+
+
1. In your app target's `Package.swift`, add the CoreATProtocol dependency:
+
+
```swift
+
.package(url: "https://github.com/your-org/CoreATProtocol.git", from: "1.0.0")
+
```
+
+
2. List ``CoreATProtocol`` in the target's dependencies:
+
+
```swift
+
.target(
+
name: "App",
+
dependencies: [
+
.product(name: "CoreATProtocol", package: "CoreATProtocol")
+
]
+
)
+
```
+
+
3. Import the module where you coordinate authentication:
+
+
```swift
+
import CoreATProtocol
+
```
+
+
## Persist a DPoP key
+
+
Bluesky issues DPoP-bound access tokens, so the app must generate and persist a single ES256 key pair. The example below stores the private key in the Keychain and recreates it when needed.
+
+
```swift
+
import CryptoKit
+
import JWTKit
+
+
final class DPoPKeyStore {
+
private let keyTag = "com.example.app.dpop"
+
+
func loadOrCreateKey() throws -> ES256PrivateKey {
+
if let raw = try loadKeyData() {
+
return try ES256PrivateKey(pem: raw)
+
}
+
+
let key = ES256PrivateKey()
+
try persist(key.pemRepresentation)
+
return key
+
}
+
+
private func loadKeyData() throws -> String? {
+
// Read from the Keychain and return the PEM string if it exists.
+
nil
+
}
+
+
private func persist(_ pem: String) throws {
+
// Write the PEM string to the Keychain.
+
}
+
}
+
```
+
+
## Expose a DPoP JWT generator
+
+
Wrap the signing key with ``DPoPJWTGenerator`` so the library can mint proofs on demand.
+
+
```swift
+
let keyStore = DPoPKeyStore()
+
let privateKey = try await keyStore.loadOrCreateKey()
+
let dpopGenerator = try await DPoPJWTGenerator(privateKey: privateKey)
+
let jwtGenerator = dpopGenerator.jwtGenerator()
+
```
+
+
Pass ``DPoPJWTGenerator.jwtGenerator()`` to ``LoginService`` and later to ``applyAuthenticationContext(login:generator:resourceNonce:)`` so API calls share the same key material.
+
+
## Configure login storage
+
+
Provide a ``LoginStorage`` implementation that reads and writes the user’s Bluesky session securely. The storage runs on the calling actor, so use async APIs.
+
+
```swift
+
import OAuthenticator
+
+
struct BlueskyLoginStore {
+
func makeStorage() -> LoginStorage {
+
LoginStorage {
+
try await loadLogin()
+
} storeLogin: { login in
+
try await persist(login)
+
}
+
}
+
+
private func loadLogin() async throws -> Login? {
+
// Decode and return the previously stored login if one exists.
+
nil
+
}
+
+
private func persist(_ login: Login) async throws {
+
// Save the login (for example, in the Keychain or the file system).
+
}
+
}
+
```
+
+
## Perform the OAuth flow
+
+
1. Configure shared environment state early in your app lifecycle:
+
+
```swift
+
await setup(
+
hostURL: "https://bsky.social",
+
accessJWT: nil,
+
refreshJWT: nil,
+
delegate: self
+
)
+
```
+
+
2. Create the services needed for authentication:
+
+
```swift
+
let loginStorage = BlueskyLoginStore().makeStorage()
+
let loginService = LoginService(jwtGenerator: jwtGenerator, loginStorage: loginStorage)
+
```
+
+
3. Start the Bluesky OAuth flow. Use the client metadata URL registered with the Authorization Server (for example, the one served from your app’s hosted metadata file).
+
+
```swift
+
let login = try await loginService.login(
+
account: "did:plc:your-user",
+
clientMetadataEndpoint: "https://example.com/.well-known/coreatprotocol-client.json"
+
)
+
```
+
+
4. Share the authentication context with CoreATProtocol so the networking layer can add DPoP proofs automatically:
+
+
```swift
+
await applyAuthenticationContext(login: login, generator: jwtGenerator)
+
```
+
+
5. When Bluesky returns a new DPoP nonce (`DPoP-Nonce` header), call ``updateResourceDPoPNonce(_:)`` with the latest value before the next request.
+
+
6. To sign the user out, call ``clearAuthenticationContext()`` and erase any stored login and keychain items.
+
+
## Make API requests
+
+
Attach the package’s router delegate to your networking stack (for example, the client that wraps ``URLSession``) so that access tokens and DPoP proofs are injected into outgoing requests.
+
+
```swift
+
var router = NetworkRouter<SomeEndpoint>(decoder: .atDecoder)
+
router.delegate = await APEnvironment.current.routerDelegate
+
```
+
+
With the context applied, subsequent calls through ``APRouterDelegate`` will refresh DPoP proofs, hash access tokens into the `ath` claim, and keep the nonce in sync with the server.
+
+
## Troubleshooting
+
+
- Ensure the DPoP key persists across app launches. If the key changes, all tokens issued by Bluesky become invalid and the user must reauthenticate.
+
- Always call ``applyAuthenticationContext(login:generator:resourceNonce:)`` after refreshing tokens via ``updateTokens(access:refresh:)`` or custom flows so the delegate has current credentials.
+
- If Bluesky rejects requests with `use_dpop_nonce`, update the cached value via ``updateResourceDPoPNonce(_:)`` and retry.
+