Compare commits

...

3 Commits

Author SHA1 Message Date
Gregory Schier
0c6b1f4465 Cleaner page 2026-01-29 14:51:49 -08:00
Gregory Schier
092eeab01c Fix external browser callback: use local server URI and singleton pattern
- Always use the local callback server's URI for the redirect instead of
  the user-configured redirectUri, which would bypass the local listener
- Add singleton pattern to callback server so only one runs at a time,
  preventing EADDRINUSE when retrying without completing the prior flow
- Change catch to finally so the server port is always released

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:23:18 -08:00
Gregory Schier
5c7751c8b2 Add external browser support for OAuth2 authorization
Allow users to complete OAuth2 authorization (authorization code and
implicit grants) in their system browser instead of the embedded webview.
This is useful when OAuth providers block embedded browsers or when users
need existing browser sessions/extensions.

Adds a local HTTP callback server using Node's built-in http module to
capture the redirect, with two callback modes:
- Localhost: stable port (default 8765) for direct callback registration
- Hosted Redirect: random port with yaak.app/oauth-callback intermediary

New UI controls: "Use External Browser" checkbox, callback type dropdown,
port input for localhost mode, and computed redirect URI display with
copy action. All existing embedded browser flows are preserved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:43:47 -08:00
4 changed files with 638 additions and 29 deletions

View File

