+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",
+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
+