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

[GWS-3384] Implements automatic refresh and token management for system auth and company auth #7

Open
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

azrosen92
Copy link
Collaborator

@azrosen92 azrosen92 commented Jan 29, 2025

Makes the auth interface, including token management and refresh simpler.

  • Adds a custom pre request hook for fetching a new system access token
  • Creates a wrapper function instantiating the client with a custom security callback to handle refreshing and managing company auth access tokens.
  • Adds some open api overrides to support the new system access token auth.

Ticket: https://gustohq.atlassian.net/browse/GWS-3384

Testing in node console
First, apply these changes to your local branch to enable a local build (copy and then use pbpaste | git apply)

diff --git a/.speakeasy/workflow.yaml b/.speakeasy/workflow.yaml
index 2932a89..1e4936e 100644
--- a/.speakeasy/workflow.yaml
+++ b/.speakeasy/workflow.yaml
@@ -13,6 +13,14 @@ sources:
             - location: gusto_embedded/.speakeasy/speakeasy-modifications-overlay.yaml
         registry:
             location: registry.speakeasyapi.dev/gusto/ruby-sdk/gusto-embedded-oas
+    GustoEmbedded-local:
+        inputs:
+            - location: ../Gusto-Partner-API/generated/embedded/api.v2024-04-01.embedded.yaml
+        overlays:
+            - location: ../Gusto-Partner-API/.speakeasy/speakeasy-modifications-overlay.yaml
+            - location: gusto_embedded/.speakeasy/speakeasy-modifications-overlay.yaml
+        registry:
+            location: registry.speakeasyapi.dev/gusto/ruby-sdk/gusto-embedded-local
 targets:
     gusto-embedded:
         target: typescript
@@ -26,3 +34,14 @@ targets:
                 location: registry.speakeasyapi.dev/gusto/ruby-sdk/gusto-embedded-oas-typescript-code-samples
             labelOverride:
                 fixedValue: Typescript (SDK)
+            blocking: false
+    local:
+        target: typescript
+        source: GustoEmbedded-local
+        output: ./gusto_embedded
+        codeSamples:
+            registry:
+                location: registry.speakeasyapi.dev/gusto/ruby-sdk/gusto-embedded-oas-typescript-code-samples
+            labelOverride:
+                fixedValue: Typescript (SDK)
+            blocking: false

Generate code locally using speakeasy run --target=local

Import the generated library as a linked dependency into a node project (I like to use embedded-react-sdk). npm link --save ../gusto-typescript-client/gusto_embedded

open a node console in the node project's directory

Then try to initialize the client and start making requests.

  • You can test out the token refresh logic by instantiating CompanyAuthenticatedClient with serverURL: "http://api.gusto-dev.com:3000" instead of server: "demo". Change ZP's access_token_expires_in configuration in config/initizializers/doorkeeper.rb to something like 30.seconds.
const { GustoEmbedded } = await import('@gusto/embedded-api')
const { CompanyAuthenticatedClient } = await import(
  "@gusto/embedded-api/CompanyAuthenticatedClient"
);

const c = new GustoEmbedded()

const response = await c.companies.createPartnerManaged({ 
  clientId: "<client_id>", 
  clientSecret: "<client_secret>" 
}, {
  requestBody: { 
    user: { 
      firstName: "", 
      lastName: "", 
      email: "" 
    }, 
    company: { 
      name: "" 
    }
  }
})

const { accessToken, refreshToken, companyUuid, expiresIn } = response.object;

const companyAuthClient = CompanyAuthenticatedClient({
    clientId,
    clientSecret,
    accessToken,
    refreshToken,
    expiresIn,
    options: {
      server: "demo",
    },
});

await companyAuthClient.companies.get({ companyId: companyUuid });

@@ -0,0 +1 @@
/src/hooks/clientcredentials.ts
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is an auto-generated hook created by speakeasy when any endpoint in the API is configured to use a type: oauth2 security scheme. We are adding it to .genignore because I had to modify the file to make the fetch token request with grant_type: system_access instead of grant_type: client_credentials, which is the standard for oauth. For now, this is our only option, but in the future we should update our backend to properly support the oauth client credentials flow.