@@ -0,0 +1,195 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import http from 'node:http';
const HOSTED_CALLBACK_URL = 'https://yaak.app/oauth-callback';
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Singleton: only one callback server runs at a time across all OAuth flows. */
let activeServer: CallbackServerResult | null = null;
export interface CallbackServerResult {
/** The port the server is listening on */
port: number;
/** The full redirect URI to register with the OAuth provider */
redirectUri: string;
/** Promise that resolves with the callback URL when received */
waitForCallback: () => Promise<string>;
/** Stop the server */
stop: () => void;
}
/**
* Start a local HTTP server to receive OAuth callbacks.
* Only one server runs at a time — if a previous server is still active,
* it is stopped before starting the new one.
* Returns the port, redirect URI, and a promise that resolves when the callback is received.
*/
export function startCallbackServer(options: {
/** Specific port to use, or 0 for random available port */
port?: number;
/** Path for the callback endpoint */
path?: string;
/** Timeout in milliseconds (default 5 minutes) */
timeoutMs?: number;
}): Promise<CallbackServerResult> {
// Stop any previously active server before starting a new one
if (activeServer) {
console.log('[oauth2] Stopping previous callback server before starting new one');
activeServer.stop();
activeServer = null;
}
const { port = 0, path = '/callback', timeoutMs = CALLBACK_TIMEOUT_MS } = options;
return new Promise((resolve, reject) => {
let callbackResolve: ((url: string) => void) | null = null;
let callbackReject: ((err: Error) => void) | null = null;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
const reqUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
// Only handle the callback path
if (reqUrl.pathname !== path && reqUrl.pathname !== `${path}/`) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
// Build the full callback URL
const fullCallbackUrl = reqUrl.toString();
// Send success response
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(getSuccessHtml());
// Resolve the callback promise
if (callbackResolve) {
callbackResolve(fullCallbackUrl);
callbackResolve = null;
callbackReject = null;
}
// Stop the server after a short delay to ensure response is sent
setTimeout(() => stopServer(), 100);
});
server.on('error', (err: Error) => {
if (!stopped) {
reject(err);
}
});
const stopServer = () => {
if (stopped) return;
stopped = true;
// Clear the singleton reference
if (activeServer?.stop === stopServer) {
activeServer = null;
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
server.close();
if (callbackReject) {
callbackReject(new Error('Callback server stopped'));
callbackResolve = null;
callbackReject = null;
}
};
server.listen(port, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
return;
}
const actualPort = address.port;
const redirectUri = `http://127.0.0.1:${actualPort}${path}`;
console.log(`[oauth2] Callback server listening on ${redirectUri}`);
const result: CallbackServerResult = {
port: actualPort,
redirectUri,
waitForCallback: () => {
return new Promise<string>((res, rej) => {
if (stopped) {
rej(new Error('Callback server already stopped'));
return;
}
callbackResolve = res;
callbackReject = rej;
// Set timeout
timeoutHandle = setTimeout(() => {
if (callbackReject) {
callbackReject(new Error('Authorization timed out'));
callbackResolve = null;
callbackReject = null;
}
stopServer();
}, timeoutMs);
});
},
stop: stopServer,
};
activeServer = result;
resolve(result);
});
});
}
/**
* Build the redirect URI for the hosted callback page.
* The hosted page will redirect to the local server with the OAuth response.
*/
export function buildHostedCallbackRedirectUri(localPort: number, localPath: string): string {
const localRedirectUri = `http://127.0.0.1:${localPort}${localPath}`;
// The hosted callback page will read params and redirect to the local server
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`;
}
/**
* HTML page shown to the user after successful callback
*/
function getSuccessHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<title>Yaak</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: hsl(244,23%,14%);
color: hsl(245,23%,85%);
}
.container { text-align: center; }
.logo { width: 100px; height: 100px; margin: 0 auto 32px; border-radius: 50%; }
h1 { font-size: 28px; font-weight: 600; margin-bottom: 12px; }
p { font-size: 16px; color: hsl(245,18%,58%); }
</style>
</head>
<body>
<div class="container">
<svg class="logo" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(649.94,712.03,-712.03,649.94,179.25,220.59)"><stop offset="0" stop-color="#4cc48c"/><stop offset=".5" stop-color="#476cc9"/><stop offset="1" stop-color="#ba1ab7"/></linearGradient></defs><rect x="0" y="0" width="1024" height="1024" fill="url(#g)"/><g transform="matrix(0.822,0,0,0.822,91.26,91.26)"><path d="M766.775,105.176C902.046,190.129 992.031,340.639 992.031,512C992.031,706.357 876.274,873.892 710,949.361C684.748,838.221 632.417,791.074 538.602,758.96C536.859,790.593 545.561,854.983 522.327,856.611C477.951,859.719 321.557,782.368 310.75,710.135C300.443,641.237 302.536,535.834 294.475,482.283C86.974,483.114 245.65,303.256 245.65,303.256L261.925,368.357L294.475,368.357C294.475,368.357 298.094,296.03 310.75,286.981C326.511,275.713 366.457,254.592 473.502,254.431C519.506,190.629 692.164,133.645 766.775,105.176ZM603.703,352.082C598.577,358.301 614.243,384.787 623.39,401.682C639.967,432.299 672.34,459.32 760.231,456.739C780.796,456.135 808.649,456.743 831.555,448.316C919.689,369.191 665.548,260.941 652.528,270.706C629.157,288.235 677.433,340.481 685.079,352.082C663.595,350.818 630.521,352.121 603.703,352.082ZM515.817,516.822C491.026,516.822 470.898,536.949 470.898,561.741C470.898,586.532 491.026,606.66 515.817,606.66C540.609,606.66 560.736,586.532 560.736,561.741C560.736,536.949 540.609,516.822 515.817,516.822ZM656.608,969.83C610.979,984.25 562.391,992.031 512,992.031C247.063,992.031 31.969,776.937 31.969,512C31.969,247.063 247.063,31.969 512,31.969C581.652,31.969 647.859,46.835 707.634,73.574C674.574,86.913 627.224,104.986 620,103.081C343.573,30.201 98.64,283.528 98.64,511.993C98.64,761.842 376.244,989.043 627.831,910C637.21,907.053 645.743,936.753 656.608,969.83Z" fill="#fff"/></g></svg>
<h1>Authorization Complete</h1>
<p>You may close this tab and return to Yaak.</p>
</div>
</body>
</html>`;
}

View File

