-
Notifications
You must be signed in to change notification settings - Fork 0
[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
base: main
Are you sure you want to change the base?
Changes from all commits
5288c0d
c882fdc
c973f0a
4b12dd8
bff2f93
ddd76d2
b56930f
b02a4d5
05d935f
e3b189b
83b851b
1022738
27995bc
d41a9a5
7edf492
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
/src/hooks/clientcredentials.ts | ||
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this module gets imported on the client, |
||
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", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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: { | ||
|
@@ -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: { | ||
|
@@ -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) |
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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The This is relying on Speakeasy's concept of "hooks". |
||
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
This should both be caught by the type checker. The
Thanks for calling this out. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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`; | ||
} |
There was a problem hiding this comment.
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 withgrant_type: system_access
instead ofgrant_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.