Fix PKCE flow and clean up other flows

This commit is contained in:
Gregory Schier
2025-06-25 07:10:11 -07:00
parent f476d87613
commit 8817be679b
10 changed files with 143 additions and 107 deletions

View File

@@ -2,4 +2,4 @@
export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, }; export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };
export type PluginVersion = { id: string, version: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, }; export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };

View File

@@ -53,3 +53,7 @@ async function handleIncoming(msg: string) {
plugin.sendToWorker(pluginEvent); plugin.sendToWorker(pluginEvent);
} }
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

View File

@@ -1,8 +1,8 @@
import { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api'; import type { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { AccessTokenRawResponse } from './store'; import type { AccessTokenRawResponse } from './store';
export async function getAccessToken( export async function fetchAccessToken(
ctx: Context, ctx: Context,
{ {
accessTokenUrl, accessTokenUrl,

View File

@@ -0,0 +1,19 @@
import type { Context } from '@yaakapp/api';
import type { AccessToken } from './store';
import { getToken } from './store';
export async function getAccessTokenIfNotExpired(
ctx: Context,
contextId: string,
): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
if (token == null || isTokenExpired(token)) {
return null;
}
return token;
}
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
}

View File

@@ -1,29 +1,34 @@
import { Context, HttpRequest } from '@yaakapp/api'; import type { Context, HttpRequest } from '@yaakapp/api';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { AccessToken, AccessTokenRawResponse, deleteToken, getToken, storeToken } from './store'; import { isTokenExpired } from './getAccessTokenIfNotExpired';
import type { AccessToken, AccessTokenRawResponse } from './store';
import { deleteToken, getToken, storeToken } from './store';
export async function getOrRefreshAccessToken(ctx: Context, contextId: string, { export async function getOrRefreshAccessToken(
scope, ctx: Context,
accessTokenUrl, contextId: string,
credentialsInBody, {
clientId, scope,
clientSecret, accessTokenUrl,
forceRefresh, credentialsInBody,
}: { clientId,
scope: string | null; clientSecret,
accessTokenUrl: string; forceRefresh,
credentialsInBody: boolean; }: {
clientId: string; scope: string | null;
clientSecret: string; accessTokenUrl: string;
forceRefresh?: boolean; credentialsInBody: boolean;
}): Promise<AccessToken | null> { clientId: string;
clientSecret: string;
forceRefresh?: boolean;
},
): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId); const token = await getToken(ctx, contextId);
if (token == null) { if (token == null) {
return null; return null;
} }
const now = Date.now(); const isExpired = isTokenExpired(token);
const isExpired = token.expiresAt && now > token.expiresAt;
// Return the current access token if it's still valid // Return the current access token if it's still valid
if (!isExpired && !forceRefresh) { if (!isExpired && !forceRefresh) {
@@ -79,7 +84,9 @@ export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
console.log('[oauth2] Got refresh token response', resp.status); console.log('[oauth2] Got refresh token response', resp.status);
if (resp.status < 200 || resp.status >= 300) { if (resp.status < 200 || resp.status >= 300) {
throw new Error('Failed to refresh access token with status=' + resp.status + ' and body=' + body); throw new Error(
'Failed to refresh access token with status=' + resp.status + ' and body=' + body,
);
} }
let response; let response;
@@ -90,7 +97,9 @@ export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
} }
if (response.error) { if (response.error) {
throw new Error(`Failed to fetch access token with ${response.error} -> ${response.error_description}`); throw new Error(
`Failed to fetch access token with ${response.error} -> ${response.error_description}`,
);
} }
const newResponse: AccessTokenRawResponse = { const newResponse: AccessTokenRawResponse = {

View File

@@ -1,8 +1,9 @@
import { Context } from '@yaakapp/api'; import type { Context } from '@yaakapp/api';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { getAccessToken } from '../getAccessToken'; import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken'; import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import { AccessToken, getDataDirKey, storeToken } from '../store'; import type { AccessToken } from '../store';
import { getDataDirKey, storeToken } from '../store';
export const PKCE_SHA256 = 'S256'; export const PKCE_SHA256 = 'S256';
export const PKCE_PLAIN = 'plain'; export const PKCE_PLAIN = 'plain';
@@ -34,8 +35,8 @@ export async function getAuthorizationCode(
audience: string | null; audience: string | null;
credentialsInBody: boolean; credentialsInBody: boolean;
pkce: { pkce: {
challengeMethod: string | null; challengeMethod: string;
codeVerifier: string | null; codeVerifier: string;
} | null; } | null;
tokenName: 'access_token' | 'id_token'; tokenName: 'access_token' | 'id_token';
}, },
@@ -59,26 +60,25 @@ export async function getAuthorizationCode(
if (state) authorizationUrl.searchParams.set('state', state); if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience); if (audience) authorizationUrl.searchParams.set('audience', audience);
if (pkce) { if (pkce) {
const verifier = pkce.codeVerifier || createPkceCodeVerifier();
const challengeMethod = pkce.challengeMethod || DEFAULT_PKCE_METHOD;
authorizationUrl.searchParams.set( authorizationUrl.searchParams.set(
'code_challenge', 'code_challenge',
createPkceCodeChallenge(verifier, challengeMethod), pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod),
); );
authorizationUrl.searchParams.set('code_challenge_method', challengeMethod); authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
} }
return new Promise(async (resolve, reject) => { const logsEnabled = (await ctx.store.get('enable_logs')) ?? false;
const authorizationUrlStr = authorizationUrl.toString(); const dataDirKey = await getDataDirKey(ctx, contextId);
const logsEnabled = (await ctx.store.get('enable_logs')) ?? false; const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing', authorizationUrlStr); console.log('[oauth2] Authorizing', authorizationUrlStr);
// eslint-disable-next-line no-async-promise-executor
const code = await new Promise<string>(async (resolve, reject) => {
let foundCode = false; let foundCode = false;
const { close } = await ctx.window.openUrl({
let { close } = await ctx.window.openUrl({
url: authorizationUrlStr, url: authorizationUrlStr,
label: 'oauth-authorization-url', label: 'oauth-authorization-url',
dataDirKey: await getDataDirKey(ctx, contextId), dataDirKey,
async onClose() { async onClose() {
if (!foundCode) { if (!foundCode) {
reject(new Error('Authorization window closed')); reject(new Error('Authorization window closed'));
@@ -89,6 +89,7 @@ export async function getAuthorizationCode(
if (logsEnabled) console.log('[oauth2] Navigated to', urlStr); if (logsEnabled) console.log('[oauth2] Navigated to', urlStr);
if (url.searchParams.has('error')) { if (url.searchParams.has('error')) {
close();
return reject(new Error(`Failed to authorize: ${url.searchParams.get('error')}`)); return reject(new Error(`Failed to authorize: ${url.searchParams.get('error')}`));
} }
@@ -101,37 +102,35 @@ export async function getAuthorizationCode(
// Close the window here, because we don't need it anymore! // Close the window here, because we don't need it anymore!
foundCode = true; foundCode = true;
close(); close();
resolve(code);
console.log('[oauth2] Code found');
const response = await getAccessToken(ctx, {
grantType: 'authorization_code',
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
],
});
try {
resolve(await storeToken(ctx, contextId, response, tokenName));
} catch (err) {
reject(err);
}
}, },
}); });
}); });
console.log('[oauth2] Code found');
const response = await fetchAccessToken(ctx, {
grantType: 'authorization_code',
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
],
});
return storeToken(ctx, contextId, response, tokenName);
} }
function createPkceCodeVerifier() { export function genPkceCodeVerifier() {
return encodeForPkce(randomBytes(32)); return encodeForPkce(randomBytes(32));
} }
function createPkceCodeChallenge(verifier: string, method: string) { function pkceCodeChallenge(verifier: string, method: string) {
if (method === 'plain') { if (method === 'plain') {
return verifier; return verifier;
} }

View File

@@ -1,5 +1,6 @@
import { Context } from '@yaakapp/api'; import type { Context } from '@yaakapp/api';
import { getAccessToken } from '../getAccessToken'; import { fetchAccessToken } from '../fetchAccessToken';
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
import { getToken, storeToken } from '../store'; import { getToken, storeToken } from '../store';
export async function getClientCredentials( export async function getClientCredentials(
@@ -22,13 +23,11 @@ export async function getClientCredentials(
}, },
) { ) {
const token = await getToken(ctx, contextId); const token = await getToken(ctx, contextId);
if (token) { if (token && !isTokenExpired(token)) {
// resolve(token.response.access_token); return token;
// TODO: Refresh token if expired
// return;
} }
const response = await getAccessToken(ctx, { const response = await fetchAccessToken(ctx, {
grantType: 'client_credentials', grantType: 'client_credentials',
accessTokenUrl, accessTokenUrl,
audience, audience,

View File

@@ -1,7 +1,9 @@
import { Context } from '@yaakapp/api'; import type { Context } from '@yaakapp/api';
import { AccessToken, AccessTokenRawResponse, getToken, storeToken } from '../store'; import { isTokenExpired } from '../getAccessTokenIfNotExpired';
import type { AccessToken, AccessTokenRawResponse} from '../store';
import { getToken, storeToken } from '../store';
export function getImplicit( export async function getImplicit(
ctx: Context, ctx: Context,
contextId: string, contextId: string,
{ {
@@ -24,31 +26,30 @@ export function getImplicit(
tokenName: 'access_token' | 'id_token'; tokenName: 'access_token' | 'id_token';
}, },
): Promise<AccessToken> { ): Promise<AccessToken> {
return new Promise(async (resolve, reject) => { const token = await getToken(ctx, contextId);
const token = await getToken(ctx, contextId); if (token != null && !isTokenExpired(token)) {
if (token) { return token;
// resolve(token.response.access_token); }
// TODO: Refresh token if expired
// return;
}
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`); const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl.searchParams.set('response_type', 'token'); authorizationUrl.searchParams.set('response_type', 'token');
authorizationUrl.searchParams.set('client_id', clientId); authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri); if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope); if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state); if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience); if (audience) authorizationUrl.searchParams.set('audience', audience);
if (responseType.includes('id_token')) { if (responseType.includes('id_token')) {
authorizationUrl.searchParams.set( authorizationUrl.searchParams.set(
'nonce', 'nonce',
String(Math.floor(Math.random() * 9999999999999) + 1), String(Math.floor(Math.random() * 9999999999999) + 1),
); );
} }
const authorizationUrlStr = authorizationUrl.toString(); // eslint-disable-next-line no-async-promise-executor
const newToken = await new Promise<AccessToken>(async (resolve, reject) => {
let foundAccessToken = false; let foundAccessToken = false;
let { close } = await ctx.window.openUrl({ const authorizationUrlStr = authorizationUrl.toString();
const { close } = await ctx.window.openUrl({
url: authorizationUrlStr, url: authorizationUrlStr,
label: 'oauth-authorization-url', label: 'oauth-authorization-url',
async onClose() { async onClose() {
@@ -76,11 +77,13 @@ export function getImplicit(
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse; const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try { try {
resolve(await storeToken(ctx, contextId, response)); resolve(storeToken(ctx, contextId, response));
} catch (err) { } catch (err) {
reject(err); reject(err);
} }
}, },
}); });
}); });
return newToken;
} }

View File

@@ -1,7 +1,8 @@
import { Context } from '@yaakapp/api'; import type { Context } from '@yaakapp/api';
import { getAccessToken } from '../getAccessToken'; import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken'; import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import { AccessToken, storeToken } from '../store'; import type { AccessToken} from '../store';
import { storeToken } from '../store';
export async function getPassword( export async function getPassword(
ctx: Context, ctx: Context,
@@ -37,7 +38,7 @@ export async function getPassword(
return token; return token;
} }
const response = await getAccessToken(ctx, { const response = await fetchAccessToken(ctx, {
accessTokenUrl, accessTokenUrl,
clientId, clientId,
clientSecret, clientSecret,

View File

@@ -1,4 +1,4 @@
import { import type {
Context, Context,
FormInputSelectOption, FormInputSelectOption,
GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigRequest,
@@ -6,6 +6,7 @@ import {
PluginDefinition, PluginDefinition,
} from '@yaakapp/api'; } from '@yaakapp/api';
import { import {
genPkceCodeVerifier,
DEFAULT_PKCE_METHOD, DEFAULT_PKCE_METHOD,
getAuthorizationCode, getAuthorizationCode,
PKCE_PLAIN, PKCE_PLAIN,
@@ -14,7 +15,8 @@ import {
import { getClientCredentials } from './grants/clientCredentials'; import { getClientCredentials } from './grants/clientCredentials';
import { getImplicit } from './grants/implicit'; import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password'; import { getPassword } from './grants/password';
import { AccessToken, deleteToken, getToken, resetDataDirKey } from './store'; import type { AccessToken } from './store';
import { deleteToken, getToken, resetDataDirKey } from './store';
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
@@ -219,9 +221,9 @@ export const plugin: PluginDefinition = {
}, },
{ {
type: 'text', type: 'text',
name: 'pkceCodeVerifier', name: 'pkceCodeChallenge',
label: 'Code Verifier', label: 'Code Verifier',
placeholder: 'Automatically generated if not provided', placeholder: 'Automatically generated when not set',
optional: true, optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
}, },
@@ -325,8 +327,8 @@ export const plugin: PluginDefinition = {
credentialsInBody, credentialsInBody,
pkce: values.usePkce pkce: values.usePkce
? { ? {
challengeMethod: stringArg(values, 'pkceChallengeMethod'), challengeMethod: stringArg(values, 'pkceChallengeMethod') || DEFAULT_PKCE_METHOD,
codeVerifier: stringArgOrNull(values, 'pkceCodeVerifier'), codeVerifier: stringArg(values, 'pkceCodeVerifier') || genPkceCodeVerifier(),
} }
: null, : null,
tokenName: tokenName, tokenName: tokenName,