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#
-
In your app target's
Package.swift, add the CoreATProtocol dependency:.package(url: "https://github.com/your-org/CoreATProtocol.git", from: "1.0.0") -
List
CoreATProtocolin the target's dependencies:.target( name: "App", dependencies: [ .product(name: "CoreATProtocol", package: "CoreATProtocol") ] ) -
Import the module where you coordinate authentication:
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.
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.
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.
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#
-
Configure shared environment state early in your app lifecycle:
await setup( hostURL: "https://bsky.social", accessJWT: nil, refreshJWT: nil, delegate: self ) -
Create the services needed for authentication:
let loginStorage = BlueskyLoginStore().makeStorage() let loginService = LoginService(jwtGenerator: jwtGenerator, loginStorage: loginStorage) -
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).
let login = try await loginService.login( account: "did:plc:your-user", clientMetadataEndpoint: "https://example.com/.well-known/coreatprotocol-client.json" ) -
Share the authentication context with CoreATProtocol so the networking layer can add DPoP proofs automatically:
await applyAuthenticationContext(login: login, generator: jwtGenerator) -
When Bluesky returns a new DPoP nonce (
DPoP-Nonceheader), callupdateResourceDPoPNonce(_:)with the latest value before the next request. -
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.
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 viaupdateTokens(access:refresh:)or custom flows so the delegate has current credentials. - If Bluesky rejects requests with
use_dpop_nonce, update the cached value viaupdateResourceDPoPNonce(_:)and retry.