+9
-1
Package.swift
+9
-1
Package.swift
···
17
17
targets: ["CoreATProtocol"]
18
18
),
19
19
],
20
+
dependencies: [
21
+
.package(url: "https://github.com/ChimeHQ/OAuthenticator", branch: "main"),
22
+
.package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"),
23
+
],
20
24
targets: [
21
25
.target(
22
-
name: "CoreATProtocol"
26
+
name: "CoreATProtocol",
27
+
dependencies: [
28
+
"OAuthenticator",
29
+
.product(name: "JWTKit", package: "jwt-kit"),
30
+
],
23
31
),
24
32
.testTarget(
25
33
name: "CoreATProtocolTests",
+48
Sources/CoreATProtocol/LoginService.swift
+48
Sources/CoreATProtocol/LoginService.swift
···
1
+
//
2
+
// LoginService.swift
3
+
// CoreATProtocol
4
+
//
5
+
// Created by Thomas Rademaker on 10/17/25.
6
+
//
7
+
8
+
import Foundation
9
+
import OAuthenticator
10
+
11
+
@APActor
12
+
public final class LoginService {
13
+
public enum Error: Swift.Error {
14
+
case missingStoredLogin
15
+
}
16
+
17
+
private let loginStorage: LoginStorage
18
+
private let jwtGenerator: DPoPSigner.JWTGenerator
19
+
20
+
public init(jwtGenerator: @escaping DPoPSigner.JWTGenerator, loginStorage: LoginStorage) {
21
+
self.jwtGenerator = jwtGenerator
22
+
self.loginStorage = loginStorage
23
+
}
24
+
25
+
public func login(account: String, clientMetadataEndpoint: String) async throws -> Login {
26
+
let provider = URLSession.defaultProvider
27
+
let host = APEnvironment.current.host ?? ""
28
+
let server = if host.hasPrefix("https://") {
29
+
String(host.dropFirst(8))
30
+
} else if host.hasPrefix("http://") {
31
+
String(host.dropFirst(7))
32
+
} else { host }
33
+
34
+
let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider)
35
+
let serverConfig = try await ServerMetadata.load(for: server, provider: provider)
36
+
37
+
let tokenHandling = Bluesky.tokenHandling(account: account, server: serverConfig, jwtGenerator: jwtGenerator)
38
+
let config = Authenticator.Configuration(appCredentials: clientConfig.credentials, loginStorage: loginStorage, tokenHandling: tokenHandling, mode: .automatic)
39
+
let authenticator = Authenticator(config: config)
40
+
try await authenticator.authenticate()
41
+
42
+
guard let storedLogin = try await loginStorage.retrieveLogin() else {
43
+
throw Error.missingStoredLogin
44
+
}
45
+
46
+
return storedLogin
47
+
}
48
+
}
+60
Package.resolved
+60
Package.resolved
···
1
+
{
2
+
"originHash" : "2237e2c10a8d530dcbd1f9770efc8fcf2a9fc2ca2c63a19882551fea7ab9fe25",
3
+
"pins" : [
4
+
{
5
+
"identity" : "jwt-kit",
6
+
"kind" : "remoteSourceControl",
7
+
"location" : "https://github.com/vapor/jwt-kit.git",
8
+
"state" : {
9
+
"revision" : "2033b3e661238dda3d30e36a2d40987499d987de",
10
+
"version" : "5.2.0"
11
+
}
12
+
},
13
+
{
14
+
"identity" : "oauthenticator",
15
+
"kind" : "remoteSourceControl",
16
+
"location" : "https://github.com/ChimeHQ/OAuthenticator",
17
+
"state" : {
18
+
"branch" : "main",
19
+
"revision" : "618971d4d341650db664925fd0479032294064ad"
20
+
}
21
+
},
22
+
{
23
+
"identity" : "swift-asn1",
24
+
"kind" : "remoteSourceControl",
25
+
"location" : "https://github.com/apple/swift-asn1.git",
26
+
"state" : {
27
+
"revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d",
28
+
"version" : "1.5.0"
29
+
}
30
+
},
31
+
{
32
+
"identity" : "swift-certificates",
33
+
"kind" : "remoteSourceControl",
34
+
"location" : "https://github.com/apple/swift-certificates.git",
35
+
"state" : {
36
+
"revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a",
37
+
"version" : "1.15.0"
38
+
}
39
+
},
40
+
{
41
+
"identity" : "swift-crypto",
42
+
"kind" : "remoteSourceControl",
43
+
"location" : "https://github.com/apple/swift-crypto.git",
44
+
"state" : {
45
+
"revision" : "bcd2b89f2a4446395830b82e4e192765edd71e18",
46
+
"version" : "4.0.0"
47
+
}
48
+
},
49
+
{
50
+
"identity" : "swift-log",
51
+
"kind" : "remoteSourceControl",
52
+
"location" : "https://github.com/apple/swift-log.git",
53
+
"state" : {
54
+
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
55
+
"version" : "1.6.4"
56
+
}
57
+
}
58
+
],
59
+
"version" : 3
60
+
}
+6
-1
Sources/CoreATProtocol/APEnvironment.swift
+6
-1
Sources/CoreATProtocol/APEnvironment.swift
···
5
5
// Created by Thomas Rademaker on 10/10/25.
6
6
//
7
7
8
+
import OAuthenticator
9
+
8
10
@APActor
9
11
public class APEnvironment {
10
12
public static var current: APEnvironment = APEnvironment()
···
12
14
public var host: String?
13
15
public var accessToken: String?
14
16
public var refreshToken: String?
17
+
public var login: Login?
18
+
public var dpopProofGenerator: DPoPSigner.JWTGenerator?
19
+
public var resourceServerNonce: String?
15
20
public var atProtocoldelegate: CoreATProtocolDelegate?
16
21
public let routerDelegate = APRouterDelegate()
22
+
public let resourceDPoPSigner = DPoPSigner()
17
23
18
24
private init() {}
19
25
···
23
29
// self.userAgent = userAgent
24
30
// }
25
31
}
26
-
+28
Sources/CoreATProtocol/CoreATProtocol.swift
+28
Sources/CoreATProtocol/CoreATProtocol.swift
···
1
1
// The Swift Programming Language
2
2
// https://docs.swift.org/swift-book
3
3
4
+
@_exported import OAuthenticator
5
+
4
6
public protocol CoreATProtocolDelegate: AnyObject {}
5
7
6
8
@APActor
···
26
28
public func update(hostURL: String?) {
27
29
APEnvironment.current.host = hostURL
28
30
}
31
+
32
+
@APActor
33
+
public func applyAuthenticationContext(login: Login, generator: @escaping DPoPSigner.JWTGenerator, resourceNonce: String? = nil) {
34
+
APEnvironment.current.login = login
35
+
APEnvironment.current.accessToken = login.accessToken.value
36
+
APEnvironment.current.refreshToken = login.refreshToken?.value
37
+
APEnvironment.current.dpopProofGenerator = generator
38
+
APEnvironment.current.resourceServerNonce = resourceNonce
39
+
APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce
40
+
}
41
+
42
+
@APActor
43
+
public func clearAuthenticationContext() {
44
+
APEnvironment.current.login = nil
45
+
APEnvironment.current.dpopProofGenerator = nil
46
+
APEnvironment.current.resourceServerNonce = nil
47
+
APEnvironment.current.accessToken = nil
48
+
APEnvironment.current.refreshToken = nil
49
+
APEnvironment.current.resourceDPoPSigner.nonce = nil
50
+
}
51
+
52
+
@APActor
53
+
public func updateResourceDPoPNonce(_ nonce: String?) {
54
+
APEnvironment.current.resourceServerNonce = nonce
55
+
APEnvironment.current.resourceDPoPSigner.nonce = nonce
56
+
}
+83
Sources/CoreATProtocol/DPoPJWTGenerator.swift
+83
Sources/CoreATProtocol/DPoPJWTGenerator.swift
···
1
+
import Foundation
2
+
import JWTKit
3
+
import OAuthenticator
4
+
5
+
public enum DPoPKeyMaterialError: Error, Equatable {
6
+
case publicKeyUnavailable
7
+
case invalidCoordinate
8
+
}
9
+
10
+
public actor DPoPJWTGenerator {
11
+
private let privateKey: ES256PrivateKey
12
+
private let keys: JWTKeyCollection
13
+
private let jwkHeader: [String: JWTHeaderField]
14
+
15
+
public init(privateKey: ES256PrivateKey) async throws {
16
+
self.privateKey = privateKey
17
+
self.keys = JWTKeyCollection()
18
+
self.jwkHeader = try Self.makeJWKHeader(from: privateKey)
19
+
await self.keys.add(ecdsa: privateKey)
20
+
}
21
+
22
+
public func jwtGenerator() -> DPoPSigner.JWTGenerator {
23
+
{ params in
24
+
try await self.makeJWT(for: params)
25
+
}
26
+
}
27
+
28
+
public func makeJWT(for params: DPoPSigner.JWTParameters) async throws -> String {
29
+
var header = JWTHeader()
30
+
header.typ = params.keyType
31
+
header.alg = header.alg ?? "ES256"
32
+
header.jwk = jwkHeader
33
+
34
+
let issuedAt = Date()
35
+
let payload = DPoPPayload(
36
+
htm: params.httpMethod,
37
+
htu: params.requestEndpoint,
38
+
iat: IssuedAtClaim(value: issuedAt),
39
+
exp: ExpirationClaim(value: issuedAt.addingTimeInterval(60)),
40
+
jti: IDClaim(value: UUID().uuidString),
41
+
nonce: params.nonce,
42
+
iss: params.issuingServer.map { IssuerClaim(value: $0) },
43
+
ath: params.tokenHash
44
+
)
45
+
46
+
return try await keys.sign(payload, header: header)
47
+
}
48
+
49
+
private static func makeJWKHeader(from key: ES256PrivateKey) throws -> [String: JWTHeaderField] {
50
+
guard let parameters = key.publicKey.parameters else {
51
+
throw DPoPKeyMaterialError.publicKeyUnavailable
52
+
}
53
+
54
+
guard
55
+
let xData = Data(base64Encoded: parameters.x),
56
+
let yData = Data(base64Encoded: parameters.y)
57
+
else {
58
+
throw DPoPKeyMaterialError.invalidCoordinate
59
+
}
60
+
61
+
return [
62
+
"kty": .string("EC"),
63
+
"crv": .string("P-256"),
64
+
"x": .string(xData.base64URLEncodedString()),
65
+
"y": .string(yData.base64URLEncodedString())
66
+
]
67
+
}
68
+
}
69
+
70
+
struct DPoPPayload: JWTPayload {
71
+
let htm: String
72
+
let htu: String
73
+
let iat: IssuedAtClaim
74
+
let exp: ExpirationClaim
75
+
let jti: IDClaim
76
+
let nonce: String?
77
+
let iss: IssuerClaim?
78
+
let ath: String?
79
+
80
+
func verify(using key: some JWTAlgorithm) throws {
81
+
try exp.verifyNotExpired(currentDate: Date())
82
+
}
83
+
}
+11
Sources/CoreATProtocol/Extensions/Data+Base64URL.swift
+11
Sources/CoreATProtocol/Extensions/Data+Base64URL.swift
···
1
+
import Foundation
2
+
3
+
extension Data {
4
+
/// Returns a URL-safe Base64 representation without padding.
5
+
func base64URLEncodedString() -> String {
6
+
base64EncodedString()
7
+
.replacingOccurrences(of: "+", with: "-")
8
+
.replacingOccurrences(of: "/", with: "_")
9
+
.replacingOccurrences(of: "=", with: "")
10
+
}
11
+
}
+35
-4
Sources/CoreATProtocol/Networking.swift
+35
-4
Sources/CoreATProtocol/Networking.swift
···
6
6
//
7
7
8
8
import Foundation
9
+
import CryptoKit
10
+
@preconcurrency import OAuthenticator
9
11
10
12
extension JSONDecoder {
11
13
public static var atDecoder: JSONDecoder {
···
30
32
return differenceInMinutes >= timeLimit
31
33
}
32
34
33
-
@APActor
35
+
@MainActor
34
36
public class APRouterDelegate: NetworkRouterDelegate {
35
37
private var shouldRefreshToken = false
36
38
37
39
public func intercept(_ request: inout URLRequest) async {
38
-
if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken {
40
+
if let generator = await APEnvironment.current.dpopProofGenerator,
41
+
let login = await APEnvironment.current.login {
42
+
let token = login.accessToken.value
43
+
let tokenHash = tokenHash(for: token)
44
+
let signer = await APEnvironment.current.resourceDPoPSigner
45
+
signer.nonce = await APEnvironment.current.resourceServerNonce
46
+
47
+
do {
48
+
try await signer.authenticateRequest(
49
+
&request,
50
+
isolation: MainActor.shared,
51
+
using: generator,
52
+
token: token,
53
+
tokenHash: tokenHash,
54
+
issuer: login.issuingServer
55
+
)
56
+
} catch {
57
+
// If DPoP signing fails, fall back to providing the token directly.
58
+
request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization")
59
+
}
60
+
61
+
return
62
+
}
63
+
64
+
if let refreshToken = await APEnvironment.current.refreshToken, shouldRefreshToken {
39
65
shouldRefreshToken = false
40
66
request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization")
41
-
} else if let accessToken = APEnvironment.current.accessToken {
67
+
} else if let accessToken = await APEnvironment.current.accessToken {
42
68
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
43
69
}
44
70
}
···
65
91
message.error == AtErrorType.expiredToken.rawValue {
66
92
return try await getNewToken()
67
93
}
68
-
94
+
69
95
return false
70
96
}
97
+
98
+
private func tokenHash(for token: String) -> String {
99
+
let digest = SHA256.hash(data: Data(token.utf8))
100
+
return Data(digest).base64URLEncodedString()
101
+
}
71
102
}
+1
-1
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
+1
-1
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
+157
Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md
+157
Documentation/CoreATProtocol.docc/BuildBlueskyLogin.md
···
1
+
# Build a Bluesky Login Flow
2
+
3
+
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.
4
+
5
+
## Add the package to your app
6
+
7
+
1. In your app target's `Package.swift`, add the CoreATProtocol dependency:
8
+
9
+
```swift
10
+
.package(url: "https://github.com/your-org/CoreATProtocol.git", from: "1.0.0")
11
+
```
12
+
13
+
2. List ``CoreATProtocol`` in the target's dependencies:
14
+
15
+
```swift
16
+
.target(
17
+
name: "App",
18
+
dependencies: [
19
+
.product(name: "CoreATProtocol", package: "CoreATProtocol")
20
+
]
21
+
)
22
+
```
23
+
24
+
3. Import the module where you coordinate authentication:
25
+
26
+
```swift
27
+
import CoreATProtocol
28
+
```
29
+
30
+
## Persist a DPoP key
31
+
32
+
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.
33
+
34
+
```swift
35
+
import CryptoKit
36
+
import JWTKit
37
+
38
+
final class DPoPKeyStore {
39
+
private let keyTag = "com.example.app.dpop"
40
+
41
+
func loadOrCreateKey() throws -> ES256PrivateKey {
42
+
if let raw = try loadKeyData() {
43
+
return try ES256PrivateKey(pem: raw)
44
+
}
45
+
46
+
let key = ES256PrivateKey()
47
+
try persist(key.pemRepresentation)
48
+
return key
49
+
}
50
+
51
+
private func loadKeyData() throws -> String? {
52
+
// Read from the Keychain and return the PEM string if it exists.
53
+
nil
54
+
}
55
+
56
+
private func persist(_ pem: String) throws {
57
+
// Write the PEM string to the Keychain.
58
+
}
59
+
}
60
+
```
61
+
62
+
## Expose a DPoP JWT generator
63
+
64
+
Wrap the signing key with ``DPoPJWTGenerator`` so the library can mint proofs on demand.
65
+
66
+
```swift
67
+
let keyStore = DPoPKeyStore()
68
+
let privateKey = try await keyStore.loadOrCreateKey()
69
+
let dpopGenerator = try await DPoPJWTGenerator(privateKey: privateKey)
70
+
let jwtGenerator = dpopGenerator.jwtGenerator()
71
+
```
72
+
73
+
Pass ``DPoPJWTGenerator.jwtGenerator()`` to ``LoginService`` and later to ``applyAuthenticationContext(login:generator:resourceNonce:)`` so API calls share the same key material.
74
+
75
+
## Configure login storage
76
+
77
+
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.
78
+
79
+
```swift
80
+
import OAuthenticator
81
+
82
+
struct BlueskyLoginStore {
83
+
func makeStorage() -> LoginStorage {
84
+
LoginStorage {
85
+
try await loadLogin()
86
+
} storeLogin: { login in
87
+
try await persist(login)
88
+
}
89
+
}
90
+
91
+
private func loadLogin() async throws -> Login? {
92
+
// Decode and return the previously stored login if one exists.
93
+
nil
94
+
}
95
+
96
+
private func persist(_ login: Login) async throws {
97
+
// Save the login (for example, in the Keychain or the file system).
98
+
}
99
+
}
100
+
```
101
+
102
+
## Perform the OAuth flow
103
+
104
+
1. Configure shared environment state early in your app lifecycle:
105
+
106
+
```swift
107
+
await setup(
108
+
hostURL: "https://bsky.social",
109
+
accessJWT: nil,
110
+
refreshJWT: nil,
111
+
delegate: self
112
+
)
113
+
```
114
+
115
+
2. Create the services needed for authentication:
116
+
117
+
```swift
118
+
let loginStorage = BlueskyLoginStore().makeStorage()
119
+
let loginService = LoginService(jwtGenerator: jwtGenerator, loginStorage: loginStorage)
120
+
```
121
+
122
+
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).
123
+
124
+
```swift
125
+
let login = try await loginService.login(
126
+
account: "did:plc:your-user",
127
+
clientMetadataEndpoint: "https://example.com/.well-known/coreatprotocol-client.json"
128
+
)
129
+
```
130
+
131
+
4. Share the authentication context with CoreATProtocol so the networking layer can add DPoP proofs automatically:
132
+
133
+
```swift
134
+
await applyAuthenticationContext(login: login, generator: jwtGenerator)
135
+
```
136
+
137
+
5. When Bluesky returns a new DPoP nonce (`DPoP-Nonce` header), call ``updateResourceDPoPNonce(_:)`` with the latest value before the next request.
138
+
139
+
6. To sign the user out, call ``clearAuthenticationContext()`` and erase any stored login and keychain items.
140
+
141
+
## Make API requests
142
+
143
+
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.
144
+
145
+
```swift
146
+
var router = NetworkRouter<SomeEndpoint>(decoder: .atDecoder)
147
+
router.delegate = await APEnvironment.current.routerDelegate
148
+
```
149
+
150
+
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.
151
+
152
+
## Troubleshooting
153
+
154
+
- Ensure the DPoP key persists across app launches. If the key changes, all tokens issued by Bluesky become invalid and the user must reauthenticate.
155
+
- Always call ``applyAuthenticationContext(login:generator:resourceNonce:)`` after refreshing tokens via ``updateTokens(access:refresh:)`` or custom flows so the delegate has current credentials.
156
+
- If Bluesky rejects requests with `use_dpop_nonce`, update the cached value via ``updateResourceDPoPNonce(_:)`` and retry.
157
+