Better external OAuth callback format

This commit is contained in:
Gregory Schier
2026-03-04 09:10:49 -08:00
parent 30f006401a
commit 0c7051d59c
4 changed files with 131 additions and 105 deletions
+30 -6
View File
@@ -18,7 +18,12 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, }; export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -34,9 +39,17 @@ export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, up
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end"; export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
/**
* Server URL (http for plaintext or https for secure)
*/
url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -55,11 +68,18 @@ export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed"; export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, }; export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type PluginSource = "bundled" | "filesystem" | "registry";
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" }; export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
@@ -77,7 +97,11 @@ export type WebsocketEvent = { model: "websocket_event", id: string, createdAt:
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
+12 -14
View File
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import http from 'node:http'; import http from 'node:http';
import type { Context } from '@yaakapp/api'; import type { Context } from '@yaakapp/api';
export const HOSTED_CALLBACK_URL = 'https://oauth.yaak.app/redirect'; export const HOSTED_CALLBACK_URL_BASE = 'https://oauth.yaak.app/redirect';
export const DEFAULT_LOCALHOST_PORT = 8765; export const DEFAULT_LOCALHOST_PORT = 8765;
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -176,12 +176,15 @@ export function startCallbackServer(options: {
/** /**
* Build the redirect URI for the hosted callback page. * Build the redirect URI for the hosted callback page.
* The hosted page will redirect to the local server with the OAuth response. * The port is encoded in the URL path so the hosted page can redirect
* to the local server without relying on query params (which some OAuth
* providers strip). The default port is omitted for a cleaner URL.
*/ */
export function buildHostedCallbackRedirectUri(localPort: number, localPath: string): string { export function buildHostedCallbackRedirectUri(localPort: number): string {
const localRedirectUri = `http://127.0.0.1:${localPort}${localPath}`; if (localPort === DEFAULT_LOCALHOST_PORT) {
// The hosted callback page will read params and redirect to the local server return HOSTED_CALLBACK_URL_BASE;
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`; }
return `${HOSTED_CALLBACK_URL_BASE}/${localPort}`;
} }
/** /**
@@ -213,14 +216,9 @@ export async function getRedirectUrlViaExternalBrowser(
): Promise<{ callbackUrl: string; redirectUri: string }> { ): Promise<{ callbackUrl: string; redirectUri: string }> {
const { callbackType, callbackPort } = options; const { callbackType, callbackPort } = options;
// Determine port based on callback type: const port = callbackPort ?? DEFAULT_LOCALHOST_PORT;
// - 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;
console.log( console.log(`[oauth2] Starting callback server (type: ${callbackType}, port: ${port})`);
`[oauth2] Starting callback server (type: ${callbackType}, port: ${port || 'random'})`,
);
const server = await startCallbackServer({ const server = await startCallbackServer({
port, port,
@@ -232,7 +230,7 @@ export async function getRedirectUrlViaExternalBrowser(
let oauthRedirectUri: string; let oauthRedirectUri: string;
if (callbackType === 'hosted') { if (callbackType === 'hosted') {
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback'); oauthRedirectUri = buildHostedCallbackRedirectUri(server.port);
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri); console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
} else { } else {
oauthRedirectUri = server.redirectUri; oauthRedirectUri = server.redirectUri;
+9 -5
View File
@@ -6,7 +6,12 @@ import type {
PluginDefinition, PluginDefinition,
} from '@yaakapp/api'; } from '@yaakapp/api';
import type { Algorithm } from 'jsonwebtoken'; import type { Algorithm } from 'jsonwebtoken';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer'; import {
buildHostedCallbackRedirectUri,
DEFAULT_LOCALHOST_PORT,
HOSTED_CALLBACK_URL_BASE,
stopActiveServer,
} from './callbackServer';
import { import {
type CallbackType, type CallbackType,
DEFAULT_PKCE_METHOD, DEFAULT_PKCE_METHOD,
@@ -300,8 +305,7 @@ export const plugin: PluginDefinition = {
optional: true, optional: true,
dynamic: hiddenIfNot( dynamic: hiddenIfNot(
['authorization_code', 'implicit'], ['authorization_code', 'implicit'],
({ useExternalBrowser, callbackType }) => ({ useExternalBrowser }) => !!useExternalBrowser,
!!useExternalBrowser && callbackType === 'localhost',
), ),
}, },
], ],
@@ -328,11 +332,11 @@ export const plugin: PluginDefinition = {
} }
// Compute the redirect URI based on callback type // Compute the redirect URI based on callback type
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
let redirectUri: string; let redirectUri: string;
if (callbackType === 'hosted') { if (callbackType === 'hosted') {
redirectUri = HOSTED_CALLBACK_URL; redirectUri = buildHostedCallbackRedirectUri(port);
} else { } else {
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
redirectUri = `http://127.0.0.1:${port}/callback`; redirectUri = `http://127.0.0.1:${port}/callback`;
} }