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.

feat(atproto/oauth): add OAuthCoordinator with sign-in + progressive scope escalation

Stitches metadata discovery, PAR, browser consent, token exchange, and
token storage into a single signIn(handle:) flow; ensureScope(_:for:) is
a no-op when granted scopes already satisfy the request, triggering a
fresh browser flow only on actual scope gaps.

Also fixes AuthorizationURL.build to percent-encode colons in query
values (request_uri urn: prefix), required by RFC 9126 §2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+395 -3
+23 -3
Packages/ATProto/Sources/ATProto/OAuth/Flow/AuthorizationURL.swift
··· 1 1 import Foundation 2 2 3 + private extension CharacterSet { 4 + /// Characters allowed as query *values* in an application/x-www-form-urlencoded 5 + /// context. Colons, slashes, and other sub-delimiters that `urlQueryAllowed` 6 + /// permits are excluded so values like `urn:ietf:params:oauth:request_uri:…` 7 + /// are fully percent-encoded. 8 + static let urlQueryValueAllowed: CharacterSet = { 9 + var allowed = CharacterSet.urlQueryAllowed 10 + allowed.remove(charactersIn: ":/?#[]@!$&'()*+,;=") 11 + return allowed 12 + }() 13 + } 14 + 3 15 public enum AuthorizationURL { 4 16 /// Builds the authorize URL using the PAR-returned `request_uri`. 5 17 /// Per RFC 9126, the authorize URL needs only `client_id` + `request_uri`. ··· 11 23 guard var comps = URLComponents(url: authorizeEndpoint, resolvingAgainstBaseURL: false) else { 12 24 throw ATProtoError.invalidURL(authorizeEndpoint.absoluteString) 13 25 } 14 - comps.queryItems = [ 15 - URLQueryItem(name: "client_id", value: clientId), 16 - URLQueryItem(name: "request_uri", value: requestURI), 26 + // Use percentEncodedQueryItems so that characters like colons in 27 + // `request_uri` values (e.g. `urn:ietf:params:oauth:request_uri:…`) 28 + // are percent-encoded. URLComponents.url leaves colons unencoded in 29 + // query values per RFC 3986, but authorization servers require full 30 + // form-encoding of the `request_uri` parameter per RFC 9126 §2. 31 + let encode: (String) -> String = { s in 32 + s.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? s 33 + } 34 + comps.percentEncodedQueryItems = [ 35 + URLQueryItem(name: "client_id", value: encode(clientId)), 36 + URLQueryItem(name: "request_uri", value: encode(requestURI)), 17 37 ] 18 38 guard let url = comps.url else { 19 39 throw ATProtoError.invalidURL(authorizeEndpoint.absoluteString)
+197
Packages/ATProto/Sources/ATProto/OAuth/OAuthCoordinator.swift
··· 1 + import Foundation 2 + 3 + /// Top-level OAuth orchestrator. Exposes sign-in and progressive scope 4 + /// escalation. Holds references to the HTTP session, token store, browser 5 + /// driver, and client metadata. 6 + public final class OAuthCoordinator: @unchecked Sendable { 7 + let session: URLSession 8 + let tokenStore: any TokenStore 9 + let browser: any BrowserDriver 10 + let clientMetadata: ClientMetadata 11 + let identityResolver: IdentityResolver 12 + let metadataResolver: MetadataResolver 13 + let nonceStore: DPoPNonceStore 14 + 15 + /// Testing hook: if set, called with the state token generated for each 16 + /// OAuth flow so stub browsers can echo it back in the callback URL. 17 + public var testingStateHook: (@Sendable (String) -> Void)? 18 + 19 + public init( 20 + session: URLSession = .shared, 21 + tokenStore: any TokenStore, 22 + browser: any BrowserDriver, 23 + clientMetadata: ClientMetadata 24 + ) { 25 + self.session = session 26 + self.tokenStore = tokenStore 27 + self.browser = browser 28 + self.clientMetadata = clientMetadata 29 + self.identityResolver = IdentityResolver(session: session) 30 + self.metadataResolver = MetadataResolver(session: session) 31 + self.nonceStore = DPoPNonceStore() 32 + } 33 + 34 + /// Initial sign-in: resolves handle → PDS, runs the OAuth flow 35 + /// requesting the full scope envelope (`atproto` + all four write 36 + /// scopes). The PDS consent UI renders the four write scopes as 37 + /// independent toggles; whatever the user approves is what we store. 38 + @discardableResult 39 + public func signIn(handle: String) async throws -> ResolvedIdentity { 40 + let resolved = try await identityResolver.resolve(handleOrDID: handle) 41 + guard let pds = resolved.pds else { 42 + throw ATProtoError.invalidIdentity("no PDS endpoint for \(resolved.did)") 43 + } 44 + try await runFlow( 45 + did: resolved.did, 46 + pds: pds, 47 + scopes: OAuthCoordinator.defaultSignInScopes, 48 + loginHint: handle 49 + ) 50 + return resolved 51 + } 52 + 53 + /// The full scope envelope requested at sign-in. The PDS consent UI 54 + /// renders the four write scopes as individual toggles the user can 55 + /// uncheck; whatever comes back in the token response is stored 56 + /// verbatim and used by `Scope.satisfies` for later authorization 57 + /// checks. 58 + public static let defaultSignInScopes: Set<Scope> = [ 59 + .atproto, 60 + .repo(collections: [.wildcard], actions: [.create]), 61 + .repo(collections: [.wildcard], actions: [.update]), 62 + .repo(collections: [.wildcard], actions: [.delete]), 63 + .blob(accepts: ["*/*"]), 64 + ] 65 + 66 + /// Ensures the given scopes are granted for the given DID. No-op if all 67 + /// requested scopes are semantically satisfied by what's already granted 68 + /// (wildcards and action supersets count). Otherwise runs a fresh browser 69 + /// flow with `granted ∪ scopes` and replaces the stored tokens. 70 + public func ensureScope(_ scopes: Set<Scope>, for did: DID) async throws { 71 + guard let stored = try await tokenStore.load(did: did) else { 72 + throw ATProtoError.notAuthenticated 73 + } 74 + if Scope.satisfies(granted: stored.scopes, requested: scopes) { return } 75 + 76 + let doc = try await identityResolver.resolve(did: did) 77 + guard let pds = doc.pdsEndpoint else { 78 + throw ATProtoError.invalidIdentity("no PDS endpoint for \(did)") 79 + } 80 + let superset = stored.scopes.union(scopes) 81 + try await runFlow( 82 + did: did, 83 + pds: pds, 84 + scopes: superset, 85 + loginHint: doc.handles.first 86 + ) 87 + } 88 + 89 + /// Returns a provider ready to sign XRPC requests for the given DID. 90 + /// Throws `.notAuthenticated` if no tokens are stored for the DID. 91 + public func provider(for did: DID) async throws -> OAuthAuthTokenProvider { 92 + guard try await tokenStore.load(did: did) != nil else { 93 + throw ATProtoError.notAuthenticated 94 + } 95 + // Re-discover to get a fresh token_endpoint (cheap, cached inside IdentityResolver). 96 + let doc = try await identityResolver.resolve(did: did) 97 + guard let pds = doc.pdsEndpoint else { 98 + throw ATProtoError.invalidIdentity("no PDS endpoint for \(did)") 99 + } 100 + let (_, authMeta) = try await metadataResolver.resolve(pds: pds) 101 + return OAuthAuthTokenProvider( 102 + did: did, 103 + tokenStore: tokenStore, 104 + nonceStore: nonceStore, 105 + tokenEndpoint: authMeta.tokenEndpoint, 106 + clientId: clientMetadata.clientId, 107 + session: session 108 + ) 109 + } 110 + 111 + // MARK: - Internals 112 + 113 + /// Full OAuth flow: metadata discovery → PAR → browser consent → code exchange → save tokens. 114 + private func runFlow( 115 + did: DID, 116 + pds: URL, 117 + scopes: Set<Scope>, 118 + loginHint: String? 119 + ) async throws { 120 + let (_, authMeta) = try await metadataResolver.resolve(pds: pds) 121 + guard let parEndpoint = authMeta.pushedAuthorizationRequestEndpoint else { 122 + throw ATProtoError.invalidIdentity("authorization server does not support PAR") 123 + } 124 + 125 + let dpopKey = try InMemoryES256DPoPKey.generate() 126 + let keyIdentifier = UUID().uuidString 127 + try await tokenStore.saveDPoPKey(dpopKey, identifier: keyIdentifier) 128 + 129 + let pkce = PKCE.generate() 130 + let state = UUID().uuidString 131 + testingStateHook?(state) 132 + 133 + let par = PushedAuthorizationRequest( 134 + endpoint: parEndpoint, 135 + session: session, 136 + nonceStore: nonceStore 137 + ) 138 + let parResp = try await par.submit( 139 + clientId: clientMetadata.clientId, 140 + redirectURI: clientMetadata.redirectURIs.first ?? "pdfs://oauth/callback", 141 + scopes: scopes, 142 + state: state, 143 + pkceChallenge: pkce.challenge, 144 + loginHint: loginHint, 145 + dpopKey: dpopKey 146 + ) 147 + 148 + let authURL = try AuthorizationURL.build( 149 + authorizeEndpoint: authMeta.authorizationEndpoint, 150 + clientId: clientMetadata.clientId, 151 + requestURI: parResp.requestURI 152 + ) 153 + 154 + let redirectScheme = redirectSchemeFromRedirectURI( 155 + clientMetadata.redirectURIs.first ?? "pdfs://oauth/callback" 156 + ) 157 + let callbackURL = try await browser.authenticate( 158 + authorizationURL: authURL, 159 + redirectScheme: redirectScheme 160 + ) 161 + let callback = try CallbackURL.parse(url: callbackURL) 162 + guard callback.state == state else { 163 + throw ATProtoError.invalidIdentity("state mismatch in OAuth callback") 164 + } 165 + if let iss = callback.issuer, iss != authMeta.issuer { 166 + throw ATProtoError.invalidIdentity("issuer mismatch: \(iss) != \(authMeta.issuer)") 167 + } 168 + 169 + let exchange = TokenExchange( 170 + endpoint: authMeta.tokenEndpoint, 171 + session: session, 172 + nonceStore: nonceStore 173 + ) 174 + let tokens = try await exchange.exchangeCode( 175 + clientId: clientMetadata.clientId, 176 + redirectURI: clientMetadata.redirectURIs.first ?? "pdfs://oauth/callback", 177 + code: callback.code, 178 + codeVerifier: pkce.verifier, 179 + dpopKey: dpopKey 180 + ) 181 + 182 + let stored = StoredTokens( 183 + did: did, 184 + accessToken: tokens.accessToken, 185 + refreshToken: tokens.refreshToken, 186 + expiresAt: Date().addingTimeInterval(TimeInterval(tokens.expiresIn)), 187 + scopes: Scope.parse(tokens.scope), 188 + dpopKeyIdentifier: keyIdentifier, 189 + issuer: authMeta.issuer 190 + ) 191 + try await tokenStore.save(stored) 192 + } 193 + 194 + private func redirectSchemeFromRedirectURI(_ uri: String) -> String { 195 + uri.split(separator: ":", maxSplits: 1).first.map(String.init) ?? "pdfs" 196 + } 197 + }
+175
Packages/ATProto/Tests/ATProtoTests/OAuth/OAuthCoordinatorTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("OAuthCoordinator", .serialized) 6 + struct OAuthCoordinatorTests { 7 + func makeSession(handler: @escaping (URLRequest) -> URLProtocolStub.Response) -> URLSession { 8 + URLProtocolStub.install(handler: handler) 9 + } 10 + 11 + /// Wires up the standard identity resolve + metadata discover + PAR + 12 + /// token exchange handlers for a mocked natemoo.re identity. 13 + func makeHappyHandler() -> (URLRequest) -> URLProtocolStub.Response { 14 + return { request in 15 + let url = request.url?.absoluteString ?? "" 16 + switch url { 17 + case let u where u.contains("cloudflare-dns.com"): 18 + return .json(#""" 19 + {"Status":0,"Answer":[{"data":"\"did=did:plc:abc\""}]} 20 + """#) 21 + case "https://plc.directory/did:plc:abc": 22 + return .json(#""" 23 + {"id":"did:plc:abc","alsoKnownAs":["at://natemoo.re"], 24 + "service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://bsky.social"}]} 25 + """#) 26 + case "https://bsky.social/.well-known/oauth-protected-resource": 27 + return .json(#""" 28 + {"resource":"https://bsky.social", 29 + "authorization_servers":["https://bsky.social"]} 30 + """#) 31 + case "https://bsky.social/.well-known/oauth-authorization-server": 32 + return .json(#""" 33 + {"issuer":"https://bsky.social", 34 + "authorization_endpoint":"https://bsky.social/oauth/authorize", 35 + "token_endpoint":"https://bsky.social/oauth/token", 36 + "pushed_authorization_request_endpoint":"https://bsky.social/oauth/par", 37 + "dpop_signing_alg_values_supported":["ES256"], 38 + "scopes_supported":["atproto"]} 39 + """#) 40 + case "https://bsky.social/oauth/par": 41 + return .json(#""" 42 + {"request_uri":"urn:ietf:params:oauth:request_uri:xyz","expires_in":90} 43 + """#) 44 + case "https://bsky.social/oauth/token": 45 + return .json(#""" 46 + {"access_token":"AT-1","refresh_token":"RT-1","token_type":"DPoP", 47 + "expires_in":3600,"scope":"atproto","sub":"did:plc:abc"} 48 + """#) 49 + default: 50 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 51 + } 52 + } 53 + } 54 + 55 + @Test("signIn requests full envelope; stores only what PDS returned as granted") 56 + func signInHappyPath() async throws { 57 + let session = makeSession(handler: makeHappyHandler()) 58 + defer { URLProtocolStub.reset(session: session) } 59 + 60 + let store = InMemoryTokenStore() 61 + let browser = StubBrowserDriver { authURL, _ in 62 + // Verify the authorize URL has the request_uri from PAR. 63 + #expect(authURL.absoluteString.contains("request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Axyz")) 64 + return URL(string: "pdfs://oauth/callback?code=CODE&state=\(OAuthCoordinatorTests.stateCapture ?? "")&iss=https://bsky.social")! 65 + } 66 + 67 + let coordinator = OAuthCoordinator( 68 + session: session, 69 + tokenStore: store, 70 + browser: browser, 71 + clientMetadata: .defaultPDFS 72 + ) 73 + // Hook: capture the state the coordinator generates so the stub can echo it back. 74 + coordinator.testingStateHook = { state in OAuthCoordinatorTests.stateCapture = state } 75 + 76 + let resolved = try await coordinator.signIn(handle: "natemoo.re") 77 + #expect(resolved.did.rawValue == "did:plc:abc") 78 + 79 + // Mock token endpoint returns scope="atproto" — simulating a user 80 + // who unchecked all four write toggles in the PDS consent UI. 81 + // Test verifies we store exactly what was granted, not what was 82 + // requested. 83 + let tokens = try await store.load(did: resolved.did) 84 + #expect(tokens?.accessToken == "AT-1") 85 + #expect(tokens?.scopes == [.atproto]) 86 + } 87 + 88 + @Test("ensureScope is a no-op when scope already granted") 89 + func ensureScopeNoOp() async throws { 90 + let session = URLProtocolStub.install { _ in 91 + Issue.record("no HTTP should fire") 92 + return .json("{}") 93 + } 94 + defer { URLProtocolStub.reset(session: session) } 95 + 96 + let did = DID("did:plc:abc")! 97 + let store = InMemoryTokenStore() 98 + try await store.save(StoredTokens( 99 + did: did, accessToken: "AT", refreshToken: "RT", 100 + expiresAt: Date().addingTimeInterval(3600), 101 + scopes: [ 102 + .atproto, 103 + .repo(collections: [.wildcard], actions: [.create]), 104 + .repo(collections: [.wildcard], actions: [.update]), 105 + .repo(collections: [.wildcard], actions: [.delete]), 106 + .blob(accepts: ["*/*"]), 107 + ], 108 + dpopKeyIdentifier: "k1", issuer: "https://bsky.social" 109 + )) 110 + let key = try InMemoryES256DPoPKey.generate() 111 + try await store.saveDPoPKey(key, identifier: "k1") 112 + 113 + let coordinator = OAuthCoordinator( 114 + session: session, 115 + tokenStore: store, 116 + browser: StubBrowserDriver { _, _ in URL(string: "pdfs://oauth/callback")! }, 117 + clientMetadata: .defaultPDFS 118 + ) 119 + // Wildcard grant covers every specific collection request via 120 + // Scope.satisfies — this call must not fire the browser. 121 + try await coordinator.ensureScope( 122 + [.repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create])], 123 + for: did 124 + ) 125 + } 126 + 127 + @Test("ensureScope recovery flow: fires browser when user previously denied a scope") 128 + func ensureScopeUpgrade() async throws { 129 + let session = makeSession(handler: makeHappyHandler()) 130 + defer { URLProtocolStub.reset(session: session) } 131 + 132 + let did = DID("did:plc:abc")! 133 + let store = InMemoryTokenStore() 134 + // Simulate the "user unchecked delete" state — only atproto + a 135 + // subset of write scopes granted at sign-in. 136 + try await store.save(StoredTokens( 137 + did: did, accessToken: "AT", refreshToken: "RT", 138 + expiresAt: Date().addingTimeInterval(3600), 139 + scopes: [ 140 + .atproto, 141 + .repo(collections: [.wildcard], actions: [.create]), 142 + .repo(collections: [.wildcard], actions: [.update]), 143 + .blob(accepts: ["*/*"]), 144 + ], 145 + dpopKeyIdentifier: "k1", issuer: "https://bsky.social" 146 + )) 147 + let key = try InMemoryES256DPoPKey.generate() 148 + try await store.saveDPoPKey(key, identifier: "k1") 149 + 150 + actor BrowserCalls { var count = 0; func inc() { count += 1 } } 151 + let calls = BrowserCalls() 152 + let browser = StubBrowserDriver { _, _ in 153 + await calls.inc() 154 + return URL(string: "pdfs://oauth/callback?code=CODE&state=\(OAuthCoordinatorTests.stateCapture ?? "")&iss=https://bsky.social")! 155 + } 156 + 157 + let coordinator = OAuthCoordinator( 158 + session: session, tokenStore: store, 159 + browser: browser, clientMetadata: .defaultPDFS 160 + ) 161 + coordinator.testingStateHook = { state in OAuthCoordinatorTests.stateCapture = state } 162 + 163 + // User had denied delete at sign-in; now tries to delete. The 164 + // recovery flow should fire the browser to request the missing 165 + // scope alongside current grants. 166 + try await coordinator.ensureScope( 167 + [.repo(collections: [.wildcard], actions: [.delete])], 168 + for: did 169 + ) 170 + #expect(await calls.count == 1) 171 + } 172 + 173 + // State capture so stub browser can echo back the state the coordinator generated. 174 + nonisolated(unsafe) static var stateCapture: String? 175 + }