mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-26 11:21:30 +01:00
Merge main into proxy branch (formatting and docs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ The most secure and commonly used OAuth 2.0 flow for web applications.
|
||||
|
||||
### Client Credentials Flow
|
||||
|
||||
Ideal for server-to-server authentication where no user interaction is required.
|
||||
Ideal for server-to-server authentication where no user interaction is required.
|
||||
|
||||
### Implicit Flow
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@yaak/auth-oauth2",
|
||||
"displayName": "OAuth 2.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Authenticate requests using OAuth 2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mountain-loop/yaak.git",
|
||||
"directory": "plugins/auth-oauth2"
|
||||
},
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev",
|
||||
"test": "vitest --run tests"
|
||||
"test": "vp test --run tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import http from 'node:http';
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import http from "node:http";
|
||||
import type { Context } from "@yaakapp/api";
|
||||
|
||||
export const HOSTED_CALLBACK_URL_BASE = 'https://oauth.yaak.app/redirect';
|
||||
export const HOSTED_CALLBACK_URL_BASE = "https://oauth.yaak.app/redirect";
|
||||
export const DEFAULT_LOCALHOST_PORT = 8765;
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -36,12 +36,12 @@ export function startCallbackServer(options: {
|
||||
}): 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');
|
||||
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;
|
||||
const { port = 0, path = "/callback", timeoutMs = CALLBACK_TIMEOUT_MS } = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let callbackResolve: ((url: string) => void) | null = null;
|
||||
@@ -50,33 +50,33 @@ export function startCallbackServer(options: {
|
||||
let stopped = false;
|
||||
|
||||
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
const reqUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
||||
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');
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
if (req.method === "POST") {
|
||||
// POST: read JSON body with the final callback URL and resolve
|
||||
let body = '';
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const { url: callbackUrl } = JSON.parse(body);
|
||||
if (!callbackUrl || typeof callbackUrl !== 'string') {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end('Missing url in request body');
|
||||
if (!callbackUrl || typeof callbackUrl !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Missing url in request body");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send success response
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('OK');
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("OK");
|
||||
|
||||
// Resolve the callback promise
|
||||
if (callbackResolve) {
|
||||
@@ -88,19 +88,19 @@ export function startCallbackServer(options: {
|
||||
// Stop the server after a short delay to ensure response is sent
|
||||
setTimeout(() => stopServer(), 100);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end('Invalid JSON');
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Invalid JSON");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET: serve intermediate page that reads the fragment and POSTs back
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(getFragmentForwardingHtml());
|
||||
});
|
||||
|
||||
server.on('error', (err: Error) => {
|
||||
server.on("error", (err: Error) => {
|
||||
if (!stopped) {
|
||||
reject(err);
|
||||
}
|
||||
@@ -123,16 +123,16 @@ export function startCallbackServer(options: {
|
||||
server.close();
|
||||
|
||||
if (callbackReject) {
|
||||
callbackReject(new Error('Callback server stopped'));
|
||||
callbackReject(new Error("Callback server stopped"));
|
||||
callbackResolve = null;
|
||||
callbackReject = null;
|
||||
}
|
||||
};
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Failed to get server address'));
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Failed to get server address"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function startCallbackServer(options: {
|
||||
waitForCallback: () => {
|
||||
return new Promise<string>((res, rej) => {
|
||||
if (stopped) {
|
||||
rej(new Error('Callback server already stopped'));
|
||||
rej(new Error("Callback server already stopped"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export function startCallbackServer(options: {
|
||||
// Set timeout
|
||||
timeoutHandle = setTimeout(() => {
|
||||
if (callbackReject) {
|
||||
callbackReject(new Error('Authorization timed out'));
|
||||
callbackReject(new Error("Authorization timed out"));
|
||||
callbackResolve = null;
|
||||
callbackReject = null;
|
||||
}
|
||||
@@ -193,7 +193,7 @@ export function buildHostedCallbackRedirectUri(localPort: number): string {
|
||||
*/
|
||||
export function stopActiveServer(): void {
|
||||
if (activeServer) {
|
||||
console.log('[oauth2] Stopping active callback server during dispose');
|
||||
console.log("[oauth2] Stopping active callback server during dispose");
|
||||
activeServer.stop();
|
||||
activeServer = null;
|
||||
}
|
||||
@@ -210,7 +210,7 @@ export async function getRedirectUrlViaExternalBrowser(
|
||||
ctx: Context,
|
||||
authorizationUrl: URL,
|
||||
options: {
|
||||
callbackType: 'localhost' | 'hosted';
|
||||
callbackType: "localhost" | "hosted";
|
||||
callbackPort?: number;
|
||||
},
|
||||
): Promise<{ callbackUrl: string; redirectUri: string }> {
|
||||
@@ -222,31 +222,31 @@ export async function getRedirectUrlViaExternalBrowser(
|
||||
|
||||
const server = await startCallbackServer({
|
||||
port,
|
||||
path: '/callback',
|
||||
path: "/callback",
|
||||
});
|
||||
|
||||
try {
|
||||
// Determine the redirect URI to send to the OAuth provider
|
||||
let oauthRedirectUri: string;
|
||||
|
||||
if (callbackType === 'hosted') {
|
||||
if (callbackType === "hosted") {
|
||||
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port);
|
||||
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
|
||||
console.log("[oauth2] Using hosted callback redirect:", oauthRedirectUri);
|
||||
} else {
|
||||
oauthRedirectUri = server.redirectUri;
|
||||
console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri);
|
||||
console.log("[oauth2] Using localhost callback redirect:", oauthRedirectUri);
|
||||
}
|
||||
|
||||
// Set the redirect URI on the authorization URL
|
||||
authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri);
|
||||
authorizationUrl.searchParams.set("redirect_uri", oauthRedirectUri);
|
||||
|
||||
const authorizationUrlStr = authorizationUrl.toString();
|
||||
console.log('[oauth2] Opening external browser:', authorizationUrlStr);
|
||||
console.log("[oauth2] Opening external browser:", authorizationUrlStr);
|
||||
|
||||
// Show toast to inform user
|
||||
await ctx.toast.show({
|
||||
message: 'Opening browser for authorization...',
|
||||
icon: 'info',
|
||||
message: "Opening browser for authorization...",
|
||||
icon: "info",
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
@@ -254,10 +254,10 @@ export async function getRedirectUrlViaExternalBrowser(
|
||||
await ctx.window.openExternalUrl(authorizationUrlStr);
|
||||
|
||||
// Wait for the callback
|
||||
console.log('[oauth2] Waiting for callback on', server.redirectUri);
|
||||
console.log("[oauth2] Waiting for callback on", server.redirectUri);
|
||||
const callbackUrl = await server.waitForCallback();
|
||||
|
||||
console.log('[oauth2] Received callback:', callbackUrl);
|
||||
console.log("[oauth2] Received callback:", callbackUrl);
|
||||
|
||||
return { callbackUrl, redirectUri: oauthRedirectUri };
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
|
||||
import type { AccessTokenRawResponse } from './store';
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { Context, HttpRequest, HttpUrlParameter } from "@yaakapp/api";
|
||||
import type { AccessTokenRawResponse } from "./store";
|
||||
|
||||
export async function fetchAccessToken(
|
||||
ctx: Context,
|
||||
@@ -14,64 +14,64 @@ export async function fetchAccessToken(
|
||||
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
|
||||
): Promise<AccessTokenRawResponse> {
|
||||
const { clientId, grantType, accessTokenUrl, scope, audience, params } = args;
|
||||
console.log('[oauth2] Getting access token', accessTokenUrl);
|
||||
console.log("[oauth2] Getting access token", accessTokenUrl);
|
||||
const httpRequest: Partial<HttpRequest> = {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
url: accessTokenUrl,
|
||||
bodyType: 'application/x-www-form-urlencoded',
|
||||
bodyType: "application/x-www-form-urlencoded",
|
||||
body: {
|
||||
form: [{ name: 'grant_type', value: grantType }, ...params],
|
||||
form: [{ name: "grant_type", value: grantType }, ...params],
|
||||
},
|
||||
headers: [
|
||||
{ name: 'User-Agent', value: 'yaak' },
|
||||
{ name: "User-Agent", value: "yaak" },
|
||||
{
|
||||
name: 'Accept',
|
||||
value: 'application/x-www-form-urlencoded, application/json',
|
||||
name: "Accept",
|
||||
value: "application/x-www-form-urlencoded, application/json",
|
||||
},
|
||||
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
|
||||
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
|
||||
],
|
||||
};
|
||||
|
||||
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
|
||||
if (audience) httpRequest.body?.form.push({ name: 'audience', value: audience });
|
||||
if (scope) httpRequest.body?.form.push({ name: "scope", value: scope });
|
||||
if (audience) httpRequest.body?.form.push({ name: "audience", value: audience });
|
||||
|
||||
if ('clientAssertion' in args) {
|
||||
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
|
||||
if ("clientAssertion" in args) {
|
||||
httpRequest.body?.form.push({ name: "client_id", value: clientId });
|
||||
httpRequest.body?.form.push({
|
||||
name: 'client_assertion_type',
|
||||
value: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
name: "client_assertion_type",
|
||||
value: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
});
|
||||
httpRequest.body?.form.push({
|
||||
name: 'client_assertion',
|
||||
name: "client_assertion",
|
||||
value: args.clientAssertion,
|
||||
});
|
||||
} else if (args.credentialsInBody) {
|
||||
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
|
||||
httpRequest.body?.form.push({ name: "client_id", value: clientId });
|
||||
httpRequest.body?.form.push({
|
||||
name: 'client_secret',
|
||||
name: "client_secret",
|
||||
value: args.clientSecret,
|
||||
});
|
||||
} else {
|
||||
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString('base64')}`;
|
||||
httpRequest.headers?.push({ name: 'Authorization', value });
|
||||
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString("base64")}`;
|
||||
httpRequest.headers?.push({ name: "Authorization", value });
|
||||
}
|
||||
|
||||
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
|
||||
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
|
||||
const resp = await ctx.httpRequest.send({ httpRequest });
|
||||
|
||||
console.log('[oauth2] Got access token response', resp.status);
|
||||
console.log("[oauth2] Got access token response", resp.status);
|
||||
|
||||
if (resp.error) {
|
||||
throw new Error(`Failed to fetch access token: ${resp.error}`);
|
||||
}
|
||||
|
||||
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
|
||||
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
|
||||
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw new Error(`Failed to fetch access token with status=${resp.status} and body=${body}`);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
let response: any;
|
||||
try {
|
||||
response = JSON.parse(body);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { Context, HttpRequest } from '@yaakapp/api';
|
||||
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from './store';
|
||||
import { deleteToken, getToken, storeToken } from './store';
|
||||
import { isTokenExpired } from './util';
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { Context, HttpRequest } from "@yaakapp/api";
|
||||
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from "./store";
|
||||
import { deleteToken, getToken, storeToken } from "./store";
|
||||
import { isTokenExpired } from "./util";
|
||||
|
||||
export async function getOrRefreshAccessToken(
|
||||
ctx: Context,
|
||||
@@ -42,33 +42,33 @@ export async function getOrRefreshAccessToken(
|
||||
|
||||
// Access token is expired, so get a new one
|
||||
const httpRequest: Partial<HttpRequest> = {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
url: accessTokenUrl,
|
||||
bodyType: 'application/x-www-form-urlencoded',
|
||||
bodyType: "application/x-www-form-urlencoded",
|
||||
body: {
|
||||
form: [
|
||||
{ name: 'grant_type', value: 'refresh_token' },
|
||||
{ name: 'refresh_token', value: token.response.refresh_token },
|
||||
{ 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' },
|
||||
{ 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 (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 });
|
||||
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 value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
|
||||
httpRequest.headers?.push({ name: "Authorization", value });
|
||||
}
|
||||
|
||||
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
|
||||
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
|
||||
const resp = await ctx.httpRequest.send({ httpRequest });
|
||||
|
||||
if (resp.error) {
|
||||
@@ -78,20 +78,20 @@ export async function getOrRefreshAccessToken(
|
||||
if (resp.status >= 400 && resp.status < 500) {
|
||||
// Client errors (4xx) indicate the refresh token is invalid, expired, or revoked
|
||||
// Delete the token and return null to trigger a fresh authorization flow
|
||||
console.log('[oauth2] Refresh token request failed with client error, deleting token');
|
||||
console.log("[oauth2] Refresh token request failed with client error, deleting token");
|
||||
await deleteToken(ctx, tokenArgs);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
|
||||
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
|
||||
|
||||
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) {
|
||||
throw new Error(`Failed to refresh access token with status=${resp.status} and body=${body}`);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
let response: any;
|
||||
try {
|
||||
response = JSON.parse(body);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
|
||||
import { fetchAccessToken } from '../fetchAccessToken';
|
||||
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
|
||||
import type { AccessToken, TokenStoreArgs } from '../store';
|
||||
import { getDataDirKey, storeToken } from '../store';
|
||||
import { extractCode } from '../util';
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
|
||||
import { fetchAccessToken } from "../fetchAccessToken";
|
||||
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
|
||||
import type { AccessToken, TokenStoreArgs } from "../store";
|
||||
import { getDataDirKey, storeToken } from "../store";
|
||||
import { extractCode } from "../util";
|
||||
|
||||
export const PKCE_SHA256 = 'S256';
|
||||
export const PKCE_PLAIN = 'plain';
|
||||
export const PKCE_SHA256 = "S256";
|
||||
export const PKCE_PLAIN = "plain";
|
||||
export const DEFAULT_PKCE_METHOD = PKCE_SHA256;
|
||||
|
||||
export type CallbackType = 'localhost' | 'hosted';
|
||||
export type CallbackType = "localhost" | "hosted";
|
||||
|
||||
export interface ExternalBrowserOptions {
|
||||
useExternalBrowser: boolean;
|
||||
@@ -50,7 +50,7 @@ export async function getAuthorizationCode(
|
||||
challengeMethod: string;
|
||||
codeVerifier: string;
|
||||
} | null;
|
||||
tokenName: 'access_token' | 'id_token';
|
||||
tokenName: "access_token" | "id_token";
|
||||
externalBrowser?: ExternalBrowserOptions;
|
||||
},
|
||||
): Promise<AccessToken> {
|
||||
@@ -74,21 +74,21 @@ export async function getAuthorizationCode(
|
||||
|
||||
let authorizationUrl: URL;
|
||||
try {
|
||||
authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
|
||||
authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`);
|
||||
} catch {
|
||||
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
|
||||
}
|
||||
authorizationUrl.searchParams.set('response_type', 'code');
|
||||
authorizationUrl.searchParams.set('client_id', clientId);
|
||||
if (scope) authorizationUrl.searchParams.set('scope', scope);
|
||||
if (state) authorizationUrl.searchParams.set('state', state);
|
||||
if (audience) authorizationUrl.searchParams.set('audience', audience);
|
||||
authorizationUrl.searchParams.set("response_type", "code");
|
||||
authorizationUrl.searchParams.set("client_id", clientId);
|
||||
if (scope) authorizationUrl.searchParams.set("scope", scope);
|
||||
if (state) authorizationUrl.searchParams.set("state", state);
|
||||
if (audience) authorizationUrl.searchParams.set("audience", audience);
|
||||
if (pkce) {
|
||||
authorizationUrl.searchParams.set(
|
||||
'code_challenge',
|
||||
"code_challenge",
|
||||
pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod),
|
||||
);
|
||||
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
|
||||
authorizationUrl.searchParams.set("code_challenge_method", pkce.challengeMethod);
|
||||
}
|
||||
|
||||
let code: string;
|
||||
@@ -103,21 +103,21 @@ export async function getAuthorizationCode(
|
||||
// Pass null to skip redirect URI matching — the callback came from our own local server
|
||||
const extractedCode = extractCode(result.callbackUrl, null);
|
||||
if (!extractedCode) {
|
||||
throw new Error('No authorization code found in callback URL');
|
||||
throw new Error("No authorization code found in callback URL");
|
||||
}
|
||||
code = extractedCode;
|
||||
actualRedirectUri = result.redirectUri;
|
||||
} else {
|
||||
// Use embedded browser flow (original behavior)
|
||||
if (redirectUri) {
|
||||
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
}
|
||||
code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri);
|
||||
}
|
||||
|
||||
console.log('[oauth2] Code found');
|
||||
console.log("[oauth2] Code found");
|
||||
const response = await fetchAccessToken(ctx, {
|
||||
grantType: 'authorization_code',
|
||||
grantType: "authorization_code",
|
||||
accessTokenUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
@@ -125,9 +125,9 @@ export async function getAuthorizationCode(
|
||||
audience,
|
||||
credentialsInBody,
|
||||
params: [
|
||||
{ name: 'code', value: code },
|
||||
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
|
||||
...(actualRedirectUri ? [{ name: 'redirect_uri', value: actualRedirectUri }] : []),
|
||||
{ name: "code", value: code },
|
||||
...(pkce ? [{ name: "code_verifier", value: pkce.codeVerifier }] : []),
|
||||
...(actualRedirectUri ? [{ name: "redirect_uri", value: actualRedirectUri }] : []),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -146,18 +146,18 @@ async function getCodeViaEmbeddedBrowser(
|
||||
): Promise<string> {
|
||||
const dataDirKey = await getDataDirKey(ctx, contextId);
|
||||
const authorizationUrlStr = authorizationUrl.toString();
|
||||
console.log('[oauth2] Authorizing via embedded browser', authorizationUrlStr);
|
||||
console.log("[oauth2] Authorizing via embedded browser", authorizationUrlStr);
|
||||
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
|
||||
// oxlint-disable-next-line no-async-promise-executor -- Required for this pattern
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
let foundCode = false;
|
||||
const { close } = await ctx.window.openUrl({
|
||||
dataDirKey,
|
||||
url: authorizationUrlStr,
|
||||
label: 'oauth-authorization-url',
|
||||
label: "oauth-authorization-url",
|
||||
async onClose() {
|
||||
if (!foundCode) {
|
||||
reject(new Error('Authorization window closed'));
|
||||
reject(new Error("Authorization window closed"));
|
||||
}
|
||||
},
|
||||
async onNavigate({ url: urlStr }) {
|
||||
@@ -187,21 +187,21 @@ export function genPkceCodeVerifier() {
|
||||
}
|
||||
|
||||
function pkceCodeChallenge(verifier: string, method: string) {
|
||||
if (method === 'plain') {
|
||||
if (method === "plain") {
|
||||
return verifier;
|
||||
}
|
||||
|
||||
const hash = encodeForPkce(createHash('sha256').update(verifier).digest());
|
||||
const hash = encodeForPkce(createHash("sha256").update(verifier).digest());
|
||||
return hash
|
||||
.replace(/=/g, '') // Remove padding '='
|
||||
.replace(/\+/g, '-') // Replace '+' with '-'
|
||||
.replace(/\//g, '_'); // Replace '/' with '_'
|
||||
.replace(/=/g, "") // Remove padding '='
|
||||
.replace(/\+/g, "-") // Replace '+' with '-'
|
||||
.replace(/\//g, "_"); // Replace '/' with '_'
|
||||
}
|
||||
|
||||
function encodeForPkce(bytes: Buffer) {
|
||||
return bytes
|
||||
.toString('base64')
|
||||
.replace(/=/g, '') // Remove padding '='
|
||||
.replace(/\+/g, '-') // Replace '+' with '-'
|
||||
.replace(/\//g, '_'); // Replace '/' with '_'
|
||||
.toString("base64")
|
||||
.replace(/=/g, "") // Remove padding '='
|
||||
.replace(/\+/g, "-") // Replace '+' with '-'
|
||||
.replace(/\//g, "_"); // Replace '/' with '_'
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { createPrivateKey, randomUUID } from 'node:crypto';
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import jwt, { type Algorithm } from 'jsonwebtoken';
|
||||
import { fetchAccessToken } from '../fetchAccessToken';
|
||||
import type { TokenStoreArgs } from '../store';
|
||||
import { getToken, storeToken } from '../store';
|
||||
import { isTokenExpired } from '../util';
|
||||
import { createPrivateKey, randomUUID } from "node:crypto";
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import jwt, { type Algorithm } from "jsonwebtoken";
|
||||
import { fetchAccessToken } from "../fetchAccessToken";
|
||||
import type { TokenStoreArgs } from "../store";
|
||||
import { getToken, storeToken } from "../store";
|
||||
import { isTokenExpired } from "../util";
|
||||
|
||||
export const jwtAlgorithms = [
|
||||
'HS256',
|
||||
'HS384',
|
||||
'HS512',
|
||||
'RS256',
|
||||
'RS384',
|
||||
'RS512',
|
||||
'PS256',
|
||||
'PS384',
|
||||
'PS512',
|
||||
'ES256',
|
||||
'ES384',
|
||||
'ES512',
|
||||
'none',
|
||||
"HS256",
|
||||
"HS384",
|
||||
"HS512",
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"none",
|
||||
] as const;
|
||||
|
||||
export const defaultJwtAlgorithm = jwtAlgorithms[0];
|
||||
@@ -40,7 +40,7 @@ function buildClientAssertionJwt(params: {
|
||||
}): string {
|
||||
const { clientId, accessTokenUrl, secret, algorithm } = params;
|
||||
|
||||
const isHmac = algorithm.startsWith('HS') || algorithm === 'none';
|
||||
const isHmac = algorithm.startsWith("HS") || algorithm === "none";
|
||||
|
||||
// Resolve the signing key depending on format
|
||||
let signingKey: jwt.Secret;
|
||||
@@ -51,24 +51,25 @@ function buildClientAssertionJwt(params: {
|
||||
if (isHmac) {
|
||||
// HMAC algorithms use the raw secret (string or Buffer)
|
||||
signingKey = secret;
|
||||
} else if (trimmed.startsWith('{')) {
|
||||
} else if (trimmed.startsWith("{")) {
|
||||
// Looks like JSON - treat as JWK. There is surely a better way to detect JWK vs a raw secret, but this should work in most cases.
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
let jwk: any;
|
||||
try {
|
||||
jwk = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error('Client Assertion secret looks like JSON but is not valid');
|
||||
throw new Error("Client Assertion secret looks like JSON but is not valid");
|
||||
}
|
||||
|
||||
kid = jwk?.kid;
|
||||
signingKey = createPrivateKey({ key: jwk, format: 'jwk' });
|
||||
} else if (trimmed.startsWith('-----')) {
|
||||
signingKey = createPrivateKey({ key: jwk, format: "jwk" });
|
||||
} else if (trimmed.startsWith("-----")) {
|
||||
// PEM-encoded key
|
||||
signingKey = createPrivateKey({ key: trimmed, format: 'pem' });
|
||||
signingKey = createPrivateKey({ key: trimmed, format: "pem" });
|
||||
} else {
|
||||
throw new Error(
|
||||
'Client Assertion secret must be a JWK JSON object, a PEM-encoded key ' +
|
||||
'(starting with -----), or a raw secret for HMAC algorithms.',
|
||||
"Client Assertion secret must be a JWK JSON object, a PEM-encoded key " +
|
||||
"(starting with -----), or a raw secret for HMAC algorithms.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ function buildClientAssertionJwt(params: {
|
||||
};
|
||||
|
||||
// Build the JWT header; include "kid" when available
|
||||
const header: jwt.JwtHeader = { alg: algorithm, typ: 'JWT' };
|
||||
const header: jwt.JwtHeader = { alg: algorithm, typ: "JWT" };
|
||||
if (kid) {
|
||||
header.kid = kid;
|
||||
}
|
||||
@@ -134,9 +135,9 @@ export async function getClientCredentials(
|
||||
|
||||
const common: Omit<
|
||||
Parameters<typeof fetchAccessToken>[1],
|
||||
'clientAssertion' | 'clientSecret' | 'credentialsInBody'
|
||||
"clientAssertion" | "clientSecret" | "credentialsInBody"
|
||||
> = {
|
||||
grantType: 'client_credentials',
|
||||
grantType: "client_credentials",
|
||||
accessTokenUrl,
|
||||
audience,
|
||||
clientId,
|
||||
@@ -145,7 +146,7 @@ export async function getClientCredentials(
|
||||
};
|
||||
|
||||
const fetchParams: Parameters<typeof fetchAccessToken>[1] =
|
||||
clientCredentialsMethod === 'client_assertion'
|
||||
clientCredentialsMethod === "client_assertion"
|
||||
? {
|
||||
...common,
|
||||
clientAssertion: buildClientAssertionJwt({
|
||||
@@ -153,7 +154,7 @@ export async function getClientCredentials(
|
||||
algorithm: clientAssertionAlgorithm as Algorithm,
|
||||
accessTokenUrl,
|
||||
secret: clientAssertionSecretBase64
|
||||
? Buffer.from(clientAssertionSecret, 'base64').toString('utf-8')
|
||||
? Buffer.from(clientAssertionSecret, "base64").toString("utf-8")
|
||||
: clientAssertionSecret,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
|
||||
import type { AccessToken, AccessTokenRawResponse } from '../store';
|
||||
import { getDataDirKey, getToken, storeToken } from '../store';
|
||||
import { isTokenExpired } from '../util';
|
||||
import type { ExternalBrowserOptions } from './authorizationCode';
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
|
||||
import type { AccessToken, AccessTokenRawResponse } from "../store";
|
||||
import { getDataDirKey, getToken, storeToken } from "../store";
|
||||
import { isTokenExpired } from "../util";
|
||||
import type { ExternalBrowserOptions } from "./authorizationCode";
|
||||
|
||||
export async function getImplicit(
|
||||
ctx: Context,
|
||||
@@ -26,7 +26,7 @@ export async function getImplicit(
|
||||
scope: string | null;
|
||||
state: string | null;
|
||||
audience: string | null;
|
||||
tokenName: 'access_token' | 'id_token';
|
||||
tokenName: "access_token" | "id_token";
|
||||
externalBrowser?: ExternalBrowserOptions;
|
||||
},
|
||||
): Promise<AccessToken> {
|
||||
@@ -43,18 +43,18 @@ export async function getImplicit(
|
||||
|
||||
let authorizationUrl: URL;
|
||||
try {
|
||||
authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
|
||||
authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`);
|
||||
} catch {
|
||||
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
|
||||
}
|
||||
authorizationUrl.searchParams.set('response_type', responseType);
|
||||
authorizationUrl.searchParams.set('client_id', clientId);
|
||||
if (scope) authorizationUrl.searchParams.set('scope', scope);
|
||||
if (state) authorizationUrl.searchParams.set('state', state);
|
||||
if (audience) authorizationUrl.searchParams.set('audience', audience);
|
||||
if (responseType.includes('id_token')) {
|
||||
authorizationUrl.searchParams.set("response_type", responseType);
|
||||
authorizationUrl.searchParams.set("client_id", clientId);
|
||||
if (scope) authorizationUrl.searchParams.set("scope", scope);
|
||||
if (state) authorizationUrl.searchParams.set("state", state);
|
||||
if (audience) authorizationUrl.searchParams.set("audience", audience);
|
||||
if (responseType.includes("id_token")) {
|
||||
authorizationUrl.searchParams.set(
|
||||
'nonce',
|
||||
"nonce",
|
||||
String(Math.floor(Math.random() * 9999999999999) + 1),
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export async function getImplicit(
|
||||
} else {
|
||||
// Use embedded browser flow (original behavior)
|
||||
if (redirectUri) {
|
||||
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
}
|
||||
newToken = await getTokenViaEmbeddedBrowser(
|
||||
ctx,
|
||||
@@ -99,28 +99,28 @@ async function getTokenViaEmbeddedBrowser(
|
||||
accessTokenUrl: null;
|
||||
authorizationUrl: string;
|
||||
},
|
||||
tokenName: 'access_token' | 'id_token',
|
||||
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);
|
||||
console.log("[oauth2] Authorizing via embedded browser (implicit)", authorizationUrlStr);
|
||||
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
|
||||
// oxlint-disable-next-line no-async-promise-executor -- Required for this pattern
|
||||
return new Promise<AccessToken>(async (resolve, reject) => {
|
||||
let foundAccessToken = false;
|
||||
const { close } = await ctx.window.openUrl({
|
||||
dataDirKey,
|
||||
url: authorizationUrlStr,
|
||||
label: 'oauth-authorization-url',
|
||||
label: "oauth-authorization-url",
|
||||
async onClose() {
|
||||
if (!foundAccessToken) {
|
||||
reject(new Error('Authorization window closed'));
|
||||
reject(new Error("Authorization window closed"));
|
||||
}
|
||||
},
|
||||
async onNavigate({ url: urlStr }) {
|
||||
const url = new URL(urlStr);
|
||||
if (url.searchParams.has('error')) {
|
||||
return reject(Error(`Failed to authorize: ${url.searchParams.get('error')}`));
|
||||
if (url.searchParams.has("error")) {
|
||||
return reject(Error(`Failed to authorize: ${url.searchParams.get("error")}`));
|
||||
}
|
||||
|
||||
const hash = url.hash.slice(1);
|
||||
@@ -158,13 +158,13 @@ async function extractImplicitToken(
|
||||
accessTokenUrl: null;
|
||||
authorizationUrl: string;
|
||||
},
|
||||
tokenName: 'access_token' | 'id_token',
|
||||
tokenName: "access_token" | "id_token",
|
||||
): Promise<AccessToken> {
|
||||
const url = new URL(callbackUrl);
|
||||
|
||||
// Check for errors
|
||||
if (url.searchParams.has('error')) {
|
||||
throw new Error(`Failed to authorize: ${url.searchParams.get('error')}`);
|
||||
if (url.searchParams.has("error")) {
|
||||
throw new Error(`Failed to authorize: ${url.searchParams.get("error")}`);
|
||||
}
|
||||
|
||||
// Extract token from fragment
|
||||
@@ -179,18 +179,18 @@ async function extractImplicitToken(
|
||||
|
||||
// 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)
|
||||
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,
|
||||
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');
|
||||
const idToken = params.get("id_token") ?? url.searchParams.get("id_token");
|
||||
if (idToken) {
|
||||
response.id_token = idToken;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import { fetchAccessToken } from '../fetchAccessToken';
|
||||
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
|
||||
import type { AccessToken, TokenStoreArgs } from '../store';
|
||||
import { storeToken } from '../store';
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import { fetchAccessToken } from "../fetchAccessToken";
|
||||
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
|
||||
import type { AccessToken, TokenStoreArgs } from "../store";
|
||||
import { storeToken } from "../store";
|
||||
|
||||
export async function getPassword(
|
||||
ctx: Context,
|
||||
@@ -50,11 +50,11 @@ export async function getPassword(
|
||||
clientSecret,
|
||||
scope,
|
||||
audience,
|
||||
grantType: 'password',
|
||||
grantType: "password",
|
||||
credentialsInBody,
|
||||
params: [
|
||||
{ name: 'username', value: username },
|
||||
{ name: 'password', value: password },
|
||||
{ name: "username", value: username },
|
||||
{ name: "password", value: password },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import type {
|
||||
GetHttpAuthenticationConfigRequest,
|
||||
JsonPrimitive,
|
||||
PluginDefinition,
|
||||
} from '@yaakapp/api';
|
||||
import type { Algorithm } from 'jsonwebtoken';
|
||||
} from "@yaakapp/api";
|
||||
import type { Algorithm } from "jsonwebtoken";
|
||||
import {
|
||||
buildHostedCallbackRedirectUri,
|
||||
DEFAULT_LOCALHOST_PORT,
|
||||
stopActiveServer,
|
||||
} from './callbackServer';
|
||||
} from "./callbackServer";
|
||||
import {
|
||||
type CallbackType,
|
||||
DEFAULT_PKCE_METHOD,
|
||||
@@ -18,31 +18,31 @@ import {
|
||||
getAuthorizationCode,
|
||||
PKCE_PLAIN,
|
||||
PKCE_SHA256,
|
||||
} from './grants/authorizationCode';
|
||||
} from "./grants/authorizationCode";
|
||||
import {
|
||||
defaultJwtAlgorithm,
|
||||
getClientCredentials,
|
||||
jwtAlgorithms,
|
||||
} from './grants/clientCredentials';
|
||||
import { getImplicit } from './grants/implicit';
|
||||
import { getPassword } from './grants/password';
|
||||
import type { AccessToken, TokenStoreArgs } from './store';
|
||||
import { deleteToken, getToken, resetDataDirKey } from './store';
|
||||
} from "./grants/clientCredentials";
|
||||
import { getImplicit } from "./grants/implicit";
|
||||
import { getPassword } from "./grants/password";
|
||||
import type { AccessToken, TokenStoreArgs } from "./store";
|
||||
import { deleteToken, getToken, resetDataDirKey } from "./store";
|
||||
|
||||
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
|
||||
type GrantType = "authorization_code" | "implicit" | "password" | "client_credentials";
|
||||
|
||||
const grantTypes: FormInputSelectOption[] = [
|
||||
{ label: 'Authorization Code', value: 'authorization_code' },
|
||||
{ label: 'Implicit', value: 'implicit' },
|
||||
{ label: 'Resource Owner Password Credential', value: 'password' },
|
||||
{ label: 'Client Credentials', value: 'client_credentials' },
|
||||
{ label: "Authorization Code", value: "authorization_code" },
|
||||
{ label: "Implicit", value: "implicit" },
|
||||
{ label: "Resource Owner Password Credential", value: "password" },
|
||||
{ label: "Client Credentials", value: "client_credentials" },
|
||||
];
|
||||
|
||||
const defaultGrantType = grantTypes[0]?.value;
|
||||
|
||||
function hiddenIfNot(
|
||||
grantTypes: GrantType[],
|
||||
...other: ((values: GetHttpAuthenticationConfigRequest['values']) => boolean)[]
|
||||
...other: ((values: GetHttpAuthenticationConfigRequest["values"]) => boolean)[]
|
||||
) {
|
||||
return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {
|
||||
const hasGrantType = grantTypes.find((t) => t === String(values.grantType ?? defaultGrantType));
|
||||
@@ -53,37 +53,37 @@ function hiddenIfNot(
|
||||
}
|
||||
|
||||
const authorizationUrls = [
|
||||
'https://github.com/login/oauth/authorize',
|
||||
'https://account.box.com/api/oauth2/authorize',
|
||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
'https://api.imgur.com/oauth2/authorize',
|
||||
'https://bitly.com/oauth/authorize',
|
||||
'https://gitlab.example.com/oauth/authorize',
|
||||
'https://medium.com/m/oauth/authorize',
|
||||
'https://public-api.wordpress.com/oauth2/authorize',
|
||||
'https://slack.com/oauth/authorize',
|
||||
'https://todoist.com/oauth/authorize',
|
||||
'https://www.dropbox.com/oauth2/authorize',
|
||||
'https://www.linkedin.com/oauth/v2/authorization',
|
||||
'https://MY_SHOP.myshopify.com/admin/oauth/access_token',
|
||||
'https://appcenter.intuit.com/app/connect/oauth2/authorize',
|
||||
"https://github.com/login/oauth/authorize",
|
||||
"https://account.box.com/api/oauth2/authorize",
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"https://api.imgur.com/oauth2/authorize",
|
||||
"https://bitly.com/oauth/authorize",
|
||||
"https://gitlab.example.com/oauth/authorize",
|
||||
"https://medium.com/m/oauth/authorize",
|
||||
"https://public-api.wordpress.com/oauth2/authorize",
|
||||
"https://slack.com/oauth/authorize",
|
||||
"https://todoist.com/oauth/authorize",
|
||||
"https://www.dropbox.com/oauth2/authorize",
|
||||
"https://www.linkedin.com/oauth/v2/authorization",
|
||||
"https://MY_SHOP.myshopify.com/admin/oauth/access_token",
|
||||
"https://appcenter.intuit.com/app/connect/oauth2/authorize",
|
||||
];
|
||||
|
||||
const accessTokenUrls = [
|
||||
'https://github.com/login/oauth/access_token',
|
||||
'https://api-ssl.bitly.com/oauth/access_token',
|
||||
'https://api.box.com/oauth2/token',
|
||||
'https://api.dropboxapi.com/oauth2/token',
|
||||
'https://api.imgur.com/oauth2/token',
|
||||
'https://api.medium.com/v1/tokens',
|
||||
'https://gitlab.example.com/oauth/token',
|
||||
'https://public-api.wordpress.com/oauth2/token',
|
||||
'https://slack.com/api/oauth.access',
|
||||
'https://todoist.com/oauth/access_token',
|
||||
'https://www.googleapis.com/oauth2/v4/token',
|
||||
'https://www.linkedin.com/oauth/v2/accessToken',
|
||||
'https://MY_SHOP.myshopify.com/admin/oauth/authorize',
|
||||
'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer',
|
||||
"https://github.com/login/oauth/access_token",
|
||||
"https://api-ssl.bitly.com/oauth/access_token",
|
||||
"https://api.box.com/oauth2/token",
|
||||
"https://api.dropboxapi.com/oauth2/token",
|
||||
"https://api.imgur.com/oauth2/token",
|
||||
"https://api.medium.com/v1/tokens",
|
||||
"https://gitlab.example.com/oauth/token",
|
||||
"https://public-api.wordpress.com/oauth2/token",
|
||||
"https://slack.com/api/oauth.access",
|
||||
"https://todoist.com/oauth/access_token",
|
||||
"https://www.googleapis.com/oauth2/v4/token",
|
||||
"https://www.linkedin.com/oauth/v2/accessToken",
|
||||
"https://MY_SHOP.myshopify.com/admin/oauth/authorize",
|
||||
"https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
|
||||
];
|
||||
|
||||
export const plugin: PluginDefinition = {
|
||||
@@ -91,59 +91,59 @@ export const plugin: PluginDefinition = {
|
||||
stopActiveServer();
|
||||
},
|
||||
authentication: {
|
||||
name: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
shortLabel: 'OAuth 2',
|
||||
name: "oauth2",
|
||||
label: "OAuth 2.0",
|
||||
shortLabel: "OAuth 2",
|
||||
actions: [
|
||||
{
|
||||
label: 'Copy Current Token',
|
||||
label: "Copy Current Token",
|
||||
async onSelect(ctx, { contextId, values }) {
|
||||
const tokenArgs: TokenStoreArgs = {
|
||||
contextId,
|
||||
authorizationUrl: stringArg(values, 'authorizationUrl'),
|
||||
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
|
||||
clientId: stringArg(values, 'clientId'),
|
||||
authorizationUrl: stringArg(values, "authorizationUrl"),
|
||||
accessTokenUrl: stringArg(values, "accessTokenUrl"),
|
||||
clientId: stringArg(values, "clientId"),
|
||||
};
|
||||
const token = await getToken(ctx, tokenArgs);
|
||||
if (token == null) {
|
||||
await ctx.toast.show({
|
||||
message: 'No token to copy',
|
||||
color: 'warning',
|
||||
message: "No token to copy",
|
||||
color: "warning",
|
||||
});
|
||||
} else {
|
||||
await ctx.clipboard.copyText(token.response.access_token);
|
||||
await ctx.toast.show({
|
||||
message: 'Token copied to clipboard',
|
||||
icon: 'copy',
|
||||
color: 'success',
|
||||
message: "Token copied to clipboard",
|
||||
icon: "copy",
|
||||
color: "success",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete Token',
|
||||
label: "Delete Token",
|
||||
async onSelect(ctx, { contextId, values }) {
|
||||
const tokenArgs: TokenStoreArgs = {
|
||||
contextId,
|
||||
authorizationUrl: stringArg(values, 'authorizationUrl'),
|
||||
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
|
||||
clientId: stringArg(values, 'clientId'),
|
||||
authorizationUrl: stringArg(values, "authorizationUrl"),
|
||||
accessTokenUrl: stringArg(values, "accessTokenUrl"),
|
||||
clientId: stringArg(values, "clientId"),
|
||||
};
|
||||
if (await deleteToken(ctx, tokenArgs)) {
|
||||
await ctx.toast.show({
|
||||
message: 'Token deleted',
|
||||
color: 'success',
|
||||
message: "Token deleted",
|
||||
color: "success",
|
||||
});
|
||||
} else {
|
||||
await ctx.toast.show({
|
||||
message: 'No token to delete',
|
||||
color: 'warning',
|
||||
message: "No token to delete",
|
||||
color: "warning",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Clear Window Session',
|
||||
label: "Clear Window Session",
|
||||
async onSelect(ctx, { contextId }) {
|
||||
await resetDataDirKey(ctx, contextId);
|
||||
},
|
||||
@@ -151,85 +151,85 @@ export const plugin: PluginDefinition = {
|
||||
],
|
||||
args: [
|
||||
{
|
||||
type: 'select',
|
||||
name: 'grantType',
|
||||
label: 'Grant Type',
|
||||
type: "select",
|
||||
name: "grantType",
|
||||
label: "Grant Type",
|
||||
defaultValue: defaultGrantType,
|
||||
options: grantTypes,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'clientCredentialsMethod',
|
||||
label: 'Authentication Method',
|
||||
type: "select",
|
||||
name: "clientCredentialsMethod",
|
||||
label: "Authentication Method",
|
||||
description:
|
||||
'"Client Secret" sends client_secret. \n' + '"Client Assertion" sends a signed JWT.',
|
||||
defaultValue: 'client_secret',
|
||||
defaultValue: "client_secret",
|
||||
options: [
|
||||
{ label: 'Client Secret', value: 'client_secret' },
|
||||
{ label: 'Client Assertion', value: 'client_assertion' },
|
||||
{ label: "Client Secret", value: "client_secret" },
|
||||
{ label: "Client Assertion", value: "client_assertion" },
|
||||
],
|
||||
dynamic: hiddenIfNot(['client_credentials']),
|
||||
dynamic: hiddenIfNot(["client_credentials"]),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'clientId',
|
||||
label: 'Client ID',
|
||||
type: "text",
|
||||
name: "clientId",
|
||||
label: "Client ID",
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'clientSecret',
|
||||
label: 'Client Secret',
|
||||
type: "text",
|
||||
name: "clientSecret",
|
||||
label: "Client Secret",
|
||||
optional: true,
|
||||
password: true,
|
||||
dynamic: hiddenIfNot(
|
||||
['authorization_code', 'password', 'client_credentials'],
|
||||
(values) => values.clientCredentialsMethod === 'client_secret',
|
||||
["authorization_code", "password", "client_credentials"],
|
||||
(values) => values.clientCredentialsMethod === "client_secret",
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'clientAssertionAlgorithm',
|
||||
label: 'JWT Algorithm',
|
||||
type: "select",
|
||||
name: "clientAssertionAlgorithm",
|
||||
label: "JWT Algorithm",
|
||||
defaultValue: defaultJwtAlgorithm,
|
||||
options: jwtAlgorithms.map((value) => ({
|
||||
label: value === 'none' ? 'None' : value,
|
||||
label: value === "none" ? "None" : value,
|
||||
value,
|
||||
})),
|
||||
dynamic: hiddenIfNot(
|
||||
['client_credentials'],
|
||||
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
|
||||
["client_credentials"],
|
||||
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'clientAssertionSecret',
|
||||
label: 'JWT Secret',
|
||||
type: "text",
|
||||
name: "clientAssertionSecret",
|
||||
label: "JWT Secret",
|
||||
description:
|
||||
'Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.',
|
||||
"Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.",
|
||||
password: true,
|
||||
optional: true,
|
||||
multiLine: true,
|
||||
dynamic: hiddenIfNot(
|
||||
['client_credentials'],
|
||||
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
|
||||
["client_credentials"],
|
||||
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'clientAssertionSecretBase64',
|
||||
label: 'JWT secret is base64 encoded',
|
||||
type: "checkbox",
|
||||
name: "clientAssertionSecretBase64",
|
||||
label: "JWT secret is base64 encoded",
|
||||
dynamic: hiddenIfNot(
|
||||
['client_credentials'],
|
||||
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
|
||||
["client_credentials"],
|
||||
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'authorizationUrl',
|
||||
type: "text",
|
||||
name: "authorizationUrl",
|
||||
optional: true,
|
||||
label: 'Authorization URL',
|
||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||
label: "Authorization URL",
|
||||
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
|
||||
placeholder: authorizationUrls[0],
|
||||
completionOptions: authorizationUrls.map((url) => ({
|
||||
label: url,
|
||||
@@ -237,103 +237,103 @@ export const plugin: PluginDefinition = {
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'accessTokenUrl',
|
||||
type: "text",
|
||||
name: "accessTokenUrl",
|
||||
optional: true,
|
||||
label: 'Access Token URL',
|
||||
label: "Access Token URL",
|
||||
placeholder: accessTokenUrls[0],
|
||||
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
|
||||
dynamic: hiddenIfNot(["authorization_code", "password", "client_credentials"]),
|
||||
completionOptions: accessTokenUrls.map((url) => ({
|
||||
label: url,
|
||||
value: url,
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'banner',
|
||||
type: "banner",
|
||||
inputs: [
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'useExternalBrowser',
|
||||
label: 'Use External Browser',
|
||||
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']),
|
||||
"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: 'text',
|
||||
name: 'redirectUri',
|
||||
label: 'Redirect URI (can be any valid URL)',
|
||||
placeholder: 'https://mysite.example.com/oauth/callback',
|
||||
type: "text",
|
||||
name: "redirectUri",
|
||||
label: "Redirect URI (can be any valid URL)",
|
||||
placeholder: "https://mysite.example.com/oauth/callback",
|
||||
description:
|
||||
'URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.',
|
||||
"URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.",
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(
|
||||
['authorization_code', 'implicit'],
|
||||
["authorization_code", "implicit"],
|
||||
({ useExternalBrowser }) => !useExternalBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'h_stack',
|
||||
type: "h_stack",
|
||||
inputs: [
|
||||
{
|
||||
type: 'select',
|
||||
name: 'callbackType',
|
||||
label: 'Callback Type',
|
||||
type: "select",
|
||||
name: "callbackType",
|
||||
label: "Callback Type",
|
||||
description:
|
||||
'"Hosted Redirect" uses an external Yaak-hosted endpoint. "Localhost" starts a local server to receive the callback.',
|
||||
defaultValue: 'hosted',
|
||||
defaultValue: "hosted",
|
||||
options: [
|
||||
{ label: 'Hosted Redirect', value: 'hosted' },
|
||||
{ label: 'Localhost', value: 'localhost' },
|
||||
{ label: "Hosted Redirect", value: "hosted" },
|
||||
{ label: "Localhost", value: "localhost" },
|
||||
],
|
||||
dynamic: hiddenIfNot(
|
||||
['authorization_code', 'implicit'],
|
||||
["authorization_code", "implicit"],
|
||||
({ useExternalBrowser }) => !!useExternalBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'callbackPort',
|
||||
label: 'Callback Port',
|
||||
type: "text",
|
||||
name: "callbackPort",
|
||||
label: "Callback Port",
|
||||
placeholder: `${DEFAULT_LOCALHOST_PORT}`,
|
||||
description:
|
||||
'Port for the local callback server. Defaults to ' +
|
||||
"Port for the local callback server. Defaults to " +
|
||||
DEFAULT_LOCALHOST_PORT +
|
||||
' if empty.',
|
||||
" if empty.",
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(
|
||||
['authorization_code', 'implicit'],
|
||||
["authorization_code", "implicit"],
|
||||
({ useExternalBrowser }) => !!useExternalBrowser,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'banner',
|
||||
color: 'info',
|
||||
type: "banner",
|
||||
color: "info",
|
||||
inputs: [
|
||||
{
|
||||
type: 'markdown',
|
||||
content: 'Redirect URI to Register',
|
||||
type: "markdown",
|
||||
content: "Redirect URI to Register",
|
||||
async dynamic(_ctx, { values }) {
|
||||
const grantType = String(values.grantType ?? defaultGrantType);
|
||||
const useExternalBrowser = !!values.useExternalBrowser;
|
||||
const callbackType = (stringArg(values, 'callbackType') ||
|
||||
'localhost') as CallbackType;
|
||||
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) ||
|
||||
!["authorization_code", "implicit"].includes(grantType) ||
|
||||
!useExternalBrowser
|
||||
) {
|
||||
return { hidden: true };
|
||||
}
|
||||
|
||||
// Compute the redirect URI based on callback type
|
||||
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
|
||||
const port = intArg(values, "callbackPort") || DEFAULT_LOCALHOST_PORT;
|
||||
let redirectUri: string;
|
||||
if (callbackType === 'hosted') {
|
||||
if (callbackType === "hosted") {
|
||||
redirectUri = buildHostedCallbackRedirectUri(port);
|
||||
} else {
|
||||
redirectUri = `http://127.0.0.1:${port}/callback`;
|
||||
@@ -350,149 +350,149 @@ export const plugin: PluginDefinition = {
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'state',
|
||||
label: 'State',
|
||||
type: "text",
|
||||
name: "state",
|
||||
label: "State",
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
|
||||
},
|
||||
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
|
||||
{ type: 'text', name: 'audience', label: 'Audience', optional: true },
|
||||
{ type: "text", name: "scope", label: "Scope", optional: true },
|
||||
{ type: "text", name: "audience", label: "Audience", optional: true },
|
||||
{
|
||||
type: 'select',
|
||||
name: 'tokenName',
|
||||
label: 'Token for authorization',
|
||||
type: "select",
|
||||
name: "tokenName",
|
||||
label: "Token for authorization",
|
||||
description:
|
||||
'Select which token to send in the "Authorization: Bearer" header. Most APIs expect ' +
|
||||
'access_token, but some (like OpenID Connect) require id_token.',
|
||||
defaultValue: 'access_token',
|
||||
"access_token, but some (like OpenID Connect) require id_token.",
|
||||
defaultValue: "access_token",
|
||||
options: [
|
||||
{ label: 'access_token', value: 'access_token' },
|
||||
{ label: 'id_token', value: 'id_token' },
|
||||
{ label: "access_token", value: "access_token" },
|
||||
{ label: "id_token", value: "id_token" },
|
||||
],
|
||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
|
||||
},
|
||||
{
|
||||
type: 'banner',
|
||||
type: "banner",
|
||||
inputs: [
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'usePkce',
|
||||
label: 'Use PKCE',
|
||||
dynamic: hiddenIfNot(['authorization_code']),
|
||||
type: "checkbox",
|
||||
name: "usePkce",
|
||||
label: "Use PKCE",
|
||||
dynamic: hiddenIfNot(["authorization_code"]),
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'pkceChallengeMethod',
|
||||
label: 'Code Challenge Method',
|
||||
type: "select",
|
||||
name: "pkceChallengeMethod",
|
||||
label: "Code Challenge Method",
|
||||
options: [
|
||||
{ label: 'SHA-256', value: PKCE_SHA256 },
|
||||
{ label: 'Plain', value: PKCE_PLAIN },
|
||||
{ label: "SHA-256", value: PKCE_SHA256 },
|
||||
{ label: "Plain", value: PKCE_PLAIN },
|
||||
],
|
||||
defaultValue: DEFAULT_PKCE_METHOD,
|
||||
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
|
||||
dynamic: hiddenIfNot(["authorization_code"], ({ usePkce }) => !!usePkce),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'pkceCodeChallenge',
|
||||
label: 'Code Verifier',
|
||||
placeholder: 'Automatically generated when not set',
|
||||
type: "text",
|
||||
name: "pkceCodeChallenge",
|
||||
label: "Code Verifier",
|
||||
placeholder: "Automatically generated when not set",
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
|
||||
dynamic: hiddenIfNot(["authorization_code"], ({ usePkce }) => !!usePkce),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'h_stack',
|
||||
type: "h_stack",
|
||||
inputs: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
type: "text",
|
||||
name: "username",
|
||||
label: "Username",
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['password']),
|
||||
dynamic: hiddenIfNot(["password"]),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: "text",
|
||||
name: "password",
|
||||
label: "Password",
|
||||
password: true,
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['password']),
|
||||
dynamic: hiddenIfNot(["password"]),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'responseType',
|
||||
label: 'Response Type',
|
||||
defaultValue: 'token',
|
||||
type: "select",
|
||||
name: "responseType",
|
||||
label: "Response Type",
|
||||
defaultValue: "token",
|
||||
options: [
|
||||
{ label: 'Access Token', value: 'token' },
|
||||
{ label: 'ID Token', value: 'id_token' },
|
||||
{ label: 'ID and Access Token', value: 'id_token token' },
|
||||
{ label: "Access Token", value: "token" },
|
||||
{ label: "ID Token", value: "id_token" },
|
||||
{ label: "ID and Access Token", value: "id_token token" },
|
||||
],
|
||||
dynamic: hiddenIfNot(['implicit']),
|
||||
dynamic: hiddenIfNot(["implicit"]),
|
||||
},
|
||||
{
|
||||
type: 'accordion',
|
||||
label: 'Advanced',
|
||||
type: "accordion",
|
||||
label: "Advanced",
|
||||
inputs: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'headerName',
|
||||
label: 'Header Name',
|
||||
defaultValue: 'Authorization',
|
||||
type: "text",
|
||||
name: "headerName",
|
||||
label: "Header Name",
|
||||
defaultValue: "Authorization",
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'headerPrefix',
|
||||
label: 'Header Prefix',
|
||||
type: "text",
|
||||
name: "headerPrefix",
|
||||
label: "Header Prefix",
|
||||
optional: true,
|
||||
defaultValue: 'Bearer',
|
||||
defaultValue: "Bearer",
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'credentials',
|
||||
label: 'Send Credentials',
|
||||
defaultValue: 'body',
|
||||
type: "select",
|
||||
name: "credentials",
|
||||
label: "Send Credentials",
|
||||
defaultValue: "body",
|
||||
options: [
|
||||
{ label: 'In Request Body', value: 'body' },
|
||||
{ label: 'As Basic Authentication', value: 'basic' },
|
||||
{ label: "In Request Body", value: "body" },
|
||||
{ label: "As Basic Authentication", value: "basic" },
|
||||
],
|
||||
dynamic: (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => ({
|
||||
hidden:
|
||||
values.grantType === 'client_credentials' &&
|
||||
values.clientCredentialsMethod === 'client_assertion',
|
||||
values.grantType === "client_credentials" &&
|
||||
values.clientCredentialsMethod === "client_assertion",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'accordion',
|
||||
label: 'Access Token Response',
|
||||
type: "accordion",
|
||||
label: "Access Token Response",
|
||||
inputs: [],
|
||||
async dynamic(ctx, { contextId, values }) {
|
||||
const tokenArgs: TokenStoreArgs = {
|
||||
contextId,
|
||||
authorizationUrl: stringArg(values, 'authorizationUrl'),
|
||||
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
|
||||
clientId: stringArg(values, 'clientId'),
|
||||
authorizationUrl: stringArg(values, "authorizationUrl"),
|
||||
accessTokenUrl: stringArg(values, "accessTokenUrl"),
|
||||
clientId: stringArg(values, "clientId"),
|
||||
};
|
||||
const token = await getToken(ctx, tokenArgs);
|
||||
if (token == null) {
|
||||
return { hidden: true };
|
||||
}
|
||||
return {
|
||||
label: 'Access Token Response',
|
||||
label: "Access Token Response",
|
||||
inputs: [
|
||||
{
|
||||
type: 'editor',
|
||||
name: 'response',
|
||||
type: "editor",
|
||||
name: "response",
|
||||
defaultValue: JSON.stringify(token.response, null, 2),
|
||||
hideLabel: true,
|
||||
readOnly: true,
|
||||
language: 'json',
|
||||
language: "json",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -500,101 +500,101 @@ export const plugin: PluginDefinition = {
|
||||
},
|
||||
],
|
||||
async onApply(ctx, { values, contextId }) {
|
||||
const headerPrefix = stringArg(values, 'headerPrefix');
|
||||
const grantType = stringArg(values, 'grantType') as GrantType;
|
||||
const credentialsInBody = values.credentials === 'body';
|
||||
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token';
|
||||
const headerPrefix = stringArg(values, "headerPrefix");
|
||||
const grantType = stringArg(values, "grantType") as GrantType;
|
||||
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,
|
||||
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');
|
||||
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
|
||||
if (grantType === "authorization_code") {
|
||||
const authorizationUrl = stringArg(values, "authorizationUrl");
|
||||
const accessTokenUrl = stringArg(values, "accessTokenUrl");
|
||||
token = await getAuthorizationCode(ctx, contextId, {
|
||||
accessTokenUrl:
|
||||
accessTokenUrl === '' || accessTokenUrl.match(/^https?:\/\//)
|
||||
accessTokenUrl === "" || accessTokenUrl.match(/^https?:\/\//)
|
||||
? accessTokenUrl
|
||||
: `https://${accessTokenUrl}`,
|
||||
authorizationUrl:
|
||||
authorizationUrl === '' || authorizationUrl.match(/^https?:\/\//)
|
||||
authorizationUrl === "" || authorizationUrl.match(/^https?:\/\//)
|
||||
? authorizationUrl
|
||||
: `https://${authorizationUrl}`,
|
||||
clientId: stringArg(values, 'clientId'),
|
||||
clientSecret: stringArg(values, 'clientSecret'),
|
||||
redirectUri: stringArgOrNull(values, 'redirectUri'),
|
||||
scope: stringArgOrNull(values, 'scope'),
|
||||
audience: stringArgOrNull(values, 'audience'),
|
||||
state: stringArgOrNull(values, 'state'),
|
||||
clientId: stringArg(values, "clientId"),
|
||||
clientSecret: stringArg(values, "clientSecret"),
|
||||
redirectUri: stringArgOrNull(values, "redirectUri"),
|
||||
scope: stringArgOrNull(values, "scope"),
|
||||
audience: stringArgOrNull(values, "audience"),
|
||||
state: stringArgOrNull(values, "state"),
|
||||
credentialsInBody,
|
||||
pkce: values.usePkce
|
||||
? {
|
||||
challengeMethod: stringArg(values, 'pkceChallengeMethod') || DEFAULT_PKCE_METHOD,
|
||||
codeVerifier: stringArg(values, 'pkceCodeVerifier') || genPkceCodeVerifier(),
|
||||
challengeMethod: stringArg(values, "pkceChallengeMethod") || DEFAULT_PKCE_METHOD,
|
||||
codeVerifier: stringArg(values, "pkceCodeVerifier") || genPkceCodeVerifier(),
|
||||
}
|
||||
: null,
|
||||
tokenName: tokenName,
|
||||
externalBrowser: externalBrowserOptions,
|
||||
});
|
||||
} else if (grantType === 'implicit') {
|
||||
const authorizationUrl = stringArg(values, 'authorizationUrl');
|
||||
} else if (grantType === "implicit") {
|
||||
const authorizationUrl = stringArg(values, "authorizationUrl");
|
||||
token = await getImplicit(ctx, contextId, {
|
||||
authorizationUrl: authorizationUrl.match(/^https?:\/\//)
|
||||
? authorizationUrl
|
||||
: `https://${authorizationUrl}`,
|
||||
clientId: stringArg(values, 'clientId'),
|
||||
redirectUri: stringArgOrNull(values, 'redirectUri'),
|
||||
responseType: stringArg(values, 'responseType'),
|
||||
scope: stringArgOrNull(values, 'scope'),
|
||||
audience: stringArgOrNull(values, 'audience'),
|
||||
state: stringArgOrNull(values, 'state'),
|
||||
clientId: stringArg(values, "clientId"),
|
||||
redirectUri: stringArgOrNull(values, "redirectUri"),
|
||||
responseType: stringArg(values, "responseType"),
|
||||
scope: stringArgOrNull(values, "scope"),
|
||||
audience: stringArgOrNull(values, "audience"),
|
||||
state: stringArgOrNull(values, "state"),
|
||||
tokenName: tokenName,
|
||||
externalBrowser: externalBrowserOptions,
|
||||
});
|
||||
} else if (grantType === 'client_credentials') {
|
||||
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
|
||||
} else if (grantType === "client_credentials") {
|
||||
const accessTokenUrl = stringArg(values, "accessTokenUrl");
|
||||
token = await getClientCredentials(ctx, contextId, {
|
||||
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
|
||||
? accessTokenUrl
|
||||
: `https://${accessTokenUrl}`,
|
||||
clientId: stringArg(values, 'clientId'),
|
||||
clientAssertionAlgorithm: stringArg(values, 'clientAssertionAlgorithm') as Algorithm,
|
||||
clientSecret: stringArg(values, 'clientSecret'),
|
||||
clientCredentialsMethod: stringArg(values, 'clientCredentialsMethod'),
|
||||
clientAssertionSecret: stringArg(values, 'clientAssertionSecret'),
|
||||
clientId: stringArg(values, "clientId"),
|
||||
clientAssertionAlgorithm: stringArg(values, "clientAssertionAlgorithm") as Algorithm,
|
||||
clientSecret: stringArg(values, "clientSecret"),
|
||||
clientCredentialsMethod: stringArg(values, "clientCredentialsMethod"),
|
||||
clientAssertionSecret: stringArg(values, "clientAssertionSecret"),
|
||||
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
|
||||
scope: stringArgOrNull(values, 'scope'),
|
||||
audience: stringArgOrNull(values, 'audience'),
|
||||
scope: stringArgOrNull(values, "scope"),
|
||||
audience: stringArgOrNull(values, "audience"),
|
||||
credentialsInBody,
|
||||
});
|
||||
} else if (grantType === 'password') {
|
||||
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
|
||||
} else if (grantType === "password") {
|
||||
const accessTokenUrl = stringArg(values, "accessTokenUrl");
|
||||
token = await getPassword(ctx, contextId, {
|
||||
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
|
||||
? accessTokenUrl
|
||||
: `https://${accessTokenUrl}`,
|
||||
clientId: stringArg(values, 'clientId'),
|
||||
clientSecret: stringArg(values, 'clientSecret'),
|
||||
username: stringArg(values, 'username'),
|
||||
password: stringArg(values, 'password'),
|
||||
scope: stringArgOrNull(values, 'scope'),
|
||||
audience: stringArgOrNull(values, 'audience'),
|
||||
clientId: stringArg(values, "clientId"),
|
||||
clientSecret: stringArg(values, "clientSecret"),
|
||||
username: stringArg(values, "username"),
|
||||
password: stringArg(values, "password"),
|
||||
scope: stringArgOrNull(values, "scope"),
|
||||
audience: stringArgOrNull(values, "audience"),
|
||||
credentialsInBody,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Invalid grant type ${grantType}`);
|
||||
throw new Error(`Invalid grant type ${String(grantType)}`);
|
||||
}
|
||||
|
||||
const headerName = stringArg(values, 'headerName') || 'Authorization';
|
||||
const headerValue = `${headerPrefix} ${token.response[tokenName]}`.trim();
|
||||
const headerName = stringArg(values, "headerName") || "Authorization";
|
||||
const headerValue = `${headerPrefix} ${token.response[tokenName] ?? ""}`.trim();
|
||||
return { setHeaders: [{ name: headerName, value: headerValue }] };
|
||||
},
|
||||
},
|
||||
@@ -605,19 +605,19 @@ function stringArgOrNull(
|
||||
name: string,
|
||||
): string | null {
|
||||
const arg = values[name];
|
||||
if (arg == null || arg === '') return null;
|
||||
if (arg == null || arg === "") return null;
|
||||
return `${arg}`;
|
||||
}
|
||||
|
||||
function stringArg(values: Record<string, JsonPrimitive | undefined>, name: string): string {
|
||||
const arg = stringArgOrNull(values, name);
|
||||
if (!arg) return '';
|
||||
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;
|
||||
if (arg == null || arg === "") return null;
|
||||
const num = parseInt(`${arg}`, 10);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Context } from "@yaakapp/api";
|
||||
|
||||
export async function storeToken(
|
||||
ctx: Context,
|
||||
args: TokenStoreArgs,
|
||||
response: AccessTokenRawResponse,
|
||||
tokenName: 'access_token' | 'id_token' = 'access_token',
|
||||
tokenName: "access_token" | "id_token" = "access_token",
|
||||
) {
|
||||
if (!response[tokenName]) {
|
||||
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(', ')}`);
|
||||
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`);
|
||||
}
|
||||
|
||||
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null;
|
||||
@@ -34,7 +34,7 @@ export async function resetDataDirKey(ctx: Context, contextId: string) {
|
||||
}
|
||||
|
||||
export async function getDataDirKey(ctx: Context, contextId: string) {
|
||||
const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? 'default';
|
||||
const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? "default";
|
||||
return `${contextId}::${key}`;
|
||||
}
|
||||
|
||||
@@ -50,17 +50,17 @@ export interface TokenStoreArgs {
|
||||
* account for slight variations (like domains with and without a protocol scheme).
|
||||
*/
|
||||
function tokenStoreKey(args: TokenStoreArgs) {
|
||||
const hash = createHash('md5');
|
||||
const hash = createHash("md5");
|
||||
if (args.contextId) hash.update(args.contextId.trim());
|
||||
if (args.clientId) hash.update(args.clientId.trim());
|
||||
if (args.accessTokenUrl) hash.update(args.accessTokenUrl.trim().replace(/^https?:\/\//, ''));
|
||||
if (args.authorizationUrl) hash.update(args.authorizationUrl.trim().replace(/^https?:\/\//, ''));
|
||||
const key = hash.digest('hex');
|
||||
return ['token', key].join('::');
|
||||
if (args.accessTokenUrl) hash.update(args.accessTokenUrl.trim().replace(/^https?:\/\//, ""));
|
||||
if (args.authorizationUrl) hash.update(args.authorizationUrl.trim().replace(/^https?:\/\//, ""));
|
||||
const key = hash.digest("hex");
|
||||
return ["token", key].join("::");
|
||||
}
|
||||
|
||||
function dataDirStoreKey(contextId: string) {
|
||||
return ['data_dir', contextId].join('::');
|
||||
return ["data_dir", contextId].join("::");
|
||||
}
|
||||
|
||||
export interface AccessToken {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AccessToken } from './store';
|
||||
import type { AccessToken } from "./store";
|
||||
|
||||
export function isTokenExpired(token: AccessToken) {
|
||||
return token.expiresAt && Date.now() > token.expiresAt;
|
||||
@@ -8,24 +8,24 @@ export function extractCode(urlStr: string, redirectUri: string | null): string
|
||||
const url = new URL(urlStr);
|
||||
|
||||
if (!urlMatchesRedirect(url, redirectUri)) {
|
||||
console.log('[oauth2] URL does not match redirect origin/path; skipping.');
|
||||
console.log("[oauth2] URL does not match redirect origin/path; skipping.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer query param; fall back to fragment if query lacks it
|
||||
|
||||
const query = url.searchParams;
|
||||
const queryError = query.get('error');
|
||||
const queryDesc = query.get('error_description');
|
||||
const queryUri = query.get('error_uri');
|
||||
const queryError = query.get("error");
|
||||
const queryDesc = query.get("error_description");
|
||||
const queryUri = query.get("error_uri");
|
||||
|
||||
let hashParams: URLSearchParams | null = null;
|
||||
if (url.hash && url.hash.length > 1) {
|
||||
hashParams = new URLSearchParams(url.hash.slice(1));
|
||||
}
|
||||
const hashError = hashParams?.get('error');
|
||||
const hashDesc = hashParams?.get('error_description');
|
||||
const hashUri = hashParams?.get('error_uri');
|
||||
const hashError = hashParams?.get("error");
|
||||
const hashDesc = hashParams?.get("error_description");
|
||||
const hashUri = hashParams?.get("error_uri");
|
||||
|
||||
const error = queryError || hashError;
|
||||
if (error) {
|
||||
@@ -37,13 +37,13 @@ export function extractCode(urlStr: string, redirectUri: string | null): string
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const queryCode = query.get('code');
|
||||
const queryCode = query.get("code");
|
||||
if (queryCode) return queryCode;
|
||||
|
||||
const hashCode = hashParams?.get('code');
|
||||
const hashCode = hashParams?.get("code");
|
||||
if (hashCode) return hashCode;
|
||||
|
||||
console.log('[oauth2] Code not found');
|
||||
console.log("[oauth2] Code not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolea
|
||||
try {
|
||||
redirect = new URL(redirectUrl);
|
||||
} catch {
|
||||
console.log('[oauth2] Invalid redirect URI; skipping.');
|
||||
console.log("[oauth2] Invalid redirect URI; skipping.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,17 +63,17 @@ export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolea
|
||||
const sameHost = url.hostname.toLowerCase() === redirect.hostname.toLowerCase();
|
||||
|
||||
const normalizePort = (u: URL) =>
|
||||
(u.protocol === 'https:' && (!u.port || u.port === '443')) ||
|
||||
(u.protocol === 'http:' && (!u.port || u.port === '80'))
|
||||
? ''
|
||||
(u.protocol === "https:" && (!u.port || u.port === "443")) ||
|
||||
(u.protocol === "http:" && (!u.port || u.port === "80"))
|
||||
? ""
|
||||
: u.port;
|
||||
|
||||
const samePort = normalizePort(url) === normalizePort(redirect);
|
||||
|
||||
const normPath = (p: string) => {
|
||||
const withLeading = p.startsWith('/') ? p : `/${p}`;
|
||||
const withLeading = p.startsWith("/") ? p : `/${p}`;
|
||||
// strip trailing slashes, keep root as "/"
|
||||
return withLeading.replace(/\/+$/g, '') || '/';
|
||||
return withLeading.replace(/\/+$/g, "") || "/";
|
||||
};
|
||||
|
||||
// Require redirect path to be a prefix of the navigated URL path
|
||||
|
||||
@@ -1,109 +1,109 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { extractCode } from '../src/util';
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { extractCode } from "../src/util";
|
||||
|
||||
describe('extractCode', () => {
|
||||
test('extracts code from query when same origin + path', () => {
|
||||
const url = 'https://app.example.com/cb?code=abc123&state=xyz';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('abc123');
|
||||
describe("extractCode", () => {
|
||||
test("extracts code from query when same origin + path", () => {
|
||||
const url = "https://app.example.com/cb?code=abc123&state=xyz";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("abc123");
|
||||
});
|
||||
|
||||
test('extracts code from query with weird path', () => {
|
||||
const url = 'https://app.example.com/cbwithextra?code=abc123&state=xyz';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
test("extracts code from query with weird path", () => {
|
||||
const url = "https://app.example.com/cbwithextra?code=abc123&state=xyz";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBeNull();
|
||||
});
|
||||
|
||||
test('allows trailing slash differences', () => {
|
||||
expect(extractCode('https://app.example.com/cb/?code=abc', 'https://app.example.com/cb')).toBe(
|
||||
'abc',
|
||||
test("allows trailing slash differences", () => {
|
||||
expect(extractCode("https://app.example.com/cb/?code=abc", "https://app.example.com/cb")).toBe(
|
||||
"abc",
|
||||
);
|
||||
expect(extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com/cb/')).toBe(
|
||||
'abc',
|
||||
expect(extractCode("https://app.example.com/cb?code=abc", "https://app.example.com/cb/")).toBe(
|
||||
"abc",
|
||||
);
|
||||
});
|
||||
|
||||
test('treats default ports as equal (https:443, http:80)', () => {
|
||||
test("treats default ports as equal (https:443, http:80)", () => {
|
||||
expect(
|
||||
extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com:443/cb'),
|
||||
).toBe('abc');
|
||||
expect(extractCode('http://app.example.com/cb?code=abc', 'http://app.example.com:80/cb')).toBe(
|
||||
'abc',
|
||||
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:443/cb"),
|
||||
).toBe("abc");
|
||||
expect(extractCode("http://app.example.com/cb?code=abc", "http://app.example.com:80/cb")).toBe(
|
||||
"abc",
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects different port', () => {
|
||||
test("rejects different port", () => {
|
||||
expect(
|
||||
extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com:8443/cb'),
|
||||
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:8443/cb"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects different hostname (including subdomain changes)', () => {
|
||||
test("rejects different hostname (including subdomain changes)", () => {
|
||||
expect(
|
||||
extractCode('https://evil.example.com/cb?code=abc', 'https://app.example.com/cb'),
|
||||
extractCode("https://evil.example.com/cb?code=abc", "https://app.example.com/cb"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('requires path to start with redirect path (ignoring query/hash)', () => {
|
||||
test("requires path to start with redirect path (ignoring query/hash)", () => {
|
||||
// same origin but wrong path -> null
|
||||
expect(
|
||||
extractCode('https://app.example.com/other?code=abc', 'https://app.example.com/cb'),
|
||||
extractCode("https://app.example.com/other?code=abc", "https://app.example.com/cb"),
|
||||
).toBeNull();
|
||||
|
||||
// deeper subpath under the redirect path -> allowed (prefix match)
|
||||
expect(
|
||||
extractCode('https://app.example.com/cb/deep?code=abc', 'https://app.example.com/cb'),
|
||||
).toBe('abc');
|
||||
extractCode("https://app.example.com/cb/deep?code=abc", "https://app.example.com/cb"),
|
||||
).toBe("abc");
|
||||
});
|
||||
|
||||
test('works with custom schemes', () => {
|
||||
expect(extractCode('myapp://cb?code=abc', 'myapp://cb')).toBe('abc');
|
||||
test("works with custom schemes", () => {
|
||||
expect(extractCode("myapp://cb?code=abc", "myapp://cb")).toBe("abc");
|
||||
});
|
||||
|
||||
test('prefers query over fragment when both present', () => {
|
||||
const url = 'https://app.example.com/cb?code=queryCode#code=hashCode';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('queryCode');
|
||||
test("prefers query over fragment when both present", () => {
|
||||
const url = "https://app.example.com/cb?code=queryCode#code=hashCode";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("queryCode");
|
||||
});
|
||||
|
||||
test('extracts code from fragment when query lacks code', () => {
|
||||
const url = 'https://app.example.com/cb#code=fromHash&state=xyz';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('fromHash');
|
||||
test("extracts code from fragment when query lacks code", () => {
|
||||
const url = "https://app.example.com/cb#code=fromHash&state=xyz";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("fromHash");
|
||||
});
|
||||
|
||||
test('returns null if no code present (query or fragment)', () => {
|
||||
const url = 'https://app.example.com/cb?state=only';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
test("returns null if no code present (query or fragment)", () => {
|
||||
const url = "https://app.example.com/cb?state=only";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when provider reports an error', () => {
|
||||
const url = 'https://app.example.com/cb?error=access_denied&error_description=oopsy';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(() => extractCode(url, redirect)).toThrow('Failed to authorize: access_denied');
|
||||
test("returns null when provider reports an error", () => {
|
||||
const url = "https://app.example.com/cb?error=access_denied&error_description=oopsy";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(() => extractCode(url, redirect)).toThrow("Failed to authorize: access_denied");
|
||||
});
|
||||
|
||||
test('when redirectUri is null, extracts code from any URL', () => {
|
||||
expect(extractCode('https://random.example.com/whatever?code=abc', null)).toBe('abc');
|
||||
test("when redirectUri is null, extracts code from any URL", () => {
|
||||
expect(extractCode("https://random.example.com/whatever?code=abc", null)).toBe("abc");
|
||||
});
|
||||
|
||||
test('handles extra params gracefully', () => {
|
||||
const url = 'https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('abc');
|
||||
test("handles extra params gracefully", () => {
|
||||
const url = "https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("abc");
|
||||
});
|
||||
|
||||
test('ignores fragment noise when code is in query', () => {
|
||||
const url = 'https://app.example.com/cb?code=abc#some=thing';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('abc');
|
||||
test("ignores fragment noise when code is in query", () => {
|
||||
const url = "https://app.example.com/cb?code=abc#some=thing";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("abc");
|
||||
});
|
||||
|
||||
// If you decide NOT to support fragment-based codes, flip these to expect null or mark as .skip
|
||||
test('supports fragment-only code for response_mode=fragment providers', () => {
|
||||
const url = 'https://app.example.com/cb#state=xyz&code=abc';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('abc');
|
||||
test("supports fragment-only code for response_mode=fragment providers", () => {
|
||||
const url = "https://app.example.com/cb#state=xyz&code=abc";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user