this repo has no description
2
fork

Configure Feed

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

more auth

+164 -61
+4 -31
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 783 783 } 784 784 785 785 let tokenResponse = try Self.decodeTokenResponse(from: data) 786 - guard tokenResponse.tokenType == "DPoP" else { 787 - throw AuthenticatorError.dpopTokenExpected(tokenResponse.tokenType) 788 - } 789 - guard tokenResponse.scopes.contains("atproto") else { 790 - throw ATProtoOAuthError.missingRequiredScope("atproto") 791 - } 786 + try TokenValidator(expectedSubjectDID: expectedSubjectDID).validate(tokenResponse) 792 787 793 788 if iss != server.issuer { 794 789 throw AuthenticatorError.issuingServerMismatch(iss, server.issuer) 795 790 } 796 791 try Self.validateIssuer(iss, matches: expectedAuthorizationServer) 797 - 798 - if let expectedSubjectDID, tokenResponse.subject != expectedSubjectDID { 799 - throw ATProtoOAuthError.subjectMismatch(expected: expectedSubjectDID, actual: tokenResponse.subject) 800 - } 801 792 802 793 return tokenResponse.login(for: iss) 803 794 } ··· 839 830 } 840 831 841 832 let tokenResponse = try Self.decodeTokenResponse(from: data) 842 - guard tokenResponse.tokenType == "DPoP" else { 843 - throw AuthenticatorError.dpopTokenExpected(tokenResponse.tokenType) 844 - } 845 - guard tokenResponse.scopes.contains("atproto") else { 846 - throw ATProtoOAuthError.missingRequiredScope("atproto") 847 - } 848 - 849 - if let expectedSubjectDID, tokenResponse.subject != expectedSubjectDID { 850 - throw ATProtoOAuthError.subjectMismatch(expected: expectedSubjectDID, actual: tokenResponse.subject) 851 - } 833 + try TokenValidator(expectedSubjectDID: expectedSubjectDID).validate(tokenResponse) 852 834 853 835 return tokenResponse.login(for: login.issuingServer ?? server.issuer) 854 836 } ··· 865 847 nonisolated private static func validateIssuer(_ issuer: String, matches expectedAuthorizationServer: String) throws { 866 848 guard let issuerURL = URL(string: issuer), 867 849 let expectedURL = URL(string: expectedAuthorizationServer), 868 - normalizedOrigin(issuerURL) == normalizedOrigin(expectedURL) else { 850 + URLOrigin.normalized(issuerURL) == URLOrigin.normalized(expectedURL) else { 869 851 throw ATProtoOAuthError.issuerMismatch(expected: expectedAuthorizationServer, actual: issuer) 870 852 } 871 - } 872 - 873 - nonisolated private static func normalizedOrigin(_ url: URL) -> String? { 874 - guard let scheme = url.scheme?.lowercased(), 875 - let host = url.host?.lowercased() else { 876 - return nil 877 - } 878 - let port = url.port.map { ":\($0)" } ?? "" 879 - return "\(scheme)://\(host)\(port)" 880 853 } 881 854 } 882 855 ··· 942 915 } 943 916 } 944 917 945 - private struct OAuthTokenResponse: Codable { 918 + struct OAuthTokenResponse: Codable, Sendable { 946 919 let accessToken: String 947 920 let refreshToken: String? 948 921 let subject: String
+2 -11
Sources/CoreATProtocol/OAuth/DPoPProofSigner.swift
··· 64 64 let nonce: String? 65 65 if let explicit = params.explicitNonce { 66 66 nonce = explicit 67 - } else if let origin = Self.origin(for: params.url) { 67 + } else if let origin = URLOrigin.normalized(params.url) { 68 68 nonce = noncesByOrigin[origin] 69 69 } else { 70 70 nonce = nil ··· 100 100 /// growth bounded — a true LRU is overkill since the realistic origin 101 101 /// count is two (auth server and PDS). 102 102 public func cacheNonce(_ nonce: String, from url: URL) { 103 - guard let origin = Self.origin(for: url) else { return } 103 + guard let origin = URLOrigin.normalized(url) else { return } 104 104 if noncesByOrigin.count >= nonceCacheLimit, noncesByOrigin[origin] == nil { 105 105 if let key = noncesByOrigin.keys.first { 106 106 noncesByOrigin.removeValue(forKey: key) ··· 120 120 components?.query = nil 121 121 components?.fragment = nil 122 122 return components?.url?.absoluteString ?? url.absoluteString 123 - } 124 - 125 - static func origin(for url: URL) -> String? { 126 - guard let scheme = url.scheme?.lowercased(), 127 - let host = url.host?.lowercased() else { 128 - return nil 129 - } 130 - let port = url.port.map { ":\($0)" } ?? "" 131 - return "\(scheme)://\(host)\(port)" 132 123 } 133 124 134 125 static func athClaim(for accessToken: String) -> String {
+3 -12
Sources/CoreATProtocol/OAuth/IdentityResolver.swift
··· 94 94 ) async throws -> Bool { 95 95 let metadata = try await protectedResourceMetadata(for: pdsEndpoint) 96 96 guard let authorizationServerURL = URL(string: authorizationServer), 97 - let inputOrigin = normalizedOrigin(from: authorizationServerURL) else { 97 + let inputOrigin = URLOrigin.normalized(authorizationServerURL) else { 98 98 return false 99 99 } 100 100 101 101 let allowedOrigins: [String] = metadata.authorizationServers.compactMap { value in 102 102 guard let url = URL(string: value) else { return nil } 103 - return normalizedOrigin(from: url) 103 + return URLOrigin.normalized(url) 104 104 } 105 105 106 106 return allowedOrigins.contains(inputOrigin) ··· 231 231 throw IdentityError.noAuthServerFound 232 232 } 233 233 guard let authServerURL = URL(string: authServer), 234 - normalizedOrigin(from: authServerURL) != nil else { 234 + URLOrigin.normalized(authServerURL) != nil else { 235 235 throw IdentityError.noAuthServerFound 236 236 } 237 237 return authServer ··· 282 282 return host 283 283 } 284 284 return nil 285 - } 286 - 287 - private func normalizedOrigin(from url: URL) -> String? { 288 - guard let scheme = url.scheme?.lowercased(), 289 - let host = url.host?.lowercased() else { 290 - return nil 291 - } 292 - let port = url.port.map { ":\($0)" } ?? "" 293 - return "\(scheme)://\(host)\(port)" 294 285 } 295 286 296 287 // MARK: - URL construction
+36
Sources/CoreATProtocol/OAuth/TokenValidator.swift
··· 1 + // 2 + // TokenValidator.swift 3 + // CoreATProtocol 4 + // 5 + 6 + import Foundation 7 + import OAuthenticator 8 + 9 + /// Validates the response payload from the AT Protocol token endpoint 10 + /// against the requirements that apply to both initial login and refresh: 11 + /// the token type must be `DPoP`, the `atproto` scope must be granted, and 12 + /// — when the caller knows which DID they expected — the response's `sub` 13 + /// must match. 14 + /// 15 + /// Issuer matching is *not* the validator's job. The login flow validates 16 + /// the `iss` query parameter from the authorization callback (which the 17 + /// validator never sees), and the refresh flow has no `iss` to validate 18 + /// against because the auth-server URL is fixed by the time refresh runs. 19 + struct TokenValidator: Sendable { 20 + let expectedSubjectDID: String? 21 + 22 + func validate(_ response: OAuthTokenResponse) throws { 23 + guard response.tokenType == "DPoP" else { 24 + throw AuthenticatorError.dpopTokenExpected(response.tokenType) 25 + } 26 + guard response.scopes.contains("atproto") else { 27 + throw ATProtoOAuthError.missingRequiredScope("atproto") 28 + } 29 + if let expectedSubjectDID, response.subject != expectedSubjectDID { 30 + throw ATProtoOAuthError.subjectMismatch( 31 + expected: expectedSubjectDID, 32 + actual: response.subject 33 + ) 34 + } 35 + } 36 + }
+25
Sources/CoreATProtocol/OAuth/URLOrigin.swift
··· 1 + // 2 + // URLOrigin.swift 3 + // CoreATProtocol 4 + // 5 + 6 + import Foundation 7 + 8 + /// Single source of truth for the "origin" representation used by the OAuth 9 + /// layer to compare URLs that should be considered equivalent endpoints 10 + /// (issuer matching, per-origin nonce caching, auth-server validation). 11 + /// 12 + /// The shape — `scheme://host[:port]` with both lowercased — matches RFC 6454 13 + /// and what AT Protocol metadata documents declare. Any divergence between 14 + /// call sites would silently misclassify equivalent endpoints, so all 15 + /// callers go through this one helper. 16 + enum URLOrigin { 17 + static func normalized(_ url: URL) -> String? { 18 + guard let scheme = url.scheme?.lowercased(), 19 + let host = url.host?.lowercased() else { 20 + return nil 21 + } 22 + let port = url.port.map { ":\($0)" } ?? "" 23 + return "\(scheme)://\(host)\(port)" 24 + } 25 + }
+79
Tests/CoreATProtocolTests/TokenValidatorTests.swift
··· 1 + import Foundation 2 + import Testing 3 + import OAuthenticator 4 + @testable import CoreATProtocol 5 + 6 + @Suite("TokenValidator") 7 + struct TokenValidatorTests { 8 + @Test("Valid response: DPoP token type, atproto scope, matching subject — no throw") 9 + func successPath() throws { 10 + let response = makeResponse() 11 + let validator = TokenValidator(expectedSubjectDID: "did:plc:abc") 12 + #expect(throws: Never.self) { 13 + try validator.validate(response) 14 + } 15 + } 16 + 17 + @Test("Wrong token type throws AuthenticatorError.dpopTokenExpected") 18 + func wrongTokenType() { 19 + let response = makeResponse(tokenType: "Bearer") 20 + let validator = TokenValidator(expectedSubjectDID: nil) 21 + #expect(throws: AuthenticatorError.self) { 22 + try validator.validate(response) 23 + } 24 + } 25 + 26 + @Test("Missing atproto scope throws ATProtoOAuthError.missingRequiredScope") 27 + func missingAtprotoScope() { 28 + let response = makeResponse(scope: "transition:generic") 29 + let validator = TokenValidator(expectedSubjectDID: nil) 30 + #expect(throws: ATProtoOAuthError.self) { 31 + try validator.validate(response) 32 + } 33 + } 34 + 35 + @Test("Subject mismatch when expected DID is set throws ATProtoOAuthError.subjectMismatch") 36 + func subjectMismatch() { 37 + let response = makeResponse(subject: "did:plc:wrong") 38 + let validator = TokenValidator(expectedSubjectDID: "did:plc:abc") 39 + #expect(throws: ATProtoOAuthError.self) { 40 + try validator.validate(response) 41 + } 42 + } 43 + 44 + @Test("Subject is not checked when expected DID is nil") 45 + func subjectIgnoredWhenExpectedNil() throws { 46 + let response = makeResponse(subject: "did:plc:anything") 47 + let validator = TokenValidator(expectedSubjectDID: nil) 48 + #expect(throws: Never.self) { 49 + try validator.validate(response) 50 + } 51 + } 52 + 53 + @Test("Multi-scope response containing atproto passes") 54 + func multiScopeWithAtprotoPasses() throws { 55 + let response = makeResponse(scope: "atproto transition:generic transition:chat.bsky") 56 + let validator = TokenValidator(expectedSubjectDID: nil) 57 + #expect(throws: Never.self) { 58 + try validator.validate(response) 59 + } 60 + } 61 + 62 + private func makeResponse( 63 + accessToken: String = "access-token", 64 + refreshToken: String? = "refresh-token", 65 + subject: String = "did:plc:abc", 66 + scope: String = "atproto transition:generic", 67 + tokenType: String = "DPoP", 68 + expiresIn: Int = 3600 69 + ) -> OAuthTokenResponse { 70 + OAuthTokenResponse( 71 + accessToken: accessToken, 72 + refreshToken: refreshToken, 73 + subject: subject, 74 + scope: scope, 75 + tokenType: tokenType, 76 + expiresIn: expiresIn 77 + ) 78 + } 79 + }
+15 -7
update_auth.md
··· 4 4 5 5 ## Status (2026-04-29) 6 6 7 - - **Steps 1–2 are landed.** Per-origin DPoP nonce caching is live. CoreATProtocol's 66 tests pass; bskyKit and EffemKit rebuild cleanly against the local checkout. 8 - - **Steps 3–5 are next.** They're independent of each other and any pair of them is a reasonable single-session chunk. 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 9 - **Step 6 remains gated** behind a SemVer-major bump. 10 10 11 - See "Notes from the Steps 1+2 implementation" near the bottom for the deviations from the original plan. 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. 12 12 13 13 ## Inventory of what we have today 14 14 ··· 197 197 - `DPoPStoreTests.swift` continues to test `DPoPNonceStore` for now. The new `DPoPSignerTests` covers the new path. 198 198 - Add an integration test in `OAuthTests.swift` that drives a fake `URLResponseProvider`, sees a `DPoP-Nonce` header, and confirms the next outbound proof carries the nonce. 199 199 200 - ### Step 3 — Centralize URL canonicalization + origin helpers 200 + ### Step 3 — Centralize URL canonicalization + origin helpers — DONE 201 201 202 202 The `htu` and origin logic now lives in `DPoPSigner` (steps 1–2). Two more places use it: 203 203 - `ATProtoOAuth.normalizedOrigin(_:)` (line 901) — for issuer comparison. ··· 218 218 219 219 Update three call sites (`DPoPSigner.origin`, `ATProtoOAuth.normalizedOrigin`, `IdentityResolver.normalizedOrigin`) to call `URLOrigin.normalized`. Internal helper, no public API change. 220 220 221 - ### Step 4 — Extract the token validator closure 221 + ### Step 4 — Extract the token validator closure — DONE 222 222 223 223 `ATProtoOAuth.loginProvider` (line 759) inlines all the token-response validation: DPoP token type, `atproto` scope, issuer matches expected auth server, sub matches expected DID. Same checks duplicate in `refreshProvider` (line 834). 224 224 ··· 325 325 326 326 1. **PR 1**: Step 1 — `DPoPSigner` + tests. Standalone, no consumer changes. **DONE**, bundled with PR 2. 327 327 2. **PR 2**: Step 2 — wire `DPoPSigner` into `Networking.swift` + `ATProtoOAuth.swift`. Largest diff. Verify against Atprosphere + effem before merge. **DONE**. 328 - 3. **PR 3**: Steps 3 + 4 — URL canonicalization + token validator. Small refactor PR. **NEXT**. 329 - 4. **PR 4**: Step 5 — resolver protocol + offline tests. 328 + 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 330 5. **PR 5** (later, behind a SemVer bump): Step 6 — file split and deprecation removal. 331 331 332 332 Steps 1–5 are non-breaking. Step 6 is breaking and can wait until you have another reason to bump the major version. ··· 339 339 - **Dropped the integration test from Step 2.** The plan suggested an end-to-end test that drives `APRouterDelegate.didReceiveErrorResponse` then `intercept` and verifies the cached nonce flows through. Implementation revealed that any test touching `ATProtoSession.shared` races with other suites that call `await ATProtoSession.shared.reset()` (`NonceDetectionTests`, `RefreshLoginTests`, `DPoPTests`). Swift Testing's `.serialized` trait only orders within a suite, not across suites. Two paths are open here: (1) globally serialize all session-touching suites, or (2) make the session instance-based so tests can spin up isolated sessions. (2) is the long-term fix and is closer to what's contemplated in `APEnvironment.swift`'s "future major release will make it instance-based" comment. Until then, the per-origin caching is fully covered by the unit tests in `DPoPProofSignerTests`, and the wiring through `Networking.swift` is straightforward delegation (the kind of code production traffic exercises immediately). 340 340 - **Verified consumers.** bskyKit and EffemKit were rebuilt against the local CoreATProtocol checkout via a temporary `.package(path:)` swap — both succeeded. Atprosphere and the effem iOS app are `.xcodeproj` consumers; their call sites were inspected and only touch preserved public APIs (`setDPoPPrivateKey(pem:)`, `ATProtoOAuth(config:storage:)`, `ATProtoOAuthConfig`, `ATProtoOAuthError`, `ATProtoSession.shared.host`, `ATProtoSession.shared.routerDelegate`). 341 341 - **Updated test:** `nonceCacheBounded` was rewritten — the original draft asserted "≥ 1 of the 5 oldest origins was evicted," which was probabilistic since Swift dictionaries don't guarantee key ordering. The new assertion is the deterministic invariant: after 30 insertions into a 25-slot cache, exactly 25 entries survive. 342 + 343 + ## Notes from the Steps 3+4 implementation 344 + 345 + - **Deleted the `normalizedOrigin` wrappers entirely** rather than having them delegate. `DPoPProofSigner.origin(for:)`, `ATProtoOAuth.normalizedOrigin(_:)`, and `IdentityResolver.normalizedOrigin(from:)` are gone; their five call sites now invoke `URLOrigin.normalized` directly. Wrappers that only forward to a one-liner are noise. 346 + - **`TokenValidator` is in its own file** — `Sources/CoreATProtocol/OAuth/TokenValidator.swift`. The plan placed it as a private nested type in `ATProtoOAuth.swift`, but the file is already 970+ lines and the validator is unit-tested directly (`TokenValidatorTests`), so a separate file is cleaner. The struct is `internal` (not `private`) so tests reach it via `@testable import CoreATProtocol`. 347 + - **`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 + - **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 + - **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.