this repo has no description
2
fork

Configure Feed

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

stability

+491 -40
+38 -12
Sources/CoreATProtocol/Networking.swift
··· 9 9 10 10 extension JSONDecoder { 11 11 public static var atDecoder: JSONDecoder { 12 - let dateFormatter = DateFormatter() 13 - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX" 14 - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 15 - dateFormatter.locale = Locale(identifier: "en_US") 16 - 17 12 let decoder = JSONDecoder() 18 13 decoder.keyDecodingStrategy = .convertFromSnakeCase 19 - decoder.dateDecodingStrategy = .formatted(dateFormatter) 14 + decoder.dateDecodingStrategy = .custom { decoder in 15 + let container = try decoder.singleValueContainer() 16 + let dateString = try container.decode(String.self) 17 + 18 + for formatter in DateParser.formatters { 19 + if let date = formatter.date(from: dateString) { 20 + return date 21 + } 22 + } 23 + 24 + throw DecodingError.dataCorrupted( 25 + DecodingError.Context( 26 + codingPath: decoder.codingPath, 27 + debugDescription: "Invalid atproto datetime: \(dateString)" 28 + ) 29 + ) 30 + } 20 31 21 32 return decoder 22 33 } ··· 32 43 33 44 @APActor 34 45 public class APRouterDelegate: NetworkRouterDelegate { 35 - private var shouldRefreshToken = false 36 46 private var refreshTask: Task<Bool, Error>? 37 47 38 48 public func intercept(_ request: inout URLRequest) async { 39 49 if APEnvironment.current.dpopPrivateKey != nil { 40 50 return 41 51 } 42 - 43 - if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { 44 - shouldRefreshToken = false 45 - request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") 46 - } else if let accessToken = APEnvironment.current.accessToken { 52 + if let accessToken = APEnvironment.current.accessToken { 47 53 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 48 54 } 49 55 } ··· 83 89 return false 84 90 } 85 91 } 92 + 93 + private enum DateParser { 94 + static let formatters: [DateFormatter] = { 95 + let formats = [ 96 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", 97 + "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", 98 + "yyyy-MM-dd'T'HH:mm:ssXXXXX", 99 + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", 100 + "yyyy-MM-dd'T'HH:mm:ss'Z'", 101 + ] 102 + 103 + return formats.map { format in 104 + let formatter = DateFormatter() 105 + formatter.dateFormat = format 106 + formatter.locale = Locale(identifier: "en_US_POSIX") 107 + formatter.timeZone = TimeZone(secondsFromGMT: 0) 108 + return formatter 109 + } 110 + }() 111 + }
+313 -10
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 62 62 case authenticationFailed(String) 63 63 case identityResolutionFailed 64 64 case privateKeyExportFailed 65 + case missingRequiredScope(String) 66 + case subjectMismatch(expected: String, actual: String) 67 + case issuerMismatch(expected: String, actual: String) 68 + case malformedAuthorizationCallback 69 + case tokenRequestFailed(String) 70 + case invalidTokenResponse 65 71 } 66 72 67 73 /// Type alias for the user authenticator callback ··· 124 130 handle: String, 125 131 userAuthenticator: @escaping UserAuthenticator 126 132 ) async throws -> ATProtoAuthResult { 133 + try await authenticate(identifier: handle, userAuthenticator: userAuthenticator) 134 + } 135 + 136 + /// Authenticate user by handle or DID. 137 + /// - Parameters: 138 + /// - identifier: The user's handle or DID. 139 + /// - userAuthenticator: Callback to present the authorization URL and return the callback URL. 140 + /// - Returns: Authentication result with tokens and user info. 141 + public func authenticate( 142 + identifier: String, 143 + userAuthenticator: @escaping UserAuthenticator 144 + ) async throws -> ATProtoAuthResult { 127 145 // Step 1: Resolve identity 128 146 let identity: IdentityResolver.ResolvedIdentity 129 147 do { 130 - identity = try await identityResolver.resolve(handle: handle) 148 + identity = try await identityResolver.resolve(identifier: identifier) 131 149 } catch { 132 150 throw ATProtoOAuthError.authenticationFailed("Identity resolution failed: \(error.localizedDescription)") 133 151 } ··· 158 176 throw ATProtoOAuthError.authenticationFailed("Failed to load server metadata from \(identity.authServerHost): \(error.localizedDescription)") 159 177 } 160 178 179 + try Self.validateIssuer(serverConfig.issuer, matches: identity.authorizationServer) 180 + let isIssuerValidForPDS = try await identityResolver.isAuthorizationServer( 181 + serverConfig.issuer, 182 + validFor: identity.pdsEndpoint 183 + ) 184 + guard isIssuerValidForPDS else { 185 + throw ATProtoOAuthError.authenticationFailed("Authorization server issuer is not valid for resolved PDS.") 186 + } 187 + 161 188 // Step 5: Create login storage 162 189 let loginStorage = LoginStorage( 163 190 retrieveLogin: storage.retrieveLogin, ··· 170 197 } 171 198 172 199 // Step 7: Create authenticator 173 - let tokenHandling = ATProto.tokenHandling( 174 - account: handle, 200 + let tokenHandling = buildTokenHandling( 201 + accountHint: identifier, 175 202 server: serverConfig, 176 - jwtGenerator: jwtGenerator 203 + jwtGenerator: jwtGenerator, 204 + expectedSubjectDID: identity.did, 205 + expectedAuthorizationServer: identity.authorizationServer 177 206 ) 178 207 179 208 let authenticatorConfig = Authenticator.Configuration( ··· 214 243 215 244 return ATProtoAuthResult( 216 245 did: identity.did, 217 - handle: identity.handle, 246 + handle: identity.handle ?? identifier, 218 247 accessToken: login.accessToken.value, 219 248 refreshToken: login.refreshToken?.value, 220 249 expiresIn: Int(login.accessToken.expiry?.timeIntervalSinceNow ?? 3600), ··· 224 253 225 254 /// Refresh tokens if the stored access token is expired (or if forced). 226 255 public func refreshLoginIfNeeded(handle: String? = nil, force: Bool = false) async throws -> Login? { 256 + try await refreshLoginIfNeeded(accountIdentifier: handle, force: force) 257 + } 258 + 259 + /// Refresh tokens if the stored access token is expired (or if forced). 260 + public func refreshLoginIfNeeded(accountIdentifier: String? = nil, force: Bool = false) async throws -> Login? { 227 261 guard let login = try await storage.retrieveLogin() else { 228 262 return nil 229 263 } ··· 240 274 return nil 241 275 } 242 276 277 + let resolvedIdentity: IdentityResolver.ResolvedIdentity? 278 + if let accountIdentifier { 279 + resolvedIdentity = try await identityResolver.resolve(identifier: accountIdentifier) 280 + } else { 281 + resolvedIdentity = nil 282 + } 283 + 243 284 let issuer: String 244 285 if let issuingServer = login.issuingServer { 245 286 issuer = issuingServer 246 - } else if let handle { 247 - let identity = try await identityResolver.resolve(handle: handle) 287 + } else if let identity = resolvedIdentity { 248 288 issuer = identity.authorizationServer 249 289 } else { 250 290 return nil ··· 257 297 let jwtGenerator: DPoPSigner.JWTGenerator = { [self] params in 258 298 try await self.generateJWT(params: params) 259 299 } 260 - let tokenHandling = ATProto.tokenHandling( 261 - account: handle, 300 + if let identity = resolvedIdentity { 301 + try Self.validateIssuer(issuer, matches: identity.authorizationServer) 302 + let isIssuerValidForPDS = try await identityResolver.isAuthorizationServer( 303 + issuer, 304 + validFor: identity.pdsEndpoint 305 + ) 306 + guard isIssuerValidForPDS else { 307 + throw ATProtoOAuthError.authenticationFailed("Authorization server issuer is not valid for resolved PDS.") 308 + } 309 + } 310 + 311 + let tokenHandling = buildTokenHandling( 312 + accountHint: accountIdentifier, 262 313 server: serverConfig, 263 - jwtGenerator: jwtGenerator 314 + jwtGenerator: jwtGenerator, 315 + expectedSubjectDID: resolvedIdentity?.did, 316 + expectedAuthorizationServer: resolvedIdentity?.authorizationServer ?? issuer 264 317 ) 265 318 266 319 guard let refreshProvider = tokenHandling.refreshProvider else { ··· 383 436 return false 384 437 } 385 438 } 439 + 440 + private func buildTokenHandling( 441 + accountHint: String?, 442 + server: ServerMetadata, 443 + jwtGenerator: @escaping DPoPSigner.JWTGenerator, 444 + expectedSubjectDID: String?, 445 + expectedAuthorizationServer: String 446 + ) -> TokenHandling { 447 + TokenHandling( 448 + parConfiguration: PARConfiguration( 449 + url: URL(string: server.pushedAuthorizationRequestEndpoint)!, 450 + parameters: { if let accountHint { ["login_hint": accountHint] } else { [:] } }() 451 + ), 452 + authorizationURLProvider: authorizationURLProvider(server: server), 453 + loginProvider: loginProvider( 454 + server: server, 455 + expectedSubjectDID: expectedSubjectDID, 456 + expectedAuthorizationServer: expectedAuthorizationServer 457 + ), 458 + refreshProvider: refreshProvider( 459 + server: server, 460 + expectedSubjectDID: expectedSubjectDID 461 + ), 462 + dpopJWTGenerator: jwtGenerator, 463 + pkce: PKCEVerifier() 464 + ) 465 + } 466 + 467 + private func authorizationURLProvider(server: ServerMetadata) -> TokenHandling.AuthorizationURLProvider { 468 + { params in 469 + guard let parRequestURI = params.parRequestURI else { 470 + throw AuthenticatorError.parRequestURIMissing 471 + } 472 + 473 + var components = URLComponents(string: server.authorizationEndpoint) 474 + components?.queryItems = [ 475 + URLQueryItem(name: "request_uri", value: parRequestURI), 476 + URLQueryItem(name: "client_id", value: params.credentials.clientId), 477 + ] 478 + 479 + guard let url = components?.url else { 480 + throw AuthenticatorError.missingAuthorizationURL 481 + } 482 + return url 483 + } 484 + } 485 + 486 + private func loginProvider( 487 + server: ServerMetadata, 488 + expectedSubjectDID: String?, 489 + expectedAuthorizationServer: String 490 + ) -> TokenHandling.LoginProvider { 491 + { params in 492 + guard let redirectComponents = URLComponents(url: params.redirectURL, resolvingAgainstBaseURL: false) else { 493 + throw ATProtoOAuthError.malformedAuthorizationCallback 494 + } 495 + 496 + guard 497 + let authCode = redirectComponents.queryItems?.first(where: { $0.name == "code" })?.value, 498 + let iss = redirectComponents.queryItems?.first(where: { $0.name == "iss" })?.value, 499 + let state = redirectComponents.queryItems?.first(where: { $0.name == "state" })?.value 500 + else { 501 + throw ATProtoOAuthError.malformedAuthorizationCallback 502 + } 503 + 504 + if state != params.stateToken { 505 + throw AuthenticatorError.stateTokenMismatch(state, params.stateToken) 506 + } 507 + 508 + guard let tokenURL = URL(string: server.tokenEndpoint) else { 509 + throw AuthenticatorError.missingTokenURL 510 + } 511 + guard let verifier = params.pcke?.verifier else { 512 + throw AuthenticatorError.pkceRequired 513 + } 514 + 515 + let tokenRequest = OAuthTokenRequest( 516 + code: authCode, 517 + codeVerifier: verifier, 518 + redirectURI: params.credentials.callbackURL.absoluteString, 519 + grantType: "authorization_code", 520 + clientID: params.credentials.clientId 521 + ) 522 + 523 + var request = URLRequest(url: tokenURL) 524 + request.httpMethod = "POST" 525 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 526 + request.setValue("application/json", forHTTPHeaderField: "Accept") 527 + request.httpBody = try JSONEncoder().encode(tokenRequest) 528 + 529 + let (data, response) = try await params.responseProvider(request) 530 + guard let httpResponse = response as? HTTPURLResponse else { 531 + throw AuthenticatorError.httpResponseExpected 532 + } 533 + guard (200..<300).contains(httpResponse.statusCode) else { 534 + throw ATProtoOAuthError.tokenRequestFailed(String(decoding: data, as: UTF8.self)) 535 + } 536 + 537 + let tokenResponse = try Self.decodeTokenResponse(from: data) 538 + guard tokenResponse.tokenType == "DPoP" else { 539 + throw AuthenticatorError.dpopTokenExpected(tokenResponse.tokenType) 540 + } 541 + guard tokenResponse.scopes.contains("atproto") else { 542 + throw ATProtoOAuthError.missingRequiredScope("atproto") 543 + } 544 + 545 + if iss != server.issuer { 546 + throw AuthenticatorError.issuingServerMismatch(iss, server.issuer) 547 + } 548 + try Self.validateIssuer(iss, matches: expectedAuthorizationServer) 549 + 550 + if let expectedSubjectDID, tokenResponse.subject != expectedSubjectDID { 551 + throw ATProtoOAuthError.subjectMismatch(expected: expectedSubjectDID, actual: tokenResponse.subject) 552 + } 553 + 554 + return tokenResponse.login(for: iss) 555 + } 556 + } 557 + 558 + private func refreshProvider( 559 + server: ServerMetadata, 560 + expectedSubjectDID: String? 561 + ) -> TokenHandling.RefreshProvider { 562 + { login, credentials, responseProvider in 563 + guard let refreshToken = login.refreshToken?.value else { 564 + throw AuthenticatorError.refreshNotPossible 565 + } 566 + guard let tokenURL = URL(string: server.tokenEndpoint) else { 567 + throw AuthenticatorError.missingTokenURL 568 + } 569 + 570 + let tokenRequest = OAuthRefreshTokenRequest( 571 + refreshToken: refreshToken, 572 + redirectURI: credentials.callbackURL.absoluteString, 573 + grantType: "refresh_token", 574 + clientID: credentials.clientId 575 + ) 576 + 577 + var request = URLRequest(url: tokenURL) 578 + request.httpMethod = "POST" 579 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 580 + request.httpBody = try JSONEncoder().encode(tokenRequest) 581 + 582 + let (data, response) = try await responseProvider(request) 583 + guard let httpResponse = response as? HTTPURLResponse, 584 + (200..<300).contains(httpResponse.statusCode) else { 585 + throw AuthenticatorError.refreshNotPossible 586 + } 587 + 588 + let tokenResponse = try Self.decodeTokenResponse(from: data) 589 + guard tokenResponse.tokenType == "DPoP" else { 590 + throw AuthenticatorError.dpopTokenExpected(tokenResponse.tokenType) 591 + } 592 + guard tokenResponse.scopes.contains("atproto") else { 593 + throw ATProtoOAuthError.missingRequiredScope("atproto") 594 + } 595 + 596 + if let expectedSubjectDID, tokenResponse.subject != expectedSubjectDID { 597 + throw ATProtoOAuthError.subjectMismatch(expected: expectedSubjectDID, actual: tokenResponse.subject) 598 + } 599 + 600 + return tokenResponse.login(for: login.issuingServer ?? server.issuer) 601 + } 602 + } 603 + 604 + nonisolated private static func decodeTokenResponse(from data: Data) throws -> OAuthTokenResponse { 605 + do { 606 + return try JSONDecoder().decode(OAuthTokenResponse.self, from: data) 607 + } catch { 608 + throw ATProtoOAuthError.invalidTokenResponse 609 + } 610 + } 611 + 612 + nonisolated private static func validateIssuer(_ issuer: String, matches expectedAuthorizationServer: String) throws { 613 + guard let issuerURL = URL(string: issuer), 614 + let expectedURL = URL(string: expectedAuthorizationServer), 615 + normalizedOrigin(issuerURL) == normalizedOrigin(expectedURL) else { 616 + throw ATProtoOAuthError.issuerMismatch(expected: expectedAuthorizationServer, actual: issuer) 617 + } 618 + } 619 + 620 + nonisolated private static func normalizedOrigin(_ url: URL) -> String? { 621 + guard let scheme = url.scheme?.lowercased(), 622 + let host = url.host?.lowercased() else { 623 + return nil 624 + } 625 + let port = url.port.map { ":\($0)" } ?? "" 626 + return "\(scheme)://\(host)\(port)" 627 + } 386 628 } 387 629 388 630 // MARK: - DPoP Payload (from AtProto.swift lines 88-98) ··· 429 671 } 430 672 } 431 673 } 674 + 675 + private struct OAuthTokenRequest: Codable { 676 + let code: String 677 + let codeVerifier: String 678 + let redirectURI: String 679 + let grantType: String 680 + let clientID: String 681 + 682 + enum CodingKeys: String, CodingKey { 683 + case code 684 + case codeVerifier = "code_verifier" 685 + case redirectURI = "redirect_uri" 686 + case grantType = "grant_type" 687 + case clientID = "client_id" 688 + } 689 + } 690 + 691 + private struct OAuthRefreshTokenRequest: Codable { 692 + let refreshToken: String 693 + let redirectURI: String 694 + let grantType: String 695 + let clientID: String 696 + 697 + enum CodingKeys: String, CodingKey { 698 + case refreshToken = "refresh_token" 699 + case redirectURI = "redirect_uri" 700 + case grantType = "grant_type" 701 + case clientID = "client_id" 702 + } 703 + } 704 + 705 + private struct OAuthTokenResponse: Codable { 706 + let accessToken: String 707 + let refreshToken: String? 708 + let subject: String 709 + let scope: String 710 + let tokenType: String 711 + let expiresIn: Int 712 + 713 + enum CodingKeys: String, CodingKey { 714 + case accessToken = "access_token" 715 + case refreshToken = "refresh_token" 716 + case subject = "sub" 717 + case scope 718 + case tokenType = "token_type" 719 + case expiresIn = "expires_in" 720 + } 721 + 722 + var scopes: Set<String> { 723 + Set(scope.split(separator: " ").map(String.init)) 724 + } 725 + 726 + func login(for issuingServer: String) -> Login { 727 + Login( 728 + accessToken: Token(value: accessToken, expiresIn: expiresIn), 729 + refreshToken: refreshToken.map { Token(value: $0) }, 730 + scopes: scope, 731 + issuingServer: issuingServer 732 + ) 733 + } 734 + }
+140 -18
Sources/CoreATProtocol/OAuth/IdentityResolver.swift
··· 4 4 case invalidHandle 5 5 case invalidDID 6 6 case resolutionFailed 7 + case invalidResponse 7 8 case noPDSFound 8 9 case noAuthServerFound 10 + case handleVerificationFailed 9 11 } 10 12 11 13 /// Resolves AT Protocol identities: handle -> DID -> PDS -> Auth Server ··· 13 15 public struct IdentityResolver: Sendable { 14 16 15 17 public struct ResolvedIdentity: Sendable { 16 - public let handle: String 18 + public let handle: String? 17 19 public let did: String 18 20 public let pdsEndpoint: String 19 21 public let authorizationServer: String 20 22 21 23 /// Server hostname for OAuthenticator's ServerMetadata.load() 22 24 public var authServerHost: String { 23 - if authorizationServer.hasPrefix("https://") { 24 - return String(authorizationServer.dropFirst(8)) 25 - } else if authorizationServer.hasPrefix("http://") { 26 - return String(authorizationServer.dropFirst(7)) 25 + if let url = URL(string: authorizationServer), 26 + let host = url.host { 27 + return host 27 28 } 28 29 return authorizationServer 29 30 } ··· 31 32 32 33 public init() {} 33 34 35 + /// Full resolution from either handle or DID. 36 + public func resolve(identifier: String) async throws -> ResolvedIdentity { 37 + if identifier.hasPrefix("did:") { 38 + return try await resolve(did: identifier) 39 + } 40 + return try await resolve(handle: identifier) 41 + } 42 + 34 43 /// Full resolution: handle -> all identity info needed for OAuth 35 44 public func resolve(handle: String) async throws -> ResolvedIdentity { 36 45 let cleanHandle = handle.replacingOccurrences(of: "@", with: "") 46 + guard !cleanHandle.isEmpty else { 47 + throw IdentityError.invalidHandle 48 + } 37 49 38 50 // Step 1: Handle -> DID 39 51 let did = try await resolveHandle(cleanHandle) 40 52 41 - // Step 2: DID -> PDS 42 - let pds = try await resolvePDS(did: did) 53 + // Step 2: DID document and bidirectional handle verification 54 + let document = try await resolveDIDDocument(did: did) 55 + try verifyHandle(cleanHandle, in: document) 43 56 44 - // Step 3: PDS -> Auth Server 57 + // Step 3: DID -> PDS 58 + let pds = try pdsEndpoint(from: document) 59 + 60 + // Step 4: PDS -> Auth Server 45 61 let authServer = try await discoverAuthServer(pdsURL: pds) 46 62 47 63 return ResolvedIdentity( ··· 52 68 ) 53 69 } 54 70 71 + /// Full resolution from DID. 72 + public func resolve(did: String) async throws -> ResolvedIdentity { 73 + guard did.hasPrefix("did:") else { 74 + throw IdentityError.invalidDID 75 + } 76 + 77 + let document = try await resolveDIDDocument(did: did) 78 + let pds = try pdsEndpoint(from: document) 79 + let authServer = try await discoverAuthServer(pdsURL: pds) 80 + 81 + return ResolvedIdentity( 82 + handle: handleFromDIDDocument(document), 83 + did: did, 84 + pdsEndpoint: pds, 85 + authorizationServer: authServer 86 + ) 87 + } 88 + 89 + /// Checks whether an authorization server is currently valid for the given PDS. 90 + public func isAuthorizationServer( 91 + _ authorizationServer: String, 92 + validFor pdsEndpoint: String 93 + ) async throws -> Bool { 94 + let metadata = try await protectedResourceMetadata(for: pdsEndpoint) 95 + guard let authorizationServerURL = URL(string: authorizationServer), 96 + let inputOrigin = normalizedOrigin(from: authorizationServerURL) else { 97 + return false 98 + } 99 + 100 + let allowedOrigins: [String] = metadata.authorizationServers.compactMap { value in 101 + guard let url = URL(string: value) else { return nil } 102 + return normalizedOrigin(from: url) 103 + } 104 + 105 + return allowedOrigins.contains(inputOrigin) 106 + } 107 + 55 108 // MARK: - Handle -> DID 56 109 57 110 private func resolveHandle(_ handle: String) async throws -> String { ··· 94 147 var request = URLRequest(url: dohURL) 95 148 request.setValue("application/dns-json", forHTTPHeaderField: "Accept") 96 149 97 - let (data, _) = try await URLSession.shared.data(for: request) 150 + let (data, response) = try await URLSession.shared.data(for: request) 151 + guard let httpResponse = response as? HTTPURLResponse, 152 + httpResponse.statusCode == 200 else { 153 + throw IdentityError.invalidResponse 154 + } 98 155 99 156 guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 100 157 let answers = json["Answer"] as? [[String: Any]] else { ··· 116 173 throw IdentityError.resolutionFailed 117 174 } 118 175 119 - // MARK: - DID -> PDS 176 + // MARK: - DID document 120 177 121 - private func resolvePDS(did: String) async throws -> String { 178 + private func resolveDIDDocument(did: String) async throws -> DIDDocument { 122 179 let url: URL 123 180 if did.hasPrefix("did:plc:") { 124 181 guard let plcURL = URL(string: "https://plc.directory/\(did)") else { ··· 135 192 throw IdentityError.invalidDID 136 193 } 137 194 138 - let (data, _) = try await URLSession.shared.data(from: url) 195 + let (data, response) = try await URLSession.shared.data(from: url) 196 + guard let httpResponse = response as? HTTPURLResponse, 197 + httpResponse.statusCode == 200 else { 198 + throw IdentityError.invalidResponse 199 + } 200 + 139 201 let document = try JSONDecoder().decode(DIDDocument.self, from: data) 202 + guard document.id == did else { 203 + throw IdentityError.invalidDID 204 + } 205 + return document 206 + } 140 207 208 + private func pdsEndpoint(from document: DIDDocument) throws -> String { 141 209 guard let pds = document.pdsEndpoint else { 142 210 throw IdentityError.noPDSFound 143 211 } 144 - 145 212 return pds 146 213 } 147 214 148 215 // MARK: - PDS -> Auth Server 149 216 150 217 private func discoverAuthServer(pdsURL: String) async throws -> String { 218 + let metadata = try await protectedResourceMetadata(for: pdsURL) 219 + guard let authServer = metadata.authorizationServers.first else { 220 + throw IdentityError.noAuthServerFound 221 + } 222 + guard let authServerURL = URL(string: authServer), 223 + normalizedOrigin(from: authServerURL) != nil else { 224 + throw IdentityError.noAuthServerFound 225 + } 226 + return authServer 227 + } 228 + 229 + private func protectedResourceMetadata(for pdsURL: String) async throws -> ResourceServerMetadata { 151 230 guard let metadataURL = URL(string: "\(pdsURL)/.well-known/oauth-protected-resource") else { 152 231 throw IdentityError.noAuthServerFound 153 232 } 154 233 155 - let (data, _) = try await URLSession.shared.data(from: metadataURL) 234 + let (data, response) = try await URLSession.shared.data(from: metadataURL) 235 + guard let httpResponse = response as? HTTPURLResponse, 236 + httpResponse.statusCode == 200 else { 237 + throw IdentityError.invalidResponse 238 + } 239 + 156 240 let metadata = try JSONDecoder().decode(ResourceServerMetadata.self, from: data) 241 + guard !metadata.authorizationServers.isEmpty else { 242 + throw IdentityError.noAuthServerFound 243 + } 244 + return metadata 245 + } 157 246 158 - guard let authServer = metadata.authorizationServers.first else { 159 - throw IdentityError.noAuthServerFound 247 + private func verifyHandle(_ handle: String, in document: DIDDocument) throws { 248 + guard let alsoKnownAs = document.alsoKnownAs else { 249 + throw IdentityError.handleVerificationFailed 250 + } 251 + 252 + let expected = handle.lowercased() 253 + let claimedHandles = Set(alsoKnownAs.compactMap { parseHandle(from: $0)?.lowercased() }) 254 + guard claimedHandles.contains(expected) else { 255 + throw IdentityError.handleVerificationFailed 256 + } 257 + } 258 + 259 + private func handleFromDIDDocument(_ document: DIDDocument) -> String? { 260 + document.alsoKnownAs?.compactMap(parseHandle(from:)).first 261 + } 262 + 263 + private func parseHandle(from alias: String) -> String? { 264 + if alias.hasPrefix("at://") { 265 + let handle = String(alias.dropFirst(5)) 266 + return handle.isEmpty ? nil : handle 267 + } 268 + if alias.hasPrefix("https://"), 269 + let url = URL(string: alias), 270 + let host = url.host { 271 + return host 160 272 } 273 + return nil 274 + } 161 275 162 - return authServer 276 + private func normalizedOrigin(from url: URL) -> String? { 277 + guard let scheme = url.scheme?.lowercased(), 278 + let host = url.host?.lowercased() else { 279 + return nil 280 + } 281 + let port = url.port.map { ":\($0)" } ?? "" 282 + return "\(scheme)://\(host)\(port)" 163 283 } 164 284 } 165 285 ··· 171 291 let service: [DIDService]? 172 292 173 293 var pdsEndpoint: String? { 174 - service?.first { $0.id.hasSuffix("#atproto_pds") }?.serviceEndpoint 294 + service?.first { 295 + $0.id.hasSuffix("#atproto_pds") || $0.type == "AtprotoPersonalDataServer" 296 + }?.serviceEndpoint 175 297 } 176 298 } 177 299