Support for OAuth 2.0 (#5)

This commit is contained in:
Gregory Schier
2025-01-26 13:32:17 -08:00
committed by GitHub
parent d142966d0c
commit 252d23bb0e
17 changed files with 855 additions and 52 deletions

View File

@@ -0,0 +1,99 @@
import { Context, HttpRequest } from '@yaakapp/api';
import { readFileSync } from 'node:fs';
import { AccessToken, AccessTokenRawResponse, deleteToken, getToken, storeToken } from './store';
export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
scope,
accessTokenUrl,
credentialsInBody,
clientId,
clientSecret,
forceRefresh,
}: {
scope: string | null;
accessTokenUrl: string;
credentialsInBody: boolean;
clientId: string;
clientSecret: string;
forceRefresh?: boolean;
}): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
if (token == null) {
return null;
}
const now = (Date.now() / 1000);
const isExpired = token.expiresAt && now > token.expiresAt;
// Return the current access token if it's still valid
if (!isExpired && !forceRefresh) {
return token;
}
// Token is expired, but there's no refresh token :(
if (!token.response.refresh_token) {
return null;
}
// Access token is expired, so get a new one
const httpRequest: Partial<HttpRequest> = {
method: 'POST',
url: accessTokenUrl,
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [
{ name: 'grant_type', value: 'refresh_token' },
{ name: 'refresh_token', value: token.response.refresh_token },
],
},
headers: [
{ name: 'User-Agent', value: 'yaak' },
{ name: 'Accept', value: 'application/x-www-form-urlencoded, application/json' },
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
],
};
if (scope) httpRequest.body!.form.push({ name: 'scope', value: scope });
if (credentialsInBody) {
httpRequest.body!.form.push({ name: 'client_id', value: clientId });
httpRequest.body!.form.push({ name: 'client_secret', value: clientSecret });
} else {
const value = 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
httpRequest.headers!.push({ name: 'Authorization', value });
}
const resp = await ctx.httpRequest.send({ httpRequest });
if (resp.status === 401) {
// Bad refresh token, so we'll force it to fetch a fresh access token by deleting
// and returning null;
console.log('Unauthorized refresh_token request');
await deleteToken(ctx, contextId);
return null;
}
if (resp.status < 200 || resp.status >= 300) {
throw new Error('Failed to fetch access token with status=' + resp.status);
}
const body = readFileSync(resp.bodyPath ?? '', 'utf8');
let response;
try {
response = JSON.parse(body);
} catch {
response = Object.fromEntries(new URLSearchParams(body));
}
if (response.error) {
throw new Error(`Failed to fetch access token with ${response.error} -> ${response.error_description}`);
}
const newResponse: AccessTokenRawResponse = {
...response,
// Assign a new one or keep the old one,
refresh_token: response.refresh_token ?? token.response.refresh_token,
};
return storeToken(ctx, contextId, newResponse);
}