mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-25 01:58:39 +02:00
Fix PKCE flow and clean up other flows
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };
|
export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };
|
||||||
|
|
||||||
export type PluginVersion = { id: string, version: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };
|
export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };
|
||||||
|
|||||||
@@ -53,3 +53,7 @@ async function handleIncoming(msg: string) {
|
|||||||
|
|
||||||
plugin.sendToWorker(pluginEvent);
|
plugin.sendToWorker(pluginEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
|
import type { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { AccessTokenRawResponse } from './store';
|
import type { AccessTokenRawResponse } from './store';
|
||||||
|
|
||||||
export async function getAccessToken(
|
export async function fetchAccessToken(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
{
|
{
|
||||||
accessTokenUrl,
|
accessTokenUrl,
|
||||||
19
plugins/auth-oauth2/src/getAccessTokenIfNotExpired.ts
Normal file
19
plugins/auth-oauth2/src/getAccessTokenIfNotExpired.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Context } from '@yaakapp/api';
|
||||||
|
import type { AccessToken } from './store';
|
||||||
|
import { getToken } from './store';
|
||||||
|
|
||||||
|
export async function getAccessTokenIfNotExpired(
|
||||||
|
ctx: Context,
|
||||||
|
contextId: string,
|
||||||
|
): Promise<AccessToken | null> {
|
||||||
|
const token = await getToken(ctx, contextId);
|
||||||
|
if (token == null || isTokenExpired(token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenExpired(token: AccessToken) {
|
||||||
|
return token.expiresAt && Date.now() > token.expiresAt;
|
||||||
|
}
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
import { Context, HttpRequest } from '@yaakapp/api';
|
import type { Context, HttpRequest } from '@yaakapp/api';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { AccessToken, AccessTokenRawResponse, deleteToken, getToken, storeToken } from './store';
|
import { isTokenExpired } from './getAccessTokenIfNotExpired';
|
||||||
|
import type { AccessToken, AccessTokenRawResponse } from './store';
|
||||||
|
import { deleteToken, getToken, storeToken } from './store';
|
||||||
|
|
||||||
export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
|
export async function getOrRefreshAccessToken(
|
||||||
scope,
|
ctx: Context,
|
||||||
accessTokenUrl,
|
contextId: string,
|
||||||
credentialsInBody,
|
{
|
||||||
clientId,
|
scope,
|
||||||
clientSecret,
|
accessTokenUrl,
|
||||||
forceRefresh,
|
credentialsInBody,
|
||||||
}: {
|
clientId,
|
||||||
scope: string | null;
|
clientSecret,
|
||||||
accessTokenUrl: string;
|
forceRefresh,
|
||||||
credentialsInBody: boolean;
|
}: {
|
||||||
clientId: string;
|
scope: string | null;
|
||||||
clientSecret: string;
|
accessTokenUrl: string;
|
||||||
forceRefresh?: boolean;
|
credentialsInBody: boolean;
|
||||||
}): Promise<AccessToken | null> {
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<AccessToken | null> {
|
||||||
const token = await getToken(ctx, contextId);
|
const token = await getToken(ctx, contextId);
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const isExpired = isTokenExpired(token);
|
||||||
const isExpired = token.expiresAt && now > token.expiresAt;
|
|
||||||
|
|
||||||
// Return the current access token if it's still valid
|
// Return the current access token if it's still valid
|
||||||
if (!isExpired && !forceRefresh) {
|
if (!isExpired && !forceRefresh) {
|
||||||
@@ -79,7 +84,9 @@ export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
|
|||||||
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) {
|
if (resp.status < 200 || resp.status >= 300) {
|
||||||
throw new Error('Failed to refresh access token with status=' + resp.status + ' and body=' + body);
|
throw new Error(
|
||||||
|
'Failed to refresh access token with status=' + resp.status + ' and body=' + body,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
@@ -90,7 +97,9 @@ export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
throw new Error(`Failed to fetch access token with ${response.error} -> ${response.error_description}`);
|
throw new Error(
|
||||||
|
`Failed to fetch access token with ${response.error} -> ${response.error_description}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newResponse: AccessTokenRawResponse = {
|
const newResponse: AccessTokenRawResponse = {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Context } from '@yaakapp/api';
|
import type { Context } from '@yaakapp/api';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { getAccessToken } from '../getAccessToken';
|
import { fetchAccessToken } from '../fetchAccessToken';
|
||||||
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
|
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
|
||||||
import { AccessToken, getDataDirKey, storeToken } from '../store';
|
import type { AccessToken } from '../store';
|
||||||
|
import { getDataDirKey, storeToken } from '../store';
|
||||||
|
|
||||||
export const PKCE_SHA256 = 'S256';
|
export const PKCE_SHA256 = 'S256';
|
||||||
export const PKCE_PLAIN = 'plain';
|
export const PKCE_PLAIN = 'plain';
|
||||||
@@ -34,8 +35,8 @@ export async function getAuthorizationCode(
|
|||||||
audience: string | null;
|
audience: string | null;
|
||||||
credentialsInBody: boolean;
|
credentialsInBody: boolean;
|
||||||
pkce: {
|
pkce: {
|
||||||
challengeMethod: string | null;
|
challengeMethod: string;
|
||||||
codeVerifier: string | null;
|
codeVerifier: string;
|
||||||
} | null;
|
} | null;
|
||||||
tokenName: 'access_token' | 'id_token';
|
tokenName: 'access_token' | 'id_token';
|
||||||
},
|
},
|
||||||
@@ -59,26 +60,25 @@ export async function getAuthorizationCode(
|
|||||||
if (state) authorizationUrl.searchParams.set('state', state);
|
if (state) authorizationUrl.searchParams.set('state', state);
|
||||||
if (audience) authorizationUrl.searchParams.set('audience', audience);
|
if (audience) authorizationUrl.searchParams.set('audience', audience);
|
||||||
if (pkce) {
|
if (pkce) {
|
||||||
const verifier = pkce.codeVerifier || createPkceCodeVerifier();
|
|
||||||
const challengeMethod = pkce.challengeMethod || DEFAULT_PKCE_METHOD;
|
|
||||||
authorizationUrl.searchParams.set(
|
authorizationUrl.searchParams.set(
|
||||||
'code_challenge',
|
'code_challenge',
|
||||||
createPkceCodeChallenge(verifier, challengeMethod),
|
pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod),
|
||||||
);
|
);
|
||||||
authorizationUrl.searchParams.set('code_challenge_method', challengeMethod);
|
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise(async (resolve, reject) => {
|
const logsEnabled = (await ctx.store.get('enable_logs')) ?? false;
|
||||||
const authorizationUrlStr = authorizationUrl.toString();
|
const dataDirKey = await getDataDirKey(ctx, contextId);
|
||||||
const logsEnabled = (await ctx.store.get('enable_logs')) ?? false;
|
const authorizationUrlStr = authorizationUrl.toString();
|
||||||
console.log('[oauth2] Authorizing', authorizationUrlStr);
|
console.log('[oauth2] Authorizing', authorizationUrlStr);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-async-promise-executor
|
||||||
|
const code = await new Promise<string>(async (resolve, reject) => {
|
||||||
let foundCode = false;
|
let foundCode = false;
|
||||||
|
const { close } = await ctx.window.openUrl({
|
||||||
let { close } = await ctx.window.openUrl({
|
|
||||||
url: authorizationUrlStr,
|
url: authorizationUrlStr,
|
||||||
label: 'oauth-authorization-url',
|
label: 'oauth-authorization-url',
|
||||||
dataDirKey: await getDataDirKey(ctx, contextId),
|
dataDirKey,
|
||||||
async onClose() {
|
async onClose() {
|
||||||
if (!foundCode) {
|
if (!foundCode) {
|
||||||
reject(new Error('Authorization window closed'));
|
reject(new Error('Authorization window closed'));
|
||||||
@@ -89,6 +89,7 @@ export async function getAuthorizationCode(
|
|||||||
if (logsEnabled) console.log('[oauth2] Navigated to', urlStr);
|
if (logsEnabled) console.log('[oauth2] Navigated to', urlStr);
|
||||||
|
|
||||||
if (url.searchParams.has('error')) {
|
if (url.searchParams.has('error')) {
|
||||||
|
close();
|
||||||
return reject(new Error(`Failed to authorize: ${url.searchParams.get('error')}`));
|
return reject(new Error(`Failed to authorize: ${url.searchParams.get('error')}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,37 +102,35 @@ export async function getAuthorizationCode(
|
|||||||
// Close the window here, because we don't need it anymore!
|
// Close the window here, because we don't need it anymore!
|
||||||
foundCode = true;
|
foundCode = true;
|
||||||
close();
|
close();
|
||||||
|
resolve(code);
|
||||||
console.log('[oauth2] Code found');
|
|
||||||
const response = await getAccessToken(ctx, {
|
|
||||||
grantType: 'authorization_code',
|
|
||||||
accessTokenUrl,
|
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
scope,
|
|
||||||
audience,
|
|
||||||
credentialsInBody,
|
|
||||||
params: [
|
|
||||||
{ name: 'code', value: code },
|
|
||||||
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolve(await storeToken(ctx, contextId, response, tokenName));
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[oauth2] Code found');
|
||||||
|
const response = await fetchAccessToken(ctx, {
|
||||||
|
grantType: 'authorization_code',
|
||||||
|
accessTokenUrl,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
scope,
|
||||||
|
audience,
|
||||||
|
credentialsInBody,
|
||||||
|
params: [
|
||||||
|
{ name: 'code', value: code },
|
||||||
|
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
|
||||||
|
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return storeToken(ctx, contextId, response, tokenName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPkceCodeVerifier() {
|
export function genPkceCodeVerifier() {
|
||||||
return encodeForPkce(randomBytes(32));
|
return encodeForPkce(randomBytes(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPkceCodeChallenge(verifier: string, method: string) {
|
function pkceCodeChallenge(verifier: string, method: string) {
|
||||||
if (method === 'plain') {
|
if (method === 'plain') {
|
||||||
return verifier;
|
return verifier;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Context } from '@yaakapp/api';
|
import type { Context } from '@yaakapp/api';
|
||||||
import { getAccessToken } from '../getAccessToken';
|
import { fetchAccessToken } from '../fetchAccessToken';
|
||||||
|
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
|
||||||
import { getToken, storeToken } from '../store';
|
import { getToken, storeToken } from '../store';
|
||||||
|
|
||||||
export async function getClientCredentials(
|
export async function getClientCredentials(
|
||||||
@@ -22,13 +23,11 @@ export async function getClientCredentials(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const token = await getToken(ctx, contextId);
|
const token = await getToken(ctx, contextId);
|
||||||
if (token) {
|
if (token && !isTokenExpired(token)) {
|
||||||
// resolve(token.response.access_token);
|
return token;
|
||||||
// TODO: Refresh token if expired
|
|
||||||
// return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getAccessToken(ctx, {
|
const response = await fetchAccessToken(ctx, {
|
||||||
grantType: 'client_credentials',
|
grantType: 'client_credentials',
|
||||||
accessTokenUrl,
|
accessTokenUrl,
|
||||||
audience,
|
audience,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Context } from '@yaakapp/api';
|
import type { Context } from '@yaakapp/api';
|
||||||
import { AccessToken, AccessTokenRawResponse, getToken, storeToken } from '../store';
|
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
|
||||||
|
import type { AccessToken, AccessTokenRawResponse} from '../store';
|
||||||
|
import { getToken, storeToken } from '../store';
|
||||||
|
|
||||||
export function getImplicit(
|
export async function getImplicit(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
contextId: string,
|
contextId: string,
|
||||||
{
|
{
|
||||||
@@ -24,31 +26,30 @@ export function getImplicit(
|
|||||||
tokenName: 'access_token' | 'id_token';
|
tokenName: 'access_token' | 'id_token';
|
||||||
},
|
},
|
||||||
): Promise<AccessToken> {
|
): Promise<AccessToken> {
|
||||||
return new Promise(async (resolve, reject) => {
|
const token = await getToken(ctx, contextId);
|
||||||
const token = await getToken(ctx, contextId);
|
if (token != null && !isTokenExpired(token)) {
|
||||||
if (token) {
|
return token;
|
||||||
// resolve(token.response.access_token);
|
}
|
||||||
// TODO: Refresh token if expired
|
|
||||||
// return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
|
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
|
||||||
authorizationUrl.searchParams.set('response_type', 'token');
|
authorizationUrl.searchParams.set('response_type', 'token');
|
||||||
authorizationUrl.searchParams.set('client_id', clientId);
|
authorizationUrl.searchParams.set('client_id', clientId);
|
||||||
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
if (scope) authorizationUrl.searchParams.set('scope', scope);
|
if (scope) authorizationUrl.searchParams.set('scope', scope);
|
||||||
if (state) authorizationUrl.searchParams.set('state', state);
|
if (state) authorizationUrl.searchParams.set('state', state);
|
||||||
if (audience) authorizationUrl.searchParams.set('audience', audience);
|
if (audience) authorizationUrl.searchParams.set('audience', audience);
|
||||||
if (responseType.includes('id_token')) {
|
if (responseType.includes('id_token')) {
|
||||||
authorizationUrl.searchParams.set(
|
authorizationUrl.searchParams.set(
|
||||||
'nonce',
|
'nonce',
|
||||||
String(Math.floor(Math.random() * 9999999999999) + 1),
|
String(Math.floor(Math.random() * 9999999999999) + 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorizationUrlStr = authorizationUrl.toString();
|
// eslint-disable-next-line no-async-promise-executor
|
||||||
|
const newToken = await new Promise<AccessToken>(async (resolve, reject) => {
|
||||||
let foundAccessToken = false;
|
let foundAccessToken = false;
|
||||||
let { close } = await ctx.window.openUrl({
|
const authorizationUrlStr = authorizationUrl.toString();
|
||||||
|
const { close } = await ctx.window.openUrl({
|
||||||
url: authorizationUrlStr,
|
url: authorizationUrlStr,
|
||||||
label: 'oauth-authorization-url',
|
label: 'oauth-authorization-url',
|
||||||
async onClose() {
|
async onClose() {
|
||||||
@@ -76,11 +77,13 @@ export function getImplicit(
|
|||||||
|
|
||||||
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
|
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
|
||||||
try {
|
try {
|
||||||
resolve(await storeToken(ctx, contextId, response));
|
resolve(storeToken(ctx, contextId, response));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return newToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Context } from '@yaakapp/api';
|
import type { Context } from '@yaakapp/api';
|
||||||
import { getAccessToken } from '../getAccessToken';
|
import { fetchAccessToken } from '../fetchAccessToken';
|
||||||
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
|
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
|
||||||
import { AccessToken, storeToken } from '../store';
|
import type { AccessToken} from '../store';
|
||||||
|
import { storeToken } from '../store';
|
||||||
|
|
||||||
export async function getPassword(
|
export async function getPassword(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -37,7 +38,7 @@ export async function getPassword(
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getAccessToken(ctx, {
|
const response = await fetchAccessToken(ctx, {
|
||||||
accessTokenUrl,
|
accessTokenUrl,
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import type {
|
||||||
Context,
|
Context,
|
||||||
FormInputSelectOption,
|
FormInputSelectOption,
|
||||||
GetHttpAuthenticationConfigRequest,
|
GetHttpAuthenticationConfigRequest,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PluginDefinition,
|
PluginDefinition,
|
||||||
} from '@yaakapp/api';
|
} from '@yaakapp/api';
|
||||||
import {
|
import {
|
||||||
|
genPkceCodeVerifier,
|
||||||
DEFAULT_PKCE_METHOD,
|
DEFAULT_PKCE_METHOD,
|
||||||
getAuthorizationCode,
|
getAuthorizationCode,
|
||||||
PKCE_PLAIN,
|
PKCE_PLAIN,
|
||||||
@@ -14,7 +15,8 @@ import {
|
|||||||
import { getClientCredentials } from './grants/clientCredentials';
|
import { getClientCredentials } from './grants/clientCredentials';
|
||||||
import { getImplicit } from './grants/implicit';
|
import { getImplicit } from './grants/implicit';
|
||||||
import { getPassword } from './grants/password';
|
import { getPassword } from './grants/password';
|
||||||
import { AccessToken, deleteToken, getToken, resetDataDirKey } from './store';
|
import type { AccessToken } from './store';
|
||||||
|
import { deleteToken, getToken, resetDataDirKey } from './store';
|
||||||
|
|
||||||
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
|
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
|
||||||
|
|
||||||
@@ -219,9 +221,9 @@ export const plugin: PluginDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'pkceCodeVerifier',
|
name: 'pkceCodeChallenge',
|
||||||
label: 'Code Verifier',
|
label: 'Code Verifier',
|
||||||
placeholder: 'Automatically generated if not provided',
|
placeholder: 'Automatically generated when not set',
|
||||||
optional: true,
|
optional: true,
|
||||||
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
|
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
|
||||||
},
|
},
|
||||||
@@ -325,8 +327,8 @@ export const plugin: PluginDefinition = {
|
|||||||
credentialsInBody,
|
credentialsInBody,
|
||||||
pkce: values.usePkce
|
pkce: values.usePkce
|
||||||
? {
|
? {
|
||||||
challengeMethod: stringArg(values, 'pkceChallengeMethod'),
|
challengeMethod: stringArg(values, 'pkceChallengeMethod') || DEFAULT_PKCE_METHOD,
|
||||||
codeVerifier: stringArgOrNull(values, 'pkceCodeVerifier'),
|
codeVerifier: stringArg(values, 'pkceCodeVerifier') || genPkceCodeVerifier(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
tokenName: tokenName,
|
tokenName: tokenName,
|
||||||
|
|||||||
Reference in New Issue
Block a user