@@ -1,5 +1,6 @@
import { createHash, randomBytes } from 'node:crypto';
import type { Context } from '@yaakapp/api';
import { buildHostedCallbackRedirectUri, startCallbackServer } from '../callbackServer';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken, TokenStoreArgs } from '../store';
@@ -10,6 +11,18 @@ export const PKCE_SHA256 = 'S256';
export const PKCE_PLAIN = 'plain';
export const DEFAULT_PKCE_METHOD = PKCE_SHA256;
/** Default port for localhost callback when user specifies a fixed port */
export const DEFAULT_LOCALHOST_PORT = 8765;
export type CallbackType = 'localhost' | 'hosted';
export interface ExternalBrowserOptions {
useExternalBrowser: boolean;
callbackType: CallbackType;
/** Port for localhost callback (only used when callbackType is 'localhost') */
callbackPort?: number;
}
export async function getAuthorizationCode(
ctx: Context,
contextId: string,
@@ -25,6 +38,7 @@ export async function getAuthorizationCode(
credentialsInBody,
pkce,
tokenName,
externalBrowser,
}: {
authorizationUrl: string;
accessTokenUrl: string;
@@ -40,6 +54,7 @@ export async function getAuthorizationCode(
codeVerifier: string;
} | null;
tokenName: 'access_token' | 'id_token';
externalBrowser?: ExternalBrowserOptions;
},
): Promise<AccessToken> {
const tokenArgs: TokenStoreArgs = {
@@ -68,7 +83,6 @@ export async function getAuthorizationCode(
}
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
@@ -80,12 +94,60 @@ export async function getAuthorizationCode(
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
}
let code: string;
let actualRedirectUri: string | null = redirectUri;
// Use external browser flow if enabled
if (externalBrowser?.useExternalBrowser) {
const result = await getCodeViaExternalBrowser(ctx, authorizationUrl, {
callbackType: externalBrowser.callbackType,
callbackPort: externalBrowser.callbackPort,
});
code = result.code;
actualRedirectUri = result.redirectUri;
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
}
code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri);
}
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 }] : []),
...(actualRedirectUri ? [{ name: 'redirect_uri', value: actualRedirectUri }] : []),
],
});
return storeToken(ctx, tokenArgs, response, tokenName);
}
/**
* Get authorization code using the embedded browser window.
* This is the original flow that monitors navigation events.
*/
async function getCodeViaEmbeddedBrowser(
ctx: Context,
contextId: string,
authorizationUrl: URL,
redirectUri: string | null,
): Promise<string> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing', authorizationUrlStr);
console.log('[oauth2] Authorizing via embedded browser', authorizationUrlStr);
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: none
const code = await new Promise<string>(async (resolve, reject) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
return new Promise<string>(async (resolve, reject) => {
let foundCode = false;
const { close } = await ctx.window.openUrl({
dataDirKey,
@@ -110,31 +172,100 @@ export async function getAuthorizationCode(
return;
}
// Close the window here, because we don't need it anymore!
foundCode = true;
close();
resolve(code);
},
});
});
}
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 }] : []),
],
/**
* Get authorization code using the system's default browser.
* Starts a local HTTP server to receive the callback.
*/
async function getCodeViaExternalBrowser(
ctx: Context,
authorizationUrl: URL,
options: {
callbackType: CallbackType;
callbackPort?: number;
},
): Promise<{ code: string; redirectUri: string }> {
const { callbackType, callbackPort } = options;
// Determine port based on callback type:
// - localhost: use specified port or default stable port
// - hosted: use random port (0) since hosted page redirects to local
const port = callbackType === 'localhost'
? (callbackPort ?? DEFAULT_LOCALHOST_PORT)
: 0; // Random port for hosted callback
console.log(`[oauth2] Starting callback server (type: ${callbackType}, port: ${port || 'random'})`);
// Start the local callback server
const server = await startCallbackServer({
port,
path: '/callback',
});
return storeToken(ctx, tokenArgs, response, tokenName);
try {
// Determine the redirect URI to send to the OAuth provider
let oauthRedirectUri: string;
if (callbackType === 'hosted') {
// For hosted callback, the OAuth provider redirects to the hosted page,
// which then redirects to our local server
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback');
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
} else {
// For localhost callback, always use the local server's URI so the
// callback actually reaches our listener. The user-configured
// redirectUri is ignored here — it only applies to embedded browser flow.
oauthRedirectUri = server.redirectUri;
console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri);
}
// Set the redirect URI on the authorization URL
authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Opening external browser:', authorizationUrlStr);
// Show toast to inform user
await ctx.toast.show({
message: 'Opening browser for authorization...',
icon: 'info',
timeout: 3000,
});
// Open the system browser
await ctx.window.openExternalUrl(authorizationUrlStr);
// Wait for the callback
console.log('[oauth2] Waiting for callback on', server.redirectUri);
const callbackUrl = await server.waitForCallback();
console.log('[oauth2] Received callback:', callbackUrl);
// Extract the authorization code from the callback URL
const code = extractCode(callbackUrl, server.redirectUri);
if (!code) {
throw new Error('No authorization code found in callback URL');
}
// For hosted callback, the redirect_uri sent in token exchange must match
// what was sent to the OAuth provider (the hosted URL)
// For localhost, use the local server's redirect URI
return {
code,
redirectUri: oauthRedirectUri,
};
} finally {
// Always stop the server to release the port, even on success.
// This is safe to call multiple times (guarded by `stopped` flag).
server.stop();
}
}
export function genPkceCodeVerifier() {

View File

@@ -1,7 +1,9 @@
import type { Context } from '@yaakapp/api';
import { buildHostedCallbackRedirectUri, startCallbackServer } from '../callbackServer';
import type { AccessToken, AccessTokenRawResponse } from '../store';
import { getDataDirKey, getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
import { DEFAULT_LOCALHOST_PORT, type CallbackType, type ExternalBrowserOptions } from './authorizationCode';
export async function getImplicit(
ctx: Context,
@@ -15,6 +17,7 @@ export async function getImplicit(
state,
audience,
tokenName,
externalBrowser,
}: {
authorizationUrl: string;
responseType: string;
@@ -24,6 +27,7 @@ export async function getImplicit(
state: string | null;
audience: string | null;
tokenName: 'access_token' | 'id_token';
externalBrowser?: ExternalBrowserOptions;
},
): Promise<AccessToken> {
const tokenArgs = {
@@ -43,9 +47,8 @@ export async function getImplicit(
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set('response_type', 'token');
authorizationUrl.searchParams.set('response_type', responseType);
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
@@ -56,11 +59,44 @@ export async function getImplicit(
);
}
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: none
const newToken = await new Promise<AccessToken>(async (resolve, reject) => {
let newToken: AccessToken;
// Use external browser flow if enabled
if (externalBrowser?.useExternalBrowser) {
newToken = await getTokenViaExternalBrowser(ctx, authorizationUrl, tokenArgs, {
callbackType: externalBrowser.callbackType,
callbackPort: externalBrowser.callbackPort,
tokenName,
});
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
}
newToken = await getTokenViaEmbeddedBrowser(ctx, contextId, authorizationUrl, tokenArgs, tokenName);
}
return newToken;
}
/**
* Get token using the embedded browser window.
* This is the original flow that monitors navigation events.
*/
async function getTokenViaEmbeddedBrowser(
ctx: Context,
contextId: string,
authorizationUrl: URL,
tokenArgs: { contextId: string; clientId: string; accessTokenUrl: null; authorizationUrl: string },
tokenName: 'access_token' | 'id_token',
): Promise<AccessToken> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing via embedded browser (implicit)', authorizationUrlStr);
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
return new Promise<AccessToken>(async (resolve, reject) => {
let foundAccessToken = false;
const authorizationUrlStr = authorizationUrl.toString();
const dataDirKey = await getDataDirKey(ctx, contextId);
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
@@ -97,6 +133,120 @@ export async function getImplicit(
},
});
});
return newToken;
}
/**
* Get token using the system's default browser.
* Starts a local HTTP server to receive the callback with token in fragment.
*/
async function getTokenViaExternalBrowser(
ctx: Context,
authorizationUrl: URL,
tokenArgs: { contextId: string; clientId: string; accessTokenUrl: null; authorizationUrl: string },
options: {
callbackType: CallbackType;
callbackPort?: number;
tokenName: 'access_token' | 'id_token';
},
): Promise<AccessToken> {
const { callbackType, callbackPort, tokenName } = options;
// Determine port based on callback type:
// - localhost: use specified port or default stable port
// - hosted: use random port (0) since hosted page redirects to local
const port = callbackType === 'localhost'
? (callbackPort ?? DEFAULT_LOCALHOST_PORT)
: 0; // Random port for hosted callback
console.log(`[oauth2] Starting callback server for implicit flow (type: ${callbackType}, port: ${port || 'random'})`);
// Start the local callback server
const server = await startCallbackServer({
port,
path: '/callback',
});
try {
// Determine the redirect URI to send to the OAuth provider
let oauthRedirectUri: string;
if (callbackType === 'hosted') {
// For hosted callback, the OAuth provider redirects to the hosted page,
// which then redirects to our local server (preserving the fragment)
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback');
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
} else {
// For localhost callback, always use the local server's URI so the
// callback actually reaches our listener. The user-configured
// redirectUri is ignored here — it only applies to embedded browser flow.
oauthRedirectUri = server.redirectUri;
console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri);
}
// Set the redirect URI on the authorization URL
authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Opening external browser (implicit):', authorizationUrlStr);
// Show toast to inform user
await ctx.toast.show({
message: 'Opening browser for authorization...',
icon: 'info',
timeout: 3000,
});
// Open the system browser
await ctx.window.openExternalUrl(authorizationUrlStr);
// Wait for the callback
// Note: For implicit flow, the token is in the URL fragment (#access_token=...)
// The hosted callback page will need to preserve this and pass it to the local server
console.log('[oauth2] Waiting for callback on', server.redirectUri);
const callbackUrl = await server.waitForCallback();
console.log('[oauth2] Received callback:', callbackUrl);
// Parse the callback URL
const url = new URL(callbackUrl);
// Check for errors
if (url.searchParams.has('error')) {
throw new Error(`Failed to authorize: ${url.searchParams.get('error')}`);
}
// Extract token from fragment
const hash = url.hash.slice(1);
const params = new URLSearchParams(hash);
// Also check query params (in case fragment was converted)
const accessToken = params.get(tokenName) ?? url.searchParams.get(tokenName);
if (!accessToken) {
throw new Error(`No ${tokenName} found in callback URL`);
}
// Build response from params (prefer fragment, fall back to query)
const response: AccessTokenRawResponse = {
access_token: params.get('access_token') ?? url.searchParams.get('access_token') ?? '',
token_type: params.get('token_type') ?? url.searchParams.get('token_type') ?? undefined,
expires_in: params.has('expires_in')
? parseInt(params.get('expires_in') ?? '0', 10)
: url.searchParams.has('expires_in')
? parseInt(url.searchParams.get('expires_in') ?? '0', 10)
: undefined,
scope: params.get('scope') ?? url.searchParams.get('scope') ?? undefined,
};
// Include id_token if present
const idToken = params.get('id_token') ?? url.searchParams.get('id_token');
if (idToken) {
response.id_token = idToken;
}
return storeToken(ctx, tokenArgs, response);
} finally {
// Always stop the server to release the port, even on success.
// This is safe to call multiple times (guarded by `stopped` flag).
server.stop();
}
}

View File

@@ -6,6 +6,8 @@ import type {
PluginDefinition,
} from '@yaakapp/api';
import {
type CallbackType,
DEFAULT_LOCALHOST_PORT,
DEFAULT_PKCE_METHOD,
genPkceCodeVerifier,
getAuthorizationCode,
@@ -18,6 +20,9 @@ import { getPassword } from './grants/password';
import type { AccessToken, TokenStoreArgs } from './store';
import { deleteToken, getToken, resetDataDirKey } from './store';
/** Hosted callback URL that redirects to local server */
const HOSTED_CALLBACK_URL = 'https://yaak.app/oauth-callback';
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
const grantTypes: FormInputSelectOption[] = [
@@ -125,6 +130,38 @@ export const plugin: PluginDefinition = {
await resetDataDirKey(ctx, contextId);
},
},
{
label: 'Copy Redirect URI',
icon: 'copy',
async onSelect(ctx, { values }) {
const useExternalBrowser = !!values.useExternalBrowser;
console.log('HELLO', values);
const callbackType = (stringArg(values, 'callbackType') || 'localhost') as CallbackType;
if (!useExternalBrowser) {
await ctx.toast.show({
message: 'External browser is not enabled',
color: 'warning',
});
return;
}
let redirectUri: string;
if (callbackType === 'hosted') {
redirectUri = HOSTED_CALLBACK_URL;
} else {
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
redirectUri = `http://127.0.0.1:${port}/callback`;
}
await ctx.clipboard.copyText(redirectUri);
await ctx.toast.show({
message: 'Redirect URI copied to clipboard',
icon: 'copy',
color: 'success',
});
},
},
],
args: [
{
@@ -173,7 +210,10 @@ export const plugin: PluginDefinition = {
name: 'redirectUri',
label: 'Redirect URI',
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser }) => !useExternalBrowser,
),
},
{
type: 'text',
@@ -182,6 +222,80 @@ export const plugin: PluginDefinition = {
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
// External browser settings
{
type: 'checkbox',
name: 'useExternalBrowser',
label: 'Use External Browser',
description:
'Open authorization URL in your system browser instead of the embedded browser. ' +
'Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{
type: 'select',
name: 'callbackType',
label: 'Callback Type',
description:
'How to receive the OAuth callback. "Localhost" uses a local server (must be registered with provider). ' +
'"Hosted Redirect" uses yaak.app to redirect back to your local server (no registration needed).',
defaultValue: 'localhost',
options: [
{ label: 'Localhost', value: 'localhost' },
{ label: 'Hosted Redirect', value: 'hosted' },
],
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
{
type: 'text',
name: 'callbackPort',
label: 'Callback Port',
placeholder: `${DEFAULT_LOCALHOST_PORT}`,
description:
'Port for the local callback server. Leave empty for the default port. ' +
'Register http://127.0.0.1:{port}/callback as a redirect URI with your OAuth provider.',
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser, callbackType }) =>
!!useExternalBrowser && callbackType === 'localhost',
),
},
{
type: 'text',
name: 'computedRedirectUri',
label: 'Redirect URI to Register',
description: 'Register this URL as a redirect URI in your OAuth provider settings.',
disabled: true,
async dynamic(_ctx, { values }) {
const grantType = String(values.grantType ?? defaultGrantType);
const useExternalBrowser = !!values.useExternalBrowser;
const callbackType = (stringArg(values, 'callbackType') || 'localhost') as CallbackType;
// Only show for authorization_code and implicit with external browser enabled
if (!['authorization_code', 'implicit'].includes(grantType) || !useExternalBrowser) {
return { hidden: true };
}
// Compute the redirect URI based on callback type
let redirectUri: string;
if (callbackType === 'hosted') {
redirectUri = HOSTED_CALLBACK_URL;
} else {
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
redirectUri = `http://127.0.0.1:${port}/callback`;
}
return {
hidden: false,
defaultValue: redirectUri,
};
},
},
{
type: 'text',
name: 'audience',
@@ -321,6 +435,16 @@ export const plugin: PluginDefinition = {
const credentialsInBody = values.credentials === 'body';
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token';
// Build external browser options if enabled
const useExternalBrowser = !!values.useExternalBrowser;
const externalBrowserOptions = useExternalBrowser
? {
useExternalBrowser: true,
callbackType: (stringArg(values, 'callbackType') || 'localhost') as CallbackType,
callbackPort: intArg(values, 'callbackPort') ?? undefined,
}
: undefined;
let token: AccessToken;
if (grantType === 'authorization_code') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
@@ -348,6 +472,7 @@ export const plugin: PluginDefinition = {
}
: null,
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'implicit') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
@@ -362,6 +487,7 @@ export const plugin: PluginDefinition = {
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'client_credentials') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
@@ -414,3 +540,10 @@ function stringArg(values: Record<string, JsonPrimitive | undefined>, name: stri
if (!arg) return '';
return arg;
}
function intArg(values: Record<string, JsonPrimitive | undefined>, name: string): number | null {
const arg = values[name];
if (arg == null || arg === '') return null;
const num = parseInt(`${arg}`, 10);
return isNaN(num) ? null : num;
}