chore: 清理macOS同步产生的重复文件
详细说明: - 删除了352个带数字后缀的重复文件 - 更新.gitignore防止未来产生此类文件 - 这些文件是由iCloud或其他同步服务冲突产生的 - 不影响项目功能,仅清理冗余文件
This commit is contained in:
@@ -1,144 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios from 'axios';
|
||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { Agent } from 'https';
|
||||
import * as qs from 'querystring';
|
||||
|
||||
import type { ClientOAuth2TokenData } from './client-oauth2-token';
|
||||
import { ClientOAuth2Token } from './client-oauth2-token';
|
||||
import { CodeFlow } from './code-flow';
|
||||
import { CredentialsFlow } from './credentials-flow';
|
||||
import type { Headers, OAuth2AccessTokenErrorResponse } from './types';
|
||||
import { getAuthError } from './utils';
|
||||
|
||||
export interface ClientOAuth2RequestObject {
|
||||
url: string;
|
||||
method: 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT';
|
||||
body?: Record<string, any>;
|
||||
query?: qs.ParsedUrlQuery;
|
||||
headers?: Headers;
|
||||
ignoreSSLIssues?: boolean;
|
||||
}
|
||||
|
||||
export interface ClientOAuth2Options {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
accessTokenUri: string;
|
||||
authentication?: 'header' | 'body';
|
||||
authorizationUri?: string;
|
||||
redirectUri?: string;
|
||||
scopes?: string[];
|
||||
scopesSeparator?: ',' | ' ';
|
||||
authorizationGrants?: string[];
|
||||
state?: string;
|
||||
additionalBodyProperties?: Record<string, any>;
|
||||
body?: Record<string, any>;
|
||||
query?: qs.ParsedUrlQuery;
|
||||
ignoreSSLIssues?: boolean;
|
||||
}
|
||||
|
||||
export class ResponseError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly body: unknown,
|
||||
readonly code = 'ESTATUS',
|
||||
readonly message = `HTTP status ${status}`,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const sslIgnoringAgent = new Agent({ rejectUnauthorized: false });
|
||||
|
||||
/**
|
||||
* Construct an object that can handle the multiple OAuth 2.0 flows.
|
||||
*/
|
||||
export class ClientOAuth2 {
|
||||
code: CodeFlow;
|
||||
|
||||
credentials: CredentialsFlow;
|
||||
|
||||
constructor(readonly options: ClientOAuth2Options) {
|
||||
this.code = new CodeFlow(this);
|
||||
this.credentials = new CredentialsFlow(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new token from existing data.
|
||||
*/
|
||||
createToken(data: ClientOAuth2TokenData, type?: string): ClientOAuth2Token {
|
||||
return new ClientOAuth2Token(this, {
|
||||
...data,
|
||||
...(typeof type === 'string' ? { token_type: type } : type),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an access token from the OAuth2 server.
|
||||
*
|
||||
* @throws {ResponseError} If the response is an unexpected status code.
|
||||
* @throws {AuthError} If the response is an authentication error.
|
||||
*/
|
||||
async accessTokenRequest(options: ClientOAuth2RequestObject): Promise<ClientOAuth2TokenData> {
|
||||
let url = options.url;
|
||||
const query = qs.stringify(options.query);
|
||||
|
||||
if (query) {
|
||||
url += (url.indexOf('?') === -1 ? '?' : '&') + query;
|
||||
}
|
||||
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
url,
|
||||
method: options.method,
|
||||
data: qs.stringify(options.body),
|
||||
headers: options.headers,
|
||||
transformResponse: (res: unknown) => res,
|
||||
// Axios rejects the promise by default for all status codes 4xx.
|
||||
// We override this to reject promises only on 5xxs
|
||||
validateStatus: (status) => status < 500,
|
||||
};
|
||||
|
||||
if (options.ignoreSSLIssues) {
|
||||
requestConfig.httpsAgent = sslIgnoringAgent;
|
||||
}
|
||||
|
||||
const response = await axios.request(requestConfig);
|
||||
|
||||
if (response.status >= 400) {
|
||||
const body = this.parseResponseBody<OAuth2AccessTokenErrorResponse>(response);
|
||||
const authErr = getAuthError(body);
|
||||
|
||||
if (authErr) throw authErr;
|
||||
else throw new ResponseError(response.status, response.data);
|
||||
}
|
||||
|
||||
if (response.status >= 300) {
|
||||
throw new ResponseError(response.status, response.data);
|
||||
}
|
||||
|
||||
return this.parseResponseBody<ClientOAuth2TokenData>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse response body based on the content type.
|
||||
*/
|
||||
private parseResponseBody<T extends object>(response: AxiosResponse<unknown>): T {
|
||||
const contentType = (response.headers['content-type'] as string) ?? '';
|
||||
const body = response.data as string;
|
||||
|
||||
if (contentType.startsWith('application/json')) {
|
||||
return JSON.parse(body) as T;
|
||||
}
|
||||
|
||||
if (contentType.startsWith('application/x-www-form-urlencoded')) {
|
||||
return qs.parse(body) as T;
|
||||
}
|
||||
|
||||
throw new ResponseError(
|
||||
response.status,
|
||||
body,
|
||||
undefined,
|
||||
`Unsupported content type: ${contentType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import * as a from 'node:assert';
|
||||
|
||||
import type { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oauth2';
|
||||
import { DEFAULT_HEADERS } from './constants';
|
||||
import { auth, expects, getRequestOptions } from './utils';
|
||||
|
||||
export interface ClientOAuth2TokenData extends Record<string, string | undefined> {
|
||||
token_type?: string | undefined;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in?: string;
|
||||
scope?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* General purpose client token generator.
|
||||
*/
|
||||
export class ClientOAuth2Token {
|
||||
readonly tokenType?: string;
|
||||
|
||||
readonly accessToken: string;
|
||||
|
||||
readonly refreshToken: string;
|
||||
|
||||
private expires: Date;
|
||||
|
||||
constructor(
|
||||
readonly client: ClientOAuth2,
|
||||
readonly data: ClientOAuth2TokenData,
|
||||
) {
|
||||
this.tokenType = data.token_type?.toLowerCase() ?? 'bearer';
|
||||
this.accessToken = data.access_token;
|
||||
this.refreshToken = data.refresh_token;
|
||||
|
||||
this.expires = new Date();
|
||||
this.expires.setSeconds(this.expires.getSeconds() + Number(data.expires_in));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a standardized request object with user authentication information.
|
||||
*/
|
||||
sign(requestObject: ClientOAuth2RequestObject): ClientOAuth2RequestObject {
|
||||
if (!this.accessToken) {
|
||||
throw new Error('Unable to sign without access token');
|
||||
}
|
||||
|
||||
requestObject.headers = requestObject.headers ?? {};
|
||||
|
||||
if (this.tokenType === 'bearer') {
|
||||
requestObject.headers.Authorization = 'Bearer ' + this.accessToken;
|
||||
} else {
|
||||
const parts = requestObject.url.split('#');
|
||||
const token = 'access_token=' + this.accessToken;
|
||||
const url = parts[0].replace(/[?&]access_token=[^&#]/, '');
|
||||
const fragment = parts[1] ? '#' + parts[1] : '';
|
||||
|
||||
// Prepend the correct query string parameter to the url.
|
||||
requestObject.url = url + (url.indexOf('?') > -1 ? '&' : '?') + token + fragment;
|
||||
|
||||
// Attempt to avoid storing the url in proxies, since the access token
|
||||
// is exposed in the query parameters.
|
||||
requestObject.headers.Pragma = 'no-store';
|
||||
requestObject.headers['Cache-Control'] = 'no-store';
|
||||
}
|
||||
|
||||
return requestObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a user access token with the refresh token.
|
||||
* As in RFC 6749 Section 6: https://www.rfc-editor.org/rfc/rfc6749.html#section-6
|
||||
*/
|
||||
async refresh(opts?: ClientOAuth2Options): Promise<ClientOAuth2Token> {
|
||||
const options = { ...this.client.options, ...opts };
|
||||
|
||||
expects(options, 'clientSecret');
|
||||
a.ok(this.refreshToken, 'refreshToken is required');
|
||||
|
||||
const { clientId, clientSecret } = options;
|
||||
const headers = { ...DEFAULT_HEADERS };
|
||||
const body: Record<string, string> = {
|
||||
refresh_token: this.refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
|
||||
if (options.authentication === 'body') {
|
||||
body.client_id = clientId;
|
||||
body.client_secret = clientSecret;
|
||||
} else {
|
||||
headers.Authorization = auth(clientId, clientSecret);
|
||||
}
|
||||
|
||||
const requestOptions = getRequestOptions(
|
||||
{
|
||||
url: options.accessTokenUri,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.accessTokenRequest(requestOptions);
|
||||
return this.client.createToken({ ...this.data, ...responseData });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the token has expired.
|
||||
*/
|
||||
expired(): boolean {
|
||||
return Date.now() > this.expires.getTime();
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import * as qs from 'querystring';
|
||||
|
||||
import type { ClientOAuth2, ClientOAuth2Options } from './client-oauth2';
|
||||
import type { ClientOAuth2Token } from './client-oauth2-token';
|
||||
import { DEFAULT_HEADERS, DEFAULT_URL_BASE } from './constants';
|
||||
import { auth, expects, getAuthError, getRequestOptions } from './utils';
|
||||
|
||||
interface CodeFlowBody {
|
||||
code: string | string[];
|
||||
grant_type: 'authorization_code';
|
||||
redirect_uri?: string;
|
||||
client_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Support authorization code OAuth 2.0 grant.
|
||||
*
|
||||
* Reference: http://tools.ietf.org/html/rfc6749#section-4.1
|
||||
*/
|
||||
export class CodeFlow {
|
||||
constructor(private client: ClientOAuth2) {}
|
||||
|
||||
/**
|
||||
* Generate the uri for doing the first redirect.
|
||||
*/
|
||||
getUri(opts?: Partial<ClientOAuth2Options>): string {
|
||||
const options: ClientOAuth2Options = { ...this.client.options, ...opts };
|
||||
|
||||
// Check the required parameters are set.
|
||||
expects(options, 'clientId', 'authorizationUri');
|
||||
|
||||
const url = new URL(options.authorizationUri);
|
||||
|
||||
const queryParams = {
|
||||
...options.query,
|
||||
client_id: options.clientId,
|
||||
redirect_uri: options.redirectUri,
|
||||
response_type: 'code',
|
||||
state: options.state,
|
||||
...(options.scopes ? { scope: options.scopes.join(options.scopesSeparator ?? ' ') } : {}),
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the code token from the redirected uri and make another request for
|
||||
* the user access token.
|
||||
*/
|
||||
async getToken(
|
||||
urlString: string,
|
||||
opts?: Partial<ClientOAuth2Options>,
|
||||
): Promise<ClientOAuth2Token> {
|
||||
const options: ClientOAuth2Options = { ...this.client.options, ...opts };
|
||||
expects(options, 'clientId', 'accessTokenUri');
|
||||
|
||||
const url = new URL(urlString, DEFAULT_URL_BASE);
|
||||
if (
|
||||
typeof options.redirectUri === 'string' &&
|
||||
typeof url.pathname === 'string' &&
|
||||
url.pathname !== new URL(options.redirectUri, DEFAULT_URL_BASE).pathname
|
||||
) {
|
||||
throw new TypeError('Redirected path should match configured path, but got: ' + url.pathname);
|
||||
}
|
||||
|
||||
if (!url.search?.substring(1)) {
|
||||
throw new TypeError(`Unable to process uri: ${urlString}`);
|
||||
}
|
||||
|
||||
const data =
|
||||
typeof url.search === 'string' ? qs.parse(url.search.substring(1)) : url.search || {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const error = getAuthError(data);
|
||||
if (error) throw error;
|
||||
|
||||
if (options.state && data.state !== options.state) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new TypeError(`Invalid state: ${data.state}`);
|
||||
}
|
||||
|
||||
// Check whether the response code is set.
|
||||
if (!data.code) {
|
||||
throw new TypeError('Missing code, unable to request token');
|
||||
}
|
||||
|
||||
const headers = { ...DEFAULT_HEADERS };
|
||||
const body: CodeFlowBody = {
|
||||
code: data.code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: options.redirectUri,
|
||||
};
|
||||
|
||||
// `client_id`: REQUIRED, if the client is not authenticating with the
|
||||
// authorization server as described in Section 3.2.1.
|
||||
// Reference: https://tools.ietf.org/html/rfc6749#section-3.2.1
|
||||
if (options.clientSecret) {
|
||||
headers.Authorization = auth(options.clientId, options.clientSecret);
|
||||
} else {
|
||||
body.client_id = options.clientId;
|
||||
}
|
||||
|
||||
const requestOptions = getRequestOptions(
|
||||
{
|
||||
url: options.accessTokenUri,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.accessTokenRequest(requestOptions);
|
||||
return this.client.createToken(responseData);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Headers } from './types';
|
||||
|
||||
export const DEFAULT_URL_BASE = 'https://example.org/';
|
||||
|
||||
/**
|
||||
* Default headers for executing OAuth 2.0 flows.
|
||||
*/
|
||||
export const DEFAULT_HEADERS: Headers = {
|
||||
Accept: 'application/json, application/x-www-form-urlencoded',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format error response types to regular strings for displaying to clients.
|
||||
*
|
||||
* Reference: http://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
*/
|
||||
export const ERROR_RESPONSES: Record<string, string> = {
|
||||
invalid_request: [
|
||||
'The request is missing a required parameter, includes an',
|
||||
'invalid parameter value, includes a parameter more than',
|
||||
'once, or is otherwise malformed.',
|
||||
].join(' '),
|
||||
invalid_client: [
|
||||
'Client authentication failed (e.g., unknown client, no',
|
||||
'client authentication included, or unsupported',
|
||||
'authentication method).',
|
||||
].join(' '),
|
||||
invalid_grant: [
|
||||
'The provided authorization grant (e.g., authorization',
|
||||
'code, resource owner credentials) or refresh token is',
|
||||
'invalid, expired, revoked, does not match the redirection',
|
||||
'URI used in the authorization request, or was issued to',
|
||||
'another client.',
|
||||
].join(' '),
|
||||
unauthorized_client: [
|
||||
'The client is not authorized to request an authorization',
|
||||
'code using this method.',
|
||||
].join(' '),
|
||||
unsupported_grant_type: [
|
||||
'The authorization grant type is not supported by the',
|
||||
'authorization server.',
|
||||
].join(' '),
|
||||
access_denied: ['The resource owner or authorization server denied the request.'].join(' '),
|
||||
unsupported_response_type: [
|
||||
'The authorization server does not support obtaining',
|
||||
'an authorization code using this method.',
|
||||
].join(' '),
|
||||
invalid_scope: ['The requested scope is invalid, unknown, or malformed.'].join(' '),
|
||||
server_error: [
|
||||
'The authorization server encountered an unexpected',
|
||||
'condition that prevented it from fulfilling the request.',
|
||||
'(This error code is needed because a 500 Internal Server',
|
||||
'Error HTTP status code cannot be returned to the client',
|
||||
'via an HTTP redirect.)',
|
||||
].join(' '),
|
||||
temporarily_unavailable: [
|
||||
'The authorization server is currently unable to handle',
|
||||
'the request due to a temporary overloading or maintenance',
|
||||
'of the server.',
|
||||
].join(' '),
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { ClientOAuth2 } from './client-oauth2';
|
||||
import type { ClientOAuth2Token } from './client-oauth2-token';
|
||||
import { DEFAULT_HEADERS } from './constants';
|
||||
import type { Headers } from './types';
|
||||
import { auth, expects, getRequestOptions } from './utils';
|
||||
|
||||
interface CredentialsFlowBody {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
grant_type: 'client_credentials';
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Support client credentials OAuth 2.0 grant.
|
||||
*
|
||||
* Reference: http://tools.ietf.org/html/rfc6749#section-4.4
|
||||
*/
|
||||
export class CredentialsFlow {
|
||||
constructor(private client: ClientOAuth2) {}
|
||||
|
||||
/**
|
||||
* Request an access token using the client credentials.
|
||||
*/
|
||||
async getToken(): Promise<ClientOAuth2Token> {
|
||||
const options = { ...this.client.options };
|
||||
expects(options, 'clientId', 'clientSecret', 'accessTokenUri');
|
||||
|
||||
const headers: Headers = { ...DEFAULT_HEADERS };
|
||||
const body: CredentialsFlowBody = {
|
||||
grant_type: 'client_credentials',
|
||||
...(options.additionalBodyProperties ?? {}),
|
||||
};
|
||||
|
||||
if (options.scopes !== undefined) {
|
||||
body.scope = options.scopes.join(options.scopesSeparator ?? ' ');
|
||||
}
|
||||
|
||||
const clientId = options.clientId;
|
||||
const clientSecret = options.clientSecret;
|
||||
|
||||
if (options.authentication === 'body') {
|
||||
body.client_id = clientId;
|
||||
body.client_secret = clientSecret;
|
||||
} else {
|
||||
headers.Authorization = auth(clientId, clientSecret);
|
||||
}
|
||||
|
||||
const requestOptions = getRequestOptions(
|
||||
{
|
||||
url: options.accessTokenUri,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.accessTokenRequest(requestOptions);
|
||||
return this.client.createToken(responseData);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oauth2';
|
||||
export { ClientOAuth2Token, ClientOAuth2TokenData } from './client-oauth2-token';
|
||||
export type * from './types';
|
||||
@@ -1,31 +0,0 @@
|
||||
export type Headers = Record<string, string | string[]>;
|
||||
|
||||
export type OAuth2GrantType = 'pkce' | 'authorizationCode' | 'clientCredentials';
|
||||
|
||||
export interface OAuth2CredentialData {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
accessTokenUrl: string;
|
||||
authentication?: 'header' | 'body';
|
||||
authUrl?: string;
|
||||
scope?: string;
|
||||
authQueryParameters?: string;
|
||||
additionalBodyProperties?: string;
|
||||
grantType: OAuth2GrantType;
|
||||
ignoreSSLIssues?: boolean;
|
||||
oauthTokenData?: {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The response from the OAuth2 server when the access token is not successfully
|
||||
* retrieved. As specified in RFC 6749 Section 5.2:
|
||||
* https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
|
||||
*/
|
||||
export interface OAuth2AccessTokenErrorResponse extends Record<string, unknown> {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oauth2';
|
||||
import { ERROR_RESPONSES } from './constants';
|
||||
|
||||
/**
|
||||
* Check if properties exist on an object and throw when they aren't.
|
||||
*/
|
||||
export function expects<Keys extends keyof ClientOAuth2Options>(
|
||||
obj: ClientOAuth2Options,
|
||||
...keys: Keys[]
|
||||
): asserts obj is ClientOAuth2Options & {
|
||||
[K in Keys]: NonNullable<ClientOAuth2Options[K]>;
|
||||
} {
|
||||
for (const key of keys) {
|
||||
if (obj[key] === null || obj[key] === undefined) {
|
||||
throw new TypeError('Expected "' + key + '" to exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly body: any,
|
||||
readonly code = 'EAUTH',
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an authentication error from the response data.
|
||||
*/
|
||||
export function getAuthError(body: {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}): Error | undefined {
|
||||
const message: string | undefined =
|
||||
ERROR_RESPONSES[body.error] ?? body.error_description ?? body.error;
|
||||
|
||||
if (message) {
|
||||
return new AuthError(message, body);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a value is a string.
|
||||
*/
|
||||
function toString(str: string | null | undefined) {
|
||||
return str === null ? '' : String(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create basic auth header.
|
||||
*/
|
||||
export function auth(username: string, password: string): string {
|
||||
return 'Basic ' + Buffer.from(toString(username) + ':' + toString(password)).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge request options from an options object.
|
||||
*/
|
||||
export function getRequestOptions(
|
||||
{ url, method, body, query, headers }: ClientOAuth2RequestObject,
|
||||
options: ClientOAuth2Options,
|
||||
): ClientOAuth2RequestObject {
|
||||
const rOptions = {
|
||||
url,
|
||||
method,
|
||||
body: { ...body, ...options.body },
|
||||
query: { ...query, ...options.query },
|
||||
headers: headers ?? {},
|
||||
ignoreSSLIssues: options.ignoreSSLIssues,
|
||||
};
|
||||
// if request authorization was overridden delete it from header
|
||||
if (rOptions.headers.Authorization === '') {
|
||||
delete rOptions.headers.Authorization;
|
||||
}
|
||||
return rOptions;
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import nock from 'nock';
|
||||
|
||||
import { ClientOAuth2, ResponseError } from '@/client-oauth2';
|
||||
import { ERROR_RESPONSES } from '@/constants';
|
||||
import { auth, AuthError } from '@/utils';
|
||||
|
||||
import * as config from './config';
|
||||
|
||||
describe('ClientOAuth2', () => {
|
||||
const client = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authentication: 'header',
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
describe('accessTokenRequest', () => {
|
||||
const authHeader = auth(config.clientId, config.clientSecret);
|
||||
|
||||
const makeTokenCall = async () =>
|
||||
await client.accessTokenRequest({
|
||||
url: config.accessTokenUri,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: {
|
||||
refresh_token: 'test',
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockTokenResponse = ({
|
||||
status = 200,
|
||||
headers,
|
||||
body,
|
||||
}: {
|
||||
status: number;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
}) =>
|
||||
nock(config.baseUrl).post('/login/oauth/access_token').once().reply(status, body, headers);
|
||||
|
||||
it('should send the correct request based on given options', async () => {
|
||||
mockTokenResponse({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const axiosSpy = jest.spyOn(axios, 'request');
|
||||
|
||||
await makeTokenCall();
|
||||
|
||||
expect(axiosSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: config.accessTokenUri,
|
||||
method: 'POST',
|
||||
data: 'refresh_token=test&grant_type=refresh_token',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
}),
|
||||
},
|
||||
{
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
body: JSON.stringify({
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
}),
|
||||
},
|
||||
{
|
||||
contentType: 'application/x-www-form-urlencoded',
|
||||
body: `access_token=${config.accessToken}&refresh_token=${config.refreshToken}`,
|
||||
},
|
||||
])('should parse response with content type $contentType', async ({ contentType, body }) => {
|
||||
mockTokenResponse({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
const response = await makeTokenCall();
|
||||
|
||||
expect(response).toEqual({
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
contentType: 'text/html',
|
||||
body: '<html><body>Hello, world!</body></html>',
|
||||
},
|
||||
{
|
||||
contentType: 'application/xml',
|
||||
body: '<xml><body>Hello, world!</body></xml>',
|
||||
},
|
||||
{
|
||||
contentType: 'text/plain',
|
||||
body: 'Hello, world!',
|
||||
},
|
||||
])('should reject content type $contentType', async ({ contentType, body }) => {
|
||||
mockTokenResponse({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(result.message).toEqual(`Unsupported content type: ${contentType}`);
|
||||
});
|
||||
|
||||
it('should reject 4xx responses with auth errors', async () => {
|
||||
mockTokenResponse({
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ error: 'access_denied' }),
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(AuthError);
|
||||
expect(result.message).toEqual(ERROR_RESPONSES.access_denied);
|
||||
expect(result.body).toEqual({ error: 'access_denied' });
|
||||
});
|
||||
|
||||
it('should reject 3xx responses with response errors', async () => {
|
||||
mockTokenResponse({
|
||||
status: 302,
|
||||
headers: {},
|
||||
body: 'Redirected',
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(ResponseError);
|
||||
expect(result.message).toEqual('HTTP status 302');
|
||||
expect(result.body).toEqual('Redirected');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { ClientOAuth2 } from '@/client-oauth2';
|
||||
import { ClientOAuth2Token } from '@/client-oauth2-token';
|
||||
import { AuthError } from '@/utils';
|
||||
|
||||
import * as config from './config';
|
||||
|
||||
describe('CodeFlow', () => {
|
||||
beforeAll(async () => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
const uri = `/auth/callback?code=${config.code}&state=${config.state}`;
|
||||
|
||||
const githubAuth = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: ['notifications'],
|
||||
});
|
||||
|
||||
describe('#getUri', () => {
|
||||
it('should return a valid uri', () => {
|
||||
expect(githubAuth.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&scope=notifications',
|
||||
);
|
||||
});
|
||||
|
||||
describe('when scopes are undefined', () => {
|
||||
it('should not include scope in the uri', () => {
|
||||
const authWithoutScopes = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
});
|
||||
expect(authWithoutScopes.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include empty scopes array as an empty string', () => {
|
||||
const authWithEmptyScopes = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: [],
|
||||
});
|
||||
expect(authWithEmptyScopes.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&scope=',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include empty scopes string as an empty string', () => {
|
||||
const authWithEmptyScopes = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: [],
|
||||
});
|
||||
expect(authWithEmptyScopes.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&scope=',
|
||||
);
|
||||
});
|
||||
|
||||
describe('when authorizationUri contains query parameters', () => {
|
||||
it('should preserve query string parameters', () => {
|
||||
const authWithParams = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: `${config.authorizationUri}?bar=qux`,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: ['notifications'],
|
||||
});
|
||||
expect(authWithParams.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?bar=qux&client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&scope=notifications',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getToken', () => {
|
||||
const mockTokenCall = () =>
|
||||
nock(config.baseUrl)
|
||||
.post(
|
||||
'/login/oauth/access_token',
|
||||
({ code, grant_type, redirect_uri }) =>
|
||||
code === config.code &&
|
||||
grant_type === 'authorization_code' &&
|
||||
redirect_uri === config.redirectUri,
|
||||
)
|
||||
.once()
|
||||
.reply(200, {
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
});
|
||||
|
||||
it('should request the token', async () => {
|
||||
mockTokenCall();
|
||||
const user = await githubAuth.code.getToken(uri);
|
||||
|
||||
expect(user).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(user.accessToken).toEqual(config.accessToken);
|
||||
expect(user.tokenType).toEqual('bearer');
|
||||
});
|
||||
|
||||
it('should reject with auth errors', async () => {
|
||||
let errored = false;
|
||||
|
||||
try {
|
||||
await githubAuth.code.getToken(`${config.redirectUri}?error=invalid_request`);
|
||||
} catch (err) {
|
||||
errored = true;
|
||||
expect(err).toBeInstanceOf(AuthError);
|
||||
if (err instanceof AuthError) {
|
||||
expect(err.code).toEqual('EAUTH');
|
||||
expect(err.body.error).toEqual('invalid_request');
|
||||
}
|
||||
}
|
||||
expect(errored).toEqual(true);
|
||||
});
|
||||
|
||||
describe('#sign', () => {
|
||||
it('should be able to sign a standard request object', async () => {
|
||||
mockTokenCall();
|
||||
const token = await githubAuth.code.getToken(uri);
|
||||
const requestOptions = token.sign({
|
||||
method: 'GET',
|
||||
url: 'http://api.github.com/user',
|
||||
});
|
||||
expect(requestOptions.headers?.Authorization).toEqual(`Bearer ${config.accessToken}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#refresh', () => {
|
||||
const mockRefreshCall = () =>
|
||||
nock(config.baseUrl)
|
||||
.post(
|
||||
'/login/oauth/access_token',
|
||||
({ refresh_token, grant_type }) =>
|
||||
refresh_token === config.refreshToken && grant_type === 'refresh_token',
|
||||
)
|
||||
.once()
|
||||
.reply(200, {
|
||||
access_token: config.refreshedAccessToken,
|
||||
refresh_token: config.refreshedRefreshToken,
|
||||
});
|
||||
|
||||
it('should make a request to get a new access token', async () => {
|
||||
mockTokenCall();
|
||||
const token = await githubAuth.code.getToken(uri, { state: config.state });
|
||||
expect(token.refreshToken).toEqual(config.refreshToken);
|
||||
|
||||
mockRefreshCall();
|
||||
const token1 = await token.refresh();
|
||||
expect(token1).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
|
||||
expect(token1.refreshToken).toEqual(config.refreshedRefreshToken);
|
||||
expect(token1.tokenType).toEqual('bearer');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
export const baseUrl = 'https://mock.auth.service';
|
||||
export const accessTokenUri = baseUrl + '/login/oauth/access_token';
|
||||
export const authorizationUri = baseUrl + '/login/oauth/authorize';
|
||||
export const redirectUri = 'http://example.com/auth/callback';
|
||||
|
||||
export const accessToken = '4430eb1615fb6127cbf828a8e403';
|
||||
export const refreshToken = 'def456token';
|
||||
export const refreshedAccessToken = 'f456okeendt';
|
||||
export const refreshedRefreshToken = 'f4f6577c0f3af456okeendt';
|
||||
|
||||
export const clientId = 'abc';
|
||||
export const clientSecret = '123';
|
||||
|
||||
export const code = 'fbe55d970377e0686746';
|
||||
export const state = '7076840850058943';
|
||||
@@ -1,215 +0,0 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { ClientOAuth2, type ClientOAuth2Options } from '@/client-oauth2';
|
||||
import { ClientOAuth2Token } from '@/client-oauth2-token';
|
||||
import type { Headers } from '@/types';
|
||||
|
||||
import * as config from './config';
|
||||
|
||||
describe('CredentialsFlow', () => {
|
||||
beforeAll(async () => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('#getToken', () => {
|
||||
const createAuthClient = ({
|
||||
scopes,
|
||||
authentication,
|
||||
}: Pick<ClientOAuth2Options, 'scopes' | 'authentication'> = {}) =>
|
||||
new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authentication,
|
||||
authorizationGrants: ['credentials'],
|
||||
scopes,
|
||||
});
|
||||
|
||||
const mockTokenCall = async ({ requestedScope }: { requestedScope?: string } = {}) => {
|
||||
const nockScope = nock(config.baseUrl)
|
||||
.post(
|
||||
'/login/oauth/access_token',
|
||||
({ scope, grant_type }) =>
|
||||
scope === requestedScope && grant_type === 'client_credentials',
|
||||
)
|
||||
.once()
|
||||
.reply(200, {
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
scope: requestedScope,
|
||||
});
|
||||
return await new Promise<{ headers: Headers; body: unknown }>((resolve) => {
|
||||
nockScope.once('request', (req) => {
|
||||
resolve({
|
||||
headers: req.headers,
|
||||
body: req.requestBodyBuffers.toString('utf-8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
it('should request the token', async () => {
|
||||
const authClient = createAuthClient({ scopes: ['notifications'] });
|
||||
const requestPromise = mockTokenCall({ requestedScope: 'notifications' });
|
||||
|
||||
const user = await authClient.credentials.getToken();
|
||||
|
||||
expect(user).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(user.accessToken).toEqual(config.accessToken);
|
||||
expect(user.tokenType).toEqual('bearer');
|
||||
expect(user.data.scope).toEqual('notifications');
|
||||
|
||||
const { headers, body } = await requestPromise;
|
||||
expect(headers.authorization).toBe('Basic YWJjOjEyMw==');
|
||||
expect(body).toEqual('grant_type=client_credentials&scope=notifications');
|
||||
});
|
||||
|
||||
it('when scopes are undefined, it should not send scopes to an auth server', async () => {
|
||||
const authClient = createAuthClient();
|
||||
const requestPromise = mockTokenCall();
|
||||
|
||||
const user = await authClient.credentials.getToken();
|
||||
expect(user).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(user.accessToken).toEqual(config.accessToken);
|
||||
expect(user.tokenType).toEqual('bearer');
|
||||
expect(user.data.scope).toEqual(undefined);
|
||||
|
||||
const { body } = await requestPromise;
|
||||
expect(body).toEqual('grant_type=client_credentials');
|
||||
});
|
||||
|
||||
it('when scopes is an empty array, it should send empty scope string to an auth server', async () => {
|
||||
const authClient = createAuthClient({ scopes: [] });
|
||||
const requestPromise = mockTokenCall({ requestedScope: '' });
|
||||
|
||||
const user = await authClient.credentials.getToken();
|
||||
expect(user).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(user.accessToken).toEqual(config.accessToken);
|
||||
expect(user.tokenType).toEqual('bearer');
|
||||
expect(user.data.scope).toEqual('');
|
||||
|
||||
const { body } = await requestPromise;
|
||||
expect(body).toEqual('grant_type=client_credentials&scope=');
|
||||
});
|
||||
|
||||
it('should handle authentication = "header"', async () => {
|
||||
const authClient = createAuthClient({ scopes: [] });
|
||||
const requestPromise = mockTokenCall({ requestedScope: '' });
|
||||
await authClient.credentials.getToken();
|
||||
const { headers, body } = await requestPromise;
|
||||
expect(headers?.authorization).toBe('Basic YWJjOjEyMw==');
|
||||
expect(body).toEqual('grant_type=client_credentials&scope=');
|
||||
});
|
||||
|
||||
it('should handle authentication = "body"', async () => {
|
||||
const authClient = createAuthClient({ scopes: [], authentication: 'body' });
|
||||
const requestPromise = mockTokenCall({ requestedScope: '' });
|
||||
await authClient.credentials.getToken();
|
||||
const { headers, body } = await requestPromise;
|
||||
expect(headers?.authorization).toBe(undefined);
|
||||
expect(body).toEqual('grant_type=client_credentials&scope=&client_id=abc&client_secret=123');
|
||||
});
|
||||
|
||||
describe('#sign', () => {
|
||||
it('should be able to sign a standard request object', async () => {
|
||||
const authClient = createAuthClient({ scopes: ['notifications'] });
|
||||
void mockTokenCall({ requestedScope: 'notifications' });
|
||||
|
||||
const token = await authClient.credentials.getToken();
|
||||
const requestOptions = token.sign({
|
||||
method: 'GET',
|
||||
url: `${config.baseUrl}/test`,
|
||||
});
|
||||
|
||||
expect(requestOptions.headers?.Authorization).toEqual(`Bearer ${config.accessToken}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#refresh', () => {
|
||||
const mockRefreshCall = async () => {
|
||||
const nockScope = nock(config.baseUrl)
|
||||
.post(
|
||||
'/login/oauth/access_token',
|
||||
({ refresh_token, grant_type }) =>
|
||||
refresh_token === config.refreshToken && grant_type === 'refresh_token',
|
||||
)
|
||||
.once()
|
||||
.reply(200, {
|
||||
access_token: config.refreshedAccessToken,
|
||||
refresh_token: config.refreshedRefreshToken,
|
||||
});
|
||||
return await new Promise<{ headers: Headers; body: unknown }>((resolve) => {
|
||||
nockScope.once('request', (req) => {
|
||||
resolve({
|
||||
headers: req.headers,
|
||||
body: req.requestBodyBuffers.toString('utf-8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
it('should make a request to get a new access token', async () => {
|
||||
const authClient = createAuthClient({ scopes: ['notifications'] });
|
||||
void mockTokenCall({ requestedScope: 'notifications' });
|
||||
|
||||
const token = await authClient.credentials.getToken();
|
||||
expect(token.accessToken).toEqual(config.accessToken);
|
||||
|
||||
const requestPromise = mockRefreshCall();
|
||||
const token1 = await token.refresh();
|
||||
await requestPromise;
|
||||
|
||||
expect(token1).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
|
||||
expect(token1.tokenType).toEqual('bearer');
|
||||
});
|
||||
|
||||
it('should make a request to get a new access token with authentication = "body"', async () => {
|
||||
const authClient = createAuthClient({ scopes: ['notifications'], authentication: 'body' });
|
||||
void mockTokenCall({ requestedScope: 'notifications' });
|
||||
|
||||
const token = await authClient.credentials.getToken();
|
||||
expect(token.accessToken).toEqual(config.accessToken);
|
||||
|
||||
const requestPromise = mockRefreshCall();
|
||||
const token1 = await token.refresh();
|
||||
const { headers, body } = await requestPromise;
|
||||
|
||||
expect(token1).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
|
||||
expect(token1.tokenType).toEqual('bearer');
|
||||
expect(headers?.authorization).toBe(undefined);
|
||||
expect(body).toEqual(
|
||||
'refresh_token=def456token&grant_type=refresh_token&client_id=abc&client_secret=123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should make a request to get a new access token with authentication = "header"', async () => {
|
||||
const authClient = createAuthClient({
|
||||
scopes: ['notifications'],
|
||||
authentication: 'header',
|
||||
});
|
||||
void mockTokenCall({ requestedScope: 'notifications' });
|
||||
|
||||
const token = await authClient.credentials.getToken();
|
||||
expect(token.accessToken).toEqual(config.accessToken);
|
||||
|
||||
const requestPromise = mockRefreshCall();
|
||||
const token1 = await token.refresh();
|
||||
const { headers, body } = await requestPromise;
|
||||
|
||||
expect(token1).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
|
||||
expect(token1.tokenType).toEqual('bearer');
|
||||
expect(headers?.authorization).toBe('Basic YWJjOjEyMw==');
|
||||
expect(body).toEqual('refresh_token=def456token&grant_type=refresh_token');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user