clientSecret: string,
accessToken: string,
refreshToken: string,
options: { tokenStore?: TokenStore; url?: string } = {}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We might want to consider making this non-optional. This is what allows the developer to hook into a data store that can store access/refresh token pairs whenever they are refreshed.

@azrosen92 azrosen92 force-pushed the ar/add-oauth branch 2 times, most recently from eeb991b to 3b479aa Compare January 29, 2025 23:07
@azrosen92 azrosen92 force-pushed the ar/add-oauth branch 2 times, most recently from c8d45d1 to 45704db Compare March 12, 2025 22:06
@azrosen92 azrosen92 changed the title Implements automatic refresh and token management for system auth and company auth [GWS-3384] Implements automatic refresh and token management for system auth and company auth Mar 13, 2025
update:
type: http
scheme: custom
x-speakeasy-custom-security-scheme:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Overlays the SystemAccessAuth security scheme from our open api spec with a custom security scheme that takes a clientId and clientSecret as inputs. These are then used to fetch a system access token before each request, using the clientcredentials hooks.

scopes: string[];
};

export class ClientCredentialsHook
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Defines beforeRequest and afterError hooks.

export function initHooks(hooks: Hooks) {
// Add hooks by calling hooks.register{ClientInit/BeforeCreateRequest/BeforeRequest/AfterSuccess/AfterError}Hook
// with an instance of a hook that implements that specific Hook interface
// Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance
const presetHooks: Array<Hook> = [new ClientCredentialsHook()];
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

registers the beforeRequest and afterError hooks

@azrosen92 azrosen92 marked this pull request as ready for review March 13, 2025 23:03
@azrosen92 azrosen92 requested a review from a team March 13, 2025 23:03
Copy link

@rqsilva rqsilva left a comment

Choose a reason for hiding this comment

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

I'm a bit rusty with TS so this admittedly isn't the most thorough review, but I left a few questions/comments and noted where I faced the most friction while testing.

Is it worth it to also add some test coverage here?

Don't want to block getting this out to Lattice, so I'm happy to approve in the meantime if you want to address the feedback in another PR

Comment on lines 73 to 79
if (serverURL) {
return `${serverURL}/oauth/token`;
}

if (url) {
return url;
}
Copy link

Choose a reason for hiding this comment

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

curious why the need to support both serverURL and url?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good call – the original implementation of this security callback that Speakeasy recommended is built in a way that consumers of the client can configure a separate auth server (the oauth spec doesn't specify that the auth server should be on the same domain as the rest of the API). However, since our implementation of oauth has the auth server on the same domain as the rest of the API, this probably isn't necessary. I'll consolidate to just allow one of these.

type Session = {
credentials: Credentials;
token: string;
expiresAt?: number;
Copy link

Choose a reason for hiding this comment

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

is this being optional to ensure support for something like a personal access token, which never expires?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm looking through the code and I'm not actually sure why I made this optional. But I think it would make sense to leave as optional for that exact case.

) {
const {
tokenStore = new InMemoryTokenStore(),
url = "https://api.gusto-demo.com/oauth/token",
Copy link

Choose a reason for hiding this comment

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

should this (or at least the demo root) be stored as a constant in a separate file?

Copy link

Choose a reason for hiding this comment

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

also is this hard coding the demo environment? (sorry, it's been a minute since I've looked at TS code 😅 )

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yup, this is defaulting to the demo environment. We default to demo in a few other places, most importantly here. So I just wanted to make that behavior consistent for auth code. I'm also changing this to use

    url = ServerList[ServerDemo],

You're right that we should be using a constant!

options: TokenRefreshOptions & SDKOptions;
};

export function CompanyAuthenticatedClient({
Copy link

Choose a reason for hiding this comment

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

are there plans to extend this to other token types? (essentially, will we offer this for system tokens as well?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The clientcredentials.ts file in this PR handles fetching and refreshing system access tokens. Consumers of the library only have to pass in a clientId and clientSecret and the clientcredentials hooks will automatically fetch or refresh a system access token as needed.

This is relying on Speakeasy's concept of "hooks". clientcredentials.ts defines a beforeRequest hook, that checks for the existence of a system access token before the request and fetches one if one hasn't been generated yet. It also defines an afterError hook that checks for a 401 response code, then deletes the saved system access token so the user of the client library can retry the request and create a new system access token.

});
}

function constructAuthUrl(
Copy link

@rqsilva rqsilva Mar 28, 2025

Choose a reason for hiding this comment

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

during my local testing, I didn't realize I had multiple options for setting the server url/env. Will partners using this SDK also be testing locally? If so, it might be worth it to improve that feedback loop.

Workarounds I tried to get it working locally:

  • setting server to local (raised an Uncaught TypeError: Invalid URL error)
  • not passing in options (raised an Uncaught: TypeError: Cannot destructure property 'server' of 'options' as it is undefined. error)

Not sure if setting a default "server" value here would be best, or if improving the error messages makes more sense. The error message might be preferable since it gives the dev more insight as to what's being executed

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In practice, partners will not be testing against a local server, so I'm hesitant to index too much on the UX of the serverURL argument. Partners should be using the server argument (either 'demo' or 'prod'), which defaults to 'demo'. If nothing is passed in for server or serverURL the client should still work.

setting server to local

This should both be caught by the type checker. The server argument's type should be 'prod' | 'demo', so the type checker would not allow a value of 'local' and should guide the developer toward the two possible values.

not passing in options

Thanks for calling this out. The options argument was not optional in the type signature, but in practice you should be able to use the client without setting any of these values. I'll change it to be optional, and update the code to avoid this scenario when the user doesn't pass in anything for options.

Copy link

Choose a reason for hiding this comment

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

In practice, partners will not be testing against a local server

Could you explain a little more about how this is to work between prod/non-prod environments? Is it expected that the partner uses this only in prod and in non-prod comes up with their own solution?

}

const client = new GustoEmbedded();
const clientId = process.env["GUSTOEMBEDDED_CLIENT_ID"]
Copy link

Choose a reason for hiding this comment

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

If this module gets imported on the client, process.env will likely not be defined. What if we deferred this until a particular action is taken instead of making it happen in the module import?

const gustoEmbedded = new GustoEmbedded({
companyAccessAuth: process.env["GUSTOEMBEDDED_COMPANY_ACCESS_AUTH"] ?? "",
});
class PersistentTokenStore implements TokenStore {
Copy link

Choose a reason for hiding this comment

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

Do we need to add some kind of documentation that this is insecure if you attempt to use it in the browser? Right now the SDK assumes it's running solely in the browser and not on the server. That's subject to change eventually but it might break some assumptions we currently make.

Copy link

Choose a reason for hiding this comment

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

The reason I bring up the SDK here is that it ships this library, so whatever imports we add here have a chance of intentionally or unintentionally impacting the SDK

*/
export interface TokenStore {
get(): Promise<
{ token: string; refreshToken: string; expires: number } | undefined
Copy link

Choose a reason for hiding this comment

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

Since the tuple token, refreshToken, expires occurs together so often I wonder if making an interface/type for them would be appropriate. What do you think?

}

private hasTokenExpired(expiresAt?: number): boolean {
return !expiresAt || Date.now() + 60000 > expiresAt;
Copy link

Choose a reason for hiding this comment

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

What do you think about extracting the magic number 60000 into an explanatory constant?

{
requestBody: {
user: {
firstName: "Frank",
Copy link

Choose a reason for hiding this comment

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

What are the implications of using these hardcoded values for all partners?

@scally
Copy link

scally commented Mar 31, 2025

I am assuming that the purpose of this is to help partners who are already using NodeJS as their backend tech. Is that correct? Is there any guidance as to how to incorporate their own authentication system with this?

}

/**
* A TokenStore is used to save and retrieve OAuth tokens for use across SDK
Copy link

Choose a reason for hiding this comment

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

This is a cool idea; I like that it's flexible enough to be agnostic about the store implementation

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.

3 participants