this repo has no description
2
fork

Configure Feed

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

even better oauth

+516 -424
+6 -30
Sources/CoreATProtocol/APEnvironment.swift
··· 3 3 // CoreATProtocol 4 4 // 5 5 6 - import JWTKit 7 - 8 6 /// Session-scoped state for a single AT Protocol session. 9 7 /// 10 8 /// Today this is a process-wide singleton accessed via ``shared``. A future ··· 23 21 public var refreshToken: String? 24 22 public var atProtocolDelegate: CoreATProtocolDelegate? 25 23 public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? 26 - public var dpopPrivateKey: ES256PrivateKey? 27 - public var dpopKeys: JWTKeyCollection? 28 24 /// Per-session signer that owns the DPoP key and the per-origin nonce 29 - /// cache (RFC 9449). Populated by ``setDPoPPrivateKey(pem:)``; production 30 - /// signing paths read from this property rather than the legacy 31 - /// ``dpopPrivateKey`` / ``dpopKeys`` / ``dpopNonceStore`` fields, which 32 - /// are retained for source compatibility until the next major release. 25 + /// cache (RFC 9449). Populated by ``setDPoPPrivateKey(pem:)``; the XRPC 26 + /// router delegate and the auth-flow `ATProtoOAuth` instance both read 27 + /// through this single source of truth. 33 28 public var dpopProofSigner: DPoPProofSigner? 34 - public let dpopNonceStore = DPoPNonceStore() 35 29 public let clockSkewStore = ClockSkewStore() 36 30 public let routerDelegate = APRouterDelegate() 37 31 ··· 39 33 40 34 /// Clears all mutable session state. Intended for test harnesses. 41 35 /// 42 - /// DPoP nonce and clock-skew stores are reset to empty; tokens, keys, host 43 - /// and delegates are nilled out. The router delegate and its coordinator 44 - /// are preserved (they hold no user-scoped state). 36 + /// Tokens, keys, host, and delegates are nilled out; the clock-skew 37 + /// observation is reset to zero. The router delegate and its 38 + /// coordinator are preserved — they hold no user-scoped state. 45 39 public func reset() async { 46 40 host = nil 47 41 accessToken = nil 48 42 refreshToken = nil 49 43 atProtocolDelegate = nil 50 44 tokenRefreshHandler = nil 51 - dpopPrivateKey = nil 52 - dpopKeys = nil 53 45 dpopProofSigner = nil 54 - await dpopNonceStore.clear() 55 46 await clockSkewStore.update(offset: 0) 56 47 } 57 48 } 58 - 59 - // MARK: - Deprecation shim 60 - // 61 - // The original name was `APEnvironment` and the singleton was `.current`. 62 - // The renames below preserve source compatibility for downstream callers 63 - // (bskyKit, EffemKit, Atprosphere) while emitting fix-its that point at the 64 - // new names. A future major release will remove these aliases. 65 - 66 - @available(*, deprecated, renamed: "ATProtoSession") 67 - public typealias APEnvironment = ATProtoSession 68 - 69 - extension ATProtoSession { 70 - @available(*, deprecated, renamed: "shared") 71 - public static var current: ATProtoSession { shared } 72 - }
+1 -13
Sources/CoreATProtocol/CoreATProtocol.swift
··· 55 55 @APActor 56 56 public func setDPoPPrivateKey(pem: String?) async throws { 57 57 guard let pem, !pem.isEmpty else { 58 - ATProtoSession.shared.dpopPrivateKey = nil 59 - ATProtoSession.shared.dpopKeys = nil 60 58 ATProtoSession.shared.dpopProofSigner = nil 61 59 return 62 60 } 63 61 64 62 let privateKey = try ES256PrivateKey(pem: pem) 65 - let signer = await DPoPProofSigner( 63 + ATProtoSession.shared.dpopProofSigner = await DPoPProofSigner( 66 64 privateKey: privateKey, 67 65 clockSkew: ATProtoSession.shared.clockSkewStore 68 66 ) 69 - ATProtoSession.shared.dpopProofSigner = signer 70 - 71 - // Backward-compat: keep the legacy fields populated until they are 72 - // removed in a future major release. Production signing paths read from 73 - // ``dpopProofSigner``; these are only here so external callers that read 74 - // the public fields keep observing a non-nil value. 75 - let keys = JWTKeyCollection() 76 - await keys.add(ecdsa: privateKey) 77 - ATProtoSession.shared.dpopPrivateKey = privateKey 78 - ATProtoSession.shared.dpopKeys = keys 79 67 } 80 68 81 69 @APActor
-23
Sources/CoreATProtocol/DPoPNonceStore.swift
··· 1 - // 2 - // DPoPNonceStore.swift 3 - // CoreATProtocol 4 - // 5 - 6 - /// Serialises reads and writes to the DPoP server-issued nonce. 7 - /// 8 - /// RFC 9449 allows a server to rotate the DPoP nonce on any response. Multiple 9 - /// in-flight requests can observe a nonce update concurrently, so a dedicated 10 - /// actor is used to keep the read/update pair ordered. 11 - public actor DPoPNonceStore { 12 - private var nonce: String? 13 - 14 - public init(nonce: String? = nil) { 15 - self.nonce = nonce 16 - } 17 - 18 - public func get() -> String? { nonce } 19 - 20 - public func update(_ nonce: String) { self.nonce = nonce } 21 - 22 - public func clear() { nonce = nil } 23 - }
-3
Sources/CoreATProtocol/Networking.swift
··· 95 95 let signer = await ATProtoSession.shared.dpopProofSigner { 96 96 await signer.cacheNonce(headerNonce, from: url) 97 97 } 98 - // Backward-compat: keep the single-slot store updated for 99 - // external readers of the public ``dpopNonceStore`` accessor. 100 - await ATProtoSession.shared.dpopNonceStore.update(headerNonce) 101 98 lastErrorHadNonceHeader = true 102 99 } else { 103 100 lastErrorHadNonceHeader = false
+24
Sources/CoreATProtocol/OAuth/ATProtoIdentityResolver.swift
··· 1 + // 2 + // ATProtoIdentityResolver.swift 3 + // CoreATProtocol 4 + // 5 + 6 + import Foundation 7 + 8 + /// The seam between the OAuth client and the identity-resolution layer. 9 + /// 10 + /// `ATProtoOAuth` depends on this protocol rather than the concrete 11 + /// ``IdentityResolver`` so tests (and, eventually, alternative resolvers 12 + /// such as a Slingshot/community DoH fallback) can be plugged in without 13 + /// touching the production OAuth code paths. 14 + /// 15 + /// `ResolvedIdentity` lives on the concrete resolver to avoid renaming an 16 + /// existing public type. A future pass may lift it to a top-level 17 + /// `ATProtoResolvedIdentity`; until then, conforming types reference the 18 + /// nested name. 19 + public protocol ATProtoIdentityResolver: Sendable { 20 + func resolve(identifier: String) async throws -> IdentityResolver.ResolvedIdentity 21 + func isAuthorizationServer(_ authorizationServer: String, validFor pdsEndpoint: String) async throws -> Bool 22 + } 23 + 24 + extension IdentityResolver: ATProtoIdentityResolver {}
+186
Sources/CoreATProtocol/OAuth/ATProtoOAuth+TokenHandling.swift
··· 1 + // 2 + // ATProtoOAuth+TokenHandling.swift 3 + // CoreATProtocol 4 + // 5 + 6 + import Foundation 7 + import OAuthenticator 8 + 9 + extension ATProtoOAuth { 10 + func buildTokenHandling( 11 + accountHint: String?, 12 + server: ServerMetadata, 13 + jwtGenerator: @escaping DPoPSigner.JWTGenerator, 14 + expectedSubjectDID: String?, 15 + expectedAuthorizationServer: String 16 + ) throws -> TokenHandling { 17 + guard let parURL = URL(string: server.pushedAuthorizationRequestEndpoint) else { 18 + throw ATProtoOAuthError.malformedServerMetadata( 19 + field: "pushed_authorization_request_endpoint", 20 + value: server.pushedAuthorizationRequestEndpoint 21 + ) 22 + } 23 + 24 + return TokenHandling( 25 + parConfiguration: PARConfiguration( 26 + url: parURL, 27 + parameters: { if let accountHint { ["login_hint": accountHint] } else { [:] } }() 28 + ), 29 + authorizationURLProvider: authorizationURLProvider(server: server), 30 + loginProvider: loginProvider( 31 + server: server, 32 + expectedSubjectDID: expectedSubjectDID, 33 + expectedAuthorizationServer: expectedAuthorizationServer 34 + ), 35 + refreshProvider: refreshProvider( 36 + server: server, 37 + expectedSubjectDID: expectedSubjectDID 38 + ), 39 + dpopJWTGenerator: jwtGenerator, 40 + pkce: PKCEVerifier() 41 + ) 42 + } 43 + 44 + private func authorizationURLProvider(server: ServerMetadata) -> TokenHandling.AuthorizationURLProvider { 45 + { params in 46 + guard let parRequestURI = params.parRequestURI else { 47 + throw AuthenticatorError.parRequestURIMissing 48 + } 49 + 50 + var components = URLComponents(string: server.authorizationEndpoint) 51 + components?.queryItems = [ 52 + URLQueryItem(name: "request_uri", value: parRequestURI), 53 + URLQueryItem(name: "client_id", value: params.credentials.clientId), 54 + ] 55 + 56 + guard let url = components?.url else { 57 + throw AuthenticatorError.missingAuthorizationURL 58 + } 59 + return url 60 + } 61 + } 62 + 63 + private func loginProvider( 64 + server: ServerMetadata, 65 + expectedSubjectDID: String?, 66 + expectedAuthorizationServer: String 67 + ) -> TokenHandling.LoginProvider { 68 + { params in 69 + guard let redirectComponents = URLComponents(url: params.redirectURL, resolvingAgainstBaseURL: false) else { 70 + throw ATProtoOAuthError.malformedAuthorizationCallback 71 + } 72 + 73 + guard 74 + let authCode = redirectComponents.queryItems?.first(where: { $0.name == "code" })?.value, 75 + let iss = redirectComponents.queryItems?.first(where: { $0.name == "iss" })?.value, 76 + let state = redirectComponents.queryItems?.first(where: { $0.name == "state" })?.value 77 + else { 78 + throw ATProtoOAuthError.malformedAuthorizationCallback 79 + } 80 + 81 + if state != params.stateToken { 82 + throw AuthenticatorError.stateTokenMismatch(state, params.stateToken) 83 + } 84 + 85 + guard let tokenURL = URL(string: server.tokenEndpoint) else { 86 + throw AuthenticatorError.missingTokenURL 87 + } 88 + guard let verifier = params.pcke?.verifier else { 89 + throw AuthenticatorError.pkceRequired 90 + } 91 + 92 + let tokenRequest = OAuthTokenRequest( 93 + code: authCode, 94 + codeVerifier: verifier, 95 + redirectURI: params.credentials.callbackURL.absoluteString, 96 + grantType: "authorization_code", 97 + clientID: params.credentials.clientId 98 + ) 99 + 100 + var request = URLRequest(url: tokenURL) 101 + request.httpMethod = "POST" 102 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 103 + request.setValue("application/json", forHTTPHeaderField: "Accept") 104 + request.httpBody = try JSONEncoder().encode(tokenRequest) 105 + 106 + let (data, response) = try await params.responseProvider(request) 107 + guard let httpResponse = response as? HTTPURLResponse else { 108 + throw AuthenticatorError.httpResponseExpected 109 + } 110 + guard (200..<300).contains(httpResponse.statusCode) else { 111 + if let oauthError = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { 112 + throw ATProtoOAuthError.tokenRequestFailed("\(oauthError.error): \(oauthError.errorDescription ?? "")") 113 + } 114 + throw ATProtoOAuthError.tokenRequestFailed(String(decoding: data, as: UTF8.self)) 115 + } 116 + 117 + let tokenResponse = try Self.decodeTokenResponse(from: data) 118 + try TokenValidator(expectedSubjectDID: expectedSubjectDID).validate(tokenResponse) 119 + 120 + if iss != server.issuer { 121 + throw AuthenticatorError.issuingServerMismatch(iss, server.issuer) 122 + } 123 + try Self.validateIssuer(iss, matches: expectedAuthorizationServer) 124 + 125 + return tokenResponse.login(for: iss) 126 + } 127 + } 128 + 129 + private func refreshProvider( 130 + server: ServerMetadata, 131 + expectedSubjectDID: String? 132 + ) -> TokenHandling.RefreshProvider { 133 + { login, credentials, responseProvider in 134 + guard let refreshToken = login.refreshToken?.value else { 135 + throw AuthenticatorError.refreshNotPossible 136 + } 137 + guard let tokenURL = URL(string: server.tokenEndpoint) else { 138 + throw AuthenticatorError.missingTokenURL 139 + } 140 + 141 + let tokenRequest = OAuthRefreshTokenRequest( 142 + refreshToken: refreshToken, 143 + redirectURI: credentials.callbackURL.absoluteString, 144 + grantType: "refresh_token", 145 + clientID: credentials.clientId 146 + ) 147 + 148 + var request = URLRequest(url: tokenURL) 149 + request.httpMethod = "POST" 150 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 151 + request.httpBody = try JSONEncoder().encode(tokenRequest) 152 + 153 + let (data, response) = try await responseProvider(request) 154 + guard let httpResponse = response as? HTTPURLResponse else { 155 + throw AuthenticatorError.httpResponseExpected 156 + } 157 + guard (200..<300).contains(httpResponse.statusCode) else { 158 + if let oauthError = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { 159 + throw ATProtoOAuthError.tokenRequestFailed("\(oauthError.error): \(oauthError.errorDescription ?? "")") 160 + } 161 + throw AuthenticatorError.refreshNotPossible 162 + } 163 + 164 + let tokenResponse = try Self.decodeTokenResponse(from: data) 165 + try TokenValidator(expectedSubjectDID: expectedSubjectDID).validate(tokenResponse) 166 + 167 + return tokenResponse.login(for: login.issuingServer ?? server.issuer) 168 + } 169 + } 170 + 171 + nonisolated static func decodeTokenResponse(from data: Data) throws -> OAuthTokenResponse { 172 + do { 173 + return try JSONDecoder().decode(OAuthTokenResponse.self, from: data) 174 + } catch { 175 + throw ATProtoOAuthError.invalidTokenResponse 176 + } 177 + } 178 + } 179 + 180 + extension URLSession { 181 + static var defaultProvider: URLResponseProvider { 182 + { request in 183 + try await URLSession.shared.data(for: request) 184 + } 185 + } 186 + }
+18 -245
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 153 153 public final class ATProtoOAuth: Sendable { 154 154 private let config: ATProtoOAuthConfig 155 155 private let storage: ATProtoAuthStorage 156 - private let identityResolver: IdentityResolver 156 + private let identityResolver: any ATProtoIdentityResolver 157 157 private let dpopRequestActor = DPoPRequestActor() 158 158 private var hasPersistedKey: Bool 159 159 ··· 165 165 let usedAuthProxy: Bool 166 166 } 167 167 168 - public init(config: ATProtoOAuthConfig, storage: ATProtoAuthStorage) async { 168 + /// - Parameter identityResolver: pluggable identity-resolution seam. Pass 169 + /// a fake in tests to avoid live network. Defaults to the production 170 + /// ``IdentityResolver`` when omitted. 171 + public init( 172 + config: ATProtoOAuthConfig, 173 + storage: ATProtoAuthStorage, 174 + identityResolver: (any ATProtoIdentityResolver)? = nil 175 + ) async { 169 176 self.config = config 170 177 self.storage = storage 171 - self.identityResolver = IdentityResolver() 178 + self.identityResolver = identityResolver ?? IdentityResolver() 172 179 173 180 if let storedKeyData = try? await storage.retrievePrivateKey(), 174 181 let pem = String(data: storedKeyData, encoding: .utf8), ··· 186 193 } 187 194 188 195 /// Initialize with existing private key (for session restoration) 189 - public init(config: ATProtoOAuthConfig, storage: ATProtoAuthStorage, privateKeyPEM: String) async throws { 196 + public init( 197 + config: ATProtoOAuthConfig, 198 + storage: ATProtoAuthStorage, 199 + privateKeyPEM: String, 200 + identityResolver: (any ATProtoIdentityResolver)? = nil 201 + ) async throws { 190 202 self.config = config 191 203 self.storage = storage 192 - self.identityResolver = IdentityResolver() 204 + self.identityResolver = identityResolver ?? IdentityResolver() 193 205 194 206 self.privateKey = try ES256PrivateKey(pem: privateKeyPEM) 195 207 self.hasPersistedKey = true ··· 675 687 } 676 688 } 677 689 678 - private func buildTokenHandling( 679 - accountHint: String?, 680 - server: ServerMetadata, 681 - jwtGenerator: @escaping DPoPSigner.JWTGenerator, 682 - expectedSubjectDID: String?, 683 - expectedAuthorizationServer: String 684 - ) throws -> TokenHandling { 685 - guard let parURL = URL(string: server.pushedAuthorizationRequestEndpoint) else { 686 - throw ATProtoOAuthError.malformedServerMetadata( 687 - field: "pushed_authorization_request_endpoint", 688 - value: server.pushedAuthorizationRequestEndpoint 689 - ) 690 - } 691 - 692 - return TokenHandling( 693 - parConfiguration: PARConfiguration( 694 - url: parURL, 695 - parameters: { if let accountHint { ["login_hint": accountHint] } else { [:] } }() 696 - ), 697 - authorizationURLProvider: authorizationURLProvider(server: server), 698 - loginProvider: loginProvider( 699 - server: server, 700 - expectedSubjectDID: expectedSubjectDID, 701 - expectedAuthorizationServer: expectedAuthorizationServer 702 - ), 703 - refreshProvider: refreshProvider( 704 - server: server, 705 - expectedSubjectDID: expectedSubjectDID 706 - ), 707 - dpopJWTGenerator: jwtGenerator, 708 - pkce: PKCEVerifier() 709 - ) 710 - } 711 - 712 - private func authorizationURLProvider(server: ServerMetadata) -> TokenHandling.AuthorizationURLProvider { 713 - { params in 714 - guard let parRequestURI = params.parRequestURI else { 715 - throw AuthenticatorError.parRequestURIMissing 716 - } 717 - 718 - var components = URLComponents(string: server.authorizationEndpoint) 719 - components?.queryItems = [ 720 - URLQueryItem(name: "request_uri", value: parRequestURI), 721 - URLQueryItem(name: "client_id", value: params.credentials.clientId), 722 - ] 723 - 724 - guard let url = components?.url else { 725 - throw AuthenticatorError.missingAuthorizationURL 726 - } 727 - return url 728 - } 729 - } 730 - 731 - private func loginProvider( 732 - server: ServerMetadata, 733 - expectedSubjectDID: String?, 734 - expectedAuthorizationServer: String 735 - ) -> TokenHandling.LoginProvider { 736 - { params in 737 - guard let redirectComponents = URLComponents(url: params.redirectURL, resolvingAgainstBaseURL: false) else { 738 - throw ATProtoOAuthError.malformedAuthorizationCallback 739 - } 740 - 741 - guard 742 - let authCode = redirectComponents.queryItems?.first(where: { $0.name == "code" })?.value, 743 - let iss = redirectComponents.queryItems?.first(where: { $0.name == "iss" })?.value, 744 - let state = redirectComponents.queryItems?.first(where: { $0.name == "state" })?.value 745 - else { 746 - throw ATProtoOAuthError.malformedAuthorizationCallback 747 - } 748 - 749 - if state != params.stateToken { 750 - throw AuthenticatorError.stateTokenMismatch(state, params.stateToken) 751 - } 752 - 753 - guard let tokenURL = URL(string: server.tokenEndpoint) else { 754 - throw AuthenticatorError.missingTokenURL 755 - } 756 - guard let verifier = params.pcke?.verifier else { 757 - throw AuthenticatorError.pkceRequired 758 - } 759 - 760 - let tokenRequest = OAuthTokenRequest( 761 - code: authCode, 762 - codeVerifier: verifier, 763 - redirectURI: params.credentials.callbackURL.absoluteString, 764 - grantType: "authorization_code", 765 - clientID: params.credentials.clientId 766 - ) 767 - 768 - var request = URLRequest(url: tokenURL) 769 - request.httpMethod = "POST" 770 - request.setValue("application/json", forHTTPHeaderField: "Content-Type") 771 - request.setValue("application/json", forHTTPHeaderField: "Accept") 772 - request.httpBody = try JSONEncoder().encode(tokenRequest) 773 - 774 - let (data, response) = try await params.responseProvider(request) 775 - guard let httpResponse = response as? HTTPURLResponse else { 776 - throw AuthenticatorError.httpResponseExpected 777 - } 778 - guard (200..<300).contains(httpResponse.statusCode) else { 779 - if let oauthError = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { 780 - throw ATProtoOAuthError.tokenRequestFailed("\(oauthError.error): \(oauthError.errorDescription ?? "")") 781 - } 782 - throw ATProtoOAuthError.tokenRequestFailed(String(decoding: data, as: UTF8.self)) 783 - } 784 - 785 - let tokenResponse = try Self.decodeTokenResponse(from: data) 786 - try TokenValidator(expectedSubjectDID: expectedSubjectDID).validate(tokenResponse) 787 - 788 - if iss != server.issuer { 789 - throw AuthenticatorError.issuingServerMismatch(iss, server.issuer) 790 - } 791 - try Self.validateIssuer(iss, matches: expectedAuthorizationServer) 792 - 793 - return tokenResponse.login(for: iss) 794 - } 795 - } 796 - 797 - private func refreshProvider( 798 - server: ServerMetadata, 799 - expectedSubjectDID: String? 800 - ) -> TokenHandling.RefreshProvider { 801 - { login, credentials, responseProvider in 802 - guard let refreshToken = login.refreshToken?.value else { 803 - throw AuthenticatorError.refreshNotPossible 804 - } 805 - guard let tokenURL = URL(string: server.tokenEndpoint) else { 806 - throw AuthenticatorError.missingTokenURL 807 - } 808 - 809 - let tokenRequest = OAuthRefreshTokenRequest( 810 - refreshToken: refreshToken, 811 - redirectURI: credentials.callbackURL.absoluteString, 812 - grantType: "refresh_token", 813 - clientID: credentials.clientId 814 - ) 815 - 816 - var request = URLRequest(url: tokenURL) 817 - request.httpMethod = "POST" 818 - request.setValue("application/json", forHTTPHeaderField: "Content-Type") 819 - request.httpBody = try JSONEncoder().encode(tokenRequest) 820 - 821 - let (data, response) = try await responseProvider(request) 822 - guard let httpResponse = response as? HTTPURLResponse else { 823 - throw AuthenticatorError.httpResponseExpected 824 - } 825 - guard (200..<300).contains(httpResponse.statusCode) else { 826 - if let oauthError = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { 827 - throw ATProtoOAuthError.tokenRequestFailed("\(oauthError.error): \(oauthError.errorDescription ?? "")") 828 - } 829 - throw AuthenticatorError.refreshNotPossible 830 - } 831 - 832 - let tokenResponse = try Self.decodeTokenResponse(from: data) 833 - try TokenValidator(expectedSubjectDID: expectedSubjectDID).validate(tokenResponse) 834 - 835 - return tokenResponse.login(for: login.issuingServer ?? server.issuer) 836 - } 837 - } 838 - 839 - nonisolated private static func decodeTokenResponse(from data: Data) throws -> OAuthTokenResponse { 840 - do { 841 - return try JSONDecoder().decode(OAuthTokenResponse.self, from: data) 842 - } catch { 843 - throw ATProtoOAuthError.invalidTokenResponse 844 - } 845 - } 846 - 847 - nonisolated private static func validateIssuer(_ issuer: String, matches expectedAuthorizationServer: String) throws { 690 + nonisolated static func validateIssuer(_ issuer: String, matches expectedAuthorizationServer: String) throws { 848 691 guard let issuerURL = URL(string: issuer), 849 692 let expectedURL = URL(string: expectedAuthorizationServer), 850 693 URLOrigin.normalized(issuerURL) == URLOrigin.normalized(expectedURL) else { ··· 875 718 } 876 719 } 877 720 878 - // MARK: - URLSession Extension 879 - 880 - extension URLSession { 881 - static var defaultProvider: URLResponseProvider { 882 - { request in 883 - try await URLSession.shared.data(for: request) 884 - } 885 - } 886 - } 887 - 888 - private struct OAuthTokenRequest: Codable { 889 - let code: String 890 - let codeVerifier: String 891 - let redirectURI: String 892 - let grantType: String 893 - let clientID: String 894 - 895 - enum CodingKeys: String, CodingKey { 896 - case code 897 - case codeVerifier = "code_verifier" 898 - case redirectURI = "redirect_uri" 899 - case grantType = "grant_type" 900 - case clientID = "client_id" 901 - } 902 - } 903 - 904 - private struct OAuthRefreshTokenRequest: Codable { 905 - let refreshToken: String 906 - let redirectURI: String 907 - let grantType: String 908 - let clientID: String 909 - 910 - enum CodingKeys: String, CodingKey { 911 - case refreshToken = "refresh_token" 912 - case redirectURI = "redirect_uri" 913 - case grantType = "grant_type" 914 - case clientID = "client_id" 915 - } 916 - } 917 - 918 - struct OAuthTokenResponse: Codable, Sendable { 919 - let accessToken: String 920 - let refreshToken: String? 921 - let subject: String 922 - let scope: String 923 - let tokenType: String 924 - let expiresIn: Int 925 - 926 - enum CodingKeys: String, CodingKey { 927 - case accessToken = "access_token" 928 - case refreshToken = "refresh_token" 929 - case subject = "sub" 930 - case scope 931 - case tokenType = "token_type" 932 - case expiresIn = "expires_in" 933 - } 934 - 935 - var scopes: Set<String> { 936 - Set(scope.split(separator: " ").map(String.init)) 937 - } 938 - 939 - func login(for issuingServer: String) -> Login { 940 - Login( 941 - accessToken: Token(value: accessToken, expiresIn: expiresIn), 942 - refreshToken: refreshToken.map { Token(value: $0) }, 943 - scopes: scope, 944 - issuingServer: issuingServer 945 - ) 946 - } 947 - }
+73
Sources/CoreATProtocol/OAuth/TokenModels.swift
··· 1 + // 2 + // TokenModels.swift 3 + // CoreATProtocol 4 + // 5 + 6 + import Foundation 7 + import OAuthenticator 8 + 9 + /// Body of a `grant_type=authorization_code` request to the token endpoint 10 + /// (RFC 6749 §4.1.3 + AT Protocol OAuth profile). 11 + struct OAuthTokenRequest: Codable, Sendable { 12 + let code: String 13 + let codeVerifier: String 14 + let redirectURI: String 15 + let grantType: String 16 + let clientID: String 17 + 18 + enum CodingKeys: String, CodingKey { 19 + case code 20 + case codeVerifier = "code_verifier" 21 + case redirectURI = "redirect_uri" 22 + case grantType = "grant_type" 23 + case clientID = "client_id" 24 + } 25 + } 26 + 27 + /// Body of a `grant_type=refresh_token` request to the token endpoint. 28 + struct OAuthRefreshTokenRequest: Codable, Sendable { 29 + let refreshToken: String 30 + let redirectURI: String 31 + let grantType: String 32 + let clientID: String 33 + 34 + enum CodingKeys: String, CodingKey { 35 + case refreshToken = "refresh_token" 36 + case redirectURI = "redirect_uri" 37 + case grantType = "grant_type" 38 + case clientID = "client_id" 39 + } 40 + } 41 + 42 + /// Successful response from the token endpoint, used for both initial 43 + /// authorization-code grants and refresh grants. 44 + struct OAuthTokenResponse: Codable, Sendable { 45 + let accessToken: String 46 + let refreshToken: String? 47 + let subject: String 48 + let scope: String 49 + let tokenType: String 50 + let expiresIn: Int 51 + 52 + enum CodingKeys: String, CodingKey { 53 + case accessToken = "access_token" 54 + case refreshToken = "refresh_token" 55 + case subject = "sub" 56 + case scope 57 + case tokenType = "token_type" 58 + case expiresIn = "expires_in" 59 + } 60 + 61 + var scopes: Set<String> { 62 + Set(scope.split(separator: " ").map(String.init)) 63 + } 64 + 65 + func login(for issuingServer: String) -> Login { 66 + Login( 67 + accessToken: Token(value: accessToken, expiresIn: expiresIn), 68 + refreshToken: refreshToken.map { Token(value: $0) }, 69 + scopes: scope, 70 + issuingServer: issuingServer 71 + ) 72 + } 73 + }
+42
Tests/CoreATProtocolTests/ClockSkewStoreTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Suite("ClockSkewStore") 6 + struct ClockSkewStoreTests { 7 + @Test("Parses RFC 1123 Date header and records offset") 8 + func rfc1123() async { 9 + let store = ClockSkewStore() 10 + let local = Date(timeIntervalSince1970: 1_700_000_000) 11 + let serverHeader = "Thu, 14 Nov 2023 22:13:40 GMT" 12 + let serverDate = try! #require(ClockSkewStore.parse(serverHeader)) 13 + 14 + await store.updateFromServerDate(serverHeader, localNow: local) 15 + let offset = await store.offset 16 + #expect(offset == serverDate.timeIntervalSince(local)) 17 + } 18 + 19 + @Test("Ignores nil or unparseable header without mutating state") 20 + func malformedHeader() async { 21 + let store = ClockSkewStore(offset: 123) 22 + await store.updateFromServerDate(nil) 23 + #expect(await store.offset == 123) 24 + 25 + await store.updateFromServerDate("not a date") 26 + #expect(await store.offset == 123) 27 + } 28 + 29 + @Test("serverAdjustedNow applies the stored offset") 30 + func adjustedNow() async { 31 + let store = ClockSkewStore(offset: 42) 32 + let base = Date(timeIntervalSince1970: 1_000_000) 33 + let adjusted = await store.serverAdjustedNow(localNow: base) 34 + #expect(adjusted.timeIntervalSince1970 == 1_000_042) 35 + } 36 + 37 + @Test("Parses the RFC 850 legacy Date format") 38 + func rfc850() { 39 + #expect(ClockSkewStore.parse("Sunday, 06-Nov-94 08:49:37 GMT") != nil) 40 + } 41 + } 42 +
-83
Tests/CoreATProtocolTests/DPoPStoreTests.swift
··· 1 - import Foundation 2 - import Testing 3 - @testable import CoreATProtocol 4 - 5 - @Suite("ClockSkewStore") 6 - struct ClockSkewStoreTests { 7 - @Test("Parses RFC 1123 Date header and records offset") 8 - func rfc1123() async { 9 - let store = ClockSkewStore() 10 - let local = Date(timeIntervalSince1970: 1_700_000_000) 11 - let serverHeader = "Thu, 14 Nov 2023 22:13:40 GMT" 12 - let serverDate = try! #require(ClockSkewStore.parse(serverHeader)) 13 - 14 - await store.updateFromServerDate(serverHeader, localNow: local) 15 - let offset = await store.offset 16 - #expect(offset == serverDate.timeIntervalSince(local)) 17 - } 18 - 19 - @Test("Ignores nil or unparseable header without mutating state") 20 - func malformedHeader() async { 21 - let store = ClockSkewStore(offset: 123) 22 - await store.updateFromServerDate(nil) 23 - #expect(await store.offset == 123) 24 - 25 - await store.updateFromServerDate("not a date") 26 - #expect(await store.offset == 123) 27 - } 28 - 29 - @Test("serverAdjustedNow applies the stored offset") 30 - func adjustedNow() async { 31 - let store = ClockSkewStore(offset: 42) 32 - let base = Date(timeIntervalSince1970: 1_000_000) 33 - let adjusted = await store.serverAdjustedNow(localNow: base) 34 - #expect(adjusted.timeIntervalSince1970 == 1_000_042) 35 - } 36 - 37 - @Test("Parses the RFC 850 legacy Date format") 38 - func rfc850() { 39 - #expect(ClockSkewStore.parse("Sunday, 06-Nov-94 08:49:37 GMT") != nil) 40 - } 41 - } 42 - 43 - @Suite("DPoPNonceStore") 44 - struct DPoPNonceStoreTests { 45 - @Test("Starts empty") 46 - func initialState() async { 47 - let store = DPoPNonceStore() 48 - #expect(await store.get() == nil) 49 - } 50 - 51 - @Test("Update and read back") 52 - func update() async { 53 - let store = DPoPNonceStore() 54 - await store.update("abc") 55 - #expect(await store.get() == "abc") 56 - await store.update("xyz") 57 - #expect(await store.get() == "xyz") 58 - } 59 - 60 - @Test("Concurrent updates settle on a valid value") 61 - func concurrentUpdates() async { 62 - let store = DPoPNonceStore() 63 - 64 - await withTaskGroup(of: Void.self) { group in 65 - for i in 0..<100 { 66 - group.addTask { await store.update("nonce-\(i)") } 67 - } 68 - } 69 - 70 - let final = await store.get() 71 - let valid = (0..<100).map { "nonce-\($0)" } 72 - #expect(final != nil) 73 - #expect(valid.contains(final ?? "")) 74 - } 75 - 76 - @Test("clear() resets to nil") 77 - func clearResets() async { 78 - let store = DPoPNonceStore(nonce: "start") 79 - #expect(await store.get() == "start") 80 - await store.clear() 81 - #expect(await store.get() == nil) 82 - } 83 - }
+100
Tests/CoreATProtocolTests/IdentityResolverProtocolTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Suite("ATProtoIdentityResolver protocol") 6 + struct IdentityResolverProtocolTests { 7 + @Test("Fake resolver returns canned ResolvedIdentity from resolve(identifier:)") 8 + func fakeResolveReturnsCannedIdentity() async throws { 9 + let canned = IdentityResolver.ResolvedIdentity( 10 + handle: "alice.test", 11 + did: "did:plc:alice", 12 + pdsEndpoint: "https://pds.test", 13 + authorizationServer: "https://auth.test" 14 + ) 15 + let fake = StubIdentityResolver(identity: canned, isValidServer: true) 16 + 17 + let result = try await fake.resolve(identifier: "alice.test") 18 + 19 + #expect(result.handle == "alice.test") 20 + #expect(result.did == "did:plc:alice") 21 + #expect(result.pdsEndpoint == "https://pds.test") 22 + #expect(result.authorizationServer == "https://auth.test") 23 + #expect(result.authServerHost == "auth.test") 24 + } 25 + 26 + @Test("Fake resolver returns canned isAuthorizationServer answer") 27 + func fakeIsAuthorizationServerReturnsCannedBool() async throws { 28 + let canned = IdentityResolver.ResolvedIdentity( 29 + handle: nil, 30 + did: "did:plc:bob", 31 + pdsEndpoint: "https://pds.example", 32 + authorizationServer: "https://auth.example" 33 + ) 34 + let approving = StubIdentityResolver(identity: canned, isValidServer: true) 35 + let denying = StubIdentityResolver(identity: canned, isValidServer: false) 36 + 37 + #expect( 38 + try await approving.isAuthorizationServer( 39 + "https://auth.example", 40 + validFor: "https://pds.example" 41 + ) 42 + ) 43 + #expect( 44 + try await !denying.isAuthorizationServer( 45 + "https://auth.example", 46 + validFor: "https://pds.example" 47 + ) 48 + ) 49 + } 50 + 51 + @Test("ATProtoOAuth.init accepts a custom identity resolver") 52 + func atProtoOAuthAcceptsCustomResolver() async throws { 53 + let canned = IdentityResolver.ResolvedIdentity( 54 + handle: "carol.test", 55 + did: "did:plc:carol", 56 + pdsEndpoint: "https://pds.test", 57 + authorizationServer: "https://auth.test" 58 + ) 59 + let fake = StubIdentityResolver(identity: canned, isValidServer: true) 60 + 61 + let storage = ATProtoAuthStorage( 62 + retrieveLogin: { nil }, 63 + storeLogin: { _ in }, 64 + retrievePrivateKey: { nil }, 65 + storePrivateKey: { _ in } 66 + ) 67 + let config = ATProtoOAuthConfig( 68 + clientMetadataURL: "https://example.com/client-metadata.json", 69 + redirectURI: "example://callback" 70 + ) 71 + 72 + // The injection seam is the value of Step 5 — verify it compiles and 73 + // accepts a non-default resolver. End-to-end behavior with the fake 74 + // can't be tested offline because authenticate(...) still goes to 75 + // live network for client/server metadata; that's a future step. 76 + let client = await ATProtoOAuth( 77 + config: config, 78 + storage: storage, 79 + identityResolver: fake 80 + ) 81 + let pem = await client.privateKeyPEM 82 + #expect(!pem.isEmpty) 83 + } 84 + } 85 + 86 + private struct StubIdentityResolver: ATProtoIdentityResolver { 87 + let identity: IdentityResolver.ResolvedIdentity 88 + let isValidServer: Bool 89 + 90 + func resolve(identifier: String) async throws -> IdentityResolver.ResolvedIdentity { 91 + identity 92 + } 93 + 94 + func isAuthorizationServer( 95 + _ authorizationServer: String, 96 + validFor pdsEndpoint: String 97 + ) async throws -> Bool { 98 + isValidServer 99 + } 100 + }
+17 -18
Tests/CoreATProtocolTests/OAuthTests.swift
··· 3 3 import OAuthenticator 4 4 @testable import CoreATProtocol 5 5 6 - @Suite("Identity Resolution") 6 + /// Live-network smoke tests for identity resolution and OAuthenticator's 7 + /// metadata loaders. Disabled by default to keep CI deterministic — set 8 + /// `CORE_ATPROTOCOL_LIVE_TESTS=1` to opt in locally: 9 + /// 10 + /// ``` 11 + /// CORE_ATPROTOCOL_LIVE_TESTS=1 swift test 12 + /// ``` 13 + @Suite("Identity Resolution (live network — opt-in)") 7 14 struct IdentityResolverTests { 15 + static let liveTestsDisabled = ProcessInfo.processInfo.environment["CORE_ATPROTOCOL_LIVE_TESTS"] == nil 8 16 9 - @Test("Resolve well-known handle via HTTPS") 17 + @Test("Resolve well-known handle via HTTPS", 18 + .disabled(if: liveTestsDisabled, "Set CORE_ATPROTOCOL_LIVE_TESTS=1 to run.")) 10 19 func testResolveHandle() async throws { 11 20 let resolver = await IdentityResolver() 12 21 13 22 // atproto.com is a stable test handle 14 23 let identity = try await resolver.resolve(handle: "atproto.com") 15 - 16 - print("DID: \(identity.did)") 17 - print("PDS: \(identity.pdsEndpoint)") 18 - print("Auth Server: \(identity.authorizationServer)") 19 - print("Auth Server Host: \(identity.authServerHost)") 20 24 21 25 #expect(identity.did.hasPrefix("did:")) 22 26 #expect(identity.pdsEndpoint.hasPrefix("https://")) ··· 24 28 #expect(!identity.authServerHost.hasPrefix("https://")) 25 29 } 26 30 27 - @Test("Handle with @ prefix is cleaned") 31 + @Test("Handle with @ prefix is cleaned", 32 + .disabled(if: liveTestsDisabled, "Set CORE_ATPROTOCOL_LIVE_TESTS=1 to run.")) 28 33 func testHandleCleaning() async throws { 29 34 let resolver = await IdentityResolver() 30 35 ··· 33 38 #expect(identity.handle == "atproto.com") 34 39 } 35 40 36 - @Test("ServerMetadata loads from auth server host") 41 + @Test("ServerMetadata loads from auth server host", 42 + .disabled(if: liveTestsDisabled, "Set CORE_ATPROTOCOL_LIVE_TESTS=1 to run.")) 37 43 func testServerMetadataLoad() async throws { 38 44 let resolver = await IdentityResolver() 39 45 let identity = try await resolver.resolve(handle: "atproto.com") ··· 42 48 try await URLSession.shared.data(for: request) 43 49 } 44 50 45 - // This should not throw - tests that authServerHost works with ServerMetadata.load 46 51 let serverConfig = try await ServerMetadata.load( 47 52 for: identity.authServerHost, 48 53 provider: provider 49 54 ) 50 55 51 - print("Authorization endpoint: \(serverConfig.authorizationEndpoint)") 52 - print("Token endpoint: \(serverConfig.tokenEndpoint)") 53 - 54 56 #expect(serverConfig.authorizationEndpoint.hasPrefix("https://")) 55 57 #expect(serverConfig.tokenEndpoint.hasPrefix("https://")) 56 58 } 57 59 58 - @Test("ClientMetadata loads from URL") 60 + @Test("ClientMetadata loads from URL", 61 + .disabled(if: liveTestsDisabled, "Set CORE_ATPROTOCOL_LIVE_TESTS=1 to run.")) 59 62 func testClientMetadataLoad() async throws { 60 63 let provider: URLResponseProvider = { request in 61 64 try await URLSession.shared.data(for: request) 62 65 } 63 66 64 - // Use the real Plume client metadata 65 67 let clientConfig = try await ClientMetadata.load( 66 68 for: "https://sparrowtek.com/plume.json", 67 69 provider: provider 68 70 ) 69 - 70 - print("Client ID: \(clientConfig.clientId)") 71 - print("Redirect URIs: \(clientConfig.redirectURIs)") 72 71 73 72 #expect(clientConfig.clientId == "https://sparrowtek.com/plume.json") 74 73 }
+49 -9
update_auth.md
··· 4 4 5 5 ## Status (2026-04-29) 6 6 7 - - **Steps 1–4 are landed.** Per-origin DPoP nonce caching, the shared `URLOrigin.normalized` helper, and the extracted `TokenValidator` are all live. CoreATProtocol's 72 tests pass; bskyKit and EffemKit rebuild cleanly against the local checkout. 8 - - **Step 5 is next.** Resolver protocol + offline tests; additive, low risk. 9 - - **Step 6 remains gated** behind a SemVer-major bump. 7 + **The plan is fully landed.** Steps 1–7 are all done. 8 + 9 + - Per-origin DPoP nonce caching, shared `URLOrigin.normalized` helper, extracted `TokenValidator`, `ATProtoIdentityResolver` injection seam, file split, and deprecated-symbol removal are all live. 10 + - CoreATProtocol has 71 tests (4 live-network ones opt-in via `CORE_ATPROTOCOL_LIVE_TESTS=1`); the deterministic suite runs in ~50ms. 11 + - bskyKit and EffemKit rebuild cleanly against the local checkout. Atprosphere + effem-iOS verified by source inspection — no consumer call sites broke. 12 + - Auto-memory updated: stale Plume entries dropped, four new memory files added covering the per-origin nonce design, consumer OAuth modes, the singleton testing constraint, and the live-tests env var. 13 + 14 + The only obvious next milestone is unrelated to this plan: **make `ATProtoSession` instance-based.** See "What didn't make this plan" near the bottom. 10 15 11 - See "Notes from the Steps 1+2 implementation" and "Notes from the Steps 3+4 implementation" near the bottom for deviations from the original plan. 16 + See the "Notes from..." sections near the bottom for deviations from the original plan at each step. 12 17 13 18 ## Inventory of what we have today 14 19 ··· 254 259 255 260 Tests: add `TokenValidatorTests` covering the four failure modes (wrong token type, missing scope, issuer mismatch, sub mismatch) and the success path. 256 261 257 - ### Step 5 — Make `IdentityResolver` a protocol 262 + ### Step 5 — Make `IdentityResolver` a protocol — DONE 258 263 259 264 Today `IdentityResolver` is a concrete struct. Tests have to hit live PDS endpoints (see `OAuthTests.swift` line 11-25 — calls `atproto.com`). That's fine for a smoke test, terrible for CI determinism. 260 265 ··· 283 288 284 289 Tests: replace the live-network identity tests in `OAuthTests.swift` with a fake `ATProtoIdentityResolver` that returns canned `ResolvedIdentity` values. Keep one live integration test, but mark it with `@Test(.disabled(if: ...))` or a tag so it's opt-in. 285 290 286 - ### Step 6 — Optional: split `ATProtoOAuth.swift` along seams 291 + ### Step 6 — Optional: split `ATProtoOAuth.swift` along seams — DONE 287 292 288 293 Only do this if Steps 1–5 still leave the file feeling unwieldy. The seams are clear: 289 294 ··· 295 300 296 301 Once the split lands, drop the deprecated `ATProtoSession.dpopPrivateKey` / `dpopKeys` / `dpopNonceStore` accessors (Step 2 deprecated them). Remove `DPoPNonceStore.swift`. Bump CoreATProtocol's tag — this is a SemVer-major change because the deprecated symbols are public. 297 302 298 - ### Step 7 — Update auto-memory 303 + ### Step 7 — Update auto-memory — DONE 299 304 300 305 After all of this lands, update `~/.claude/projects/-Users-rademaker-Developer-SparrowTek-AtProto/memory/MEMORY.md`: 301 306 - Note that DPoP signing is consolidated in `OAuth/DPoPSigner.swift`. ··· 326 331 1. **PR 1**: Step 1 — `DPoPSigner` + tests. Standalone, no consumer changes. **DONE**, bundled with PR 2. 327 332 2. **PR 2**: Step 2 — wire `DPoPSigner` into `Networking.swift` + `ATProtoOAuth.swift`. Largest diff. Verify against Atprosphere + effem before merge. **DONE**. 328 333 3. **PR 3**: Steps 3 + 4 — URL canonicalization + token validator. Small refactor PR. **DONE**. 329 - 4. **PR 4**: Step 5 — resolver protocol + offline tests. **NEXT**. 330 - 5. **PR 5** (later, behind a SemVer bump): Step 6 — file split and deprecation removal. 334 + 4. **PR 4**: Step 5 — resolver protocol + offline tests. **DONE**. 335 + 5. **PR 5** (SemVer-major): Step 6 — file split and deprecation removal. **DONE**. 336 + 6. **PR 6** (housekeeping): Step 7 — auto-memory refresh. **DONE**. 331 337 332 338 Steps 1–5 are non-breaking. Step 6 is breaking and can wait until you have another reason to bump the major version. 333 339 ··· 347 353 - **`OAuthTokenResponse` was bumped from `private` to `internal` (and `Sendable`).** Tests construct it via the synthesized memberwise init — much simpler than building JSON and round-tripping through `Codable`. `OAuthTokenRequest` and `OAuthRefreshTokenRequest` stay `private`; nothing outside `ATProtoOAuth.swift` needs them. 348 354 - **Issuer validation stays inline in `loginProvider`, not in `TokenValidator`.** The plan implied both providers shared an issuer check, but they don't: the auth-callback flow validates the `iss` query parameter (which the validator never sees), and the refresh flow has no `iss` to validate against — the auth-server URL is fixed by the time refresh runs. The validator handles only the response-payload checks (token type, scope, sub) that genuinely duplicated. Net change: ~10 lines of duplicated guards collapsed to two `validator.validate(_:)` calls. 349 355 - **Tests added:** `TokenValidatorTests` covers the success path, wrong token type, missing `atproto` scope, sub mismatch, sub passthrough when expected DID is nil, and a multi-scope success case. Six tests, all sub-millisecond. Total suite is now 72 tests. 356 + 357 + ## Notes from the Step 5 implementation 358 + 359 + - **Protocol shape matches the existing concrete API.** `ATProtoIdentityResolver` exposes only the two methods `ATProtoOAuth` actually calls — `resolve(identifier:)` and `isAuthorizationServer(_:validFor:)`. The concrete `IdentityResolver`'s extras (`resolve(handle:)`, `resolve(did:)`) stay off the protocol; tests that need them keep using the concrete type. 360 + - **Optional parameter, not an autoclosure default.** Plan's signature was `identityResolver: ATProtoIdentityResolver = IdentityResolver()`. Default expressions on a global-actor-isolated `init` get murky in Swift 6 (the expression is evaluated relative to the function's isolation, but `IdentityResolver()` is itself `@APActor`-isolated). Sidestepped with `identityResolver: (any ATProtoIdentityResolver)? = nil` and `self.identityResolver = identityResolver ?? IdentityResolver()` in the body. Same caller ergonomics, no isolation puzzle. 361 + - **`ResolvedIdentity` stays nested in `IdentityResolver`.** Lifting it to a top-level `ATProtoResolvedIdentity` would be cleaner but renames a public type. Deferred to Step 6 (which is already a SemVer-major bump). The protocol references `IdentityResolver.ResolvedIdentity` directly. 362 + - **Live tests are opt-in via env var, not removed.** Plan suggested `.disabled(if:)` or a tag. Used a single env-var gate: `CORE_ATPROTOCOL_LIVE_TESTS=1` runs the four live network tests (`testResolveHandle`, `testHandleCleaning`, `testServerMetadataLoad`, `testClientMetadataLoad`); without it they're skipped and the deterministic suite stays at ~80ms. The smoke-test value of those tests is preserved for local debugging without polluting CI. 363 + - **Three new offline tests in `IdentityResolverProtocolTests`.** A `StubIdentityResolver` round-trips canned values through both protocol methods, plus one test verifies `ATProtoOAuth.init(config:storage:identityResolver:)` accepts the injected resolver. End-to-end behavior with the fake can't be tested offline because `authenticate(...)` still goes to live network for client/server metadata — that's a future seam. 364 + - **No consumer changes.** The new `identityResolver:` parameter has a default, so all existing `await ATProtoOAuth(config: ..., storage: ...)` and `try await ATProtoOAuth(config: ..., storage: ..., privateKeyPEM: ...)` call sites compile unchanged. bskyKit, EffemKit, Atprosphere, and effem-iOS all good. 365 + 366 + ## Notes from the Steps 6+7 implementation 367 + 368 + - **Removed more than just the DPoP-deprecated surface.** Plan Step 6 listed `ATProtoSession.dpopPrivateKey` / `dpopKeys` / `dpopNonceStore` and the `DPoPNonceStore` type as the things to delete. While we were already breaking, also deleted the orphan `APEnvironment` typealias and the `ATProtoSession.current` accessor (both `@available(*, deprecated, renamed:)` shims from a previous rename). Confirmed by grep that no consumer references either symbol. 369 + - **`DPoPNonceStoreTests` deleted; the test file was renamed.** The old `Tests/CoreATProtocolTests/DPoPStoreTests.swift` only had `ClockSkewStoreTests` left after removing the nonce-store tests, so it became `ClockSkewStoreTests.swift`. Test count dropped from 75 → 71 (lost 4 tests for the deleted store; gained nothing). 370 + - **File split kept `DPoPRequestActor` in the main file.** Plan didn't specify where to put it; moving it to the extension file would have required bumping its access level. It's a small infra type used by `refreshLoginIfNeeded`, so it stays alongside its only caller in `ATProtoOAuth.swift`. 371 + - **`validateIssuer` bumped from `private` to `internal`.** Cross-file extension access for `private` doesn't work in Swift — the `loginProvider` (now in `ATProtoOAuth+TokenHandling.swift`) needs to call `Self.validateIssuer(...)`, and the source-of-truth method lives in the main file (also called from `authenticate` and `refreshLoginIfNeeded` there). `internal` keeps it module-scoped. 372 + - **Final layout:** `ATProtoOAuth.swift` (720 lines — public types + class + auth orchestration + `DPoPRequestActor` + `validateIssuer`), `ATProtoOAuth+TokenHandling.swift` (186 lines — `buildTokenHandling`, the three providers, `decodeTokenResponse`, and `URLSession.defaultProvider`), `TokenModels.swift` (73 lines — the three Codable structs). The plan's "~400 lines" estimate for `ATProtoOAuth.swift` was light; the public types + orchestration logic are unavoidable. 373 + - **Auto-memory restructured to match the rules in the system prompt.** The previous `MEMORY.md` had inline content (the auto-memory rules require it to be a flat index pointing at typed memory files). Dropped six stale Plume-app entries (Plume isn't in this checkout), kept the generic `feedback_review_struct_vs_class.md`, added four new files: `feedback_dpop_per_origin_nonce.md`, `project_consumer_oauth_modes.md`, `feedback_atproto_session_singleton.md`, `reference_live_tests_env_var.md`. 374 + - **Stale `.docc` reference noted but not fixed.** `effem/EffemKit/Sources/EffemKit/EffemKit.docc/Authentication.md` still references `APEnvironment.current.dpopPrivateKey` (an API that's been gone for a while now). It's documentation, not code, so the build is fine. Worth a follow-up cleanup in the effem repo when someone touches that doc file. 375 + 376 + ## What didn't make this plan 377 + 378 + The most obvious payoff still on the table is **making `ATProtoSession` instance-based** (already flagged in `APEnvironment.swift`'s docstring as "a future major release"): 379 + 380 + - The singleton is what blocked the integration test from Step 2 (cross-suite `reset()` calls clobber state). 381 + - A scoped session would make the routing layer testable end-to-end without env-var gates. 382 + - Consumers would need updates: `setup()`, `updateTokens()`, `setDPoPPrivateKey(pem:)`, `setDelegate()`, `setTokenRefreshHandler()`, `update(hostURL:)` would all become methods on a session instance, and `APRouterDelegate` would need to be initialized with a session reference rather than reading `ATProtoSession.shared` directly. 383 + - It's a bigger lift than any single step in this plan — touches every consumer and the entire networking layer. 384 + 385 + Other smaller follow-ups: 386 + - Lift `IdentityResolver.ResolvedIdentity` to a top-level `ATProtoResolvedIdentity` (renames a public nested type; pairs naturally with the next major bump). 387 + - The `effem` `EffemKit.docc` references stale `APEnvironment` symbols (see Steps 6+7 notes). 388 + - Add a Slingshot/community DoH fallback resolver — the `ATProtoIdentityResolver` seam exists for exactly this; not built yet because nobody needs it. 389 + - Consider extracting `authenticate` and `refreshLoginIfNeeded` into their own extension file. They're ~200 lines together; the main file would shrink to ~520. Pure organizational; only worth doing if `ATProtoOAuth.swift` starts feeling unwieldy again.