mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-21 08:11:24 +02:00
Add support for client assertions in the OAuth 2 plugin (#395)
Co-authored-by: Davide Becker <github@reg.davide.me> Co-authored-by: Gregory Schier <gschier1990@gmail.com>
This commit is contained in:
@@ -13,5 +13,11 @@
|
|||||||
"build": "yaakcli build",
|
"build": "yaakcli build",
|
||||||
"dev": "yaakcli dev",
|
"dev": "yaakcli dev",
|
||||||
"test": "vitest --run tests"
|
"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(
|
export async function fetchAccessToken(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
{
|
args: {
|
||||||
accessTokenUrl,
|
|
||||||
scope,
|
|
||||||
audience,
|
|
||||||
params,
|
|
||||||
grantType,
|
|
||||||
credentialsInBody,
|
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
}: {
|
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
|
||||||
grantType: string;
|
grantType: string;
|
||||||
accessTokenUrl: string;
|
accessTokenUrl: string;
|
||||||
scope: string | null;
|
scope: string | null;
|
||||||
audience: string | null;
|
audience: string | null;
|
||||||
credentialsInBody: boolean;
|
|
||||||
params: HttpUrlParameter[];
|
params: HttpUrlParameter[];
|
||||||
},
|
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
|
||||||
): Promise<AccessTokenRawResponse> {
|
): 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> = {
|
const httpRequest: Partial<HttpRequest> = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -34,7 +24,10 @@ export async function fetchAccessToken(
|
|||||||
},
|
},
|
||||||
headers: [
|
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' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -42,11 +35,24 @@ export async function fetchAccessToken(
|
|||||||
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
|
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
|
||||||
if (audience) httpRequest.body?.form.push({ name: 'audience', value: audience });
|
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_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 {
|
} 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 });
|
httpRequest.headers?.push({ name: 'Authorization', value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,99 @@
|
|||||||
|
import { createPrivateKey, randomUUID } from 'node:crypto';
|
||||||
import type { Context } from '@yaakapp/api';
|
import type { Context } from '@yaakapp/api';
|
||||||
|
import jwt, { type Algorithm } from 'jsonwebtoken';
|
||||||
import { fetchAccessToken } from '../fetchAccessToken';
|
import { fetchAccessToken } from '../fetchAccessToken';
|
||||||
import type { TokenStoreArgs } from '../store';
|
import type { TokenStoreArgs } from '../store';
|
||||||
import { getToken, storeToken } from '../store';
|
import { getToken, storeToken } from '../store';
|
||||||
import { isTokenExpired } from '../util';
|
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(
|
export async function getClientCredentials(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
contextId: string,
|
contextId: string,
|
||||||
@@ -14,6 +104,10 @@ export async function getClientCredentials(
|
|||||||
scope,
|
scope,
|
||||||
audience,
|
audience,
|
||||||
credentialsInBody,
|
credentialsInBody,
|
||||||
|
clientAssertionSecret,
|
||||||
|
clientAssertionSecretBase64,
|
||||||
|
clientCredentialsMethod,
|
||||||
|
clientAssertionAlgorithm,
|
||||||
}: {
|
}: {
|
||||||
accessTokenUrl: string;
|
accessTokenUrl: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -21,6 +115,10 @@ export async function getClientCredentials(
|
|||||||
scope: string | null;
|
scope: string | null;
|
||||||
audience: string | null;
|
audience: string | null;
|
||||||
credentialsInBody: boolean;
|
credentialsInBody: boolean;
|
||||||
|
clientAssertionSecret: string;
|
||||||
|
clientAssertionSecretBase64: boolean;
|
||||||
|
clientCredentialsMethod: string;
|
||||||
|
clientAssertionAlgorithm: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const tokenArgs: TokenStoreArgs = {
|
const tokenArgs: TokenStoreArgs = {
|
||||||
@@ -34,16 +132,38 @@ export async function getClientCredentials(
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetchAccessToken(ctx, {
|
const common: Omit<
|
||||||
|
Parameters<typeof fetchAccessToken>[1],
|
||||||
|
'clientAssertion' | 'clientSecret' | 'credentialsInBody'
|
||||||
|
> = {
|
||||||
grantType: 'client_credentials',
|
grantType: 'client_credentials',
|
||||||
accessTokenUrl,
|
accessTokenUrl,
|
||||||
audience,
|
audience,
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
|
||||||
scope,
|
scope,
|
||||||
credentialsInBody,
|
|
||||||
params: [],
|
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);
|
return storeToken(ctx, tokenArgs, response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
JsonPrimitive,
|
JsonPrimitive,
|
||||||
PluginDefinition,
|
PluginDefinition,
|
||||||
} from '@yaakapp/api';
|
} from '@yaakapp/api';
|
||||||
|
import type { Algorithm } from 'jsonwebtoken';
|
||||||
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
|
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
|
||||||
import {
|
import {
|
||||||
type CallbackType,
|
type CallbackType,
|
||||||
@@ -14,7 +15,11 @@ import {
|
|||||||
PKCE_PLAIN,
|
PKCE_PLAIN,
|
||||||
PKCE_SHA256,
|
PKCE_SHA256,
|
||||||
} from './grants/authorizationCode';
|
} from './grants/authorizationCode';
|
||||||
import { getClientCredentials } from './grants/clientCredentials';
|
import {
|
||||||
|
defaultJwtAlgorithm,
|
||||||
|
getClientCredentials,
|
||||||
|
jwtAlgorithms,
|
||||||
|
} from './grants/clientCredentials';
|
||||||
import { getImplicit } from './grants/implicit';
|
import { getImplicit } from './grants/implicit';
|
||||||
import { getPassword } from './grants/password';
|
import { getPassword } from './grants/password';
|
||||||
import type { AccessToken, TokenStoreArgs } from './store';
|
import type { AccessToken, TokenStoreArgs } from './store';
|
||||||
@@ -97,7 +102,10 @@ export const plugin: PluginDefinition = {
|
|||||||
};
|
};
|
||||||
const token = await getToken(ctx, tokenArgs);
|
const token = await getToken(ctx, tokenArgs);
|
||||||
if (token == null) {
|
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 {
|
} else {
|
||||||
await ctx.clipboard.copyText(token.response.access_token);
|
await ctx.clipboard.copyText(token.response.access_token);
|
||||||
await ctx.toast.show({
|
await ctx.toast.show({
|
||||||
@@ -118,9 +126,15 @@ export const plugin: PluginDefinition = {
|
|||||||
clientId: stringArg(values, 'clientId'),
|
clientId: stringArg(values, 'clientId'),
|
||||||
};
|
};
|
||||||
if (await deleteToken(ctx, tokenArgs)) {
|
if (await deleteToken(ctx, tokenArgs)) {
|
||||||
await ctx.toast.show({ message: 'Token deleted', color: 'success' });
|
await ctx.toast.show({
|
||||||
|
message: 'Token deleted',
|
||||||
|
color: 'success',
|
||||||
|
});
|
||||||
} else {
|
} 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,
|
defaultValue: defaultGrantType,
|
||||||
options: grantTypes,
|
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',
|
type: 'text',
|
||||||
name: 'clientId',
|
name: 'clientId',
|
||||||
@@ -151,7 +178,47 @@ export const plugin: PluginDefinition = {
|
|||||||
label: 'Client Secret',
|
label: 'Client Secret',
|
||||||
optional: true,
|
optional: true,
|
||||||
password: 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',
|
type: 'text',
|
||||||
@@ -160,7 +227,10 @@ export const plugin: PluginDefinition = {
|
|||||||
label: 'Authorization URL',
|
label: 'Authorization URL',
|
||||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||||
placeholder: authorizationUrls[0],
|
placeholder: authorizationUrls[0],
|
||||||
completionOptions: authorizationUrls.map((url) => ({ label: url, value: url })),
|
completionOptions: authorizationUrls.map((url) => ({
|
||||||
|
label: url,
|
||||||
|
value: url,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -169,7 +239,10 @@ export const plugin: PluginDefinition = {
|
|||||||
label: 'Access Token URL',
|
label: 'Access Token URL',
|
||||||
placeholder: accessTokenUrls[0],
|
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 })),
|
completionOptions: accessTokenUrls.map((url) => ({
|
||||||
|
label: url,
|
||||||
|
value: url,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'banner',
|
type: 'banner',
|
||||||
@@ -186,7 +259,8 @@ export const plugin: PluginDefinition = {
|
|||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'redirectUri',
|
name: 'redirectUri',
|
||||||
label: 'Redirect URI',
|
label: 'Redirect URI (can be any valid URL)',
|
||||||
|
placeholder: 'https://mysite.example.com/oauth/callback',
|
||||||
description:
|
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,
|
optional: true,
|
||||||
@@ -383,6 +457,11 @@ export const plugin: PluginDefinition = {
|
|||||||
{ label: 'In Request Body', value: 'body' },
|
{ label: 'In Request Body', value: 'body' },
|
||||||
{ label: 'As Basic Authentication', value: 'basic' },
|
{ 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
|
? accessTokenUrl
|
||||||
: `https://${accessTokenUrl}`,
|
: `https://${accessTokenUrl}`,
|
||||||
clientId: stringArg(values, 'clientId'),
|
clientId: stringArg(values, 'clientId'),
|
||||||
|
clientAssertionAlgorithm: stringArg(values, 'clientAssertionAlgorithm') as Algorithm,
|
||||||
clientSecret: stringArg(values, 'clientSecret'),
|
clientSecret: stringArg(values, 'clientSecret'),
|
||||||
|
clientCredentialsMethod: stringArg(values, 'clientCredentialsMethod'),
|
||||||
|
clientAssertionSecret: stringArg(values, 'clientAssertionSecret'),
|
||||||
|
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
|
||||||
scope: stringArgOrNull(values, 'scope'),
|
scope: stringArgOrNull(values, 'scope'),
|
||||||
audience: stringArgOrNull(values, 'audience'),
|
audience: stringArgOrNull(values, 'audience'),
|
||||||
credentialsInBody,
|
credentialsInBody,
|
||||||
|
|||||||
@@ -616,5 +616,16 @@ function KeyValueArg({
|
|||||||
|
|
||||||
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
|
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
|
||||||
if (!inputs) return false;
|
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