Skip to content

Commit 571fbc0

Browse files
committed
generated client credentials oauth flow code
1 parent 26e7c37 commit 571fbc0

File tree

3 files changed

+320
-0
lines changed

3 files changed

+320
-0
lines changed

gusto_embedded/.genignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/src/hooks/clientcredentials.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Code originally generated by Speakeasy (https://www.speakeasyapi.dev).
2+
3+
import * as z from "zod";
4+
import { stringToBase64 } from "../lib/base64.js";
5+
import { env } from "../lib/env.js";
6+
import { HTTPClient } from "../lib/http.js";
7+
import { parse } from "../lib/schemas.js";
8+
import * as components from "../models/components/index.js";
9+
import {
10+
AfterErrorContext,
11+
AfterErrorHook,
12+
BeforeRequestContext,
13+
BeforeRequestHook,
14+
SDKInitHook,
15+
SDKInitOptions,
16+
} from "./types.js";
17+
18+
type Credentials = {
19+
clientID: string;
20+
clientSecret: string;
21+
tokenURL: string | undefined;
22+
};
23+
24+
type Session = {
25+
credentials: Credentials;
26+
token: string;
27+
expiresAt?: number;
28+
scopes: string[];
29+
};
30+
31+
export class ClientCredentialsHook
32+
implements SDKInitHook, BeforeRequestHook, AfterErrorHook
33+
{
34+
private baseURL?: URL | null;
35+
private client?: HTTPClient;
36+
private sessions: Record<string, Session> = {};
37+
38+
sdkInit(opts: SDKInitOptions): SDKInitOptions {
39+
this.baseURL = opts.baseURL;
40+
this.client = opts.client;
41+
42+
return opts;
43+
}
44+
45+
async beforeRequest(
46+
hookCtx: BeforeRequestContext,
47+
request: Request
48+
): Promise<Request> {
49+
if (!hookCtx.oAuth2Scopes) {
50+
// OAuth2 not in use
51+
return request;
52+
}
53+
54+
const credentials = await this.getCredentials(hookCtx.securitySource);
55+
if (!credentials) {
56+
return request;
57+
}
58+
59+
const sessionKey = this.getSessionKey(credentials);
60+
61+
let session = this.sessions[sessionKey];
62+
if (
63+
!session ||
64+
!this.hasRequiredScopes(session.scopes, hookCtx.oAuth2Scopes) ||
65+
this.hasTokenExpired(session.expiresAt)
66+
) {
67+
session = await this.doTokenRequest(
68+
credentials,
69+
this.getScopes(hookCtx.oAuth2Scopes, session)
70+
);
71+
72+
this.sessions[sessionKey] = session;
73+
}
74+
75+
request.headers.set("Authorization", `Bearer ${session.token}`);
76+
77+
return request;
78+
}
79+
80+
async afterError(
81+
hookCtx: AfterErrorContext,
82+
response: Response | null,
83+
error: unknown
84+
): Promise<{ response: Response | null; error: unknown }> {
85+
if (!hookCtx.oAuth2Scopes) {
86+
// OAuth2 not in use
87+
return { response, error };
88+
}
89+
90+
if (error) {
91+
return { response, error };
92+
}
93+
94+
const credentials = await this.getCredentials(hookCtx.securitySource);
95+
if (!credentials) {
96+
return { response, error };
97+
}
98+
99+
if (response && response?.status === 401) {
100+
const sessionKey = this.getSessionKey(credentials);
101+
delete this.sessions[sessionKey];
102+
}
103+
104+
return { response, error };
105+
}
106+
107+
private async doTokenRequest(
108+
credentials: Credentials,
109+
scopes: string[]
110+
): Promise<Session> {
111+
const formData = new URLSearchParams();
112+
formData.append("grant_type", "system_access");
113+
formData.append("client_id", credentials.clientID);
114+
formData.append("client_secret", credentials.clientSecret);
115+
116+
if (scopes.length > 0) {
117+
formData.append("scope", scopes.join(" "));
118+
}
119+
120+
const tokenURL = new URL(credentials.tokenURL ?? "", this.baseURL ?? "");
121+
122+
const request = new Request(tokenURL, {
123+
method: "POST",
124+
headers: {
125+
"Content-Type": "application/x-www-form-urlencoded",
126+
},
127+
body: formData,
128+
});
129+
130+
const res = await this.client?.request(request);
131+
if (!res) {
132+
throw new Error("Failed to fetch token");
133+
}
134+
135+
if (res.status < 200 || res.status >= 300) {
136+
throw new Error("Unexpected status code");
137+
}
138+
139+
const data = await res.json();
140+
if (!data || typeof data !== "object") {
141+
throw new Error("Unexpected response format");
142+
}
143+
144+
if (data.token_type !== "Bearer") {
145+
throw new Error("Unexpected token type");
146+
}
147+
148+
let expiresAt: number | undefined;
149+
if (data.expires_in) {
150+
expiresAt = Date.now() + data.expires_in * 1000;
151+
}
152+
153+
const sess: Session = {
154+
credentials,
155+
token: data.access_token,
156+
scopes,
157+
};
158+
159+
if (expiresAt !== undefined) {
160+
sess.expiresAt = expiresAt;
161+
}
162+
163+
return sess;
164+
}
165+
166+
private async getCredentials(
167+
source?: unknown | (() => Promise<unknown>)
168+
): Promise<Credentials | null> {
169+
if (!source) {
170+
return null;
171+
}
172+
173+
let security = source;
174+
if (typeof source === "function") {
175+
security = await source();
176+
}
177+
const out = parse(
178+
security,
179+
(val) => z.lazy(() => components.Security$outboundSchema).parse(val),
180+
"unexpected security type"
181+
);
182+
183+
return {
184+
clientID: out?.clientID ?? env().GUSTOEMBEDDED_CLIENT_ID ?? "",
185+
clientSecret:
186+
out?.clientSecret ?? env().GUSTOEMBEDDED_CLIENT_SECRET ?? "",
187+
tokenURL: out?.tokenURL ?? env().GUSTOEMBEDDED_TOKEN_URL ?? "",
188+
};
189+
}
190+
191+
private getSessionKey(credentials: Credentials): string {
192+
const key = `${credentials.clientID}:${credentials.clientSecret}`;
193+
return stringToBase64(key);
194+
}
195+
196+
private hasRequiredScopes(
197+
scopes: string[],
198+
requiredScopes: string[]
199+
): boolean {
200+
return requiredScopes.every((scope) => scopes.includes(scope));
201+
}
202+
203+
private getScopes(requiredScopes: string[], sess?: Session): string[] {
204+
return [...new Set((sess?.scopes ?? []).concat(requiredScopes))];
205+
}
206+
207+
private hasTokenExpired(expiresAt?: number): boolean {
208+
return !expiresAt || Date.now() + 60000 > expiresAt;
209+
}
210+
}

gusto_embedded/src/systemAuth.ts

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as z from "zod";
2+
import { SDK_METADATA } from "./lib/config";
3+
4+
// TypeScript SDKs use Zod for runtime data validation. We can use Zod
5+
// to describe the shape of the response from the OAuth token endpoint. If the
6+
// response is valid, Speakeasy can safely access the token and its expiration time.
7+
const tokenResponseSchema = z.object({
8+
access_token: z.string(),
9+
expires_in: z.number().positive(),
10+
});
11+
12+
// This is a rough value that adjusts when we consider an access token to be
13+
// expired. It accounts for clock drift between the client and server
14+
// and slow or unreliable networks.
15+
const tolerance = 5 * 60 * 1000;
16+
17+
/**
18+
* A callback function that can be used to obtain an OAuth access token for use
19+
* with SDKs that require OAuth security. A new token is requested from the
20+
* OAuth provider when the current token has expired.
21+
*/
22+
export function withSystemAccess(
23+
clientID: string,
24+
clientSecret: string,
25+
options: { tokenStore?: TokenStore; url?: string } = {}
26+
) {
27+
const {
28+
tokenStore = new InMemoryTokenStore(),
29+
// Replace this with your default OAuth provider's access token endpoint.
30+
url = "https://api.gusto-demo.com/oauth/token",
31+
} = options;
32+
33+
// tokenStore.set({ token: accessToken, expires: 10 });
34+
35+
return async (): Promise<string> => {
36+
const session = await tokenStore.get();
37+
38+
// Return the current token if it has not expired yet.
39+
if (session && session.expires > Date.now()) {
40+
return session.token;
41+
}
42+
43+
try {
44+
const response = await fetch(url, {
45+
method: "POST",
46+
headers: {
47+
"content-type": "application/x-www-form-urlencoded",
48+
// Include the SDK's user agent in the request so requests can be
49+
// tracked using observability infrastructure.
50+
"user-agent": SDK_METADATA.userAgent,
51+
},
52+
body: new URLSearchParams({
53+
client_id: clientID,
54+
client_secret: clientSecret,
55+
grant_type: "system_access",
56+
}),
57+
});
58+
59+
if (!response.ok) {
60+
throw new Error("Unexpected status code: " + response.status);
61+
}
62+
63+
const json = await response.json();
64+
const data = tokenResponseSchema.parse(json);
65+
66+
await tokenStore.set({
67+
token: data.access_token,
68+
expires: Date.now() + data.expires_in * 1000 - tolerance,
69+
});
70+
71+
return data.access_token;
72+
} catch (error) {
73+
throw new Error("Failed to obtain OAuth token: " + error);
74+
}
75+
};
76+
}
77+
78+
/**
79+
* A TokenStore is used to save and retrieve OAuth tokens for use across SDK
80+
* method calls. This interface can be implemented to store tokens in memory,
81+
* a shared cache like Redis or a database table.
82+
*/
83+
export interface TokenStore {
84+
get(): Promise<{ token: string; expires: number } | undefined>;
85+
set({ token, expires }: { token: string; expires: number }): Promise<void>;
86+
}
87+
88+
/**
89+
* InMemoryTokenStore holds OAuth access tokens in memory for use by SDKs and
90+
* methods that require OAuth security.
91+
*/
92+
export class InMemoryTokenStore implements TokenStore {
93+
private token = "";
94+
private expires = Date.now();
95+
96+
constructor() {}
97+
98+
async get() {
99+
return {
100+
token: this.token,
101+
expires: this.expires,
102+
};
103+
}
104+
105+
async set({ token, expires }: { token: string; expires: number }) {
106+
this.token = token;
107+
this.expires = expires;
108+
}
109+
}

0 commit comments

Comments
 (0)