this repo has no description
2
fork

Configure Feed

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

Add auth proxy support to CoreATProtocol for confidential client OAuth

+433 -7
+67 -7
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 25 25 public let clientMetadataURL: String 26 26 public let redirectURI: String 27 27 public let scopes: [String] 28 + public let authProxyBaseURL: String? 28 29 29 30 public init( 30 31 clientMetadataURL: String, 31 32 redirectURI: String, 32 - scopes: [String] = ["atproto", "transition:generic"] 33 + scopes: [String] = ["atproto", "transition:generic"], 34 + authProxyBaseURL: String? = nil 33 35 ) { 34 36 self.clientMetadataURL = clientMetadataURL 35 37 self.redirectURI = redirectURI 36 38 self.scopes = scopes 39 + self.authProxyBaseURL = authProxyBaseURL 37 40 } 38 41 } 39 42 ··· 43 46 public let storeLogin: @Sendable (Login) async throws -> Void 44 47 public let retrievePrivateKey: @Sendable () async throws -> Data? 45 48 public let storePrivateKey: @Sendable (Data) async throws -> Void 49 + public let retrieveAuthProxyKeyID: (@Sendable () async throws -> String?)? 50 + public let storeAuthProxyKeyID: (@Sendable (String) async throws -> Void)? 46 51 47 52 public init( 48 53 retrieveLogin: @escaping @Sendable () async throws -> Login?, 49 54 storeLogin: @escaping @Sendable (Login) async throws -> Void, 50 55 retrievePrivateKey: @escaping @Sendable () async throws -> Data?, 51 - storePrivateKey: @escaping @Sendable (Data) async throws -> Void 56 + storePrivateKey: @escaping @Sendable (Data) async throws -> Void, 57 + retrieveAuthProxyKeyID: (@Sendable () async throws -> String?)? = nil, 58 + storeAuthProxyKeyID: (@Sendable (String) async throws -> Void)? = nil 52 59 ) { 53 60 self.retrieveLogin = retrieveLogin 54 61 self.storeLogin = storeLogin 55 62 self.retrievePrivateKey = retrievePrivateKey 56 63 self.storePrivateKey = storePrivateKey 64 + self.retrieveAuthProxyKeyID = retrieveAuthProxyKeyID 65 + self.storeAuthProxyKeyID = storeAuthProxyKeyID 57 66 } 58 67 } 59 68 ··· 221 230 try await self.generateJWT(params: params) 222 231 } 223 232 224 - // Step 7: Create authenticator 233 + // Step 7: Create proxy URL loader if auth proxy is configured 234 + let proxyKeyIDStorage: AuthProxyKeyIDStorage? 235 + let proxyURLLoader: URLResponseProvider? 236 + 237 + if let proxyBaseURL = config.authProxyBaseURL { 238 + let initialKeyID = try? await storage.retrieveAuthProxyKeyID?() 239 + let keyIDStorage = AuthProxyKeyIDStorage(keyID: initialKeyID) 240 + proxyKeyIDStorage = keyIDStorage 241 + proxyURLLoader = makeProxyURLLoader( 242 + proxyBaseURL: proxyBaseURL, 243 + tokenEndpoint: serverConfig.tokenEndpoint, 244 + parEndpoint: serverConfig.pushedAuthorizationRequestEndpoint, 245 + issuer: serverConfig.issuer, 246 + keyIDStorage: keyIDStorage 247 + ) 248 + } else { 249 + proxyKeyIDStorage = nil 250 + proxyURLLoader = nil 251 + } 252 + 253 + // Step 8: Create authenticator 225 254 let tokenHandling = buildTokenHandling( 226 255 accountHint: identifier, 227 256 server: serverConfig, ··· 238 267 userAuthenticator: userAuthenticator 239 268 ) 240 269 241 - let authenticator = Authenticator(config: authenticatorConfig) 270 + let authenticator = Authenticator(config: authenticatorConfig, urlLoader: proxyURLLoader) 242 271 243 - // Step 8: Trigger authentication with user interaction 272 + // Step 9: Trigger authentication with user interaction 244 273 let login: Login 245 274 do { 246 275 login = try await authenticator.authenticate() ··· 258 287 } 259 288 } 260 289 261 - // Step 9: Setup CoreATProtocol environment 290 + // Step 10: Persist auth proxy key ID 291 + if let proxyKeyIDStorage, let keyID = await proxyKeyIDStorage.keyID { 292 + try? await storage.storeAuthProxyKeyID?(keyID) 293 + } 294 + 295 + // Step 11: Setup CoreATProtocol environment 262 296 setup( 263 297 hostURL: identity.pdsEndpoint, 264 298 accessJWT: login.accessToken.value, ··· 345 379 return nil 346 380 } 347 381 382 + // Use proxy-aware provider when auth proxy is configured 383 + let baseProvider: URLResponseProvider 384 + let proxyKeyIDStorage: AuthProxyKeyIDStorage? 385 + 386 + if let proxyBaseURL = config.authProxyBaseURL { 387 + let initialKeyID = try? await storage.retrieveAuthProxyKeyID?() 388 + let keyIDStorage = AuthProxyKeyIDStorage(keyID: initialKeyID) 389 + proxyKeyIDStorage = keyIDStorage 390 + baseProvider = makeProxyURLLoader( 391 + proxyBaseURL: proxyBaseURL, 392 + tokenEndpoint: serverConfig.tokenEndpoint, 393 + parEndpoint: serverConfig.pushedAuthorizationRequestEndpoint, 394 + issuer: serverConfig.issuer, 395 + keyIDStorage: keyIDStorage 396 + ) 397 + } else { 398 + baseProvider = provider 399 + proxyKeyIDStorage = nil 400 + } 401 + 348 402 let responseProvider: URLResponseProvider = { request in 349 403 try await self.dpopRequestActor.response( 350 404 request: request, 351 405 jwtGenerator: jwtGenerator, 352 - provider: provider, 406 + provider: baseProvider, 353 407 issuingServer: issuer 354 408 ) 355 409 } 356 410 357 411 let refreshedLogin = try await refreshProvider(login, clientConfig.credentials, responseProvider) 358 412 try await storage.storeLogin(refreshedLogin) 413 + 414 + // Persist updated auth proxy key ID 415 + if let proxyKeyIDStorage, let keyID = await proxyKeyIDStorage.keyID { 416 + try? await storage.storeAuthProxyKeyID?(keyID) 417 + } 418 + 359 419 return refreshedLogin 360 420 } 361 421
+168
Sources/CoreATProtocol/OAuth/AuthProxy.swift
··· 1 + import Foundation 2 + import OAuthenticator 3 + 4 + // MARK: - Auth Proxy Request Models 5 + 6 + /// Request body for proxying token exchange/refresh through the auth proxy. 7 + struct AuthProxyTokenRequest: Encodable { 8 + let tokenEndpoint: String 9 + let issuer: String 10 + let grantType: String 11 + let code: String? 12 + let redirectURI: String? 13 + let codeVerifier: String? 14 + let refreshToken: String? 15 + let keyID: String? 16 + 17 + enum CodingKeys: String, CodingKey { 18 + case tokenEndpoint = "token_endpoint" 19 + case issuer 20 + case grantType = "grant_type" 21 + case code 22 + case redirectURI = "redirect_uri" 23 + case codeVerifier = "code_verifier" 24 + case refreshToken = "refresh_token" 25 + case keyID = "key_id" 26 + } 27 + } 28 + 29 + /// Request body for proxying PAR requests through the auth proxy. 30 + struct AuthProxyPARRequest: Encodable { 31 + let parEndpoint: String 32 + let issuer: String 33 + let keyID: String? 34 + let loginHint: String? 35 + let scope: String 36 + let codeChallenge: String 37 + let codeChallengeMethod: String 38 + let state: String 39 + let redirectURI: String 40 + 41 + enum CodingKeys: String, CodingKey { 42 + case parEndpoint = "par_endpoint" 43 + case issuer 44 + case keyID = "key_id" 45 + case loginHint = "login_hint" 46 + case scope 47 + case codeChallenge = "code_challenge" 48 + case codeChallengeMethod = "code_challenge_method" 49 + case state 50 + case redirectURI = "redirect_uri" 51 + } 52 + } 53 + 54 + // MARK: - Key ID Storage 55 + 56 + /// Thread-safe storage for the auth proxy key ID, used during a single auth flow. 57 + actor AuthProxyKeyIDStorage { 58 + var keyID: String? 59 + 60 + init(keyID: String? = nil) { 61 + self.keyID = keyID 62 + } 63 + 64 + func update(_ newKeyID: String) { 65 + keyID = newKeyID 66 + } 67 + } 68 + 69 + // MARK: - Proxy URL Loader 70 + 71 + /// Creates a `URLResponseProvider` that intercepts requests to the auth server's PAR and token 72 + /// endpoints, redirecting them through the auth proxy. DPoP proofs are generated by the caller 73 + /// for the real endpoint URLs and forwarded unchanged — the proxy is transparent to DPoP. 74 + func makeProxyURLLoader( 75 + proxyBaseURL: String, 76 + tokenEndpoint: String, 77 + parEndpoint: String, 78 + issuer: String, 79 + keyIDStorage: AuthProxyKeyIDStorage 80 + ) -> URLResponseProvider { 81 + { request in 82 + guard let requestURL = request.url?.absoluteString else { 83 + return try await URLSession.shared.data(for: request) 84 + } 85 + 86 + let isTokenRequest = requestURL == tokenEndpoint 87 + let isPARRequest = requestURL == parEndpoint 88 + 89 + guard isTokenRequest || isPARRequest else { 90 + return try await URLSession.shared.data(for: request) 91 + } 92 + 93 + let proxyPath = isTokenRequest ? "/oauth/token" : "/oauth/par" 94 + guard let proxyURL = URL(string: proxyBaseURL + proxyPath) else { 95 + return try await URLSession.shared.data(for: request) 96 + } 97 + 98 + var proxyRequest = URLRequest(url: proxyURL) 99 + proxyRequest.httpMethod = "POST" 100 + proxyRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 101 + proxyRequest.setValue("application/json", forHTTPHeaderField: "Accept") 102 + 103 + // Forward DPoP header (already generated for the real endpoint URL) 104 + if let dpop = request.value(forHTTPHeaderField: "DPoP") { 105 + proxyRequest.setValue(dpop, forHTTPHeaderField: "DPoP") 106 + } 107 + 108 + let currentKeyID = await keyIDStorage.keyID 109 + 110 + if isPARRequest { 111 + // Convert form-encoded PAR body to JSON for the proxy 112 + let formParams = parseFormEncoded(request.httpBody) 113 + let parBody = AuthProxyPARRequest( 114 + parEndpoint: parEndpoint, 115 + issuer: issuer, 116 + keyID: currentKeyID, 117 + loginHint: formParams["login_hint"], 118 + scope: formParams["scope"] ?? "", 119 + codeChallenge: formParams["code_challenge"] ?? "", 120 + codeChallengeMethod: formParams["code_challenge_method"] ?? "", 121 + state: formParams["state"] ?? "", 122 + redirectURI: formParams["redirect_uri"] ?? "" 123 + ) 124 + proxyRequest.httpBody = try JSONEncoder().encode(parBody) 125 + } else { 126 + // Token request: add proxy-specific fields to the existing JSON body 127 + var bodyDict: [String: Any] = [:] 128 + if let body = request.httpBody { 129 + bodyDict = (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) ?? [:] 130 + } 131 + bodyDict["token_endpoint"] = tokenEndpoint 132 + bodyDict["issuer"] = issuer 133 + if let keyID = currentKeyID { 134 + bodyDict["key_id"] = keyID 135 + } 136 + proxyRequest.httpBody = try JSONSerialization.data(withJSONObject: bodyDict) 137 + } 138 + 139 + let (data, response) = try await URLSession.shared.data(for: proxyRequest) 140 + 141 + // Capture Auth-Proxy-Key-ID from every proxy response 142 + if let httpResponse = response as? HTTPURLResponse, 143 + let newKeyID = httpResponse.value(forHTTPHeaderField: "Auth-Proxy-Key-ID") { 144 + await keyIDStorage.update(newKeyID) 145 + } 146 + 147 + return (data, response) 148 + } 149 + } 150 + 151 + // MARK: - Helpers 152 + 153 + /// Parses a `application/x-www-form-urlencoded` body into a dictionary. 154 + func parseFormEncoded(_ data: Data?) -> [String: String] { 155 + guard let data, let string = String(data: data, encoding: .utf8) else { 156 + return [:] 157 + } 158 + 159 + var result: [String: String] = [:] 160 + for pair in string.split(separator: "&") { 161 + let parts = pair.split(separator: "=", maxSplits: 1) 162 + guard parts.count == 2, 163 + let key = parts[0].removingPercentEncoding, 164 + let value = parts[1].removingPercentEncoding else { continue } 165 + result[key] = value 166 + } 167 + return result 168 + }
+198
Tests/CoreATProtocolTests/OAuthTests.swift
··· 74 74 } 75 75 } 76 76 77 + @Suite("Auth Proxy Models") 78 + struct AuthProxyTests { 79 + 80 + @Test("ATProtoOAuthConfig with auth proxy base URL") 81 + func testConfigWithProxy() { 82 + let config = ATProtoOAuthConfig( 83 + clientMetadataURL: "https://example.com/client-metadata.json", 84 + redirectURI: "example://callback", 85 + authProxyBaseURL: "https://auth.example.com" 86 + ) 87 + 88 + #expect(config.authProxyBaseURL == "https://auth.example.com") 89 + } 90 + 91 + @Test("ATProtoOAuthConfig defaults to nil auth proxy base URL") 92 + func testConfigWithoutProxy() { 93 + let config = ATProtoOAuthConfig( 94 + clientMetadataURL: "https://example.com/client-metadata.json", 95 + redirectURI: "example://callback" 96 + ) 97 + 98 + #expect(config.authProxyBaseURL == nil) 99 + } 100 + 101 + @Test("AuthProxyTokenRequest encodes correctly for authorization code") 102 + func testTokenRequestEncoding() throws { 103 + let request = AuthProxyTokenRequest( 104 + tokenEndpoint: "https://bsky.social/oauth/token", 105 + issuer: "https://bsky.social", 106 + grantType: "authorization_code", 107 + code: "test_code", 108 + redirectURI: "example://callback", 109 + codeVerifier: "test_verifier", 110 + refreshToken: nil, 111 + keyID: "atproto-auth-1" 112 + ) 113 + 114 + let data = try JSONEncoder().encode(request) 115 + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] 116 + 117 + #expect(json?["token_endpoint"] as? String == "https://bsky.social/oauth/token") 118 + #expect(json?["issuer"] as? String == "https://bsky.social") 119 + #expect(json?["grant_type"] as? String == "authorization_code") 120 + #expect(json?["code"] as? String == "test_code") 121 + #expect(json?["redirect_uri"] as? String == "example://callback") 122 + #expect(json?["code_verifier"] as? String == "test_verifier") 123 + #expect(json?["key_id"] as? String == "atproto-auth-1") 124 + #expect(json?["refresh_token"] == nil) 125 + } 126 + 127 + @Test("AuthProxyTokenRequest encodes correctly for refresh") 128 + func testRefreshTokenRequestEncoding() throws { 129 + let request = AuthProxyTokenRequest( 130 + tokenEndpoint: "https://bsky.social/oauth/token", 131 + issuer: "https://bsky.social", 132 + grantType: "refresh_token", 133 + code: nil, 134 + redirectURI: nil, 135 + codeVerifier: nil, 136 + refreshToken: "test_refresh_token", 137 + keyID: "atproto-auth-2" 138 + ) 139 + 140 + let data = try JSONEncoder().encode(request) 141 + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] 142 + 143 + #expect(json?["grant_type"] as? String == "refresh_token") 144 + #expect(json?["refresh_token"] as? String == "test_refresh_token") 145 + #expect(json?["key_id"] as? String == "atproto-auth-2") 146 + #expect(json?["code"] == nil) 147 + } 148 + 149 + @Test("AuthProxyPARRequest encodes correctly") 150 + func testPARRequestEncoding() throws { 151 + let request = AuthProxyPARRequest( 152 + parEndpoint: "https://bsky.social/oauth/par", 153 + issuer: "https://bsky.social", 154 + keyID: "atproto-auth-1", 155 + loginHint: "alice.bsky.social", 156 + scope: "atproto transition:generic", 157 + codeChallenge: "test_challenge", 158 + codeChallengeMethod: "S256", 159 + state: "test_state", 160 + redirectURI: "example://callback" 161 + ) 162 + 163 + let data = try JSONEncoder().encode(request) 164 + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] 165 + 166 + #expect(json?["par_endpoint"] as? String == "https://bsky.social/oauth/par") 167 + #expect(json?["issuer"] as? String == "https://bsky.social") 168 + #expect(json?["key_id"] as? String == "atproto-auth-1") 169 + #expect(json?["login_hint"] as? String == "alice.bsky.social") 170 + #expect(json?["scope"] as? String == "atproto transition:generic") 171 + #expect(json?["code_challenge"] as? String == "test_challenge") 172 + #expect(json?["code_challenge_method"] as? String == "S256") 173 + #expect(json?["state"] as? String == "test_state") 174 + #expect(json?["redirect_uri"] as? String == "example://callback") 175 + } 176 + 177 + @Test("AuthProxyPARRequest encodes nil key ID and login hint") 178 + func testPARRequestWithNils() throws { 179 + let request = AuthProxyPARRequest( 180 + parEndpoint: "https://bsky.social/oauth/par", 181 + issuer: "https://bsky.social", 182 + keyID: nil, 183 + loginHint: nil, 184 + scope: "atproto", 185 + codeChallenge: "challenge", 186 + codeChallengeMethod: "S256", 187 + state: "state", 188 + redirectURI: "example://callback" 189 + ) 190 + 191 + let data = try JSONEncoder().encode(request) 192 + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] 193 + 194 + #expect(json?["key_id"] == nil) 195 + #expect(json?["login_hint"] == nil) 196 + } 197 + 198 + @Test("parseFormEncoded handles standard form body") 199 + func testParseFormEncoded() { 200 + let body = "client_id=test&scope=atproto&redirect_uri=example%3A%2F%2Fcallback&state=abc123" 201 + let result = parseFormEncoded(body.data(using: .utf8)) 202 + 203 + #expect(result["client_id"] == "test") 204 + #expect(result["scope"] == "atproto") 205 + #expect(result["redirect_uri"] == "example://callback") 206 + #expect(result["state"] == "abc123") 207 + } 208 + 209 + @Test("parseFormEncoded handles empty and nil data") 210 + func testParseFormEncodedEdgeCases() { 211 + #expect(parseFormEncoded(nil).isEmpty) 212 + #expect(parseFormEncoded(Data()).isEmpty) 213 + } 214 + 215 + @Test("AuthProxyKeyIDStorage stores and retrieves key ID") 216 + func testKeyIDStorage() async { 217 + let storage = AuthProxyKeyIDStorage() 218 + let initial = await storage.keyID 219 + #expect(initial == nil) 220 + 221 + await storage.update("key-1") 222 + let updated = await storage.keyID 223 + #expect(updated == "key-1") 224 + 225 + await storage.update("key-2") 226 + let rotated = await storage.keyID 227 + #expect(rotated == "key-2") 228 + } 229 + 230 + @Test("AuthProxyKeyIDStorage initializes with existing key ID") 231 + func testKeyIDStorageWithInitialValue() async { 232 + let storage = AuthProxyKeyIDStorage(keyID: "existing-key") 233 + let value = await storage.keyID 234 + #expect(value == "existing-key") 235 + } 236 + 237 + @Test("ATProtoAuthStorage accepts auth proxy key ID closures") 238 + func testStorageWithProxyKeyID() async throws { 239 + let keyIDHolder = AuthProxyKeyIDStorage() 240 + 241 + let storage = ATProtoAuthStorage( 242 + retrieveLogin: { nil }, 243 + storeLogin: { _ in }, 244 + retrievePrivateKey: { nil }, 245 + storePrivateKey: { _ in }, 246 + retrieveAuthProxyKeyID: { await keyIDHolder.keyID }, 247 + storeAuthProxyKeyID: { await keyIDHolder.update($0) } 248 + ) 249 + 250 + #expect(storage.retrieveAuthProxyKeyID != nil) 251 + #expect(storage.storeAuthProxyKeyID != nil) 252 + 253 + let initial = try await storage.retrieveAuthProxyKeyID?() 254 + #expect(initial == nil) 255 + 256 + try await storage.storeAuthProxyKeyID?("test-key") 257 + let retrieved = try await storage.retrieveAuthProxyKeyID?() 258 + #expect(retrieved == "test-key") 259 + } 260 + 261 + @Test("ATProtoAuthStorage defaults proxy key ID closures to nil") 262 + func testStorageWithoutProxyKeyID() { 263 + let storage = ATProtoAuthStorage( 264 + retrieveLogin: { nil }, 265 + storeLogin: { _ in }, 266 + retrievePrivateKey: { nil }, 267 + storePrivateKey: { _ in } 268 + ) 269 + 270 + #expect(storage.retrieveAuthProxyKeyID == nil) 271 + #expect(storage.storeAuthProxyKeyID == nil) 272 + } 273 + } 274 + 77 275 @Suite("DPoP JWT") 78 276 struct DPoPTests { 79 277