chore: 清理macOS同步产生的重复文件

详细说明:
- 删除了352个带数字后缀的重复文件
- 更新.gitignore防止未来产生此类文件
- 这些文件是由iCloud或其他同步服务冲突产生的
- 不影响项目功能,仅清理冗余文件
This commit is contained in:
Yep_Q
2025-09-08 12:06:01 +08:00
parent 1564396449
commit d6f48d6d14
365 changed files with 2039 additions and 68301 deletions

View File

@@ -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}`,
);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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(' '),
};

View File

@@ -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);
}
}

View File

@@ -1,3 +0,0 @@
export { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oauth2';
export { ClientOAuth2Token, ClientOAuth2TokenData } from './client-oauth2-token';
export type * from './types';

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});
});

View File

@@ -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';

View File

@@ -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');
});
});
});
});