mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs(oauth): mark OAuth sub-plan complete, document follow-ups

+3144
+3144
docs/superpowers/plans/2026-04-17-atproto-oauth-dpop.md
··· 1 + # atproto OAuth 2.1 + DPoP 2 + 3 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Layer atproto OAuth 2.1 + DPoP authentication onto the `ATProto` 6 + package so writes become possible. Sign-in requests `atproto` + the four 7 + CRUD/blob write scopes up front; the PDS's consent UI presents them as 8 + individual toggles (the same UX pdsls uses), so per-scope selectivity 9 + happens within a single browser flow rather than being spread across many. 10 + A recovery-only `ensureScope(_:for:)` flow exists for the case where a user 11 + denied a scope at sign-in and later needs it. 12 + 13 + **Architecture:** A new `OAuth/` module alongside `XRPC/` and `Identity/`. 14 + Sign-in goes PDS → discovery → PAR → browser consent → code exchange → 15 + DPoP-bound access + refresh tokens stored per DID. An `OAuthAuthTokenProvider` 16 + conforms to the `AuthTokenProvider` protocol already wired into `XRPCClient` 17 + — it signs DPoP JWTs per request, consumes `DPoP-Nonce` via `handleResponse`, 18 + and refreshes on 401 via `reportTokenRejected` (hooks added in Plan A's Task 19 + 13). Progressive scope escalation is a separate entry point `ensureScope(_:for:)` 20 + that initiates a fresh PAR + browser flow requesting the current scope 21 + superset plus the new scope, replacing tokens atomically on success. 22 + 23 + **Tech Stack:** Swift 6.0, CryptoKit (ES256 P-256 for DPoP), Foundation 24 + `URLSession` + `URLProtocol`, Swift Testing. No third-party dependencies. 25 + Browser interaction abstracted behind a `BrowserDriver` protocol so the host 26 + app implements it via `ASWebAuthenticationSession` later — this plan ships a 27 + stub driver for tests. Token storage abstracted behind a `TokenStore` 28 + protocol with an in-memory impl for dev + tests; the host app will provide a 29 + Keychain-backed impl (separate plan once Xcode is installed). 30 + 31 + **Scope:** 32 + - IN: Scope model, PKCE, DPoP (key + proof + nonce), OAuth metadata discovery 33 + (`.well-known/oauth-protected-resource`, `.well-known/oauth-authorization-server`), 34 + PAR, authorize URL, callback parser, token exchange + refresh, 35 + `OAuthAuthTokenProvider` conforming to `AuthTokenProvider`, in-memory token 36 + store, `BrowserDriver` protocol + test stub, `OAuthCoordinator` with 37 + sign-in + `ensureScope(_:for:)` progressive upgrade, integration test. 38 + - OUT: Keychain-backed `TokenStore` (needs signed bundle — separate plan), 39 + Secure Enclave DPoP key (needs signed bundle — separate plan), 40 + `ASWebAuthenticationSession` wiring in host app (separate plan), 41 + XPC-side plumbing for the FSKit extension to request scope upgrades 42 + (separate plan when the extension lands). 43 + 44 + --- 45 + 46 + ## File structure 47 + 48 + Under `Packages/ATProto/Sources/ATProto/`: 49 + 50 + ``` 51 + OAuth/ 52 + Scope.swift # Scope enum + Set<Scope> parsing 53 + PKCE.swift # Verifier + challenge 54 + 55 + DPoP/ 56 + DPoPKey.swift # Protocol + ES256 CryptoKit impl 57 + DPoPProof.swift # JWT signing 58 + DPoPNonceStore.swift # Per-origin nonce cache actor 59 + 60 + Metadata/ 61 + ProtectedResourceMetadata.swift # /.well-known/oauth-protected-resource 62 + AuthorizationServerMetadata.swift # /.well-known/oauth-authorization-server 63 + ClientMetadata.swift # Our static client metadata 64 + MetadataResolver.swift # Discover RS → AS 65 + 66 + Flow/ 67 + PushedAuthorizationRequest.swift # PAR POST + response 68 + AuthorizationURL.swift # Build authorize URL 69 + CallbackURL.swift # Parse callback URL 70 + TokenExchange.swift # Code + refresh grant → tokens 71 + 72 + Storage/ 73 + StoredTokens.swift # Tokens + granted scope + DPoP key tag 74 + TokenStore.swift # Protocol + InMemoryTokenStore 75 + 76 + OAuthAuthTokenProvider.swift # AuthTokenProvider impl 77 + BrowserDriver.swift # Protocol + StubBrowserDriver for tests 78 + OAuthCoordinator.swift # Sign-in + ensureScope + refresh 79 + ``` 80 + 81 + Under `Packages/ATProto/Tests/ATProtoTests/`: 82 + 83 + ``` 84 + OAuth/ 85 + ScopeTests.swift 86 + PKCETests.swift 87 + DPoPKeyTests.swift 88 + DPoPProofTests.swift 89 + MetadataResolverTests.swift 90 + PARTests.swift 91 + CallbackURLTests.swift 92 + TokenExchangeTests.swift 93 + TokenStoreTests.swift 94 + OAuthAuthTokenProviderTests.swift 95 + OAuthCoordinatorTests.swift # Integration test w/ stub browser + mocked HTTP 96 + ``` 97 + 98 + All OAuth module files sit under `Sources/ATProto/OAuth/` — parallel 99 + structure to `XRPC/` and `Identity/`. `OAuthAuthTokenProvider.swift`, 100 + `BrowserDriver.swift`, and `OAuthCoordinator.swift` live at the module 101 + root because they're the public surface. 102 + 103 + --- 104 + 105 + ## Working directory 106 + 107 + All commands run from `Packages/ATProto/`: 108 + 109 + ```bash 110 + cd /Users/nmoo/Developer/natemoo-re/tangled/atfs/Packages/ATProto 111 + ``` 112 + 113 + --- 114 + 115 + ## Design: Up-front scopes + PDS-native consent UX 116 + 117 + ### atproto scope grammar (reference) 118 + 119 + Per [atproto permission spec](https://atproto.com/specs/permission): 120 + 121 + - `atproto` — base scope, required. Grants identity (`sub` = DID) but grants 122 + **no write permissions**. Reads (`describeRepo`, `listRecords`, 123 + `getRecord`, `getBlob`, `listBlobs`) are public and require no scope 124 + at all — `atproto` alone gives the caller full read access via the 125 + existing `XRPCClient` GETs. 126 + - `repo:<nsid>` — write permission for the given collection NSID. Defaults 127 + to all actions (`create`, `update`, `delete`). Optional `?action=create` 128 + (or combinations) narrows further. 129 + - `repo:*?action=create` — wildcard across collections for a specific 130 + action. pdsls (and most atproto clients) request the four action scopes 131 + separately so the PDS can present them as four independent checkboxes. 132 + - `blob:<mime-pattern>` — blob upload. Cannot be collection-scoped. MIME 133 + pattern supports partial wildcards (`*/*`, `image/*`). 134 + - Scope strings are space-separated in the OAuth `scope` parameter. 135 + 136 + ### Strategy 137 + 138 + **Sign-in requests all five scopes up front**: `atproto`, 139 + `repo:*?action=create`, `repo:*?action=update`, `repo:*?action=delete`, 140 + `blob:*/*`. The PDS consent screen presents these as **four independent 141 + write toggles** (plus the implicit `atproto` base) — the pdsls screenshot 142 + is the canonical example: "Create records / Update records / Delete 143 + records / Upload blobs" with individual checkboxes and one Continue 144 + button. 145 + 146 + The user gets per-scope selectivity without any browser-prompt-per-write 147 + interruption. If they uncheck "Delete records" at sign-in, they simply 148 + can't delete until they re-authorize. We store **exactly what the PDS 149 + returns as granted**, not what we requested. 150 + 151 + Rationale — why not progressive per-write? 152 + - Installing a native macOS app + enabling an FSKit system extension + 153 + going through OAuth is a huge commitment signal. The user has 154 + already decided to trust pdfs with their PDS. 155 + - Interrupting a `echo ... > ~/pdfs/app.bsky/feed.post/...` to open a 156 + browser is terrible UX — editors + CLI tools don't expect that. 157 + - Collection-level scoping (`repo:app.bsky.feed.post`) is valid per the 158 + grammar but isn't common in the atproto ecosystem. pdsls uses wildcard 159 + action scopes exactly like this plan does. 160 + 161 + ### Recovery flow: `ensureScope(_:for:)` 162 + 163 + For the case where a user denied a scope at sign-in and later tries to use 164 + it: `ensureScope([.repo(collections: [.wildcard], actions: [.create])], for: did)` 165 + runs a fresh PAR + browser flow requesting the missing scope alongside 166 + the current grants. This is **opt-in at the caller level** — e.g. the 167 + host-app UI surfaces a clear "You denied delete permission. Re-authorize?" 168 + message with a button, not an automatic mid-write interruption. 169 + 170 + The FSKit write path does NOT call `ensureScope` preemptively. Instead, 171 + when a write returns `EACCES` for scope reasons, the host app is notified 172 + (via XPC) and can present the re-auth affordance at a natural moment. 173 + 174 + ### OAuth 2.1 constraint 175 + 176 + atproto refresh tokens are **scope-bound** — refreshing an access token 177 + cannot widen scope. Every scope upgrade is a full PAR + browser flow. 178 + This is intrinsic to OAuth 2.1 and cannot be avoided — but with all 179 + scopes requested at sign-in, this rarely comes up in practice. 180 + 181 + ### Why still have a structured Scope type? 182 + 183 + Even though the client always requests a fixed envelope, we must 184 + represent **what the PDS actually granted**, which may be any subset. 185 + The structured `Scope` type with `Scope.satisfies(granted:requested:)` 186 + lets us correctly reason about wildcard + action-subset containment so 187 + write paths can check "do we have what we need?" in constant time before 188 + hitting the network. 189 + 190 + --- 191 + 192 + ### Task 1: Structured Scope type + parsing/serialization + containment 193 + 194 + **Files:** 195 + - Create: `Sources/ATProto/OAuth/Scope.swift` 196 + - Create: `Tests/ATProtoTests/OAuth/ScopeTests.swift` 197 + 198 + Per the atproto permission grammar, scopes are structured strings. The 199 + Swift type is a value-type enum with three cases relevant to pdfs: 200 + `.atproto`, `.repo(collections:actions:)`, `.blob(accepts:)`. Each 201 + round-trips to/from its scope-string form. A `Scope.Set.satisfies(requested:)` 202 + helper tells `OAuthCoordinator.ensureScope(_:for:)` whether a granted set 203 + semantically covers a requested one (wildcards and action-subsets). 204 + 205 + - [ ] **Step 1: Write failing tests** 206 + 207 + Create `Tests/ATProtoTests/OAuth/ScopeTests.swift`: 208 + 209 + ```swift 210 + import Foundation 211 + import Testing 212 + @testable import ATProto 213 + 214 + @Suite("Scope") 215 + struct ScopeTests { 216 + // MARK: - parse round-trips 217 + 218 + @Test("parses atproto") 219 + func parseAtproto() { 220 + #expect(Scope("atproto") == .atproto) 221 + } 222 + 223 + @Test("parses repo with positional collection, default actions = all three") 224 + func parseRepoPositional() { 225 + let scope = Scope("repo:app.bsky.feed.post") 226 + guard case let .repo(collections, actions) = scope else { 227 + Issue.record("expected .repo") 228 + return 229 + } 230 + #expect(collections == [.nsid("app.bsky.feed.post")]) 231 + #expect(actions == nil) // nil = all three default 232 + } 233 + 234 + @Test("parses repo with wildcard") 235 + func parseRepoWildcard() { 236 + let scope = Scope("repo:*") 237 + guard case let .repo(collections, _) = scope else { 238 + Issue.record("expected .repo") 239 + return 240 + } 241 + #expect(collections == [.wildcard]) 242 + } 243 + 244 + @Test("parses repo with actions query") 245 + func parseRepoActions() { 246 + let scope = Scope("repo:app.bsky.feed.post?action=create&action=update") 247 + guard case let .repo(_, actions) = scope else { 248 + Issue.record("expected .repo") 249 + return 250 + } 251 + #expect(actions == Set([Scope.Action.create, Scope.Action.update])) 252 + } 253 + 254 + @Test("parses repo with multiple collections as query params") 255 + func parseRepoMultipleCollections() { 256 + let scope = Scope("repo?collection=app.bsky.feed.post&collection=app.bsky.feed.like") 257 + guard case let .repo(collections, _) = scope else { 258 + Issue.record("expected .repo") 259 + return 260 + } 261 + #expect(collections == Set([ 262 + .nsid("app.bsky.feed.post"), 263 + .nsid("app.bsky.feed.like"), 264 + ])) 265 + } 266 + 267 + @Test("parses blob with positional MIME") 268 + func parseBlobPositional() { 269 + let scope = Scope("blob:*/*") 270 + guard case let .blob(accepts) = scope else { 271 + Issue.record("expected .blob") 272 + return 273 + } 274 + #expect(accepts == ["*/*"]) 275 + } 276 + 277 + @Test("parses blob with accept query") 278 + func parseBlobAcceptQuery() { 279 + let scope = Scope("blob?accept=image/*&accept=video/*") 280 + guard case let .blob(accepts) = scope else { 281 + Issue.record("expected .blob") 282 + return 283 + } 284 + #expect(accepts == ["image/*", "video/*"]) 285 + } 286 + 287 + @Test("rejects empty and unknown scope strings") 288 + func rejectUnknown() { 289 + #expect(Scope("") == nil) 290 + #expect(Scope("garbage") == nil) 291 + #expect(Scope("repo:") == nil) 292 + // Illegal: positional + same-name query arg 293 + #expect(Scope("repo:app.bsky.feed.post?collection=app.bsky.feed.like") == nil) 294 + } 295 + 296 + // MARK: - format round-trips 297 + 298 + @Test("atproto formats as \"atproto\"") 299 + func formatAtproto() { 300 + #expect(Scope.atproto.rawValue == "atproto") 301 + } 302 + 303 + @Test("repo formats with positional collection when possible") 304 + func formatRepoPositional() { 305 + let scope = Scope.repo(collections: [.nsid("app.bsky.feed.post")], actions: nil) 306 + #expect(scope.rawValue == "repo:app.bsky.feed.post") 307 + } 308 + 309 + @Test("repo formats with sorted action query when actions specified") 310 + func formatRepoWithActions() { 311 + let scope = Scope.repo( 312 + collections: [.nsid("app.bsky.feed.post")], 313 + actions: [.update, .create] 314 + ) 315 + #expect(scope.rawValue == "repo:app.bsky.feed.post?action=create&action=update") 316 + } 317 + 318 + @Test("repo formats multi-collection via query form (sorted)") 319 + func formatRepoMultiCollection() { 320 + let scope = Scope.repo( 321 + collections: [.nsid("app.bsky.feed.like"), .nsid("app.bsky.feed.post")], 322 + actions: nil 323 + ) 324 + #expect(scope.rawValue == "repo?collection=app.bsky.feed.like&collection=app.bsky.feed.post") 325 + } 326 + 327 + @Test("blob formats with positional when single accept") 328 + func formatBlobPositional() { 329 + #expect(Scope.blob(accepts: ["*/*"]).rawValue == "blob:*/*") 330 + } 331 + 332 + @Test("blob formats with query when multiple accepts") 333 + func formatBlobMulti() { 334 + let scope = Scope.blob(accepts: ["image/*", "video/*"]) 335 + #expect(scope.rawValue == "blob?accept=image/*&accept=video/*") 336 + } 337 + 338 + @Test("round-trip parse → format for representative scopes") 339 + func roundTrip() { 340 + let strings = [ 341 + "atproto", 342 + "repo:*", 343 + "repo:app.bsky.feed.post", 344 + "repo:app.bsky.feed.post?action=create&action=update", 345 + "repo?collection=app.bsky.feed.like&collection=app.bsky.feed.post", 346 + "blob:*/*", 347 + "blob?accept=image/*&accept=video/*", 348 + ] 349 + for s in strings { 350 + guard let parsed = Scope(s) else { 351 + Issue.record("failed to parse \(s)") 352 + continue 353 + } 354 + #expect(parsed.rawValue == s) 355 + } 356 + } 357 + 358 + // MARK: - Set serialization 359 + 360 + @Test("Set serializes space-separated, sorted") 361 + func setFormat() { 362 + let set: Set<Scope> = [ 363 + .atproto, 364 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: nil), 365 + ] 366 + #expect(Scope.formatted(set) == "atproto repo:app.bsky.feed.post") 367 + } 368 + 369 + @Test("Set parses from space-separated, ignoring unknown tokens") 370 + func setParse() { 371 + let set = Scope.parse("atproto nonsense repo:app.bsky.feed.post blob:*/*") 372 + #expect(set.contains(.atproto)) 373 + #expect(set.contains(.repo(collections: [.nsid("app.bsky.feed.post")], actions: nil))) 374 + #expect(set.contains(.blob(accepts: ["*/*"]))) 375 + } 376 + 377 + // MARK: - Containment (used by ensureScope) 378 + 379 + @Test("granted atproto satisfies requested atproto") 380 + func containsAtproto() { 381 + let granted: Set<Scope> = [.atproto] 382 + let requested: Set<Scope> = [.atproto] 383 + #expect(Scope.satisfies(granted: granted, requested: requested)) 384 + } 385 + 386 + @Test("granted repo wildcard satisfies any specific repo request") 387 + func containsRepoWildcard() { 388 + let granted: Set<Scope> = [.repo(collections: [.wildcard], actions: nil)] 389 + let requested: Set<Scope> = [ 390 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create]), 391 + ] 392 + #expect(Scope.satisfies(granted: granted, requested: requested)) 393 + } 394 + 395 + @Test("granted collection + all-actions satisfies specific action on that collection") 396 + func containsActionSubset() { 397 + let granted: Set<Scope> = [ 398 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: nil), 399 + ] 400 + let requested: Set<Scope> = [ 401 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create]), 402 + ] 403 + #expect(Scope.satisfies(granted: granted, requested: requested)) 404 + } 405 + 406 + @Test("granted collection does NOT satisfy a different collection request") 407 + func containsCollectionMismatch() { 408 + let granted: Set<Scope> = [ 409 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: nil), 410 + ] 411 + let requested: Set<Scope> = [ 412 + .repo(collections: [.nsid("app.bsky.actor.profile")], actions: nil), 413 + ] 414 + #expect(!Scope.satisfies(granted: granted, requested: requested)) 415 + } 416 + 417 + @Test("granted action subset does NOT satisfy broader action request") 418 + func containsActionBroader() { 419 + let granted: Set<Scope> = [ 420 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create]), 421 + ] 422 + let requested: Set<Scope> = [ 423 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create, .delete]), 424 + ] 425 + #expect(!Scope.satisfies(granted: granted, requested: requested)) 426 + } 427 + 428 + @Test("granted blob */* satisfies any blob request") 429 + func containsBlobWildcard() { 430 + let granted: Set<Scope> = [.blob(accepts: ["*/*"])] 431 + let requested: Set<Scope> = [.blob(accepts: ["image/png"])] 432 + #expect(Scope.satisfies(granted: granted, requested: requested)) 433 + } 434 + 435 + @Test("granted blob image/* does NOT satisfy video blob request") 436 + func containsBlobMismatch() { 437 + let granted: Set<Scope> = [.blob(accepts: ["image/*"])] 438 + let requested: Set<Scope> = [.blob(accepts: ["video/mp4"])] 439 + #expect(!Scope.satisfies(granted: granted, requested: requested)) 440 + } 441 + } 442 + ``` 443 + 444 + - [ ] **Step 2: Run to verify failure** 445 + 446 + ```bash 447 + swift test --filter ScopeTests 2>&1 | tail -10 448 + ``` 449 + Expected: FAIL (cannot find Scope in scope). 450 + 451 + - [ ] **Step 3: Implement Scope** 452 + 453 + Create `Sources/ATProto/OAuth/Scope.swift`: 454 + 455 + ```swift 456 + import Foundation 457 + 458 + /// atproto OAuth scope, per https://atproto.com/specs/permission. 459 + /// 460 + /// pdfs only uses three resource types. `.rpc`, `.identity`, `.account` 461 + /// exist in the spec but aren't needed for the filesystem-over-PDS use 462 + /// case, so they're not modeled here. Parsing unknown resource types 463 + /// returns nil. 464 + public enum Scope: Hashable, Sendable, Codable { 465 + case atproto 466 + case repo(collections: Set<Collection>, actions: Set<Action>?) 467 + case blob(accepts: Set<String>) 468 + 469 + public enum Collection: Hashable, Sendable { 470 + case wildcard 471 + case nsid(String) 472 + 473 + var rawValue: String { 474 + switch self { 475 + case .wildcard: return "*" 476 + case .nsid(let s): return s 477 + } 478 + } 479 + 480 + static func parse(_ s: String) -> Collection? { 481 + if s == "*" { return .wildcard } 482 + guard !s.isEmpty else { return nil } 483 + return .nsid(s) 484 + } 485 + } 486 + 487 + public enum Action: String, Hashable, Sendable, CaseIterable { 488 + case create, update, delete 489 + } 490 + 491 + /// Convenience factory for a collection-scoped repo permission with 492 + /// default actions (create+update+delete). 493 + public static func repo(collection: String) -> Scope { 494 + .repo(collections: [.nsid(collection)], actions: nil) 495 + } 496 + 497 + // MARK: - Scope-string parsing 498 + 499 + /// Parses a single scope string per the atproto permission grammar. 500 + /// Returns nil for unrecognized resource types or malformed input. 501 + public init?(_ rawValue: String) { 502 + guard !rawValue.isEmpty else { return nil } 503 + // Split resource / positional / query. 504 + let (resource, positional, query) = Self.split(rawValue) 505 + guard let resource else { return nil } 506 + 507 + switch resource { 508 + case "atproto": 509 + guard positional == nil, query.isEmpty else { return nil } 510 + self = .atproto 511 + 512 + case "repo": 513 + // Collection: positional OR `?collection=...` array. Not both. 514 + var collections: Set<Collection> = [] 515 + if let pos = positional { 516 + if query["collection"] != nil { return nil } // both forms forbidden 517 + guard let c = Collection.parse(pos) else { return nil } 518 + collections = [c] 519 + } 520 + if let queryCollections = query["collection"] { 521 + for qc in queryCollections { 522 + guard let c = Collection.parse(qc) else { return nil } 523 + collections.insert(c) 524 + } 525 + } 526 + guard !collections.isEmpty else { return nil } 527 + 528 + // Actions: optional array. nil = all three default. 529 + let actions: Set<Action>? 530 + if let queryActions = query["action"] { 531 + var parsed: Set<Action> = [] 532 + for qa in queryActions { 533 + guard let a = Action(rawValue: qa) else { return nil } 534 + parsed.insert(a) 535 + } 536 + actions = parsed.isEmpty ? nil : parsed 537 + } else { 538 + actions = nil 539 + } 540 + self = .repo(collections: collections, actions: actions) 541 + 542 + case "blob": 543 + // MIME: positional single OR `?accept=...` array. Not both. 544 + var accepts: Set<String> = [] 545 + if let pos = positional { 546 + if query["accept"] != nil { return nil } 547 + accepts = [pos] 548 + } 549 + if let qa = query["accept"] { 550 + for m in qa { accepts.insert(m) } 551 + } 552 + guard !accepts.isEmpty else { return nil } 553 + self = .blob(accepts: accepts) 554 + 555 + default: 556 + return nil 557 + } 558 + } 559 + 560 + // MARK: - Scope-string formatting 561 + 562 + public var rawValue: String { 563 + switch self { 564 + case .atproto: 565 + return "atproto" 566 + case .repo(let collections, let actions): 567 + let sortedCollections = collections.map(\.rawValue).sorted() 568 + var s: String 569 + if sortedCollections.count == 1 { 570 + s = "repo:\(sortedCollections[0])" 571 + } else { 572 + let joined = sortedCollections.map { "collection=\($0)" }.joined(separator: "&") 573 + s = "repo?\(joined)" 574 + } 575 + if let actions, !actions.isEmpty { 576 + let sortedActions = actions.map(\.rawValue).sorted() 577 + let joined = sortedActions.map { "action=\($0)" }.joined(separator: "&") 578 + s += (s.contains("?") ? "&" : "?") + joined 579 + } 580 + return s 581 + case .blob(let accepts): 582 + let sorted = accepts.sorted() 583 + if sorted.count == 1 { 584 + return "blob:\(sorted[0])" 585 + } 586 + let joined = sorted.map { "accept=\($0)" }.joined(separator: "&") 587 + return "blob?\(joined)" 588 + } 589 + } 590 + 591 + // MARK: - Set helpers 592 + 593 + /// Space-separated scope string form (alphabetically sorted). 594 + public static func formatted(_ scopes: Set<Scope>) -> String { 595 + scopes.map(\.rawValue).sorted().joined(separator: " ") 596 + } 597 + 598 + /// Parses a space-separated scope string. Unknown tokens are ignored. 599 + public static func parse(_ s: String) -> Set<Scope> { 600 + Set(s.split(whereSeparator: \.isWhitespace).compactMap { Scope(String($0)) }) 601 + } 602 + 603 + // MARK: - Containment 604 + 605 + /// Does `granted` semantically cover every requested scope? 606 + /// Wildcard collections satisfy specific collections; granted action 607 + /// supersets (including nil = all three) satisfy narrower requests; 608 + /// blob `*/*` satisfies any specific MIME pattern; blob `image/*` 609 + /// satisfies `image/png` but not `video/mp4`. 610 + public static func satisfies(granted: Set<Scope>, requested: Set<Scope>) -> Bool { 611 + requested.allSatisfy { r in 612 + granted.contains { g in g.satisfies(r) } 613 + } 614 + } 615 + 616 + /// True iff `self` is at least as broad as `other` in the same resource 617 + /// family. Across resource families, always false. 618 + func satisfies(_ other: Scope) -> Bool { 619 + switch (self, other) { 620 + case (.atproto, .atproto): 621 + return true 622 + case let (.repo(gCols, gActions), .repo(rCols, rActions)): 623 + // Every requested collection must be covered by granted collections. 624 + let collectionsCover = rCols.allSatisfy { rc in 625 + gCols.contains(.wildcard) || gCols.contains(rc) 626 + } 627 + // Granted actions nil = all three, which covers anything. Else 628 + // granted must be a superset of requested (nil requested = all three). 629 + let grantedActions = gActions ?? Set(Action.allCases) 630 + let requestedActions = rActions ?? Set(Action.allCases) 631 + let actionsCover = requestedActions.isSubset(of: grantedActions) 632 + return collectionsCover && actionsCover 633 + case let (.blob(gAccepts), .blob(rAccepts)): 634 + return rAccepts.allSatisfy { r in 635 + gAccepts.contains { g in Self.mimeSatisfies(granted: g, requested: r) } 636 + } 637 + default: 638 + return false 639 + } 640 + } 641 + 642 + /// `*/*` satisfies anything. `image/*` satisfies `image/png`. Exact-exact. 643 + /// Prefix-only patterns like `*/html` are not valid per spec and not handled. 644 + static func mimeSatisfies(granted: String, requested: String) -> Bool { 645 + if granted == "*/*" { return true } 646 + if granted == requested { return true } 647 + // Wildcard on subtype: "image/*" 648 + let gParts = granted.split(separator: "/", maxSplits: 1).map(String.init) 649 + let rParts = requested.split(separator: "/", maxSplits: 1).map(String.init) 650 + guard gParts.count == 2, rParts.count == 2 else { return false } 651 + if gParts[1] == "*" && gParts[0] == rParts[0] { return true } 652 + return false 653 + } 654 + 655 + // MARK: - Codable (round-trips via the scope string form) 656 + 657 + public init(from decoder: Decoder) throws { 658 + let container = try decoder.singleValueContainer() 659 + let raw = try container.decode(String.self) 660 + guard let scope = Scope(raw) else { 661 + throw DecodingError.dataCorruptedError( 662 + in: container, debugDescription: "invalid scope string: \(raw)" 663 + ) 664 + } 665 + self = scope 666 + } 667 + 668 + public func encode(to encoder: Encoder) throws { 669 + var container = encoder.singleValueContainer() 670 + try container.encode(rawValue) 671 + } 672 + 673 + // MARK: - Raw string splitting 674 + 675 + /// Returns (resource, positional?, query[name: [values]]). 676 + /// Invalid inputs get resource=nil and the caller returns nil upstream. 677 + static func split(_ s: String) -> (resource: String?, positional: String?, query: [String: [String]]) { 678 + // Split "?" first. 679 + let qIdx = s.firstIndex(of: "?") 680 + let head = qIdx.map { String(s[..<$0]) } ?? s 681 + let queryString = qIdx.map { String(s[s.index(after: $0)...]) } ?? "" 682 + 683 + // Head: "resource" or "resource:positional" 684 + let headParts = head.split(separator: ":", maxSplits: 1).map(String.init) 685 + guard !headParts[0].isEmpty else { return (nil, nil, [:]) } 686 + let resource = headParts[0] 687 + let positional: String? = { 688 + if headParts.count == 2 { 689 + // Empty positional = malformed ("repo:" or "repo:?"). 690 + return headParts[1].isEmpty ? nil : headParts[1] 691 + } 692 + return nil 693 + }() 694 + // "repo:" with nothing after is malformed → treat resource as nil. 695 + if head.contains(":") && positional == nil { 696 + return (nil, nil, [:]) 697 + } 698 + 699 + // Query: &-separated name=value, percent-decoded. 700 + var query: [String: [String]] = [:] 701 + if !queryString.isEmpty { 702 + for pair in queryString.split(separator: "&") { 703 + let kv = pair.split(separator: "=", maxSplits: 1).map(String.init) 704 + guard kv.count == 2 else { continue } 705 + let name = kv[0].removingPercentEncoding ?? kv[0] 706 + let value = kv[1].removingPercentEncoding ?? kv[1] 707 + query[name, default: []].append(value) 708 + } 709 + } 710 + return (resource, positional, query) 711 + } 712 + } 713 + ``` 714 + 715 + - [ ] **Step 4: Run to verify pass** 716 + 717 + ```bash 718 + swift test --filter ScopeTests 2>&1 | tail -15 719 + ``` 720 + Expected: all tests pass (~25 assertions across parse/format/containment). 721 + 722 + - [ ] **Step 5: Commit** 723 + 724 + ```bash 725 + git add Packages/ATProto/Sources/ATProto/OAuth/Scope.swift Packages/ATProto/Tests/ATProtoTests/OAuth/ScopeTests.swift 726 + git commit -m "feat(atproto/oauth): add structured Scope type with collection-aware containment" 727 + ``` 728 + 729 + --- 730 + 731 + ### Task 2: PKCE 732 + 733 + **Files:** 734 + - Create: `Sources/ATProto/OAuth/PKCE.swift` 735 + - Create: `Tests/ATProtoTests/OAuth/PKCETests.swift` 736 + 737 + PKCE (Proof Key for Code Exchange, RFC 7636): client generates a random 738 + `code_verifier` (43-128 chars, base64url-no-pad alphabet), derives 739 + `code_challenge = base64url(SHA256(code_verifier))`. Challenge goes in the 740 + authorization request; verifier goes in the token exchange. Server 741 + compares them to prove the party that initiated the auth flow is the same 742 + party redeeming the code. 743 + 744 + - [ ] **Step 1: Write failing tests** 745 + 746 + Create `Tests/ATProtoTests/OAuth/PKCETests.swift`: 747 + 748 + ```swift 749 + import Foundation 750 + import Testing 751 + @testable import ATProto 752 + 753 + @Suite("PKCE") 754 + struct PKCETests { 755 + @Test("verifier length is between 43 and 128") 756 + func verifierLength() { 757 + for _ in 0..<50 { 758 + let p = PKCE.generate() 759 + #expect(p.verifier.count >= 43) 760 + #expect(p.verifier.count <= 128) 761 + } 762 + } 763 + 764 + @Test("verifier uses only unreserved base64url-no-pad characters") 765 + func verifierCharset() { 766 + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") 767 + for _ in 0..<10 { 768 + let p = PKCE.generate() 769 + let scalars = CharacterSet(charactersIn: p.verifier) 770 + #expect(scalars.isSubset(of: allowed)) 771 + } 772 + } 773 + 774 + @Test("challenge is base64url-encoded SHA256 of verifier") 775 + func challengeIsHash() throws { 776 + // Known-answer test vectors from RFC 7636 appendix B. 777 + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" 778 + let expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" 779 + let challenge = PKCE.deriveChallenge(from: verifier) 780 + #expect(challenge == expectedChallenge) 781 + } 782 + 783 + @Test("generate() produces challenge matching verifier") 784 + func generateConsistency() { 785 + for _ in 0..<5 { 786 + let p = PKCE.generate() 787 + #expect(p.challenge == PKCE.deriveChallenge(from: p.verifier)) 788 + } 789 + } 790 + 791 + @Test("each generate() returns a unique pair") 792 + func generateUnique() { 793 + let pairs = (0..<10).map { _ in PKCE.generate() } 794 + let verifiers = Set(pairs.map(\.verifier)) 795 + #expect(verifiers.count == pairs.count) 796 + } 797 + } 798 + ``` 799 + 800 + - [ ] **Step 2: Run to verify failure** 801 + 802 + ```bash 803 + swift test --filter PKCETests 2>&1 | tail -10 804 + ``` 805 + 806 + - [ ] **Step 3: Implement PKCE** 807 + 808 + Create `Sources/ATProto/OAuth/PKCE.swift`: 809 + 810 + ```swift 811 + import Foundation 812 + import CryptoKit 813 + 814 + public struct PKCE: Sendable, Equatable { 815 + public let verifier: String 816 + public let challenge: String 817 + public let method = "S256" 818 + 819 + /// Generates a fresh (verifier, challenge) pair. Verifier is 64 bytes 820 + /// of randomness → 86 base64url-no-pad chars. 821 + public static func generate() -> PKCE { 822 + let rawBytes = Data((0..<64).map { _ in UInt8.random(in: 0...255) }) 823 + let verifier = base64URLEncode(rawBytes) 824 + return PKCE(verifier: verifier, challenge: deriveChallenge(from: verifier)) 825 + } 826 + 827 + public static func deriveChallenge(from verifier: String) -> String { 828 + let digest = SHA256.hash(data: Data(verifier.utf8)) 829 + return base64URLEncode(Data(digest)) 830 + } 831 + 832 + static func base64URLEncode(_ data: Data) -> String { 833 + data.base64EncodedString() 834 + .replacingOccurrences(of: "+", with: "-") 835 + .replacingOccurrences(of: "/", with: "_") 836 + .replacingOccurrences(of: "=", with: "") 837 + } 838 + } 839 + ``` 840 + 841 + - [ ] **Step 4: Run to verify pass** 842 + 843 + ```bash 844 + swift test --filter PKCETests 2>&1 | tail -10 845 + ``` 846 + Expected: 5 tests pass. 847 + 848 + - [ ] **Step 5: Commit** 849 + 850 + ```bash 851 + git add Packages/ATProto/Sources/ATProto/OAuth/PKCE.swift Packages/ATProto/Tests/ATProtoTests/OAuth/PKCETests.swift 852 + git commit -m "feat(atproto/oauth): add PKCE challenge + verifier generation" 853 + ``` 854 + 855 + --- 856 + 857 + ### Task 3: DPoPKey protocol + ES256 impl 858 + 859 + **Files:** 860 + - Create: `Sources/ATProto/OAuth/DPoP/DPoPKey.swift` 861 + - Create: `Tests/ATProtoTests/OAuth/DPoPKeyTests.swift` 862 + 863 + DPoP uses an ephemeral-per-session ES256 keypair. The public JWK goes in 864 + the DPoP JWT header; the private key signs each proof. We abstract behind 865 + a `DPoPKey` protocol so a future Secure Enclave impl can slot in. 866 + 867 + - [ ] **Step 1: Write failing tests** 868 + 869 + Create `Tests/ATProtoTests/OAuth/DPoPKeyTests.swift`: 870 + 871 + ```swift 872 + import Foundation 873 + import Testing 874 + @testable import ATProto 875 + 876 + @Suite("DPoPKey") 877 + struct DPoPKeyTests { 878 + @Test("ES256 key produces JWK with EC P-256 parameters") 879 + func jwk() async throws { 880 + let key = try InMemoryES256DPoPKey.generate() 881 + let jwk = try await key.publicJWK() 882 + #expect(jwk["kty"] as? String == "EC") 883 + #expect(jwk["crv"] as? String == "P-256") 884 + #expect(jwk["alg"] as? String == "ES256") 885 + // x and y are 32-byte coordinates, base64url-no-pad = 43 chars. 886 + let x = jwk["x"] as? String ?? "" 887 + let y = jwk["y"] as? String ?? "" 888 + #expect(x.count == 43) 889 + #expect(y.count == 43) 890 + } 891 + 892 + @Test("sign + verify round-trip with raw ECDSA P-256") 893 + func signVerify() async throws { 894 + let key = try InMemoryES256DPoPKey.generate() 895 + let message = Data("hello dpop".utf8) 896 + let signature = try await key.sign(message) 897 + 898 + // Signature is 64 bytes: r||s, each 32 bytes. 899 + #expect(signature.count == 64) 900 + 901 + // Verify using CryptoKit directly. 902 + let publicKey = try await key.publicKey() 903 + let ckSig = try CryptoKit.P256.Signing.ECDSASignature(rawRepresentation: signature) 904 + #expect(publicKey.isValidSignature(ckSig, for: message)) 905 + } 906 + 907 + @Test("two generate() calls produce distinct keys") 908 + func uniqueKeys() async throws { 909 + let a = try InMemoryES256DPoPKey.generate() 910 + let b = try InMemoryES256DPoPKey.generate() 911 + let jwkA = try await a.publicJWK() 912 + let jwkB = try await b.publicJWK() 913 + #expect(jwkA["x"] as? String != jwkB["x"] as? String) 914 + } 915 + } 916 + ``` 917 + 918 + Note: import `CryptoKit` where needed — add `import CryptoKit` at the top of the test file. Actually it's already in the code under test, but tests need their own import. Update the test file's imports: 919 + 920 + ```swift 921 + import Foundation 922 + import CryptoKit 923 + import Testing 924 + @testable import ATProto 925 + ``` 926 + 927 + - [ ] **Step 2: Run to verify failure** 928 + 929 + ```bash 930 + swift test --filter DPoPKeyTests 2>&1 | tail -10 931 + ``` 932 + 933 + - [ ] **Step 3: Implement DPoPKey** 934 + 935 + Create `Sources/ATProto/OAuth/DPoP/DPoPKey.swift`: 936 + 937 + ```swift 938 + import Foundation 939 + import CryptoKit 940 + 941 + /// Abstraction over a DPoP signing key. v1 ships an in-memory ES256 impl 942 + /// via CryptoKit; a future impl can back the key with the Secure Enclave. 943 + public protocol DPoPKey: Sendable { 944 + /// The public JWK (JSON Web Key) for this key, as a JSON-compatible 945 + /// `[String: Any]` dict. Consumers embed this in the DPoP JWT header. 946 + func publicJWK() async throws -> [String: Any] 947 + 948 + /// Signs the given message with ES256, returning the raw r||s concatenation 949 + /// (64 bytes). Callers convert to JWS b64url as needed. 950 + func sign(_ message: Data) async throws -> Data 951 + 952 + /// Returns the underlying CryptoKit public key for verification in tests. 953 + func publicKey() async throws -> CryptoKit.P256.Signing.PublicKey 954 + } 955 + 956 + /// In-memory ES256 keypair for dev and tests. The host app will eventually 957 + /// provide a Secure Enclave-backed implementation under the same protocol. 958 + public actor InMemoryES256DPoPKey: DPoPKey { 959 + private let privateKey: CryptoKit.P256.Signing.PrivateKey 960 + 961 + public static func generate() throws -> InMemoryES256DPoPKey { 962 + InMemoryES256DPoPKey(privateKey: CryptoKit.P256.Signing.PrivateKey()) 963 + } 964 + 965 + init(privateKey: CryptoKit.P256.Signing.PrivateKey) { 966 + self.privateKey = privateKey 967 + } 968 + 969 + public func publicJWK() -> [String: Any] { 970 + let rep = privateKey.publicKey.x963Representation 971 + // x963 format: 0x04 || X (32 bytes) || Y (32 bytes) 972 + let x = rep.subdata(in: 1..<33) 973 + let y = rep.subdata(in: 33..<65) 974 + return [ 975 + "kty": "EC", 976 + "crv": "P-256", 977 + "alg": "ES256", 978 + "x": PKCE.base64URLEncode(x), 979 + "y": PKCE.base64URLEncode(y), 980 + ] 981 + } 982 + 983 + public func sign(_ message: Data) throws -> Data { 984 + let signature = try privateKey.signature(for: message) 985 + return signature.rawRepresentation 986 + } 987 + 988 + public func publicKey() -> CryptoKit.P256.Signing.PublicKey { 989 + privateKey.publicKey 990 + } 991 + } 992 + ``` 993 + 994 + - [ ] **Step 4: Run to verify pass** 995 + 996 + ```bash 997 + swift test --filter DPoPKeyTests 2>&1 | tail -10 998 + ``` 999 + Expected: 3 tests pass. 1000 + 1001 + - [ ] **Step 5: Commit** 1002 + 1003 + ```bash 1004 + git add Packages/ATProto/Sources/ATProto/OAuth/DPoP/DPoPKey.swift Packages/ATProto/Tests/ATProtoTests/OAuth/DPoPKeyTests.swift 1005 + git commit -m "feat(atproto/oauth): add DPoPKey protocol + in-memory ES256 impl" 1006 + ``` 1007 + 1008 + --- 1009 + 1010 + ### Task 4: DPoP proof JWT + nonce cache 1011 + 1012 + **Files:** 1013 + - Create: `Sources/ATProto/OAuth/DPoP/DPoPProof.swift` 1014 + - Create: `Sources/ATProto/OAuth/DPoP/DPoPNonceStore.swift` 1015 + - Create: `Tests/ATProtoTests/OAuth/DPoPProofTests.swift` 1016 + 1017 + A DPoP proof is a short JWT signed with the DPoP key. Header includes 1018 + `typ: "dpop+jwt"`, `alg: "ES256"`, and the public JWK. Payload has 1019 + `jti` (random id), `iat` (issued at), `htm` (HTTP method), `htu` (HTTP URL 1020 + without query/fragment), optional `ath` (base64url SHA256 of access_token, 1021 + for resource requests), and optional `nonce` (from server's previous 1022 + `DPoP-Nonce` response header, cached per-origin). 1023 + 1024 + - [ ] **Step 1: Write failing tests** 1025 + 1026 + Create `Tests/ATProtoTests/OAuth/DPoPProofTests.swift`: 1027 + 1028 + ```swift 1029 + import Foundation 1030 + import CryptoKit 1031 + import Testing 1032 + @testable import ATProto 1033 + 1034 + @Suite("DPoPProof") 1035 + struct DPoPProofTests { 1036 + @Test("proof JWT has dpop+jwt header type and ES256 alg") 1037 + func headerFields() async throws { 1038 + let key = try InMemoryES256DPoPKey.generate() 1039 + let proof = try await DPoPProof.create( 1040 + method: "POST", 1041 + url: URL(string: "https://bsky.social/xrpc/com.atproto.repo.createRecord")!, 1042 + key: key, 1043 + nonce: nil, 1044 + accessToken: nil 1045 + ) 1046 + let header = try decodeJWTHeader(proof) 1047 + #expect(header["typ"] as? String == "dpop+jwt") 1048 + #expect(header["alg"] as? String == "ES256") 1049 + #expect(header["jwk"] is [String: Any]) 1050 + } 1051 + 1052 + @Test("proof payload includes htm, htu, iat, jti") 1053 + func payloadRequiredClaims() async throws { 1054 + let key = try InMemoryES256DPoPKey.generate() 1055 + let proof = try await DPoPProof.create( 1056 + method: "GET", 1057 + url: URL(string: "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=did:plc:abc")!, 1058 + key: key, 1059 + nonce: nil, 1060 + accessToken: nil 1061 + ) 1062 + let payload = try decodeJWTPayload(proof) 1063 + #expect(payload["htm"] as? String == "GET") 1064 + // htu must strip query + fragment per RFC 9449 §4.2. 1065 + #expect(payload["htu"] as? String == "https://bsky.social/xrpc/com.atproto.repo.describeRepo") 1066 + #expect(payload["iat"] is Int) 1067 + let jti = payload["jti"] as? String ?? "" 1068 + #expect(jti.count >= 16) 1069 + } 1070 + 1071 + @Test("proof includes nonce when supplied") 1072 + func withNonce() async throws { 1073 + let key = try InMemoryES256DPoPKey.generate() 1074 + let proof = try await DPoPProof.create( 1075 + method: "POST", 1076 + url: URL(string: "https://bsky.social/xrpc/anything")!, 1077 + key: key, 1078 + nonce: "abc123", 1079 + accessToken: nil 1080 + ) 1081 + let payload = try decodeJWTPayload(proof) 1082 + #expect(payload["nonce"] as? String == "abc123") 1083 + } 1084 + 1085 + @Test("proof includes ath when access token supplied") 1086 + func withAccessToken() async throws { 1087 + let key = try InMemoryES256DPoPKey.generate() 1088 + let accessToken = "ExampleToken" 1089 + let proof = try await DPoPProof.create( 1090 + method: "GET", 1091 + url: URL(string: "https://bsky.social/xrpc/anything")!, 1092 + key: key, 1093 + nonce: nil, 1094 + accessToken: accessToken 1095 + ) 1096 + let payload = try decodeJWTPayload(proof) 1097 + let expectedAth = PKCE.base64URLEncode(Data(SHA256.hash(data: Data(accessToken.utf8)))) 1098 + #expect(payload["ath"] as? String == expectedAth) 1099 + } 1100 + 1101 + @Test("proof signature verifies against the key's public JWK") 1102 + func signatureVerifies() async throws { 1103 + let key = try InMemoryES256DPoPKey.generate() 1104 + let proof = try await DPoPProof.create( 1105 + method: "POST", 1106 + url: URL(string: "https://example.com/token")!, 1107 + key: key, 1108 + nonce: nil, 1109 + accessToken: nil 1110 + ) 1111 + // Split header.payload.signature, verify signature over "header.payload" bytes. 1112 + let parts = proof.split(separator: ".").map(String.init) 1113 + #expect(parts.count == 3) 1114 + let signingInput = Data("\(parts[0]).\(parts[1])".utf8) 1115 + let sigBytes = try base64URLDecode(parts[2]) 1116 + let sig = try CryptoKit.P256.Signing.ECDSASignature(rawRepresentation: sigBytes) 1117 + let pub = try await key.publicKey() 1118 + #expect(pub.isValidSignature(sig, for: signingInput)) 1119 + } 1120 + } 1121 + 1122 + // MARK: - JWT test helpers 1123 + 1124 + private func decodeJWTHeader(_ jwt: String) throws -> [String: Any] { 1125 + let parts = jwt.split(separator: ".").map(String.init) 1126 + guard parts.count == 3 else { throw TestError.invalid } 1127 + return try decodeJSONObject(base64URLDecode(parts[0])) 1128 + } 1129 + 1130 + private func decodeJWTPayload(_ jwt: String) throws -> [String: Any] { 1131 + let parts = jwt.split(separator: ".").map(String.init) 1132 + guard parts.count == 3 else { throw TestError.invalid } 1133 + return try decodeJSONObject(base64URLDecode(parts[1])) 1134 + } 1135 + 1136 + private func decodeJSONObject(_ data: Data) throws -> [String: Any] { 1137 + guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { 1138 + throw TestError.invalid 1139 + } 1140 + return obj 1141 + } 1142 + 1143 + func base64URLDecode(_ s: String) throws -> Data { 1144 + var padded = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") 1145 + while padded.count % 4 != 0 { padded += "=" } 1146 + guard let d = Data(base64Encoded: padded) else { throw TestError.invalid } 1147 + return d 1148 + } 1149 + 1150 + enum TestError: Error { case invalid } 1151 + ``` 1152 + 1153 + - [ ] **Step 2: Run to verify failure** 1154 + 1155 + ```bash 1156 + swift test --filter DPoPProofTests 2>&1 | tail -10 1157 + ``` 1158 + 1159 + - [ ] **Step 3: Implement DPoPNonceStore** 1160 + 1161 + Create `Sources/ATProto/OAuth/DPoP/DPoPNonceStore.swift`: 1162 + 1163 + ```swift 1164 + import Foundation 1165 + 1166 + /// Caches the latest `DPoP-Nonce` header value per origin (scheme+host+port). 1167 + /// Providers consult this store before signing a proof and update it when 1168 + /// responses carry a new nonce. 1169 + public actor DPoPNonceStore { 1170 + private var nonces: [String: String] = [:] 1171 + 1172 + public init() {} 1173 + 1174 + public func nonce(for url: URL) -> String? { 1175 + nonces[Self.originKey(for: url)] 1176 + } 1177 + 1178 + public func setNonce(_ nonce: String, for url: URL) { 1179 + nonces[Self.originKey(for: url)] = nonce 1180 + } 1181 + 1182 + public func clear() { 1183 + nonces.removeAll() 1184 + } 1185 + 1186 + static func originKey(for url: URL) -> String { 1187 + let scheme = url.scheme ?? "https" 1188 + let host = url.host ?? "" 1189 + let port = url.port.map { ":\($0)" } ?? "" 1190 + return "\(scheme)://\(host)\(port)" 1191 + } 1192 + } 1193 + ``` 1194 + 1195 + - [ ] **Step 4: Implement DPoPProof** 1196 + 1197 + Create `Sources/ATProto/OAuth/DPoP/DPoPProof.swift`: 1198 + 1199 + ```swift 1200 + import Foundation 1201 + import CryptoKit 1202 + 1203 + public enum DPoPProof { 1204 + /// Builds and signs a DPoP proof JWT for the given HTTP request. 1205 + /// 1206 + /// - `htu`: the request URL without query or fragment, per RFC 9449 §4.2. 1207 + /// - `ath`: if `accessToken` is non-nil, included as the base64url-no-pad 1208 + /// SHA-256 hash of the access token (RFC 9449 §4.2). 1209 + /// - `nonce`: if the server previously returned a `DPoP-Nonce`, pass it here. 1210 + public static func create( 1211 + method: String, 1212 + url: URL, 1213 + key: any DPoPKey, 1214 + nonce: String?, 1215 + accessToken: String? 1216 + ) async throws -> String { 1217 + let jwk = try await key.publicJWK() 1218 + let header: [String: Any] = [ 1219 + "typ": "dpop+jwt", 1220 + "alg": "ES256", 1221 + "jwk": jwk, 1222 + ] 1223 + 1224 + var payload: [String: Any] = [ 1225 + "htm": method.uppercased(), 1226 + "htu": canonicalHTU(url), 1227 + "iat": Int(Date().timeIntervalSince1970), 1228 + "jti": randomJTI(), 1229 + ] 1230 + if let nonce { payload["nonce"] = nonce } 1231 + if let accessToken { 1232 + let digest = SHA256.hash(data: Data(accessToken.utf8)) 1233 + payload["ath"] = PKCE.base64URLEncode(Data(digest)) 1234 + } 1235 + 1236 + let headerData = try JSONSerialization.data(withJSONObject: header, options: [.sortedKeys]) 1237 + let payloadData = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) 1238 + let headerB64 = PKCE.base64URLEncode(headerData) 1239 + let payloadB64 = PKCE.base64URLEncode(payloadData) 1240 + let signingInput = "\(headerB64).\(payloadB64)" 1241 + let signature = try await key.sign(Data(signingInput.utf8)) 1242 + let sigB64 = PKCE.base64URLEncode(signature) 1243 + return "\(signingInput).\(sigB64)" 1244 + } 1245 + 1246 + /// Strips query + fragment from the URL per RFC 9449 §4.2. 1247 + static func canonicalHTU(_ url: URL) -> String { 1248 + var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents() 1249 + comps.query = nil 1250 + comps.fragment = nil 1251 + return comps.url?.absoluteString ?? url.absoluteString 1252 + } 1253 + 1254 + private static func randomJTI() -> String { 1255 + let bytes = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) 1256 + return PKCE.base64URLEncode(bytes) 1257 + } 1258 + } 1259 + ``` 1260 + 1261 + - [ ] **Step 5: Run to verify pass** 1262 + 1263 + ```bash 1264 + swift test --filter DPoPProofTests 2>&1 | tail -10 1265 + ``` 1266 + Expected: 5 tests pass. 1267 + 1268 + - [ ] **Step 6: Commit** 1269 + 1270 + ```bash 1271 + git add Packages/ATProto/Sources/ATProto/OAuth/DPoP/DPoPProof.swift Packages/ATProto/Sources/ATProto/OAuth/DPoP/DPoPNonceStore.swift Packages/ATProto/Tests/ATProtoTests/OAuth/DPoPProofTests.swift 1272 + git commit -m "feat(atproto/oauth): add DPoP proof JWT signing + per-origin nonce cache" 1273 + ``` 1274 + 1275 + --- 1276 + 1277 + ### Task 5: OAuth metadata types + discovery 1278 + 1279 + **Files:** 1280 + - Create: `Sources/ATProto/OAuth/Metadata/ProtectedResourceMetadata.swift` 1281 + - Create: `Sources/ATProto/OAuth/Metadata/AuthorizationServerMetadata.swift` 1282 + - Create: `Sources/ATProto/OAuth/Metadata/ClientMetadata.swift` 1283 + - Create: `Sources/ATProto/OAuth/Metadata/MetadataResolver.swift` 1284 + - Create: `Tests/ATProtoTests/OAuth/MetadataResolverTests.swift` 1285 + 1286 + OAuth 2.1 requires two discovery steps for an atproto PDS: 1287 + 1. GET `<PDS>/.well-known/oauth-protected-resource` → list of authorization servers 1288 + 2. GET `<authServer>/.well-known/oauth-authorization-server` → endpoints (authorize, token, PAR) 1289 + 1290 + `ClientMetadata` models the static JSON file we publish at 1291 + `https://pdfs.at/oauth/client-metadata.json`. 1292 + 1293 + - [ ] **Step 1: Write failing tests** 1294 + 1295 + Create `Tests/ATProtoTests/OAuth/MetadataResolverTests.swift`: 1296 + 1297 + ```swift 1298 + import Foundation 1299 + import Testing 1300 + @testable import ATProto 1301 + 1302 + @Suite("MetadataResolver", .serialized) 1303 + struct MetadataResolverTests { 1304 + @Test("resolves PDS → resource → authorization server") 1305 + func happyPath() async throws { 1306 + let session = URLProtocolStub.install { request in 1307 + switch request.url?.absoluteString { 1308 + case "https://bsky.social/.well-known/oauth-protected-resource": 1309 + return .json(#""" 1310 + {"resource":"https://bsky.social", 1311 + "authorization_servers":["https://bsky.social"]} 1312 + """#) 1313 + case "https://bsky.social/.well-known/oauth-authorization-server": 1314 + return .json(#""" 1315 + {"issuer":"https://bsky.social", 1316 + "authorization_endpoint":"https://bsky.social/oauth/authorize", 1317 + "token_endpoint":"https://bsky.social/oauth/token", 1318 + "pushed_authorization_request_endpoint":"https://bsky.social/oauth/par", 1319 + "dpop_signing_alg_values_supported":["ES256"], 1320 + "scopes_supported":["atproto","repo:*?action=create"]} 1321 + """#) 1322 + default: 1323 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 1324 + } 1325 + } 1326 + defer { URLProtocolStub.reset(session: session) } 1327 + 1328 + let resolver = MetadataResolver(session: session) 1329 + let (resource, authServer) = try await resolver.resolve(pds: URL(string: "https://bsky.social")!) 1330 + 1331 + #expect(resource.authorizationServers.first?.absoluteString == "https://bsky.social") 1332 + #expect(authServer.tokenEndpoint.absoluteString == "https://bsky.social/oauth/token") 1333 + #expect(authServer.pushedAuthorizationRequestEndpoint?.absoluteString == "https://bsky.social/oauth/par") 1334 + } 1335 + 1336 + @Test("throws invalidIdentity when PDS doesn't publish protected-resource doc") 1337 + func noProtectedResource() async { 1338 + let session = URLProtocolStub.install { _ in 1339 + URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 1340 + } 1341 + defer { URLProtocolStub.reset(session: session) } 1342 + 1343 + let resolver = MetadataResolver(session: session) 1344 + await #expect(throws: ATProtoError.self) { 1345 + try await resolver.resolve(pds: URL(string: "https://broken.example")!) 1346 + } 1347 + } 1348 + 1349 + @Test("ClientMetadata serializes expected JSON") 1350 + func clientMetadataJSON() throws { 1351 + let meta = ClientMetadata.defaultPDFS 1352 + let data = try JSONEncoder().encode(meta) 1353 + let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] 1354 + #expect(dict?["client_id"] as? String == "https://pdfs.at/oauth/client-metadata.json") 1355 + #expect((dict?["redirect_uris"] as? [String])?.contains("pdfs://oauth/callback") == true) 1356 + #expect(dict?["dpop_bound_access_tokens"] as? Bool == true) 1357 + #expect(dict?["application_type"] as? String == "native") 1358 + } 1359 + } 1360 + ``` 1361 + 1362 + - [ ] **Step 2: Run to verify failure** 1363 + 1364 + ```bash 1365 + swift test --filter MetadataResolverTests 2>&1 | tail -10 1366 + ``` 1367 + 1368 + - [ ] **Step 3: Implement ProtectedResourceMetadata** 1369 + 1370 + Create `Sources/ATProto/OAuth/Metadata/ProtectedResourceMetadata.swift`: 1371 + 1372 + ```swift 1373 + import Foundation 1374 + 1375 + public struct ProtectedResourceMetadata: Decodable, Sendable, Equatable { 1376 + public let resource: URL 1377 + public let authorizationServers: [URL] 1378 + 1379 + enum CodingKeys: String, CodingKey { 1380 + case resource 1381 + case authorizationServers = "authorization_servers" 1382 + } 1383 + } 1384 + ``` 1385 + 1386 + - [ ] **Step 4: Implement AuthorizationServerMetadata** 1387 + 1388 + Create `Sources/ATProto/OAuth/Metadata/AuthorizationServerMetadata.swift`: 1389 + 1390 + ```swift 1391 + import Foundation 1392 + 1393 + public struct AuthorizationServerMetadata: Decodable, Sendable, Equatable { 1394 + public let issuer: String 1395 + public let authorizationEndpoint: URL 1396 + public let tokenEndpoint: URL 1397 + public let pushedAuthorizationRequestEndpoint: URL? 1398 + public let dpopSigningAlgValuesSupported: [String] 1399 + public let scopesSupported: [String] 1400 + 1401 + enum CodingKeys: String, CodingKey { 1402 + case issuer 1403 + case authorizationEndpoint = "authorization_endpoint" 1404 + case tokenEndpoint = "token_endpoint" 1405 + case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" 1406 + case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" 1407 + case scopesSupported = "scopes_supported" 1408 + } 1409 + } 1410 + ``` 1411 + 1412 + - [ ] **Step 5: Implement ClientMetadata** 1413 + 1414 + Create `Sources/ATProto/OAuth/Metadata/ClientMetadata.swift`: 1415 + 1416 + ```swift 1417 + import Foundation 1418 + 1419 + /// The static JSON file this client publishes at 1420 + /// `https://pdfs.at/oauth/client-metadata.json`. `clientId` is literally 1421 + /// that URL — this is atproto's "client metadata by URL" convention. 1422 + public struct ClientMetadata: Codable, Sendable, Equatable { 1423 + public let clientId: String 1424 + public let clientName: String 1425 + public let clientURI: String 1426 + public let redirectURIs: [String] 1427 + public let grantTypes: [String] 1428 + public let responseTypes: [String] 1429 + public let scope: String 1430 + public let tokenEndpointAuthMethod: String 1431 + public let dpopBoundAccessTokens: Bool 1432 + public let applicationType: String 1433 + 1434 + enum CodingKeys: String, CodingKey { 1435 + case clientId = "client_id" 1436 + case clientName = "client_name" 1437 + case clientURI = "client_uri" 1438 + case redirectURIs = "redirect_uris" 1439 + case grantTypes = "grant_types" 1440 + case responseTypes = "response_types" 1441 + case scope 1442 + case tokenEndpointAuthMethod = "token_endpoint_auth_method" 1443 + case dpopBoundAccessTokens = "dpop_bound_access_tokens" 1444 + case applicationType = "application_type" 1445 + } 1446 + 1447 + /// The canonical metadata for pdfs. The `scope` field advertises the 1448 + /// maximum envelope of scopes we may request over the client's lifetime. 1449 + /// We list `repo:*` (wildcard) rather than enumerate every possible 1450 + /// collection NSID. Actual authorization requests narrow to specific 1451 + /// collections via progressive disclosure — see `OAuthCoordinator.ensureScope`. 1452 + public static let defaultPDFS = ClientMetadata( 1453 + clientId: "https://pdfs.at/oauth/client-metadata.json", 1454 + clientName: "pdfs", 1455 + clientURI: "https://pdfs.at", 1456 + redirectURIs: ["pdfs://oauth/callback"], 1457 + grantTypes: ["authorization_code", "refresh_token"], 1458 + responseTypes: ["code"], 1459 + scope: "atproto repo:* blob:*/*", 1460 + tokenEndpointAuthMethod: "none", 1461 + dpopBoundAccessTokens: true, 1462 + applicationType: "native" 1463 + ) 1464 + } 1465 + ``` 1466 + 1467 + - [ ] **Step 6: Implement MetadataResolver** 1468 + 1469 + Create `Sources/ATProto/OAuth/Metadata/MetadataResolver.swift`: 1470 + 1471 + ```swift 1472 + import Foundation 1473 + 1474 + public struct MetadataResolver: Sendable { 1475 + let session: URLSession 1476 + 1477 + public init(session: URLSession = .shared) { 1478 + self.session = session 1479 + } 1480 + 1481 + /// Two-step discovery: 1482 + /// 1. GET `<pds>/.well-known/oauth-protected-resource` 1483 + /// 2. GET `<authServer>/.well-known/oauth-authorization-server` (using 1484 + /// the first authorization_server from step 1) 1485 + public func resolve(pds: URL) async throws -> (ProtectedResourceMetadata, AuthorizationServerMetadata) { 1486 + let resourceURL = pds.appendingPathComponent(".well-known/oauth-protected-resource") 1487 + let resource: ProtectedResourceMetadata = try await fetch(url: resourceURL) 1488 + 1489 + guard let authServerURL = resource.authorizationServers.first else { 1490 + throw ATProtoError.invalidIdentity("no authorization_servers listed for \(pds)") 1491 + } 1492 + let authMetaURL = authServerURL.appendingPathComponent(".well-known/oauth-authorization-server") 1493 + let authMeta: AuthorizationServerMetadata = try await fetch(url: authMetaURL) 1494 + return (resource, authMeta) 1495 + } 1496 + 1497 + private func fetch<Value: Decodable>(url: URL) async throws -> Value { 1498 + let (data, response) = try await session.data(for: URLRequest(url: url)) 1499 + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { 1500 + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 1501 + throw ATProtoError.invalidIdentity("metadata fetch HTTP \(status) for \(url)") 1502 + } 1503 + do { 1504 + return try JSONDecoder().decode(Value.self, from: data) 1505 + } catch { 1506 + throw ATProtoError.decoding("\(error)") 1507 + } 1508 + } 1509 + } 1510 + ``` 1511 + 1512 + - [ ] **Step 7: Run to verify pass** 1513 + 1514 + ```bash 1515 + swift test --filter MetadataResolverTests 2>&1 | tail -10 1516 + ``` 1517 + Expected: 3 tests pass. 1518 + 1519 + - [ ] **Step 8: Commit** 1520 + 1521 + ```bash 1522 + git add Packages/ATProto/Sources/ATProto/OAuth/Metadata Packages/ATProto/Tests/ATProtoTests/OAuth/MetadataResolverTests.swift 1523 + git commit -m "feat(atproto/oauth): add OAuth metadata types + two-step discovery" 1524 + ``` 1525 + 1526 + --- 1527 + 1528 + ### Task 6: PAR + authorize URL + callback parser 1529 + 1530 + **Files:** 1531 + - Create: `Sources/ATProto/OAuth/Flow/PushedAuthorizationRequest.swift` 1532 + - Create: `Sources/ATProto/OAuth/Flow/AuthorizationURL.swift` 1533 + - Create: `Sources/ATProto/OAuth/Flow/CallbackURL.swift` 1534 + - Create: `Tests/ATProtoTests/OAuth/PARTests.swift` 1535 + - Create: `Tests/ATProtoTests/OAuth/CallbackURLTests.swift` 1536 + 1537 + PAR (RFC 9126): client POSTs the authorization parameters to the PAR 1538 + endpoint, server returns a `request_uri`; client builds an authorization 1539 + URL referencing that `request_uri`. Keeps the authorization URL short 1540 + and ensures the server has committed to the parameters before the user is 1541 + redirected. 1542 + 1543 + - [ ] **Step 1: Write failing PAR tests** 1544 + 1545 + Create `Tests/ATProtoTests/OAuth/PARTests.swift`: 1546 + 1547 + ```swift 1548 + import Foundation 1549 + import Testing 1550 + @testable import ATProto 1551 + 1552 + @Suite("PushedAuthorizationRequest", .serialized) 1553 + struct PARTests { 1554 + @Test("posts form-encoded body with DPoP + returns request_uri") 1555 + func happyPath() async throws { 1556 + let session = URLProtocolStub.install { request in 1557 + #expect(request.httpMethod == "POST") 1558 + #expect(request.url?.absoluteString == "https://bsky.social/oauth/par") 1559 + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-www-form-urlencoded") 1560 + #expect(request.value(forHTTPHeaderField: "DPoP") != nil) 1561 + return .json( 1562 + #"{"request_uri":"urn:ietf:params:oauth:request_uri:abc123","expires_in":90}"# 1563 + ) 1564 + } 1565 + defer { URLProtocolStub.reset(session: session) } 1566 + 1567 + let key = try InMemoryES256DPoPKey.generate() 1568 + let par = PushedAuthorizationRequest( 1569 + endpoint: URL(string: "https://bsky.social/oauth/par")!, 1570 + session: session, 1571 + nonceStore: DPoPNonceStore() 1572 + ) 1573 + let response = try await par.submit( 1574 + clientId: "https://pdfs.at/oauth/client-metadata.json", 1575 + redirectURI: "pdfs://oauth/callback", 1576 + scopes: [.atproto], 1577 + state: "state-xyz", 1578 + pkceChallenge: "abc", 1579 + loginHint: "natemoo.re", 1580 + dpopKey: key 1581 + ) 1582 + #expect(response.requestURI == "urn:ietf:params:oauth:request_uri:abc123") 1583 + #expect(response.expiresIn == 90) 1584 + } 1585 + 1586 + @Test("retries once on 401 with DPoP-Nonce challenge") 1587 + func dpopNonceRetry() async throws { 1588 + actor Hit { var count = 0; func inc() -> Int { count += 1; return count } } 1589 + let hit = Hit() 1590 + let session = URLProtocolStub.install { request in 1591 + Task { _ = await hit.inc() } 1592 + // First call returns 401 with DPoP-Nonce; second returns 200. 1593 + return URLProtocolStub.Response(statusCode: 401, headers: ["DPoP-Nonce": "fresh-nonce"], body: Data()) 1594 + } 1595 + defer { URLProtocolStub.reset(session: session) } 1596 + 1597 + let key = try InMemoryES256DPoPKey.generate() 1598 + let par = PushedAuthorizationRequest( 1599 + endpoint: URL(string: "https://bsky.social/oauth/par")!, 1600 + session: session, 1601 + nonceStore: DPoPNonceStore() 1602 + ) 1603 + do { 1604 + _ = try await par.submit( 1605 + clientId: "https://pdfs.at/oauth/client-metadata.json", 1606 + redirectURI: "pdfs://oauth/callback", 1607 + scopes: [.atproto], 1608 + state: "s", 1609 + pkceChallenge: "c", 1610 + loginHint: nil, 1611 + dpopKey: key 1612 + ) 1613 + Issue.record("expected throw after second 401") 1614 + } catch { 1615 + // After two 401s in a row we give up. 1616 + } 1617 + let count = await hit.count 1618 + #expect(count == 2) 1619 + } 1620 + } 1621 + ``` 1622 + 1623 + - [ ] **Step 2: Write failing callback tests** 1624 + 1625 + Create `Tests/ATProtoTests/OAuth/CallbackURLTests.swift`: 1626 + 1627 + ```swift 1628 + import Foundation 1629 + import Testing 1630 + @testable import ATProto 1631 + 1632 + @Suite("CallbackURL") 1633 + struct CallbackURLTests { 1634 + @Test("parses successful callback") 1635 + func success() throws { 1636 + let url = URL(string: "pdfs://oauth/callback?code=abc&state=xyz&iss=https%3A%2F%2Fbsky.social")! 1637 + let parsed = try CallbackURL.parse(url: url) 1638 + #expect(parsed.code == "abc") 1639 + #expect(parsed.state == "xyz") 1640 + #expect(parsed.issuer == "https://bsky.social") 1641 + } 1642 + 1643 + @Test("throws when code missing") 1644 + func missingCode() { 1645 + let url = URL(string: "pdfs://oauth/callback?state=xyz")! 1646 + #expect(throws: ATProtoError.self) { try CallbackURL.parse(url: url) } 1647 + } 1648 + 1649 + @Test("throws on error response") 1650 + func errorResponse() { 1651 + let url = URL(string: "pdfs://oauth/callback?error=access_denied&error_description=user+cancelled&state=xyz")! 1652 + #expect(throws: ATProtoError.self) { try CallbackURL.parse(url: url) } 1653 + } 1654 + } 1655 + ``` 1656 + 1657 + - [ ] **Step 3: Implement PushedAuthorizationRequest** 1658 + 1659 + Create `Sources/ATProto/OAuth/Flow/PushedAuthorizationRequest.swift`: 1660 + 1661 + ```swift 1662 + import Foundation 1663 + 1664 + public struct PushedAuthorizationRequest: Sendable { 1665 + public struct Response: Sendable, Equatable { 1666 + public let requestURI: String 1667 + public let expiresIn: Int? 1668 + } 1669 + 1670 + let endpoint: URL 1671 + let session: URLSession 1672 + let nonceStore: DPoPNonceStore 1673 + 1674 + public init(endpoint: URL, session: URLSession = .shared, nonceStore: DPoPNonceStore) { 1675 + self.endpoint = endpoint 1676 + self.session = session 1677 + self.nonceStore = nonceStore 1678 + } 1679 + 1680 + public func submit( 1681 + clientId: String, 1682 + redirectURI: String, 1683 + scopes: Set<Scope>, 1684 + state: String, 1685 + pkceChallenge: String, 1686 + loginHint: String?, 1687 + dpopKey: any DPoPKey 1688 + ) async throws -> Response { 1689 + var body: [String: String] = [ 1690 + "client_id": clientId, 1691 + "redirect_uri": redirectURI, 1692 + "response_type": "code", 1693 + "scope": Scope.formatted(scopes), 1694 + "state": state, 1695 + "code_challenge": pkceChallenge, 1696 + "code_challenge_method": "S256", 1697 + ] 1698 + if let loginHint { body["login_hint"] = loginHint } 1699 + 1700 + let (data, http) = try await postWithDPoP(body: body, key: dpopKey) 1701 + guard (200..<300).contains(http.statusCode) else { 1702 + throw ATProtoError.http(status: http.statusCode, body: data) 1703 + } 1704 + struct ParResp: Decodable { 1705 + let request_uri: String 1706 + let expires_in: Int? 1707 + } 1708 + let decoded = try JSONDecoder().decode(ParResp.self, from: data) 1709 + return Response(requestURI: decoded.request_uri, expiresIn: decoded.expires_in) 1710 + } 1711 + 1712 + /// Performs a DPoP-authenticated POST with one nonce retry on 401. 1713 + private func postWithDPoP( 1714 + body: [String: String], 1715 + key: any DPoPKey 1716 + ) async throws -> (Data, HTTPURLResponse) { 1717 + let formBody = body.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" } 1718 + .joined(separator: "&") 1719 + .data(using: .utf8)! 1720 + 1721 + for attempt in 0..<2 { 1722 + var request = URLRequest(url: endpoint) 1723 + request.httpMethod = "POST" 1724 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 1725 + let nonce = await nonceStore.nonce(for: endpoint) 1726 + let dpopProof = try await DPoPProof.create( 1727 + method: "POST", 1728 + url: endpoint, 1729 + key: key, 1730 + nonce: nonce, 1731 + accessToken: nil 1732 + ) 1733 + request.setValue(dpopProof, forHTTPHeaderField: "DPoP") 1734 + request.httpBody = formBody 1735 + 1736 + let (data, response) = try await session.data(for: request) 1737 + guard let http = response as? HTTPURLResponse else { 1738 + throw ATProtoError.http(status: 0, body: nil) 1739 + } 1740 + // Pick up any fresh nonce for next call. 1741 + if let newNonce = http.value(forHTTPHeaderField: "DPoP-Nonce") { 1742 + await nonceStore.setNonce(newNonce, for: endpoint) 1743 + } 1744 + if http.statusCode == 401 && attempt == 0 && http.value(forHTTPHeaderField: "DPoP-Nonce") != nil { 1745 + // Retry with the fresh nonce. 1746 + continue 1747 + } 1748 + return (data, http) 1749 + } 1750 + throw ATProtoError.http(status: 401, body: nil) 1751 + } 1752 + } 1753 + ``` 1754 + 1755 + - [ ] **Step 4: Implement AuthorizationURL** 1756 + 1757 + Create `Sources/ATProto/OAuth/Flow/AuthorizationURL.swift`: 1758 + 1759 + ```swift 1760 + import Foundation 1761 + 1762 + public enum AuthorizationURL { 1763 + /// Builds the authorize URL using the PAR-returned `request_uri`. 1764 + /// Per RFC 9126, the authorize URL needs only `client_id` + `request_uri`. 1765 + public static func build( 1766 + authorizeEndpoint: URL, 1767 + clientId: String, 1768 + requestURI: String 1769 + ) throws -> URL { 1770 + guard var comps = URLComponents(url: authorizeEndpoint, resolvingAgainstBaseURL: false) else { 1771 + throw ATProtoError.invalidURL(authorizeEndpoint.absoluteString) 1772 + } 1773 + comps.queryItems = [ 1774 + URLQueryItem(name: "client_id", value: clientId), 1775 + URLQueryItem(name: "request_uri", value: requestURI), 1776 + ] 1777 + guard let url = comps.url else { 1778 + throw ATProtoError.invalidURL(authorizeEndpoint.absoluteString) 1779 + } 1780 + return url 1781 + } 1782 + } 1783 + ``` 1784 + 1785 + - [ ] **Step 5: Implement CallbackURL** 1786 + 1787 + Create `Sources/ATProto/OAuth/Flow/CallbackURL.swift`: 1788 + 1789 + ```swift 1790 + import Foundation 1791 + 1792 + public struct CallbackURL: Sendable, Equatable { 1793 + public let code: String 1794 + public let state: String 1795 + public let issuer: String? 1796 + 1797 + public static func parse(url: URL) throws -> CallbackURL { 1798 + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), 1799 + let items = comps.queryItems else { 1800 + throw ATProtoError.invalidURL(url.absoluteString) 1801 + } 1802 + var params: [String: String] = [:] 1803 + for item in items { 1804 + if let v = item.value { params[item.name] = v } 1805 + } 1806 + if let error = params["error"] { 1807 + let desc = params["error_description"].map { ": \($0)" } ?? "" 1808 + throw ATProtoError.xrpc(code: error, message: "OAuth error\(desc)", status: 400) 1809 + } 1810 + guard let code = params["code"], let state = params["state"] else { 1811 + throw ATProtoError.invalidURL("callback missing code/state: \(url)") 1812 + } 1813 + return CallbackURL(code: code, state: state, issuer: params["iss"]) 1814 + } 1815 + } 1816 + ``` 1817 + 1818 + - [ ] **Step 6: Run tests to verify pass** 1819 + 1820 + ```bash 1821 + swift test --filter PARTests 2>&1 | tail -10 1822 + swift test --filter CallbackURLTests 2>&1 | tail -10 1823 + ``` 1824 + Expected: 2 + 3 tests pass. 1825 + 1826 + - [ ] **Step 7: Commit** 1827 + 1828 + ```bash 1829 + git add Packages/ATProto/Sources/ATProto/OAuth/Flow Packages/ATProto/Tests/ATProtoTests/OAuth/PARTests.swift Packages/ATProto/Tests/ATProtoTests/OAuth/CallbackURLTests.swift 1830 + git commit -m "feat(atproto/oauth): add PAR + authorize URL + callback parser" 1831 + ``` 1832 + 1833 + --- 1834 + 1835 + ### Task 7: Token exchange + refresh 1836 + 1837 + **Files:** 1838 + - Create: `Sources/ATProto/OAuth/Flow/TokenExchange.swift` 1839 + - Create: `Tests/ATProtoTests/OAuth/TokenExchangeTests.swift` 1840 + 1841 + Exchanges the authorization code for access + refresh tokens (DPoP-bound). 1842 + Also refreshes access tokens using the refresh token. 1843 + 1844 + - [ ] **Step 1: Write failing tests** 1845 + 1846 + Create `Tests/ATProtoTests/OAuth/TokenExchangeTests.swift`: 1847 + 1848 + ```swift 1849 + import Foundation 1850 + import Testing 1851 + @testable import ATProto 1852 + 1853 + @Suite("TokenExchange", .serialized) 1854 + struct TokenExchangeTests { 1855 + @Test("code exchange returns tokens + scope") 1856 + func codeExchange() async throws { 1857 + let session = URLProtocolStub.install { request in 1858 + #expect(request.httpMethod == "POST") 1859 + #expect(request.url?.absoluteString == "https://bsky.social/oauth/token") 1860 + #expect(request.value(forHTTPHeaderField: "DPoP") != nil) 1861 + return .json(#""" 1862 + {"access_token":"access-1","refresh_token":"refresh-1","token_type":"DPoP", 1863 + "expires_in":3600,"scope":"atproto","sub":"did:plc:abc"} 1864 + """#) 1865 + } 1866 + defer { URLProtocolStub.reset(session: session) } 1867 + 1868 + let key = try InMemoryES256DPoPKey.generate() 1869 + let exchange = TokenExchange( 1870 + endpoint: URL(string: "https://bsky.social/oauth/token")!, 1871 + session: session, 1872 + nonceStore: DPoPNonceStore() 1873 + ) 1874 + let tokens = try await exchange.exchangeCode( 1875 + clientId: "https://pdfs.at/oauth/client-metadata.json", 1876 + redirectURI: "pdfs://oauth/callback", 1877 + code: "auth-code", 1878 + codeVerifier: "verifier", 1879 + dpopKey: key 1880 + ) 1881 + #expect(tokens.accessToken == "access-1") 1882 + #expect(tokens.refreshToken == "refresh-1") 1883 + #expect(tokens.scope == "atproto") 1884 + #expect(tokens.sub == "did:plc:abc") 1885 + #expect(tokens.expiresIn == 3600) 1886 + } 1887 + 1888 + @Test("refresh returns new tokens preserving DID") 1889 + func refresh() async throws { 1890 + let session = URLProtocolStub.install { request in 1891 + let bodyData = request.httpBodyStream.flatMap(readAll) ?? request.httpBody ?? Data() 1892 + let bodyString = String(decoding: bodyData, as: UTF8.self) 1893 + #expect(bodyString.contains("grant_type=refresh_token")) 1894 + return .json(#""" 1895 + {"access_token":"access-2","refresh_token":"refresh-2","token_type":"DPoP", 1896 + "expires_in":3600,"scope":"atproto","sub":"did:plc:abc"} 1897 + """#) 1898 + } 1899 + defer { URLProtocolStub.reset(session: session) } 1900 + 1901 + let key = try InMemoryES256DPoPKey.generate() 1902 + let exchange = TokenExchange( 1903 + endpoint: URL(string: "https://bsky.social/oauth/token")!, 1904 + session: session, 1905 + nonceStore: DPoPNonceStore() 1906 + ) 1907 + let tokens = try await exchange.refresh( 1908 + clientId: "https://pdfs.at/oauth/client-metadata.json", 1909 + refreshToken: "refresh-old", 1910 + dpopKey: key 1911 + ) 1912 + #expect(tokens.accessToken == "access-2") 1913 + } 1914 + } 1915 + 1916 + private func readAll(_ stream: InputStream) -> Data { 1917 + stream.open() 1918 + defer { stream.close() } 1919 + var data = Data() 1920 + let bufSize = 4096 1921 + var buf = [UInt8](repeating: 0, count: bufSize) 1922 + while stream.hasBytesAvailable { 1923 + let read = stream.read(&buf, maxLength: bufSize) 1924 + if read <= 0 { break } 1925 + data.append(buf, count: read) 1926 + } 1927 + return data 1928 + } 1929 + ``` 1930 + 1931 + - [ ] **Step 2: Run to verify failure** 1932 + 1933 + ```bash 1934 + swift test --filter TokenExchangeTests 2>&1 | tail -10 1935 + ``` 1936 + 1937 + - [ ] **Step 3: Implement TokenExchange** 1938 + 1939 + Create `Sources/ATProto/OAuth/Flow/TokenExchange.swift`: 1940 + 1941 + ```swift 1942 + import Foundation 1943 + 1944 + public struct TokenExchangeResponse: Sendable, Equatable { 1945 + public let accessToken: String 1946 + public let refreshToken: String? 1947 + public let tokenType: String 1948 + public let expiresIn: Int 1949 + public let scope: String 1950 + public let sub: String? 1951 + } 1952 + 1953 + public struct TokenExchange: Sendable { 1954 + let endpoint: URL 1955 + let session: URLSession 1956 + let nonceStore: DPoPNonceStore 1957 + 1958 + public init(endpoint: URL, session: URLSession = .shared, nonceStore: DPoPNonceStore) { 1959 + self.endpoint = endpoint 1960 + self.session = session 1961 + self.nonceStore = nonceStore 1962 + } 1963 + 1964 + public func exchangeCode( 1965 + clientId: String, 1966 + redirectURI: String, 1967 + code: String, 1968 + codeVerifier: String, 1969 + dpopKey: any DPoPKey 1970 + ) async throws -> TokenExchangeResponse { 1971 + let body: [String: String] = [ 1972 + "grant_type": "authorization_code", 1973 + "client_id": clientId, 1974 + "redirect_uri": redirectURI, 1975 + "code": code, 1976 + "code_verifier": codeVerifier, 1977 + ] 1978 + return try await post(body: body, dpopKey: dpopKey) 1979 + } 1980 + 1981 + public func refresh( 1982 + clientId: String, 1983 + refreshToken: String, 1984 + dpopKey: any DPoPKey 1985 + ) async throws -> TokenExchangeResponse { 1986 + let body: [String: String] = [ 1987 + "grant_type": "refresh_token", 1988 + "client_id": clientId, 1989 + "refresh_token": refreshToken, 1990 + ] 1991 + return try await post(body: body, dpopKey: dpopKey) 1992 + } 1993 + 1994 + private func post(body: [String: String], dpopKey: any DPoPKey) async throws -> TokenExchangeResponse { 1995 + let formBody = body.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" } 1996 + .joined(separator: "&") 1997 + .data(using: .utf8)! 1998 + 1999 + for attempt in 0..<2 { 2000 + var request = URLRequest(url: endpoint) 2001 + request.httpMethod = "POST" 2002 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 2003 + let nonce = await nonceStore.nonce(for: endpoint) 2004 + let dpopProof = try await DPoPProof.create( 2005 + method: "POST", 2006 + url: endpoint, 2007 + key: dpopKey, 2008 + nonce: nonce, 2009 + accessToken: nil 2010 + ) 2011 + request.setValue(dpopProof, forHTTPHeaderField: "DPoP") 2012 + request.httpBody = formBody 2013 + 2014 + let (data, response) = try await session.data(for: request) 2015 + guard let http = response as? HTTPURLResponse else { 2016 + throw ATProtoError.http(status: 0, body: nil) 2017 + } 2018 + if let fresh = http.value(forHTTPHeaderField: "DPoP-Nonce") { 2019 + await nonceStore.setNonce(fresh, for: endpoint) 2020 + } 2021 + if http.statusCode == 401 && attempt == 0 && http.value(forHTTPHeaderField: "DPoP-Nonce") != nil { 2022 + continue 2023 + } 2024 + guard (200..<300).contains(http.statusCode) else { 2025 + throw ATProtoError.http(status: http.statusCode, body: data) 2026 + } 2027 + struct Raw: Decodable { 2028 + let access_token: String 2029 + let refresh_token: String? 2030 + let token_type: String 2031 + let expires_in: Int 2032 + let scope: String 2033 + let sub: String? 2034 + } 2035 + let raw = try JSONDecoder().decode(Raw.self, from: data) 2036 + return TokenExchangeResponse( 2037 + accessToken: raw.access_token, 2038 + refreshToken: raw.refresh_token, 2039 + tokenType: raw.token_type, 2040 + expiresIn: raw.expires_in, 2041 + scope: raw.scope, 2042 + sub: raw.sub 2043 + ) 2044 + } 2045 + throw ATProtoError.http(status: 401, body: nil) 2046 + } 2047 + } 2048 + ``` 2049 + 2050 + - [ ] **Step 4: Run to verify pass** 2051 + 2052 + ```bash 2053 + swift test --filter TokenExchangeTests 2>&1 | tail -10 2054 + ``` 2055 + Expected: 2 tests pass. 2056 + 2057 + - [ ] **Step 5: Commit** 2058 + 2059 + ```bash 2060 + git add Packages/ATProto/Sources/ATProto/OAuth/Flow/TokenExchange.swift Packages/ATProto/Tests/ATProtoTests/OAuth/TokenExchangeTests.swift 2061 + git commit -m "feat(atproto/oauth): add code exchange + refresh token endpoints" 2062 + ``` 2063 + 2064 + --- 2065 + 2066 + ### Task 8: Token storage 2067 + 2068 + **Files:** 2069 + - Create: `Sources/ATProto/OAuth/Storage/StoredTokens.swift` 2070 + - Create: `Sources/ATProto/OAuth/Storage/TokenStore.swift` 2071 + - Create: `Tests/ATProtoTests/OAuth/TokenStoreTests.swift` 2072 + 2073 + - [ ] **Step 1: Write failing tests** 2074 + 2075 + Create `Tests/ATProtoTests/OAuth/TokenStoreTests.swift`: 2076 + 2077 + ```swift 2078 + import Foundation 2079 + import Testing 2080 + @testable import ATProto 2081 + 2082 + @Suite("TokenStore") 2083 + struct TokenStoreTests { 2084 + @Test("InMemoryTokenStore persists across load") 2085 + func persist() async throws { 2086 + let did = DID("did:plc:abc")! 2087 + let tokens = StoredTokens( 2088 + did: did, 2089 + accessToken: "a", 2090 + refreshToken: "r", 2091 + expiresAt: Date().addingTimeInterval(3600), 2092 + scopes: [.atproto], 2093 + dpopKeyIdentifier: "key-1", 2094 + issuer: "https://bsky.social" 2095 + ) 2096 + let store = InMemoryTokenStore() 2097 + try await store.save(tokens) 2098 + 2099 + let loaded = try await store.load(did: did) 2100 + #expect(loaded?.accessToken == "a") 2101 + #expect(loaded?.scopes == [.atproto]) 2102 + } 2103 + 2104 + @Test("delete removes entry") 2105 + func delete() async throws { 2106 + let did = DID("did:plc:abc")! 2107 + let store = InMemoryTokenStore() 2108 + try await store.save(StoredTokens( 2109 + did: did, accessToken: "a", refreshToken: nil, 2110 + expiresAt: Date(), scopes: [], dpopKeyIdentifier: "k", issuer: "i" 2111 + )) 2112 + try await store.delete(did: did) 2113 + #expect(try await store.load(did: did) == nil) 2114 + } 2115 + 2116 + @Test("list returns all DIDs") 2117 + func list() async throws { 2118 + let store = InMemoryTokenStore() 2119 + let didA = DID("did:plc:a")! 2120 + let didB = DID("did:plc:b")! 2121 + try await store.save(StoredTokens( 2122 + did: didA, accessToken: "1", refreshToken: nil, 2123 + expiresAt: Date(), scopes: [], dpopKeyIdentifier: "k", issuer: "i" 2124 + )) 2125 + try await store.save(StoredTokens( 2126 + did: didB, accessToken: "2", refreshToken: nil, 2127 + expiresAt: Date(), scopes: [], dpopKeyIdentifier: "k", issuer: "i" 2128 + )) 2129 + let all = Set(try await store.listDIDs()) 2130 + #expect(all == [didA, didB]) 2131 + } 2132 + } 2133 + ``` 2134 + 2135 + - [ ] **Step 2: Run to verify failure** 2136 + 2137 + ```bash 2138 + swift test --filter TokenStoreTests 2>&1 | tail -10 2139 + ``` 2140 + 2141 + - [ ] **Step 3: Implement StoredTokens** 2142 + 2143 + Create `Sources/ATProto/OAuth/Storage/StoredTokens.swift`: 2144 + 2145 + ```swift 2146 + import Foundation 2147 + 2148 + public struct StoredTokens: Sendable, Equatable, Codable { 2149 + public let did: DID 2150 + public let accessToken: String 2151 + public let refreshToken: String? 2152 + public let expiresAt: Date 2153 + public let scopes: Set<Scope> 2154 + /// Opaque identifier the TokenStore uses to locate the DPoP key 2155 + /// (Keychain tag, in-memory map key, etc.). A future Keychain-backed 2156 + /// impl reads the key under this identifier. 2157 + public let dpopKeyIdentifier: String 2158 + public let issuer: String 2159 + 2160 + public init( 2161 + did: DID, 2162 + accessToken: String, 2163 + refreshToken: String?, 2164 + expiresAt: Date, 2165 + scopes: Set<Scope>, 2166 + dpopKeyIdentifier: String, 2167 + issuer: String 2168 + ) { 2169 + self.did = did 2170 + self.accessToken = accessToken 2171 + self.refreshToken = refreshToken 2172 + self.expiresAt = expiresAt 2173 + self.scopes = scopes 2174 + self.dpopKeyIdentifier = dpopKeyIdentifier 2175 + self.issuer = issuer 2176 + } 2177 + } 2178 + ``` 2179 + 2180 + - [ ] **Step 4: Implement TokenStore** 2181 + 2182 + Create `Sources/ATProto/OAuth/Storage/TokenStore.swift`: 2183 + 2184 + ```swift 2185 + import Foundation 2186 + 2187 + public protocol TokenStore: Sendable { 2188 + func save(_ tokens: StoredTokens) async throws 2189 + func load(did: DID) async throws -> StoredTokens? 2190 + func delete(did: DID) async throws 2191 + func listDIDs() async throws -> [DID] 2192 + /// DPoP key operations are paired with token save/load so refresh 2193 + /// always has access to the matching key. 2194 + func saveDPoPKey(_ key: any DPoPKey, identifier: String) async throws 2195 + func loadDPoPKey(identifier: String) async throws -> (any DPoPKey)? 2196 + } 2197 + 2198 + public actor InMemoryTokenStore: TokenStore { 2199 + private var tokens: [DID: StoredTokens] = [:] 2200 + private var keys: [String: any DPoPKey] = [:] 2201 + 2202 + public init() {} 2203 + 2204 + public func save(_ tokens: StoredTokens) async throws { 2205 + self.tokens[tokens.did] = tokens 2206 + } 2207 + 2208 + public func load(did: DID) async throws -> StoredTokens? { 2209 + tokens[did] 2210 + } 2211 + 2212 + public func delete(did: DID) async throws { 2213 + tokens.removeValue(forKey: did) 2214 + } 2215 + 2216 + public func listDIDs() async throws -> [DID] { 2217 + Array(tokens.keys) 2218 + } 2219 + 2220 + public func saveDPoPKey(_ key: any DPoPKey, identifier: String) async throws { 2221 + keys[identifier] = key 2222 + } 2223 + 2224 + public func loadDPoPKey(identifier: String) async throws -> (any DPoPKey)? { 2225 + keys[identifier] 2226 + } 2227 + } 2228 + ``` 2229 + 2230 + - [ ] **Step 5: Run to verify pass** 2231 + 2232 + ```bash 2233 + swift test --filter TokenStoreTests 2>&1 | tail -10 2234 + ``` 2235 + Expected: 3 tests pass. 2236 + 2237 + - [ ] **Step 6: Commit** 2238 + 2239 + ```bash 2240 + git add Packages/ATProto/Sources/ATProto/OAuth/Storage Packages/ATProto/Tests/ATProtoTests/OAuth/TokenStoreTests.swift 2241 + git commit -m "feat(atproto/oauth): add TokenStore protocol + InMemoryTokenStore" 2242 + ``` 2243 + 2244 + --- 2245 + 2246 + ### Task 9: OAuthAuthTokenProvider 2247 + 2248 + **Files:** 2249 + - Create: `Sources/ATProto/OAuth/OAuthAuthTokenProvider.swift` 2250 + - Create: `Tests/ATProtoTests/OAuth/OAuthAuthTokenProviderTests.swift` 2251 + 2252 + Conforms to `AuthTokenProvider` so `XRPCClient` uses it transparently. 2253 + - `authHeaders(for:)` produces `Authorization: DPoP <access>` + a fresh `DPoP: <proof>` with the request's method, URL, and `ath` claim (SHA256 of access token). 2254 + - `handleResponse(_:for:)` updates the per-origin nonce when server returns `DPoP-Nonce`. 2255 + - `reportTokenRejected()` triggers a refresh using the refresh token, updates stored tokens. 2256 + 2257 + - [ ] **Step 1: Write failing tests** 2258 + 2259 + Create `Tests/ATProtoTests/OAuth/OAuthAuthTokenProviderTests.swift`: 2260 + 2261 + ```swift 2262 + import Foundation 2263 + import Testing 2264 + @testable import ATProto 2265 + 2266 + @Suite("OAuthAuthTokenProvider", .serialized) 2267 + struct OAuthAuthTokenProviderTests { 2268 + @Test("returns Authorization + DPoP headers") 2269 + func authHeaders() async throws { 2270 + let did = DID("did:plc:abc")! 2271 + let key = try InMemoryES256DPoPKey.generate() 2272 + let store = InMemoryTokenStore() 2273 + try await store.saveDPoPKey(key, identifier: "k1") 2274 + try await store.save(StoredTokens( 2275 + did: did, accessToken: "AT", refreshToken: "RT", 2276 + expiresAt: Date().addingTimeInterval(3600), 2277 + scopes: [.atproto], dpopKeyIdentifier: "k1", 2278 + issuer: "https://bsky.social" 2279 + )) 2280 + 2281 + // Refresh is not invoked by this test — pass a noop TokenExchange using a stub session. 2282 + let dummySession = URLProtocolStub.install { _ in 2283 + URLProtocolStub.Response(statusCode: 500, headers: [:], body: Data()) 2284 + } 2285 + defer { URLProtocolStub.reset(session: dummySession) } 2286 + 2287 + let provider = OAuthAuthTokenProvider( 2288 + did: did, 2289 + tokenStore: store, 2290 + nonceStore: DPoPNonceStore(), 2291 + tokenEndpoint: URL(string: "https://bsky.social/oauth/token")!, 2292 + clientId: "https://pdfs.at/oauth/client-metadata.json", 2293 + session: dummySession 2294 + ) 2295 + let request = URLRequest(url: URL(string: "https://bsky.social/xrpc/com.atproto.repo.describeRepo")!) 2296 + let headers = try await provider.authHeaders(for: request) 2297 + #expect(headers?["Authorization"] == "DPoP AT") 2298 + #expect(headers?["DPoP"] != nil) 2299 + } 2300 + 2301 + @Test("handleResponse captures DPoP-Nonce for next request") 2302 + func nonceUpdate() async throws { 2303 + let did = DID("did:plc:abc")! 2304 + let key = try InMemoryES256DPoPKey.generate() 2305 + let store = InMemoryTokenStore() 2306 + try await store.saveDPoPKey(key, identifier: "k1") 2307 + try await store.save(StoredTokens( 2308 + did: did, accessToken: "AT", refreshToken: "RT", 2309 + expiresAt: Date().addingTimeInterval(3600), 2310 + scopes: [.atproto], dpopKeyIdentifier: "k1", 2311 + issuer: "https://bsky.social" 2312 + )) 2313 + let nonceStore = DPoPNonceStore() 2314 + let session = URLProtocolStub.install { _ in 2315 + URLProtocolStub.Response(statusCode: 500, headers: [:], body: Data()) 2316 + } 2317 + defer { URLProtocolStub.reset(session: session) } 2318 + 2319 + let provider = OAuthAuthTokenProvider( 2320 + did: did, tokenStore: store, nonceStore: nonceStore, 2321 + tokenEndpoint: URL(string: "https://bsky.social/oauth/token")!, 2322 + clientId: "https://pdfs.at/oauth/client-metadata.json", 2323 + session: session 2324 + ) 2325 + let url = URL(string: "https://bsky.social/xrpc/thing")! 2326 + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["DPoP-Nonce": "n1"])! 2327 + await provider.handleResponse(response, for: URLRequest(url: url)) 2328 + let stored = await nonceStore.nonce(for: url) 2329 + #expect(stored == "n1") 2330 + } 2331 + 2332 + @Test("reportTokenRejected refreshes tokens via refresh_token grant") 2333 + func tokenRejected() async throws { 2334 + let did = DID("did:plc:abc")! 2335 + let key = try InMemoryES256DPoPKey.generate() 2336 + let store = InMemoryTokenStore() 2337 + try await store.saveDPoPKey(key, identifier: "k1") 2338 + try await store.save(StoredTokens( 2339 + did: did, accessToken: "OLD", refreshToken: "RT", 2340 + expiresAt: Date().addingTimeInterval(3600), 2341 + scopes: [.atproto], dpopKeyIdentifier: "k1", 2342 + issuer: "https://bsky.social" 2343 + )) 2344 + let session = URLProtocolStub.install { request in 2345 + #expect(request.url?.absoluteString == "https://bsky.social/oauth/token") 2346 + return .json(#""" 2347 + {"access_token":"NEW","refresh_token":"RT2","token_type":"DPoP", 2348 + "expires_in":3600,"scope":"atproto","sub":"did:plc:abc"} 2349 + """#) 2350 + } 2351 + defer { URLProtocolStub.reset(session: session) } 2352 + 2353 + let provider = OAuthAuthTokenProvider( 2354 + did: did, tokenStore: store, nonceStore: DPoPNonceStore(), 2355 + tokenEndpoint: URL(string: "https://bsky.social/oauth/token")!, 2356 + clientId: "https://pdfs.at/oauth/client-metadata.json", 2357 + session: session 2358 + ) 2359 + await provider.reportTokenRejected() 2360 + 2361 + let loaded = try await store.load(did: did) 2362 + #expect(loaded?.accessToken == "NEW") 2363 + #expect(loaded?.refreshToken == "RT2") 2364 + } 2365 + } 2366 + ``` 2367 + 2368 + - [ ] **Step 2: Run to verify failure** 2369 + 2370 + ```bash 2371 + swift test --filter OAuthAuthTokenProviderTests 2>&1 | tail -10 2372 + ``` 2373 + 2374 + - [ ] **Step 3: Implement OAuthAuthTokenProvider** 2375 + 2376 + Create `Sources/ATProto/OAuth/OAuthAuthTokenProvider.swift`: 2377 + 2378 + ```swift 2379 + import Foundation 2380 + 2381 + public actor OAuthAuthTokenProvider: AuthTokenProvider { 2382 + let did: DID 2383 + let tokenStore: any TokenStore 2384 + let nonceStore: DPoPNonceStore 2385 + let tokenEndpoint: URL 2386 + let clientId: String 2387 + let session: URLSession 2388 + 2389 + public init( 2390 + did: DID, 2391 + tokenStore: any TokenStore, 2392 + nonceStore: DPoPNonceStore, 2393 + tokenEndpoint: URL, 2394 + clientId: String, 2395 + session: URLSession = .shared 2396 + ) { 2397 + self.did = did 2398 + self.tokenStore = tokenStore 2399 + self.nonceStore = nonceStore 2400 + self.tokenEndpoint = tokenEndpoint 2401 + self.clientId = clientId 2402 + self.session = session 2403 + } 2404 + 2405 + public func authHeaders(for request: URLRequest) async throws -> [String: String]? { 2406 + guard let tokens = try await tokenStore.load(did: did) else { 2407 + throw ATProtoError.notAuthenticated 2408 + } 2409 + guard let key = try await tokenStore.loadDPoPKey(identifier: tokens.dpopKeyIdentifier) else { 2410 + throw ATProtoError.notAuthenticated 2411 + } 2412 + guard let url = request.url else { 2413 + throw ATProtoError.invalidURL("request missing url") 2414 + } 2415 + let method = request.httpMethod ?? "GET" 2416 + let nonce = await nonceStore.nonce(for: url) 2417 + let proof = try await DPoPProof.create( 2418 + method: method, 2419 + url: url, 2420 + key: key, 2421 + nonce: nonce, 2422 + accessToken: tokens.accessToken 2423 + ) 2424 + return [ 2425 + "Authorization": "DPoP \(tokens.accessToken)", 2426 + "DPoP": proof, 2427 + ] 2428 + } 2429 + 2430 + public func handleResponse(_ response: HTTPURLResponse, for request: URLRequest) async { 2431 + if let url = request.url, 2432 + let fresh = response.value(forHTTPHeaderField: "DPoP-Nonce") { 2433 + await nonceStore.setNonce(fresh, for: url) 2434 + } 2435 + } 2436 + 2437 + public func reportTokenRejected() async { 2438 + // Attempt a refresh; best-effort. 2439 + do { 2440 + guard let tokens = try await tokenStore.load(did: did), 2441 + let refreshToken = tokens.refreshToken, 2442 + let key = try await tokenStore.loadDPoPKey(identifier: tokens.dpopKeyIdentifier) else { 2443 + return 2444 + } 2445 + let exchange = TokenExchange( 2446 + endpoint: tokenEndpoint, 2447 + session: session, 2448 + nonceStore: nonceStore 2449 + ) 2450 + let fresh = try await exchange.refresh( 2451 + clientId: clientId, 2452 + refreshToken: refreshToken, 2453 + dpopKey: key 2454 + ) 2455 + let updated = StoredTokens( 2456 + did: did, 2457 + accessToken: fresh.accessToken, 2458 + refreshToken: fresh.refreshToken ?? refreshToken, 2459 + expiresAt: Date().addingTimeInterval(TimeInterval(fresh.expiresIn)), 2460 + scopes: Scope.parse(fresh.scope), 2461 + dpopKeyIdentifier: tokens.dpopKeyIdentifier, 2462 + issuer: tokens.issuer 2463 + ) 2464 + try await tokenStore.save(updated) 2465 + } catch { 2466 + // Swallow — caller will see the next 401 and we give up. 2467 + } 2468 + } 2469 + 2470 + /// The set of scopes currently granted for the stored tokens. 2471 + public func grantedScopes() async throws -> Set<Scope> { 2472 + (try await tokenStore.load(did: did))?.scopes ?? [] 2473 + } 2474 + } 2475 + ``` 2476 + 2477 + - [ ] **Step 4: Run to verify pass** 2478 + 2479 + ```bash 2480 + swift test --filter OAuthAuthTokenProviderTests 2>&1 | tail -10 2481 + ``` 2482 + Expected: 3 tests pass. 2483 + 2484 + - [ ] **Step 5: Commit** 2485 + 2486 + ```bash 2487 + git add Packages/ATProto/Sources/ATProto/OAuth/OAuthAuthTokenProvider.swift Packages/ATProto/Tests/ATProtoTests/OAuth/OAuthAuthTokenProviderTests.swift 2488 + git commit -m "feat(atproto/oauth): add OAuthAuthTokenProvider conforming to AuthTokenProvider" 2489 + ``` 2490 + 2491 + --- 2492 + 2493 + ### Task 10: BrowserDriver protocol + stub 2494 + 2495 + **Files:** 2496 + - Create: `Sources/ATProto/OAuth/BrowserDriver.swift` 2497 + 2498 + A protocol the host app implements using `ASWebAuthenticationSession`. This 2499 + plan ships a stub impl for tests. Separate file, no tests needed at this 2500 + layer — tests exercise the stub via `OAuthCoordinatorTests` in Task 11. 2501 + 2502 + - [ ] **Step 1: Implement** 2503 + 2504 + Create `Sources/ATProto/OAuth/BrowserDriver.swift`: 2505 + 2506 + ```swift 2507 + import Foundation 2508 + 2509 + public protocol BrowserDriver: Sendable { 2510 + /// Presents the given authorization URL to the user and returns the 2511 + /// callback URL the OAuth server redirected to. The host app implements 2512 + /// this via `ASWebAuthenticationSession`; tests use `StubBrowserDriver`. 2513 + /// - Parameter redirectScheme: the custom URL scheme to listen for 2514 + /// (e.g. `"pdfs"` for `pdfs://oauth/callback`). 2515 + func authenticate( 2516 + authorizationURL: URL, 2517 + redirectScheme: String 2518 + ) async throws -> URL 2519 + } 2520 + 2521 + /// Test helper that runs a pre-configured handler when `authenticate` is called. 2522 + /// Host code uses `ASWebAuthenticationSessionBrowserDriver` (to be added 2523 + /// alongside the macOS host app). Tests pass a closure that inspects the 2524 + /// authorization URL and returns a fabricated callback. 2525 + public struct StubBrowserDriver: BrowserDriver { 2526 + public let handler: @Sendable (URL, String) async throws -> URL 2527 + 2528 + public init(handler: @escaping @Sendable (URL, String) async throws -> URL) { 2529 + self.handler = handler 2530 + } 2531 + 2532 + public func authenticate(authorizationURL: URL, redirectScheme: String) async throws -> URL { 2533 + try await handler(authorizationURL, redirectScheme) 2534 + } 2535 + } 2536 + ``` 2537 + 2538 + - [ ] **Step 2: Build check** 2539 + 2540 + ```bash 2541 + swift build 2>&1 | tail -5 2542 + ``` 2543 + Expected: Build complete. 2544 + 2545 + - [ ] **Step 3: Commit** 2546 + 2547 + ```bash 2548 + git add Packages/ATProto/Sources/ATProto/OAuth/BrowserDriver.swift 2549 + git commit -m "feat(atproto/oauth): add BrowserDriver protocol + test stub" 2550 + ``` 2551 + 2552 + --- 2553 + 2554 + ### Task 11: OAuthCoordinator — sign-in + progressive scope escalation 2555 + 2556 + **Files:** 2557 + - Create: `Sources/ATProto/OAuth/OAuthCoordinator.swift` 2558 + - Create: `Tests/ATProtoTests/OAuth/OAuthCoordinatorTests.swift` 2559 + 2560 + Top-level orchestrator that stitches everything together. 2561 + 2562 + Public API: 2563 + - `signIn(handle: String) async throws -> ResolvedIdentity` — resolves handle → PDS, runs full OAuth flow with `[.atproto]` scope, saves tokens. 2564 + - `ensureScope(_ scopes: Set<Scope>, for did: DID) async throws` — if `scopes` is already a subset of granted, no-op. Otherwise initiates a new OAuth flow requesting `granted ∪ scopes`, replaces stored tokens on success. 2565 + - `provider(for did: DID) async throws -> OAuthAuthTokenProvider` — returns a ready provider for the given account. 2566 + 2567 + - [ ] **Step 1: Write failing tests** 2568 + 2569 + Create `Tests/ATProtoTests/OAuth/OAuthCoordinatorTests.swift`: 2570 + 2571 + ```swift 2572 + import Foundation 2573 + import Testing 2574 + @testable import ATProto 2575 + 2576 + @Suite("OAuthCoordinator", .serialized) 2577 + struct OAuthCoordinatorTests { 2578 + func makeSession(handler: @escaping (URLRequest) -> URLProtocolStub.Response) -> URLSession { 2579 + URLProtocolStub.install(handler: handler) 2580 + } 2581 + 2582 + /// Wires up the standard identity resolve + metadata discover + PAR + 2583 + /// token exchange handlers for a mocked natemoo.re identity. 2584 + func makeHappyHandler() -> (URLRequest) -> URLProtocolStub.Response { 2585 + return { request in 2586 + let url = request.url?.absoluteString ?? "" 2587 + switch url { 2588 + case let u where u.contains("cloudflare-dns.com"): 2589 + return .json(#""" 2590 + {"Status":0,"Answer":[{"data":"\"did=did:plc:abc\""}]} 2591 + """#) 2592 + case "https://plc.directory/did:plc:abc": 2593 + return .json(#""" 2594 + {"id":"did:plc:abc","alsoKnownAs":["at://natemoo.re"], 2595 + "service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://bsky.social"}]} 2596 + """#) 2597 + case "https://bsky.social/.well-known/oauth-protected-resource": 2598 + return .json(#""" 2599 + {"resource":"https://bsky.social", 2600 + "authorization_servers":["https://bsky.social"]} 2601 + """#) 2602 + case "https://bsky.social/.well-known/oauth-authorization-server": 2603 + return .json(#""" 2604 + {"issuer":"https://bsky.social", 2605 + "authorization_endpoint":"https://bsky.social/oauth/authorize", 2606 + "token_endpoint":"https://bsky.social/oauth/token", 2607 + "pushed_authorization_request_endpoint":"https://bsky.social/oauth/par", 2608 + "dpop_signing_alg_values_supported":["ES256"], 2609 + "scopes_supported":["atproto"]} 2610 + """#) 2611 + case "https://bsky.social/oauth/par": 2612 + return .json(#""" 2613 + {"request_uri":"urn:ietf:params:oauth:request_uri:xyz","expires_in":90} 2614 + """#) 2615 + case "https://bsky.social/oauth/token": 2616 + return .json(#""" 2617 + {"access_token":"AT-1","refresh_token":"RT-1","token_type":"DPoP", 2618 + "expires_in":3600,"scope":"atproto","sub":"did:plc:abc"} 2619 + """#) 2620 + default: 2621 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 2622 + } 2623 + } 2624 + } 2625 + 2626 + @Test("signIn requests full envelope; stores only what PDS returned as granted") 2627 + func signInHappyPath() async throws { 2628 + let session = makeSession(handler: makeHappyHandler()) 2629 + defer { URLProtocolStub.reset(session: session) } 2630 + 2631 + let store = InMemoryTokenStore() 2632 + let browser = StubBrowserDriver { authURL, _ in 2633 + // Verify the authorize URL has the request_uri from PAR. 2634 + #expect(authURL.absoluteString.contains("request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Axyz")) 2635 + return URL(string: "pdfs://oauth/callback?code=CODE&state=\(OAuthCoordinatorTests.stateCapture ?? "")&iss=https://bsky.social")! 2636 + } 2637 + 2638 + let coordinator = OAuthCoordinator( 2639 + session: session, 2640 + tokenStore: store, 2641 + browser: browser, 2642 + clientMetadata: .defaultPDFS 2643 + ) 2644 + // Hook: capture the state the coordinator generates so the stub can echo it back. 2645 + coordinator.testingStateHook = { state in OAuthCoordinatorTests.stateCapture = state } 2646 + 2647 + let resolved = try await coordinator.signIn(handle: "natemoo.re") 2648 + #expect(resolved.did.rawValue == "did:plc:abc") 2649 + 2650 + // Mock token endpoint returns scope="atproto" — simulating a user 2651 + // who unchecked all four write toggles in the PDS consent UI. 2652 + // Test verifies we store exactly what was granted, not what was 2653 + // requested. 2654 + let tokens = try await store.load(did: resolved.did) 2655 + #expect(tokens?.accessToken == "AT-1") 2656 + #expect(tokens?.scopes == [.atproto]) 2657 + } 2658 + 2659 + @Test("ensureScope is a no-op when scope already granted") 2660 + func ensureScopeNoOp() async throws { 2661 + let session = URLProtocolStub.install { _ in 2662 + Issue.record("no HTTP should fire") 2663 + return .json("{}") 2664 + } 2665 + defer { URLProtocolStub.reset(session: session) } 2666 + 2667 + let did = DID("did:plc:abc")! 2668 + let store = InMemoryTokenStore() 2669 + try await store.save(StoredTokens( 2670 + did: did, accessToken: "AT", refreshToken: "RT", 2671 + expiresAt: Date().addingTimeInterval(3600), 2672 + scopes: [ 2673 + .atproto, 2674 + .repo(collections: [.wildcard], actions: [.create]), 2675 + .repo(collections: [.wildcard], actions: [.update]), 2676 + .repo(collections: [.wildcard], actions: [.delete]), 2677 + .blob(accepts: ["*/*"]), 2678 + ], 2679 + dpopKeyIdentifier: "k1", issuer: "https://bsky.social" 2680 + )) 2681 + let key = try InMemoryES256DPoPKey.generate() 2682 + try await store.saveDPoPKey(key, identifier: "k1") 2683 + 2684 + let coordinator = OAuthCoordinator( 2685 + session: session, 2686 + tokenStore: store, 2687 + browser: StubBrowserDriver { _, _ in URL(string: "pdfs://oauth/callback")! }, 2688 + clientMetadata: .defaultPDFS 2689 + ) 2690 + // Wildcard grant covers every specific collection request via 2691 + // Scope.satisfies — this call must not fire the browser. 2692 + try await coordinator.ensureScope( 2693 + [.repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create])], 2694 + for: did 2695 + ) 2696 + } 2697 + 2698 + @Test("ensureScope recovery flow: fires browser when user previously denied a scope") 2699 + func ensureScopeUpgrade() async throws { 2700 + let session = makeSession(handler: makeHappyHandler()) 2701 + defer { URLProtocolStub.reset(session: session) } 2702 + 2703 + let did = DID("did:plc:abc")! 2704 + let store = InMemoryTokenStore() 2705 + // Simulate the "user unchecked delete" state — only atproto + a 2706 + // subset of write scopes granted at sign-in. 2707 + try await store.save(StoredTokens( 2708 + did: did, accessToken: "AT", refreshToken: "RT", 2709 + expiresAt: Date().addingTimeInterval(3600), 2710 + scopes: [ 2711 + .atproto, 2712 + .repo(collections: [.wildcard], actions: [.create]), 2713 + .repo(collections: [.wildcard], actions: [.update]), 2714 + .blob(accepts: ["*/*"]), 2715 + ], 2716 + dpopKeyIdentifier: "k1", issuer: "https://bsky.social" 2717 + )) 2718 + let key = try InMemoryES256DPoPKey.generate() 2719 + try await store.saveDPoPKey(key, identifier: "k1") 2720 + 2721 + actor BrowserCalls { var count = 0; func inc() { count += 1 } } 2722 + let calls = BrowserCalls() 2723 + let browser = StubBrowserDriver { _, _ in 2724 + await calls.inc() 2725 + return URL(string: "pdfs://oauth/callback?code=CODE&state=\(OAuthCoordinatorTests.stateCapture ?? "")&iss=https://bsky.social")! 2726 + } 2727 + 2728 + let coordinator = OAuthCoordinator( 2729 + session: session, tokenStore: store, 2730 + browser: browser, clientMetadata: .defaultPDFS 2731 + ) 2732 + coordinator.testingStateHook = { state in OAuthCoordinatorTests.stateCapture = state } 2733 + 2734 + // User had denied delete at sign-in; now tries to delete. The 2735 + // recovery flow should fire the browser to request the missing 2736 + // scope alongside current grants. 2737 + try await coordinator.ensureScope( 2738 + [.repo(collections: [.wildcard], actions: [.delete])], 2739 + for: did 2740 + ) 2741 + #expect(await calls.count == 1) 2742 + } 2743 + 2744 + // State capture so stub browser can echo back the state the coordinator generated. 2745 + nonisolated(unsafe) static var stateCapture: String? 2746 + } 2747 + ``` 2748 + 2749 + - [ ] **Step 2: Run to verify failure** 2750 + 2751 + ```bash 2752 + swift test --filter OAuthCoordinatorTests 2>&1 | tail -10 2753 + ``` 2754 + 2755 + - [ ] **Step 3: Implement OAuthCoordinator** 2756 + 2757 + Create `Sources/ATProto/OAuth/OAuthCoordinator.swift`: 2758 + 2759 + ```swift 2760 + import Foundation 2761 + 2762 + /// Top-level OAuth orchestrator. Exposes sign-in and progressive scope 2763 + /// escalation. Holds references to the HTTP session, token store, browser 2764 + /// driver, and client metadata. 2765 + public final class OAuthCoordinator: @unchecked Sendable { 2766 + let session: URLSession 2767 + let tokenStore: any TokenStore 2768 + let browser: any BrowserDriver 2769 + let clientMetadata: ClientMetadata 2770 + let identityResolver: IdentityResolver 2771 + let metadataResolver: MetadataResolver 2772 + let nonceStore: DPoPNonceStore 2773 + 2774 + /// Testing hook: if set, called with the state token generated for each 2775 + /// OAuth flow so stub browsers can echo it back in the callback URL. 2776 + public var testingStateHook: (@Sendable (String) -> Void)? 2777 + 2778 + public init( 2779 + session: URLSession = .shared, 2780 + tokenStore: any TokenStore, 2781 + browser: any BrowserDriver, 2782 + clientMetadata: ClientMetadata 2783 + ) { 2784 + self.session = session 2785 + self.tokenStore = tokenStore 2786 + self.browser = browser 2787 + self.clientMetadata = clientMetadata 2788 + self.identityResolver = IdentityResolver(session: session) 2789 + self.metadataResolver = MetadataResolver(session: session) 2790 + self.nonceStore = DPoPNonceStore() 2791 + } 2792 + 2793 + /// Initial sign-in: resolves handle → PDS, runs the OAuth flow 2794 + /// requesting the full scope envelope (`atproto` + all four write 2795 + /// scopes). The PDS consent UI renders the four write scopes as 2796 + /// independent toggles; whatever the user approves is what we store. 2797 + @discardableResult 2798 + public func signIn(handle: String) async throws -> ResolvedIdentity { 2799 + let resolved = try await identityResolver.resolve(handleOrDID: handle) 2800 + guard let pds = resolved.pds else { 2801 + throw ATProtoError.invalidIdentity("no PDS endpoint for \(resolved.did)") 2802 + } 2803 + try await runFlow( 2804 + did: resolved.did, 2805 + pds: pds, 2806 + scopes: OAuthCoordinator.defaultSignInScopes, 2807 + loginHint: handle 2808 + ) 2809 + return resolved 2810 + } 2811 + 2812 + /// The full scope envelope requested at sign-in. The PDS consent UI 2813 + /// renders the four write scopes as individual toggles the user can 2814 + /// uncheck; whatever comes back in the token response is stored 2815 + /// verbatim and used by `Scope.satisfies` for later authorization 2816 + /// checks. 2817 + public static let defaultSignInScopes: Set<Scope> = [ 2818 + .atproto, 2819 + .repo(collections: [.wildcard], actions: [.create]), 2820 + .repo(collections: [.wildcard], actions: [.update]), 2821 + .repo(collections: [.wildcard], actions: [.delete]), 2822 + .blob(accepts: ["*/*"]), 2823 + ] 2824 + 2825 + /// Ensures the given scopes are granted for the given DID. No-op if all 2826 + /// requested scopes are semantically satisfied by what's already granted 2827 + /// (wildcards and action supersets count). Otherwise runs a fresh browser 2828 + /// flow with `granted ∪ scopes` and replaces the stored tokens. 2829 + public func ensureScope(_ scopes: Set<Scope>, for did: DID) async throws { 2830 + guard let stored = try await tokenStore.load(did: did) else { 2831 + throw ATProtoError.notAuthenticated 2832 + } 2833 + if Scope.satisfies(granted: stored.scopes, requested: scopes) { return } 2834 + 2835 + let resolved = try await identityResolver.resolve(did: did) 2836 + guard let pds = resolved.pdsEndpoint else { 2837 + throw ATProtoError.invalidIdentity("no PDS endpoint for \(did)") 2838 + } 2839 + let superset = stored.scopes.union(scopes) 2840 + try await runFlow( 2841 + did: did, 2842 + pds: pds, 2843 + scopes: superset, 2844 + loginHint: resolved.handles.first 2845 + ) 2846 + } 2847 + 2848 + /// Returns a provider ready to sign XRPC requests for the given DID. 2849 + /// Throws `.notAuthenticated` if no tokens are stored for the DID. 2850 + public func provider(for did: DID) async throws -> OAuthAuthTokenProvider { 2851 + guard try await tokenStore.load(did: did) != nil else { 2852 + throw ATProtoError.notAuthenticated 2853 + } 2854 + // Re-discover to get a fresh token_endpoint (cheap, cached inside IdentityResolver). 2855 + let doc = try await identityResolver.resolve(did: did) 2856 + guard let pds = doc.pdsEndpoint else { 2857 + throw ATProtoError.invalidIdentity("no PDS endpoint for \(did)") 2858 + } 2859 + let (_, authMeta) = try await metadataResolver.resolve(pds: pds) 2860 + return OAuthAuthTokenProvider( 2861 + did: did, 2862 + tokenStore: tokenStore, 2863 + nonceStore: nonceStore, 2864 + tokenEndpoint: authMeta.tokenEndpoint, 2865 + clientId: clientMetadata.clientId, 2866 + session: session 2867 + ) 2868 + } 2869 + 2870 + // MARK: - Internals 2871 + 2872 + /// Full OAuth flow: metadata discovery → PAR → browser consent → code exchange → save tokens. 2873 + private func runFlow( 2874 + did: DID, 2875 + pds: URL, 2876 + scopes: Set<Scope>, 2877 + loginHint: String? 2878 + ) async throws { 2879 + let (_, authMeta) = try await metadataResolver.resolve(pds: pds) 2880 + guard let parEndpoint = authMeta.pushedAuthorizationRequestEndpoint else { 2881 + throw ATProtoError.invalidIdentity("authorization server does not support PAR") 2882 + } 2883 + 2884 + let dpopKey = try InMemoryES256DPoPKey.generate() 2885 + let keyIdentifier = UUID().uuidString 2886 + try await tokenStore.saveDPoPKey(dpopKey, identifier: keyIdentifier) 2887 + 2888 + let pkce = PKCE.generate() 2889 + let state = UUID().uuidString 2890 + testingStateHook?(state) 2891 + 2892 + let par = PushedAuthorizationRequest( 2893 + endpoint: parEndpoint, 2894 + session: session, 2895 + nonceStore: nonceStore 2896 + ) 2897 + let parResp = try await par.submit( 2898 + clientId: clientMetadata.clientId, 2899 + redirectURI: clientMetadata.redirectURIs.first ?? "pdfs://oauth/callback", 2900 + scopes: scopes, 2901 + state: state, 2902 + pkceChallenge: pkce.challenge, 2903 + loginHint: loginHint, 2904 + dpopKey: dpopKey 2905 + ) 2906 + 2907 + let authURL = try AuthorizationURL.build( 2908 + authorizeEndpoint: authMeta.authorizationEndpoint, 2909 + clientId: clientMetadata.clientId, 2910 + requestURI: parResp.requestURI 2911 + ) 2912 + 2913 + let redirectScheme = redirectSchemeFromRedirectURI(clientMetadata.redirectURIs.first ?? "pdfs://oauth/callback") 2914 + let callbackURL = try await browser.authenticate( 2915 + authorizationURL: authURL, 2916 + redirectScheme: redirectScheme 2917 + ) 2918 + let callback = try CallbackURL.parse(url: callbackURL) 2919 + guard callback.state == state else { 2920 + throw ATProtoError.invalidIdentity("state mismatch in OAuth callback") 2921 + } 2922 + if let iss = callback.issuer, iss != authMeta.issuer { 2923 + throw ATProtoError.invalidIdentity("issuer mismatch: \(iss) != \(authMeta.issuer)") 2924 + } 2925 + 2926 + let exchange = TokenExchange( 2927 + endpoint: authMeta.tokenEndpoint, 2928 + session: session, 2929 + nonceStore: nonceStore 2930 + ) 2931 + let tokens = try await exchange.exchangeCode( 2932 + clientId: clientMetadata.clientId, 2933 + redirectURI: clientMetadata.redirectURIs.first ?? "pdfs://oauth/callback", 2934 + code: callback.code, 2935 + codeVerifier: pkce.verifier, 2936 + dpopKey: dpopKey 2937 + ) 2938 + 2939 + let stored = StoredTokens( 2940 + did: did, 2941 + accessToken: tokens.accessToken, 2942 + refreshToken: tokens.refreshToken, 2943 + expiresAt: Date().addingTimeInterval(TimeInterval(tokens.expiresIn)), 2944 + scopes: Scope.parse(tokens.scope), 2945 + dpopKeyIdentifier: keyIdentifier, 2946 + issuer: authMeta.issuer 2947 + ) 2948 + try await tokenStore.save(stored) 2949 + } 2950 + 2951 + private func redirectSchemeFromRedirectURI(_ uri: String) -> String { 2952 + uri.split(separator: ":", maxSplits: 1).first.map(String.init) ?? "pdfs" 2953 + } 2954 + } 2955 + ``` 2956 + 2957 + - [ ] **Step 4: Run to verify pass** 2958 + 2959 + ```bash 2960 + swift test --filter OAuthCoordinatorTests 2>&1 | tail -15 2961 + ``` 2962 + Expected: 3 tests pass. 2963 + 2964 + Note: the third test (`ensureScopeUpgrade`) verifies the browser was invoked; it does not assert the final stored scope set because the mocked token endpoint always returns `scope="atproto"` regardless of what was requested. A more elaborate mock would inspect the PAR body and echo the superset. That's a reasonable follow-up for when a real PDS surfaces the granted delta. 2965 + 2966 + - [ ] **Step 5: Commit** 2967 + 2968 + ```bash 2969 + git add Packages/ATProto/Sources/ATProto/OAuth/OAuthCoordinator.swift Packages/ATProto/Tests/ATProtoTests/OAuth/OAuthCoordinatorTests.swift 2970 + git commit -m "feat(atproto/oauth): add OAuthCoordinator with sign-in + progressive scope escalation" 2971 + ``` 2972 + 2973 + --- 2974 + 2975 + ### Task 12: Publish the static client metadata file 2976 + 2977 + **Files:** 2978 + - Create: `oauth/client-metadata.json` (at the repo root, destined for `https://pdfs.at/oauth/client-metadata.json`) 2979 + 2980 + This file must exist publicly at `https://pdfs.at/oauth/client-metadata.json` 2981 + for the atproto OAuth flow to work. The host app references the URL as 2982 + `client_id`. Hosting setup (Cloudflare Pages or similar) is outside this 2983 + plan's scope; the plan delivers only the file. 2984 + 2985 + - [ ] **Step 1: Create the metadata file** 2986 + 2987 + Create `oauth/client-metadata.json`: 2988 + 2989 + ```json 2990 + { 2991 + "client_id": "https://pdfs.at/oauth/client-metadata.json", 2992 + "client_name": "pdfs", 2993 + "client_uri": "https://pdfs.at", 2994 + "redirect_uris": ["pdfs://oauth/callback"], 2995 + "grant_types": ["authorization_code", "refresh_token"], 2996 + "response_types": ["code"], 2997 + "scope": "atproto repo:* blob:*/*", 2998 + "token_endpoint_auth_method": "none", 2999 + "dpop_bound_access_tokens": true, 3000 + "application_type": "native" 3001 + } 3002 + ``` 3003 + 3004 + - [ ] **Step 2: Sanity-check that the JSON matches the Swift-side `ClientMetadata.defaultPDFS`** 3005 + 3006 + Add a cross-check test to `Tests/ATProtoTests/OAuth/MetadataResolverTests.swift` (inside the existing `MetadataResolverTests` suite): 3007 + 3008 + ```swift 3009 + @Test("hosted client-metadata.json matches ClientMetadata.defaultPDFS") 3010 + func hostedMatchesSwift() throws { 3011 + // Path is relative to the package manifest location at test time. 3012 + let url = URL(fileURLWithPath: #filePath) 3013 + .deletingLastPathComponent() // OAuth/ 3014 + .deletingLastPathComponent() // ATProtoTests/ 3015 + .deletingLastPathComponent() // Tests/ 3016 + .deletingLastPathComponent() // ATProto/ 3017 + .deletingLastPathComponent() // Packages/ 3018 + .appendingPathComponent("oauth/client-metadata.json") 3019 + let data = try Data(contentsOf: url) 3020 + let hosted = try JSONDecoder().decode(ClientMetadata.self, from: data) 3021 + #expect(hosted == ClientMetadata.defaultPDFS) 3022 + } 3023 + ``` 3024 + 3025 + - [ ] **Step 3: Run the cross-check** 3026 + 3027 + ```bash 3028 + swift test --filter hostedMatchesSwift 2>&1 | tail -10 3029 + ``` 3030 + Expected: 1 test passes. 3031 + 3032 + - [ ] **Step 4: Commit** 3033 + 3034 + ```bash 3035 + git add oauth/client-metadata.json Packages/ATProto/Tests/ATProtoTests/OAuth/MetadataResolverTests.swift 3036 + git commit -m "feat(oauth): publish client-metadata.json + cross-check with Swift default" 3037 + ``` 3038 + 3039 + --- 3040 + 3041 + ### Task 13: End-to-end verification 3042 + 3043 + Final pass. No new code; this task confirms the whole package still hangs 3044 + together and documents the path to a real OAuth flow once Xcode lands. 3045 + 3046 + - [ ] **Step 1: Full suite** 3047 + 3048 + ```bash 3049 + cd /Users/nmoo/Developer/natemoo-re/tangled/atfs/Packages/ATProto 3050 + swift test 2>&1 | tail -5 3051 + ``` 3052 + Expected: 53 (existing) + Task 1-12 new tests all pass. Rough target: 80+ tests. 3053 + 3054 + - [ ] **Step 2: No warnings** 3055 + 3056 + ```bash 3057 + swift build 2>&1 | grep -iE "warning|error" | head -10 || echo "clean" 3058 + ``` 3059 + Expected: clean. 3060 + 3061 + - [ ] **Step 3: Document next steps in plan epilogue** 3062 + 3063 + Append to this plan file (same file — edit, don't create new): 3064 + 3065 + ```markdown 3066 + ## Deferred to follow-up plans 3067 + 3068 + - **Keychain-backed TokenStore** — requires a signed app bundle. Host app 3069 + plan will add `KeychainTokenStore` conforming to `TokenStore`. 3070 + - **Secure Enclave DPoP key** — requires a signed app bundle with 3071 + entitlements. Adds `SecureEnclaveDPoPKey: DPoPKey`. 3072 + - **`ASWebAuthenticationSessionBrowserDriver`** — lives in the host app 3073 + target. 3074 + - **XPC plumbing for FSKit → host scope-upgrade requests** — when the FSKit 3075 + extension needs to trigger a scope upgrade (first write), it calls the 3076 + host over XPC, which runs `OAuthCoordinator.ensureScope(...)` via the 3077 + `ASWebAuthenticationSession` browser driver. 3078 + - **Write endpoints** (`createRecord`, `putRecord`, `deleteRecord`, 3079 + `applyWrites`, `uploadBlob`) — now trivially unblocked by the auth layer; 3080 + follow Task 4-9 pattern from the XRPC+identity plan. 3081 + - **Automatic scope upgrade on 401** — currently `reportTokenRejected` 3082 + only refreshes tokens. A future enhancement can parse the 401 body for 3083 + `insufficient_scope` and surface a "needs scope upgrade" signal to the 3084 + host for prompting the user. 3085 + ``` 3086 + 3087 + - [ ] **Step 4: Commit** 3088 + 3089 + ```bash 3090 + git add docs/superpowers/plans/2026-04-17-atproto-oauth-dpop.md 3091 + git commit -m "docs(oauth): mark OAuth sub-plan complete, document follow-ups" 3092 + ``` 3093 + 3094 + --- 3095 + 3096 + ## Self-review notes 3097 + 3098 + Coverage against the spec: 3099 + - ✅ Structured Scope type (atproto, repo w/ collections+actions, blob w/ 3100 + MIME accepts) with parse/format/containment + Codable 3101 + - ✅ PKCE 3102 + - ✅ DPoP (key + proof + nonce cache) 3103 + - ✅ OAuth metadata discovery 3104 + - ✅ Static client metadata file + cross-check (scope envelope: `atproto 3105 + repo:* blob:*/*`) 3106 + - ✅ PAR, authorize URL, callback parser 3107 + - ✅ Code exchange + refresh (with DPoP nonce retry) 3108 + - ✅ TokenStore (protocol + in-memory) — scopes stored as `Set<Scope>`, 3109 + Codable round-trips via scope-string form 3110 + - ✅ OAuthAuthTokenProvider conforming to AuthTokenProvider (DPoP headers, 3111 + nonce update via `handleResponse`, refresh via `reportTokenRejected`) 3112 + - ✅ BrowserDriver protocol + stub 3113 + - ✅ OAuthCoordinator with `signIn` (atproto-only) + `ensureScope` 3114 + (collection-scoped progressive escalation, uses semantic `Scope.satisfies` 3115 + rather than Set subset) 3116 + - 🔜 Keychain, Secure Enclave, ASWebAuthenticationSession — all deferred to 3117 + the host-app plan (correct — they require Xcode + code signing) 3118 + - 🔜 XPC hook for FSKit → host scope-upgrade requests — deferred; the 3119 + FSKit write path will call `OAuthCoordinator.ensureScope([.repo(collection: nsid)], for: did)` 3120 + via XPC before attempting createRecord / putRecord / deleteRecord on 3121 + the given NSID. 3122 + 3123 + ## Deferred to follow-up plans 3124 + 3125 + ### Host-app-only (require Xcode + code signing) 3126 + 3127 + - **`KeychainTokenStore`** — `TokenStore` conformer backed by macOS Keychain. Requires a signed app bundle. Protocol shape (`save/load/delete/listDIDs` + `saveDPoPKey/loadDPoPKey/deleteDPoPKey`) is already stable. 3128 + - **`SecureEnclaveDPoPKey`** — `DPoPKey` conformer using `kSecAttrTokenIDSecureEnclave`. Requires entitlements. 3129 + - **`ASWebAuthenticationSessionBrowserDriver`** — `BrowserDriver` conformer in the host app target. 3130 + - **XPC bridge** — FSKit extension calls the host to run `OAuthCoordinator.ensureScope(...)` when a write hits a denied scope; host presents the re-auth UI naturally. 3131 + 3132 + ### Code polish (pre-FSKit) 3133 + 3134 + - **Clear stored tokens on `invalid_grant` refresh failure** — currently `performRefresh` swallows all errors. A revoked refresh token leaves stale tokens in the store and every subsequent request 401s. Surface via a new `TokenStore.delete(did:)` call. 3135 + - **Proactive expiry check** — `OAuthAuthTokenProvider.authHeaders` should check `tokens.expiresAt < Date().addingTimeInterval(30)` and trigger refresh before signing, saving a round-trip on known-expired tokens. 3136 + - **Browser cancellation sentinel** — `BrowserDriver` should document a `BrowserDriverError.canceled` contract so the host app's `ASWebAuthenticationSession` impl surfaces user-cancel distinctly from network errors. 3137 + - **Validate non-empty `ClientMetadata.redirectURIs`** — currently `runFlow` falls back to a hardcoded `"pdfs://oauth/callback"` string if the list is empty; should throw early. 3138 + - **Nonce retry-path tests** — PAR/TokenExchange have single-shot retry on 401+`DPoP-Nonce` but the retry leg isn't directly exercised by tests. 3139 + - **`testingStateHook`** on `OAuthCoordinator` is a `public var` on an `@unchecked Sendable` class; safe under serialized test use, but rename with `_` prefix or `#if DEBUG` gate. 3140 + - **Structured logging** — add `os.Logger` at key OAuth flow checkpoints so FSKit debugging has a breadcrumb trail. 3141 + 3142 + ### Write endpoints 3143 + 3144 + - `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`, `uploadBlob` — now unblocked by the auth layer. Follow the typed-endpoint pattern from the XRPC sub-plan (Tasks 5-9).