Rework DPoPSigner, fixing nonce tracking and other errors#50
Conversation
|
|
||
| public func response( | ||
| isolation: isolated (any Actor), | ||
| for request: URLRequest, |
There was a problem hiding this comment.
We don't currently have handling for this, due to the FIXME below, however, the request can include a Authorization: DPoP access-token header, this is the case when making a request with an OAuth Session. We do not encounter this in the tests currently.
If we see that we have an Authorization: DPoP access-token header, then we need to take the access-token value and get the sha256 of the first five characters of that value, and use that as the new Authorization header, so:
token = "e8db0489-6851-4f1d-bafa-b3914b37eb9c"
tokenHash = "jxjZyne7ZIPxhQeKKN-_qgN40uiaMa8izmRqIRqCGow"
request = { Authorization: "DPoP e8db0489-6851-4f1d-bafa-b3914b37eb9c" }
// `buildProof` method receives:
// - not: `e8db0489-6851-4f1d-bafa-b3914b37eb9c`
// - but: `jxjZyne7ZIPxhQeKKN-_qgN40uiaMa8izmRqIRqCGow`
//
// Which is: base64Url(SHA256("e8db0489-6851-4f1d-bafa-b3914b37eb9c"))
request = {
Authorization: "DPoP JWT('jxjZyne7ZIPxhQeKKN-_qgN40uiaMa8izmRqIRqCGow')"
}
This prevents leaking the Access Token to an untrusted party through the DPoP ath parameter for a request with an Authorization header. However, as we're not using swift-crypto here, we actually need to use the tokenHash supplied from a external source, which is what the FIXME is about.
The current implementation would just completely discard the existing Authorization header.
We could provide a SHA256 method to DPoP Signer which would allow the DPoP Signer to calculate the tokenHash. It would just be pkce.hashFunction however in the current code, and that's probably just as messy, and we should prefer using swift crypto instead.
There was a problem hiding this comment.
I think a function to abstract crypto needs, along with a conditionally-compiled import of CryptoKit + default implementation would actually work great for stuff like this. I think I've done that in other places too.
6d8733b to
e17e65b
Compare
| } | ||
|
|
||
| @discardableResult | ||
| public func setNonce(from response: URLResponse) -> Bool { |
There was a problem hiding this comment.
I'm not sure where this method was ever called. I couldn't find any references to it in the code.
There was a problem hiding this comment.
it's public API, so question is if this is valid public API; why would a client call it?
There was a problem hiding this comment.
Well, that's the thing, a client shouldn't ever be mutating the nonce cache. It's an internal detail to the OAuth Session / OAuth Client.
@mattmassicotte why does this API exist? I can't find any references to it anywhere.
There was a problem hiding this comment.
I cannot recall, haha.
I'm fine removing public API here, because it sounds like this should not be used anyways. And in that case, introducing breakage seems like it makes sense to me.
|
|
||
| let (data, _) = try await params.responseProvider(request) | ||
|
|
||
| let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) |
There was a problem hiding this comment.
We'd use OAuthErrorResponse here too, to actually decode Authorization Server errors, and throw those as a well formed Error. This would give the client details as to why the token exchange request failed.
There was a problem hiding this comment.
We've actually a fix for this in a different branch, and I'll try to get that upstreamed to you.
| } | ||
| } | ||
|
|
||
| extension DPoPSigner { |
There was a problem hiding this comment.
Technically, the DPoPSigner should be negotiating with the authorization server as to supported key algorithms, as to not use a incorrect key with the authorization server: https://github.com/bluesky-social/atproto/blob/4e96e2c7b7cc0231607d3065c95704069c4ca2a2/packages/oauth/oauth-client/src/fetch-dpop.ts#L226
However, in AT Protocol, we have this lovely part of the spec:
The ES256 (NIST "P-256") cryptographic algorithm must be supported by all clients and servers for DPoP JWT signing. The set of algorithms recommended for use is expected to evolve over time. Clients and Servers may implement additional algorithms and declare them in metadata documents to facilitate cryptographic evolution and negotiation.
From: https://atproto.com/specs/oauth#demonstrating-proof-of-possession-d-po-p
So because we're using ES256 keys in our DPoPKey implementation, this "mismatch" between supported algorithms and the key's algorithm, we never hit this issue. Yay, shortcuts?
|
Wow! I'm delighted to see you looking at this. I don't really understand this area very deeply, so I'm sure you'll find lots of stuff to improve. While I have no looked closely at all, one thing does give me pause. Changing the type of I will admit that I do not actually even know if there are other subclasses of I wonder if this could be achieved in a less-invasive way by building a small wrapper function with this type and shape? public typealias HTTPURLResponseProvider = @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) |
f2d4240 to
d3409b6
Compare
0806c7c to
81802a9
Compare
|
@mattmassicotte okay, now I know why you responded to this, because it caught me by surprise! I'd actually intended to open against the germ-network repo, but uhh.. github's UI got me. I guess it's fine you saw this before it was ready. |
| var originComponents = URLComponents() | ||
| originComponents.scheme = scheme | ||
| originComponents.host = host | ||
| originComponents.path = self.relativePath |
There was a problem hiding this comment.
Should this actually be self.path? https://www.avanderlee.com/swift/url-components/#working-with-url-components
There was a problem hiding this comment.
Yes it should! Not sure how that stuck in there.
There was a problem hiding this comment.
Weirdly self.path isn't available on all platforms OAuthenticator supports, I think?
|
@ThisIsMissEm ahh. Ha! well that's fine. I've just been letting you do your thing, but if you need/want any feedback from me at any point I'd be happy to provide it. |
|
@mattmassicotte we're just putting it through it's paces in practice in an application, and the other Germ team members found some issues, and I'm going to be debugging them with them. Interestingly enough these bugs did not show up in test coverage. |
1fbb5a1 to
ee138ec
Compare
|
We've now tested this PR in an actual application, to verify it's working as expected. There was an issue discovered due to how the code figured out whether or not it was a request against an authorization server or not, which triggers different error handling to detect DPoP Nonce errors. I've now fixed that issue. |
f1e8400 to
48c09a0
Compare
|
|
||
| let storedLogin = Login( | ||
| accessToken: Token(value: "EXPIRE SOON", expiry: Date().addingTimeInterval(5)), | ||
| accessToken: Token(value: "EXPIRE SOON", expiry: Date().addingTimeInterval(15)), |
There was a problem hiding this comment.
See b81a754
This was an issue due to timing.
There was a problem hiding this comment.
I struggled a lot with figuring out a reliable way to do this, and I'm not 100% sure it actually is fixable with only delays. However, I don't think that actually matters overall and I'm very happy to kick this can down the road.
This also ensures we clearly signal to the DPoP request method whether we are talking to an authorization server or a resource server (since these give different OAuth Error responses). The test coverage has increased dramatically here.
48c09a0 to
780e6ac
Compare
|
@mattmassicotte this should now be ready for review + merge. (We've tested in https://github.com/germ-network/ATProtoLiteClient/compare/test/oauthenticator-dpop-changes ) |
| #endif | ||
|
|
||
| // Only sets the port if the port is not the default port for http or https requests: | ||
| internal func omitWebDefaultPort(components: inout URLComponents, port: Int?, scheme: String) { |
There was a problem hiding this comment.
This is purely stylistic, but I think\ an extension on URLComponents with a mutating member is a nice way to do exactly this that keeps the functionality scoped to the type it can operation on.
|
I trust you on the implementation here. But I don't fully get the use of a cache, particularly one that is owned by a value type. Can you help me understand that better? |
|
The reference type NSCache is a property of the class Because the nonces are issued on a rolling time window for an Oauth Session (mapping 1:1 to an Authenticator object) (and valid concurrently within that window), it seems correct to have stateful storage (but doesn't need to be persisted because of the short time window) within Authenticator of the nonce (+origin) sliding window. I think Emelia reached for the NSCache as a self-evicting data structure for the |
|
Yeah, and because we can explicitly limit the cache size if you're interacting with many different resource server origins (e.g., multiple app views). Most apps will likely only have the Authorization Server and the PDS in here |
That's what I expected to see, but it does not look to me like that's how it works. Am I missing it?
This is also something I don't fully understand. How would this work? One Authenticator object that is shared across different domains? |
Yes, the DPoPSigner is a singleton across the OAuthenticator instance. You could definitely change the API to allow passing in a DPoP Cache from outside of the OAuthenticator instance, but there's not a huge value to that. Majority of the requests you make will be through requests to resource servers, not authorization servers. So because the nonce cache (NSCache) is a private property of the DPoPSigner, you effectively have a singleton of that too per Authenticator. The code this was inspired by does actually pass a DPoP Cache into a signer, as to share it across multiple sessions and clients, but that's not the architecture of OAuthenticator.
Yes, resource servers can and often are on different domains to authorization servers. That's also why there's that note about handling 401's for resource requests is currently incorrect. |
|
Ok I understand, thank you! NSCache has a long history and is a surprisingly complex type that doesn't always work as people expect given the name. It might make sense to (eventually) revisit this so library clients have a little more control. But this would be completely transparent API-wise and I don't think it will affect any of the behaviors here. So let's just go for it! |
This completely reworks the DPoP Nonce handling, such that you have one DPoPSigner still, however, it is request-origin aware, and tracks DPoP Nonce's per origin.
The DPoPSigner internally uses a method to convert
URLResponseProvider'sURLResponseto aHTTPUrlResponsein order to simplify the logic in the DPoPSigner code.The
DPoPSigner.JWTParametersno longer contains aissueras this isn't in the DPoP JWT Parameters, and would leak the user's authorization server to a third-party.We now correctly handle errors and retries: If the Authorization Server (issuer) returns a OAuth Error Response we will only retry if the
errorisuse_dpop_nonceper RFC 9449. This means that all other errors from the Authorization Server do not trigger a retry.If we are requesting against a Resource Server (not a Authorization Server), and we get a
WWW-Authenticateheader with a value likeDPoP error="use_dpop_nonce", then we will retry the request with the server-suppliedDPoP-Nonce. All other errors from the Resource Server will not be retried.No retries will happen if the server does not return a
DPoP-Nonceheader, or if the newDPoP-Nonceheader is the same as the previous DPoP Nonce that we had for that origin, since the retry would just fail again. (That is it's only possible to retry on DPoP Nonce failure if the response contains a newDPoP-Noncevalue.The DPoP
htuis now correctly the request URL without query parameters or hash fragments, per https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-4.6 (This is therequestEndpointinDPoPSigner.JWTParameters). This could have been causing issues previously.Test Coverage
I've significantly increased the test coverage of the
DPoPSigner, going from one test to an entire suite containing 16 test cases (5 are in a parameterized test case).This test coverage includes verifying that different origins don't clobber each other's DPoP Nonce values, and asserting that the retry logic works correctly.
Before Merging