Skip to content

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

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

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .speakeasy/workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ sources:
- location: https://raw.githubusercontent.com/Gusto/Gusto-Partner-API/refs/heads/main/.speakeasy/speakeasy-modifications-overlay.yaml
authHeader: Authorization
authSecret: $openapi_doc_auth_token
- location: gusto_embedded/.speakeasy/speakeasy-modifications-overlay.yaml
registry:
location: registry.speakeasyapi.dev/gusto/ruby-sdk/gusto-embedded-oas
targets:
Expand Down
1 change: 1 addition & 0 deletions gusto_embedded/.genignore
Original file line number Diff line number Diff line change
@@ -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.

24 changes: 24 additions & 0 deletions gusto_embedded/.speakeasy/speakeasy-modifications-overlay.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
overlay: 1.0.0
info:
title: Speakeasy Modifications
version: 0.0.2
x-speakeasy-metadata:
after: ""
before: ""
type: speakeasy-modifications
actions:
- target: $["components"]["securitySchemes"]["SystemAccessAuth"]
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.

schema:
type: object
properties:
clientId:
type: string
clientSecret:
type: string
required:
- clientId
- clientSecret
89 changes: 77 additions & 12 deletions gusto_embedded/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,29 +94,92 @@ yarn add @tanstack/react-query react react-dom
For supported JavaScript runtimes, please consult [RUNTIMES.md](RUNTIMES.md).
<!-- End Requirements [requirements] -->

<!-- Start SDK Example Usage [usage] -->
## SDK Example Usage

### Example
Automatic token refresh using a persistent data store.

```typescript
import { GustoEmbedded } from "@gusto/embedded-api";
import { CompanyAuthenticatedClient } = "@gusto/embedded-api/CompanyAuthenticatedClient";

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

constructor() {}

async get() {
const { token, expires, refreshToken } = retrieveToken();

return {
token,
expires,
refreshToken,
};
}

async set({
token,
expires,
refreshToken,
}: {
token: string;
expires: number;
refreshToken: string;
}) {
saveToken(token, refreshToken, expires);
}
}

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 clientSecret = process.env["GUSTOEMBEDDED_CLIENT_SECRET"];

async function run() {
const result = await gustoEmbedded.introspection.getInfo({});
const response = await client.companies.createPartnerManaged(
{
clientId,
clientSecret,
},
{
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?

lastName: "Ocean",
email: "[email protected]",
phone: "2345558899",
},
company: {
name: "Frank's Ocean, LLC",
tradeName: "Frank’s Ocean",
ein: "123456789",
contractorOnly: false,
},
},
}
);

// Handle the result
console.log(result);
const { accessToken, refreshToken, companyUuid, expiresIn } = response.object;

const tokenStore = PersistentTokenStore();

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

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

run();

```
<!-- End SDK Example Usage [usage] -->
<!-- No SDK Example Usage [usage] -->

<!-- Start Authentication [security] -->
## Authentication
Expand Down Expand Up @@ -158,7 +221,8 @@ const gustoEmbedded = new GustoEmbedded();

async function run() {
const result = await gustoEmbedded.companies.createPartnerManaged({
systemAccessAuth: process.env["GUSTOEMBEDDED_SYSTEM_ACCESS_AUTH"] ?? "",
clientId: process.env["GUSTOEMBEDDED_CLIENT_ID"] ?? "",
clientSecret: process.env["GUSTOEMBEDDED_CLIENT_SECRET"] ?? "",
}, {
requestBody: {
user: {
Expand Down Expand Up @@ -1262,7 +1326,8 @@ async function run() {
let result;
try {
result = await gustoEmbedded.companies.createPartnerManaged({
systemAccessAuth: process.env["GUSTOEMBEDDED_SYSTEM_ACCESS_AUTH"] ?? "",
clientId: process.env["GUSTOEMBEDDED_CLIENT_ID"] ?? "",
clientSecret: process.env["GUSTOEMBEDDED_CLIENT_SECRET"] ?? "",
}, {
requestBody: {
user: {
Expand Down Expand Up @@ -1458,7 +1523,7 @@ looking for the latest version.

## Contributions

While we value open-source contributions to this SDK, this library is generated programmatically. Any manual changes added to internal files will be overwritten on the next generation.
We look forward to hearing your feedback. Feel free to open a PR or an issue with a proof of concept and we'll do our best to include it in a future release.
While we value open-source contributions to this SDK, this library is generated programmatically. Any manual changes added to internal files will be overwritten on the next generation.
We look forward to hearing your feedback. Feel free to open a PR or an issue with a proof of concept and we'll do our best to include it in a future release.

### SDK Created by [Speakeasy](https://www.speakeasy.com/?utm_source=gusto-embedded&utm_campaign=typescript)
78 changes: 78 additions & 0 deletions gusto_embedded/src/CompanyAuthenticatedClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { HTTPClient } from "./lib/http.js";
import {
InMemoryTokenStore,
refreshAndSaveAuthToken,
TokenRefreshOptions,
withTokenRefresh,
} from "./companyAuth.js";
import { GustoEmbedded } from "./sdk/sdk.js";
import { SDKOptions, ServerDemo, ServerList } from "./lib/config.js";

type ClientArguments = {
clientId: string;
clientSecret: string;
accessToken: string;
refreshToken: string;
expiresIn: number;
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.

clientId,
clientSecret,
accessToken,
refreshToken,
expiresIn,
options = {},
}: ClientArguments) {
const authUrl = constructAuthUrl(options);
const tokenStore = new InMemoryTokenStore();

const httpClientWithTokenRefresh = options.httpClient ?? new HTTPClient();

httpClientWithTokenRefresh.addHook("response", async (res) => {
if (res.status === 401) {
console.log("Unauthorized, attempting to refresh token");

await refreshAndSaveAuthToken(
authUrl,
{ clientId, clientSecret, refreshToken },
tokenStore
);
}
});

return new GustoEmbedded({
...options,
httpClient: httpClientWithTokenRefresh,
companyAccessAuth: withTokenRefresh(
clientId,
clientSecret,
accessToken,
refreshToken,
expiresIn,
{
tokenStore,
...options,
url: authUrl,
}
),
});
}

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?

options: TokenRefreshOptions & Pick<SDKOptions, "server" | "serverURL">
) {
const { server, serverURL } = options;

if (server) {
const baseUrl = ServerList[server] || "";
return `${baseUrl}/oauth/token`;
}

if (serverURL) {
return `${serverURL}/oauth/token`;
}

return `${ServerList[ServerDemo]}/oauth/token`;
}
Loading