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