Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🚧 OAuth - Client SDK #2434

Closed
wants to merge 11 commits into from
Closed

Conversation

matthieusieben
Copy link
Contributor

@matthieusieben matthieusieben commented Apr 22, 2024

Why

From a conceptual point of view, An XRPC client should not expose which "service" will be triggered under the hood when the call() method is invoked. All is should expose is a simple call(method, ...) signature.

This separation of concerns is especially useful in Bluesky's architecture since in that particular case, two "services" are actually needed to properly route calls: The entryway (that provides credentials) and the PDS (that handles the actual HTTP request).

This separation of concerns becomes even more useful when we introduce the support for various authentication strategies (e.g. OAuth), which may introduce special behaviors (e.g. automatic session refresh, sixgning of HTTP requests, DPoP proofs).

In its current form, the Client and ServiceClient classes from @atproto/xrpc do expose the uri being triggered. This yields to users of that class having to know the internals of the XRPC Client and requires them to manually change that uri when needed:

https://github.com/bluesky-social/social-app/blob/1e484c6318c0f1a2fa46c028d53b92d817293d11/src/state/session/index.tsx#L316

This PR inverts this logic by allowing a specialized class (XrpcDispatcher) to decide how to actually dispatch the HTTP call. That class can not only "decide" which service endpoint will be triggered but it can also perform additional tasks such as handling authentication. In particular, it can inject authorization headers and handle http errors in order to retry.

This change was motivated by the fact that session management in case of OAuth is radically different to what exists now.

Details

A dispatcher has a signature:

interface Dispatcher {
  dispatch(path: string, init: RequestInit): Promise<Response>
}

A Dispatcher is responsible to:

  1. build the full URL
  2. make the HTTP call (typically using fetch())
  3. handle extra stuff (auth headers, session refresh, retries, etc.)

The dispatcher is used like so:

declare const dispatcher: Dispatcher // Define this somehow

const client = new XrpcClient(dispatcher)
await client.call('com.atproto.server....', params)

The default XrpcDispatcher can be used to perform basic calls:

const dispatcher = new XrpcDispatcher({
  service: 'http://example.com',
  headers: { authorization: 'Bearer 123' }
})

const client = new XrpcClient(dispatcher)

Note that the XrpcClient constructor will instantiate a default XrpcDispatcher if a dispatcher was not passed are argument:

const client = new XrpcClient({ service: "https://example.com" })

A more advanced use case would typically inject a dispatch function:

let service = "https://example.com"
const client = new XrpcClient(async (url: string, init: RequestInit): Promise<Request> => {
  // Retry any fetch() error
  const fullUrl = new URL(url, service)
  try {
    return await fetch(fullUrl, init)
  } catch (err) {
    return await fetch(fullUrl, init)
  }
})

The AtpDispatcher extends XrpcDispatcher by adding:

  • getDid(): get the did of the currently logged in user
  • getServiceUrl(): get the entry way url

This abstraction is extended by either:

  • OAuthDispatcher that will implement this interface using OAuth endpoints
  • SessionDispatcher where were moved the following items
    • Making the fetch() call is performed here
    • Building the endpoint url (pdrUrl || serviceUrl)
    • Adds the Authorization header
    • Perform session management (all the methods previously defined on AtpAgent related to session management): createAccount, login, resumeSession & refreshSession + emitting of session changed related events (AtpPersistSessionHandler). The logic here is exactly was was present in AtpAgent.

Because the OAuthDispatcher will no longer have the ability to login(), createAccount(), etc. in the same fashion, those methods are no longer exposed on the AtpAgent itself.

The impact of this is that now, in order to use the conventional session management methods, one must use the SessionDispatcher instead.

Before

  const agent = new BskyAgent({service})
  await agent.login({identifier, password, authFactorToken})

After

  const dispatcher = new SessionDispatcher({service})
  await dispatcher.login({identifier, password, authFactorToken})
  const agent = new BskyAgent(dispatcher)

Future: The end goal is to replace the SessionDispatcher by an oauthClient like so:

  const agent = new BskyAgent(oauthClient)

@matthieusieben matthieusieben marked this pull request as draft April 22, 2024 19:23
@matthieusieben matthieusieben marked this pull request as ready for review April 23, 2024 08:32
const transferEncoding = req.headers['transfer-encoding']
return (contentLength && parseInt(contentLength, 10) > 0) || transferEncoding
export function hasBody(req: express.Request): boolean {
// xrpc client will send a content-type header if, and only if, there is a body
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While our xrpc client will send a content-type header, I'm not sure it's formally a requirement, especially for any endpoint that receives application/octet-stream (since servers may interpret a missing content type in this way). We want to ensure we're compatible with other implementations, and I think that means sticking close to HTTP in any cases of ambiguity.

Though I'm also curious, was there something wrong with the existing implementation that required this change? The only thing that stands out to me is that it's possible we should be checking for chunked encoding as opposed to any transfer encoding.

Copy link
Contributor Author

@matthieusieben matthieusieben Apr 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is currently not possible to upload an empty text file. I added a test for that.

I also changed this again since you reviewed to work more like it used to. I "just" added the following condition that will also considered as having a body:

// e.g. an empty txt file
if (req.headers['content-length'] === "0" && req.headers['content-length']) return true

The rest of the diff in this function is purely aestetical.

Copy link
Collaborator

@devinivy devinivy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good 👍

packages/xrpc/src/util.ts Outdated Show resolved Hide resolved
@matthieusieben matthieusieben marked this pull request as draft April 23, 2024 18:49
@matthieusieben matthieusieben force-pushed the xrpc-blob-upload branch 4 times, most recently from d6bbf1b to 5b13cd5 Compare April 26, 2024 10:15
@matthieusieben matthieusieben changed the title Support Blob payload in XRPC procedures XRPC reword Apr 26, 2024
@matthieusieben matthieusieben changed the title XRPC reword XRPC rework Apr 26, 2024
@matthieusieben matthieusieben marked this pull request as ready for review April 26, 2024 12:29
@matthieusieben matthieusieben force-pushed the xrpc-blob-upload branch 6 times, most recently from 2d86132 to 3b9a883 Compare May 2, 2024 08:04
@matthieusieben matthieusieben changed the title XRPC rework ✅ XRPC rework May 3, 2024
@pfrazee
Copy link
Collaborator

pfrazee commented May 10, 2024

Okay this is on a good path. My main concern at this stage is that there's a lot of complexity that you inherited. You made very strong choices within that -- but we still have a pretty complicated class hierarchy:

CleanShot 2024-05-09 at 21 54 45@2x

The "XRPC" concept being separated may not be doing us any real favors at this stage. I wonder if we can compress it along these lines:

Good Better?
CleanShot 2024-05-09 at 21 58 46@2x CleanShot 2024-05-09 at 22 01 45@2x

This is a significant amount of additional work so I think we should talk about this. I don't want to unduly hold up OAuth more than I already have.

@matthieusieben matthieusieben force-pushed the xrpc-blob-upload branch 2 times, most recently from 8cbf61e to 5d924cc Compare May 13, 2024 11:31
@matthieusieben matthieusieben changed the base branch from main to feat-oauth-server May 13, 2024 11:31
@matthieusieben matthieusieben changed the title ✅ XRPC rework 🚧 XRPC rework May 13, 2024
@matthieusieben matthieusieben changed the title 🚧 XRPC rework 🚧 OAuth - Client SDK May 13, 2024
@matthieusieben
Copy link
Contributor Author

reworked in #2483

@matthieusieben matthieusieben deleted the xrpc-blob-upload branch May 13, 2024 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants