mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-15 04:07:43 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d11cb6d3 |
@@ -13,5 +13,11 @@
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev",
|
||||
"test": "vitest --run tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,16 @@ import type { AccessTokenRawResponse } from './store';
|
||||
|
||||
export async function fetchAccessToken(
|
||||
ctx: Context,
|
||||
{
|
||||
accessTokenUrl,
|
||||
scope,
|
||||
audience,
|
||||
params,
|
||||
grantType,
|
||||
credentialsInBody,
|
||||
clientId,
|
||||
clientSecret,
|
||||
}: {
|
||||
args: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
grantType: string;
|
||||
accessTokenUrl: string;
|
||||
scope: string | null;
|
||||
audience: string | null;
|
||||
credentialsInBody: boolean;
|
||||
params: HttpUrlParameter[];
|
||||
},
|
||||
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
|
||||
): Promise<AccessTokenRawResponse> {
|
||||
const { clientId, grantType, accessTokenUrl, scope, audience, params } = args;
|
||||
console.log('[oauth2] Getting access token', accessTokenUrl);
|
||||
const httpRequest: Partial<HttpRequest> = {
|
||||
method: 'POST',
|
||||
@@ -34,7 +24,10 @@ export async function fetchAccessToken(
|
||||
},
|
||||
headers: [
|
||||
{ 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' },
|
||||
],
|
||||
};
|
||||
@@ -42,11 +35,24 @@ export async function fetchAccessToken(
|
||||
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
|
||||
if (audience) httpRequest.body?.form.push({ name: 'audience', value: audience });
|
||||
|
||||
if (credentialsInBody) {
|
||||
if ('clientAssertion' in args) {
|
||||
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
|
||||
httpRequest.body?.form.push({ name: 'client_secret', value: clientSecret });
|
||||
httpRequest.body?.form.push({
|
||||
name: 'client_assertion_type',
|
||||
value: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
});
|
||||
httpRequest.body?.form.push({
|
||||
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_secret',
|
||||
value: args.clientSecret,
|
||||
});
|
||||
} else {
|
||||
const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
|
||||
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString('base64')}`;
|
||||
httpRequest.headers?.push({ name: 'Authorization', value });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,99 @@
|
||||
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',
|
||||
] as const;
|
||||
|
||||
export const defaultJwtAlgorithm = jwtAlgorithms[0];
|
||||
|
||||
/**
|
||||
* Build a signed JWT for the client_assertion parameter (RFC 7523).
|
||||
*
|
||||
* The `secret` value is auto-detected as one of:
|
||||
* - **JWK** – a JSON string containing a private-key object (has a `kty` field).
|
||||
* - **PEM** – a string whose trimmed form starts with `-----`.
|
||||
* - **HMAC secret** – anything else, used as-is for HS* algorithms.
|
||||
*/
|
||||
function buildClientAssertionJwt(params: {
|
||||
clientId: string;
|
||||
accessTokenUrl: string;
|
||||
secret: string;
|
||||
algorithm: Algorithm;
|
||||
}): string {
|
||||
const { clientId, accessTokenUrl, secret, algorithm } = params;
|
||||
|
||||
const isHmac = algorithm.startsWith('HS') || algorithm === 'none';
|
||||
|
||||
// Resolve the signing key depending on format
|
||||
let signingKey: jwt.Secret;
|
||||
let kid: string | undefined;
|
||||
|
||||
const trimmed = secret.trim();
|
||||
|
||||
if (isHmac) {
|
||||
// HMAC algorithms use the raw secret (string or Buffer)
|
||||
signingKey = secret;
|
||||
} 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.
|
||||
let jwk: any;
|
||||
try {
|
||||
jwk = JSON.parse(trimmed);
|
||||
} catch {
|
||||
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('-----')) {
|
||||
// PEM-encoded key
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: clientId,
|
||||
sub: clientId,
|
||||
aud: accessTokenUrl,
|
||||
iat: now,
|
||||
exp: now + 300, // 5 minutes
|
||||
jti: randomUUID(),
|
||||
};
|
||||
|
||||
// Build the JWT header; include "kid" when available
|
||||
const header: jwt.JwtHeader = { alg: algorithm, typ: 'JWT' };
|
||||
if (kid) {
|
||||
header.kid = kid;
|
||||
}
|
||||
|
||||
return jwt.sign(JSON.stringify(payload), signingKey, {
|
||||
algorithm: algorithm as jwt.Algorithm,
|
||||
header,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getClientCredentials(
|
||||
ctx: Context,
|
||||
contextId: string,
|
||||
@@ -14,6 +104,10 @@ export async function getClientCredentials(
|
||||
scope,
|
||||
audience,
|
||||
credentialsInBody,
|
||||
clientAssertionSecret,
|
||||
clientAssertionSecretBase64,
|
||||
clientCredentialsMethod,
|
||||
clientAssertionAlgorithm,
|
||||
}: {
|
||||
accessTokenUrl: string;
|
||||
clientId: string;
|
||||
@@ -21,6 +115,10 @@ export async function getClientCredentials(
|
||||
scope: string | null;
|
||||
audience: string | null;
|
||||
credentialsInBody: boolean;
|
||||
clientAssertionSecret: string;
|
||||
clientAssertionSecretBase64: boolean;
|
||||
clientCredentialsMethod: string;
|
||||
clientAssertionAlgorithm: string;
|
||||
},
|
||||
) {
|
||||
const tokenArgs: TokenStoreArgs = {
|
||||
@@ -34,16 +132,38 @@ export async function getClientCredentials(
|
||||
return token;
|
||||
}
|
||||
|
||||
const response = await fetchAccessToken(ctx, {
|
||||
const common: Omit<
|
||||
Parameters<typeof fetchAccessToken>[1],
|
||||
'clientAssertion' | 'clientSecret' | 'credentialsInBody'
|
||||
> = {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl,
|
||||
audience,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope,
|
||||
credentialsInBody,
|
||||
params: [],
|
||||
});
|
||||
};
|
||||
|
||||
const fetchParams: Parameters<typeof fetchAccessToken>[1] =
|
||||
clientCredentialsMethod === 'client_assertion'
|
||||
? {
|
||||
...common,
|
||||
clientAssertion: buildClientAssertionJwt({
|
||||
clientId,
|
||||
algorithm: clientAssertionAlgorithm as Algorithm,
|
||||
accessTokenUrl,
|
||||
secret: clientAssertionSecretBase64
|
||||
? Buffer.from(clientAssertionSecret, 'base64').toString('utf-8')
|
||||
: clientAssertionSecret,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
...common,
|
||||
clientSecret,
|
||||
credentialsInBody,
|
||||
};
|
||||
|
||||
const response = await fetchAccessToken(ctx, fetchParams);
|
||||
|
||||
return storeToken(ctx, tokenArgs, response);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
JsonPrimitive,
|
||||
PluginDefinition,
|
||||
} from '@yaakapp/api';
|
||||
import type { Algorithm } from 'jsonwebtoken';
|
||||
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
|
||||
import {
|
||||
type CallbackType,
|
||||
@@ -14,7 +15,11 @@ import {
|
||||
PKCE_PLAIN,
|
||||
PKCE_SHA256,
|
||||
} from './grants/authorizationCode';
|
||||
import { getClientCredentials } from './grants/clientCredentials';
|
||||
import {
|
||||
defaultJwtAlgorithm,
|
||||
getClientCredentials,
|
||||
jwtAlgorithms,
|
||||
} from './grants/clientCredentials';
|
||||
import { getImplicit } from './grants/implicit';
|
||||
import { getPassword } from './grants/password';
|
||||
import type { AccessToken, TokenStoreArgs } from './store';
|
||||
@@ -97,7 +102,10 @@ export const plugin: PluginDefinition = {
|
||||
};
|
||||
const token = await getToken(ctx, tokenArgs);
|
||||
if (token == null) {
|
||||
await ctx.toast.show({ message: 'No token to copy', color: 'warning' });
|
||||
await ctx.toast.show({
|
||||
message: 'No token to copy',
|
||||
color: 'warning',
|
||||
});
|
||||
} else {
|
||||
await ctx.clipboard.copyText(token.response.access_token);
|
||||
await ctx.toast.show({
|
||||
@@ -118,9 +126,15 @@ export const plugin: PluginDefinition = {
|
||||
clientId: stringArg(values, 'clientId'),
|
||||
};
|
||||
if (await deleteToken(ctx, tokenArgs)) {
|
||||
await ctx.toast.show({ message: 'Token deleted', color: 'success' });
|
||||
await ctx.toast.show({
|
||||
message: 'Token deleted',
|
||||
color: 'success',
|
||||
});
|
||||
} else {
|
||||
await ctx.toast.show({ message: 'No token to delete', color: 'warning' });
|
||||
await ctx.toast.show({
|
||||
message: 'No token to delete',
|
||||
color: 'warning',
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -139,6 +153,19 @@ export const plugin: PluginDefinition = {
|
||||
defaultValue: defaultGrantType,
|
||||
options: grantTypes,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'clientCredentialsMethod',
|
||||
label: 'Authentication Method',
|
||||
description:
|
||||
'"Client Secret" sends client_secret. \n' + '"Client Assertion" sends a signed JWT.',
|
||||
defaultValue: 'client_secret',
|
||||
options: [
|
||||
{ label: 'Client Secret', value: 'client_secret' },
|
||||
{ label: 'Client Assertion', value: 'client_assertion' },
|
||||
],
|
||||
dynamic: hiddenIfNot(['client_credentials']),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'clientId',
|
||||
@@ -151,7 +178,47 @@ export const plugin: PluginDefinition = {
|
||||
label: 'Client Secret',
|
||||
optional: true,
|
||||
password: true,
|
||||
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
|
||||
dynamic: hiddenIfNot(
|
||||
['authorization_code', 'password', 'client_credentials'],
|
||||
(values) => values.clientCredentialsMethod === 'client_secret',
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'clientAssertionAlgorithm',
|
||||
label: 'JWT Algorithm',
|
||||
defaultValue: defaultJwtAlgorithm,
|
||||
options: jwtAlgorithms.map((value) => ({
|
||||
label: value === 'none' ? 'None' : value,
|
||||
value,
|
||||
})),
|
||||
dynamic: hiddenIfNot(
|
||||
['client_credentials'],
|
||||
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'clientAssertionSecret',
|
||||
label: 'JWT Secret',
|
||||
description:
|
||||
'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',
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'clientAssertionSecretBase64',
|
||||
label: 'JWT secret is base64 encoded',
|
||||
dynamic: hiddenIfNot(
|
||||
['client_credentials'],
|
||||
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
@@ -160,7 +227,10 @@ export const plugin: PluginDefinition = {
|
||||
label: 'Authorization URL',
|
||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||
placeholder: authorizationUrls[0],
|
||||
completionOptions: authorizationUrls.map((url) => ({ label: url, value: url })),
|
||||
completionOptions: authorizationUrls.map((url) => ({
|
||||
label: url,
|
||||
value: url,
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
@@ -169,7 +239,10 @@ export const plugin: PluginDefinition = {
|
||||
label: 'Access Token URL',
|
||||
placeholder: accessTokenUrls[0],
|
||||
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
|
||||
completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })),
|
||||
completionOptions: accessTokenUrls.map((url) => ({
|
||||
label: url,
|
||||
value: url,
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'banner',
|
||||
@@ -186,7 +259,8 @@ export const plugin: PluginDefinition = {
|
||||
{
|
||||
type: 'text',
|
||||
name: 'redirectUri',
|
||||
label: 'Redirect URI',
|
||||
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.',
|
||||
optional: true,
|
||||
@@ -383,6 +457,11 @@ export const plugin: PluginDefinition = {
|
||||
{ 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',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -484,7 +563,11 @@ export const plugin: PluginDefinition = {
|
||||
? 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'),
|
||||
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
|
||||
scope: stringArgOrNull(values, 'scope'),
|
||||
audience: stringArgOrNull(values, 'audience'),
|
||||
credentialsInBody,
|
||||
|
||||
@@ -616,5 +616,16 @@ function KeyValueArg({
|
||||
|
||||
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
|
||||
if (!inputs) return false;
|
||||
return inputs.some((i) => !i.hidden);
|
||||
|
||||
for (const input of inputs) {
|
||||
if ('inputs' in input && !hasVisibleInputs(input.inputs)) {
|
||||
// Has children, but none are visible
|
||||
return false;
|
||||
}
|
||||
if (!input.hidden) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user