diff --git a/.speakeasy/workflow.yaml b/.speakeasy/workflow.yaml index dc908881..2932a89b 100644 --- a/.speakeasy/workflow.yaml +++ b/.speakeasy/workflow.yaml @@ -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: diff --git a/gusto_embedded/.genignore b/gusto_embedded/.genignore new file mode 100644 index 00000000..bdd373ab --- /dev/null +++ b/gusto_embedded/.genignore @@ -0,0 +1 @@ +/src/hooks/clientcredentials.ts diff --git a/gusto_embedded/.speakeasy/speakeasy-modifications-overlay.yaml b/gusto_embedded/.speakeasy/speakeasy-modifications-overlay.yaml new file mode 100644 index 00000000..dcb0cf05 --- /dev/null +++ b/gusto_embedded/.speakeasy/speakeasy-modifications-overlay.yaml @@ -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: + schema: + type: object + properties: + clientId: + type: string + clientSecret: + type: string + required: + - clientId + - clientSecret diff --git a/gusto_embedded/README.md b/gusto_embedded/README.md index dc660d37..c0fa5cb5 100644 --- a/gusto_embedded/README.md +++ b/gusto_embedded/README.md @@ -94,29 +94,92 @@ yarn add @tanstack/react-query react react-dom For supported JavaScript runtimes, please consult [RUNTIMES.md](RUNTIMES.md). - ## 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 { + 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"] +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", + lastName: "Ocean", + email: "frank@example.com", + 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(); ``` - + ## 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) diff --git a/gusto_embedded/src/CompanyAuthenticatedClient.ts b/gusto_embedded/src/CompanyAuthenticatedClient.ts new file mode 100644 index 00000000..30b98699 --- /dev/null +++ b/gusto_embedded/src/CompanyAuthenticatedClient.ts @@ -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({ + 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( + options: TokenRefreshOptions & Pick +) { + 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`; +} diff --git a/gusto_embedded/src/companyAuth.ts b/gusto_embedded/src/companyAuth.ts new file mode 100644 index 00000000..a1a45342 --- /dev/null +++ b/gusto_embedded/src/companyAuth.ts @@ -0,0 +1,164 @@ +import * as z from "zod"; +import { SDK_METADATA } from "./lib/config.js"; +import { ServerDemo, ServerList } from "./lib/config.js"; + +// TypeScript SDKs use Zod for runtime data validation. We can use Zod +// to describe the shape of the response from the OAuth token endpoint. If the +// response is valid, Speakeasy can safely access the token and its expiration time. +const tokenResponseSchema = z.object({ + access_token: z.string(), + expires_in: z.number().positive(), + refresh_token: z.string(), +}); + +// This is a rough value that adjusts when we consider an access token to be +// expired. It accounts for clock drift between the client and server +// and slow or unreliable networks. +const tolerance = 5 * 60 * 1000; + +export type TokenRefreshOptions = { tokenStore?: TokenStore; url?: string }; + +/** + * A callback function that can be used to obtain an OAuth access token for use + * with SDKs that require OAuth security. A new token is requested from the + * OAuth provider when the current token has expired. + */ +export function withTokenRefresh( + clientId: string, + clientSecret: string, + accessToken: string, + refreshToken: string, + expiresIn: number, + options: TokenRefreshOptions = {} +) { + const { + tokenStore = new InMemoryTokenStore(), + url = ServerList[ServerDemo], + } = options; + + tokenStore.set({ + token: accessToken, + refreshToken, + expires: Date.now() + expiresIn * 1000 - tolerance, + }); + + return async (): Promise => { + const session = await tokenStore.get(); + + // Return the current token if it has not expired yet. + if (session && session.expires > Date.now()) { + return session.token; + } + + return await refreshAndSaveAuthToken( + url, + { + clientId, + clientSecret, + refreshToken: session?.refreshToken ?? refreshToken, + }, + tokenStore + ); + }; +} + +export async function refreshAndSaveAuthToken( + authUrl: string, + refreshCredentials: { + clientId: string; + clientSecret: string; + refreshToken: string; + }, + tokenStore: TokenStore +): Promise { + const { clientId, clientSecret, refreshToken } = refreshCredentials; + + try { + const response = await fetch(authUrl, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + // Include the SDK's user agent in the request so requests can be + // tracked using observability infrastructure. + "user-agent": SDK_METADATA.userAgent, + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + throw new Error("Unexpected status code: " + response.status); + } + + const json = await response.json(); + const data = tokenResponseSchema.parse(json); + + await tokenStore.set({ + token: data.access_token, + expires: Date.now() + data.expires_in * 1000 - tolerance, + refreshToken: data.refresh_token, + }); + + return data.access_token; + } catch (error) { + throw new Error("Failed to obtain OAuth token: " + error); + } +} + +/** + * A TokenStore is used to save and retrieve OAuth tokens for use across SDK + * method calls. This interface can be implemented to store tokens in memory, + * a shared cache like Redis or a database table. + */ +export interface TokenStore { + get(): Promise< + { token: string; refreshToken: string; expires: number } | undefined + >; + set({ + token, + expires, + refreshToken, + }: { + token: string; + expires: number; + refreshToken: string; + }): Promise; +} + +/** + * InMemoryTokenStore holds OAuth access tokens in memory for use by SDKs and + * methods that require OAuth security. + */ +export class InMemoryTokenStore implements TokenStore { + private token = ""; + private expires = Date.now(); + private refreshToken = ""; + + constructor() {} + + async get() { + return { + token: this.token, + expires: this.expires, + refreshToken: this.refreshToken, + }; + } + + async set({ + token, + expires, + refreshToken, + }: { + token: string; + expires: number; + refreshToken: string; + }) { + this.token = token; + this.refreshToken = refreshToken; + this.expires = expires; + } +} diff --git a/gusto_embedded/src/hooks/clientcredentials.ts b/gusto_embedded/src/hooks/clientcredentials.ts new file mode 100644 index 00000000..7b455fb6 --- /dev/null +++ b/gusto_embedded/src/hooks/clientcredentials.ts @@ -0,0 +1,205 @@ +// Code originally generated by Speakeasy (https://www.speakeasyapi.dev). + +import * as z from "zod"; +import { stringToBase64 } from "../lib/base64.js"; +import { HTTPClient } from "../lib/http.js"; +import { parse } from "../lib/schemas.js"; +import { PostV1PartnerManagedCompaniesSecurity$outboundSchema } from "../models/operations/postv1partnermanagedcompanies.js"; +import { + AfterErrorContext, + AfterErrorHook, + BeforeRequestContext, + BeforeRequestHook, + SDKInitHook, + SDKInitOptions, +} from "./types.js"; + +type Credentials = { + clientID: string; + clientSecret: string; +}; + +type Session = { + credentials: Credentials; + token: string; + expiresAt?: number; + scopes: string[]; +}; + +export class ClientCredentialsHook + implements SDKInitHook, BeforeRequestHook, AfterErrorHook +{ + private baseURL?: URL | null; + private client?: HTTPClient; + private sessions: Record = {}; + + sdkInit(opts: SDKInitOptions): SDKInitOptions { + this.baseURL = opts.baseURL; + this.client = opts.client; + + return opts; + } + + async beforeRequest( + hookCtx: BeforeRequestContext, + request: Request + ): Promise { + const credentials = await this.getCredentials(hookCtx.securitySource); + if (!credentials) { + return request; + } + + const sessionKey = this.getSessionKey(credentials); + + let session = this.sessions[sessionKey]; + if ( + !session || + !this.hasRequiredScopes(session.scopes, hookCtx.oAuth2Scopes || []) || + this.hasTokenExpired(session.expiresAt) + ) { + session = await this.doTokenRequest( + credentials, + this.getScopes(hookCtx.oAuth2Scopes || [], session) + ); + + this.sessions[sessionKey] = session; + } + + request.headers.set("Authorization", `Bearer ${session.token}`); + + return request; + } + + async afterError( + hookCtx: AfterErrorContext, + response: Response | null, + error: unknown + ): Promise<{ response: Response | null; error: unknown }> { + if (error) { + return { response, error }; + } + + const credentials = await this.getCredentials(hookCtx.securitySource); + if (!credentials) { + return { response, error }; + } + + if (response && response?.status === 401) { + const sessionKey = this.getSessionKey(credentials); + delete this.sessions[sessionKey]; + } + + return { response, error }; + } + + private async doTokenRequest( + credentials: Credentials, + scopes: string[] + ): Promise { + const formData = new URLSearchParams(); + formData.append("grant_type", "system_access"); + formData.append("client_id", credentials.clientID); + formData.append("client_secret", credentials.clientSecret); + + if (scopes.length > 0) { + formData.append("scope", scopes.join(" ")); + } + + const tokenURL = `${this.baseURL}/oauth/token`; + + const request = new Request(tokenURL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formData, + }); + + const res = await this.client?.request(request); + if (!res) { + throw new Error("Failed to fetch token"); + } + + if (res.status < 200 || res.status >= 300) { + throw new Error("Unexpected status code"); + } + + const data = await res.json(); + if (!data || typeof data !== "object") { + throw new Error("Unexpected response format"); + } + + if (data.token_type !== "Bearer") { + throw new Error("Unexpected token type"); + } + + let expiresAt: number | undefined; + if (data.expires_in) { + expiresAt = Date.now() + data.expires_in * 1000; + } + + const sess: Session = { + credentials, + token: data.access_token, + scopes, + }; + + if (expiresAt !== undefined) { + sess.expiresAt = expiresAt; + } + + return sess; + } + + private async getCredentials( + source?: unknown | (() => Promise) + ): Promise { + if (!source) { + return null; + } + + let security = source; + if (typeof source === "function") { + security = await source(); + } + + // The client was passed a raw access token, no need to fetch one. + if (typeof security === "string") { + return null; + } + + const out = parse( + security, + (val) => + z + .lazy(() => PostV1PartnerManagedCompaniesSecurity$outboundSchema) + .parse(val), + "unexpected security type" + ); + + return { + clientID: out.clientId ?? "", + clientSecret: out.clientSecret ?? "", + }; + } + + private getSessionKey(credentials: Credentials): string { + const key = `${credentials.clientID}:${credentials.clientSecret}`; + return stringToBase64(key); + } + + private hasRequiredScopes( + scopes: string[], + requiredScopes: string[] + ): boolean { + return requiredScopes.every((scope) => scopes.includes(scope)); + } + + private getScopes(requiredScopes: string[], sess?: Session): string[] { + return [...new Set((sess?.scopes ?? []).concat(requiredScopes))]; + } + + private hasTokenExpired(expiresAt?: number): boolean { + return !expiresAt || Date.now() + 60000 > expiresAt; + } +} diff --git a/gusto_embedded/src/hooks/registration.ts b/gusto_embedded/src/hooks/registration.ts index 70649734..71d474fe 100644 --- a/gusto_embedded/src/hooks/registration.ts +++ b/gusto_embedded/src/hooks/registration.ts @@ -1,4 +1,5 @@ -import { Hooks } from "./types.js"; +import { Hooks, Hook } from "./types.js"; +import { ClientCredentialsHook } from "./clientcredentials.js"; /* * This file is only ever generated once on the first generation and then is free to be modified. @@ -6,9 +7,27 @@ import { Hooks } from "./types.js"; * in this file or in separate files in the hooks folder. */ -// @ts-expect-error remove this line when you add your first hook and hooks is used 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 = [new ClientCredentialsHook()]; + + for (const hook of presetHooks) { + if ("sdkInit" in hook) { + hooks.registerSDKInitHook(hook); + } + if ("beforeCreateRequest" in hook) { + hooks.registerBeforeCreateRequestHook(hook); + } + if ("beforeRequest" in hook) { + hooks.registerBeforeRequestHook(hook); + } + if ("afterSuccess" in hook) { + hooks.registerAfterSuccessHook(hook); + } + if ("afterError" in hook) { + hooks.registerAfterErrorHook(hook); + } + } }