first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
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';
|
||||
@@ -0,0 +1,215 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { ClientOAuth2 } from '@/client-oauth2';
|
||||
import { ClientOAuth2Token } from '@/client-oauth2-token';
|
||||
import type { Headers } from '@/types';
|
||||
|
||||
import * as config from './config';
|
||||
|
||||
describe('PKCE Flow', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('PKCE Authorization Code Flow', () => {
|
||||
const createPkceClient = (clientSecret?: string) =>
|
||||
new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: ['read', 'write'],
|
||||
});
|
||||
|
||||
describe('#getUri with PKCE parameters', () => {
|
||||
it('should include code_challenge and code_challenge_method in authorization URI', () => {
|
||||
const client = createPkceClient();
|
||||
const codeChallenge = 'test_code_challenge';
|
||||
const uri = client.code.getUri({
|
||||
query: {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
},
|
||||
});
|
||||
|
||||
expect(uri).toContain(`code_challenge=${codeChallenge}`);
|
||||
expect(uri).toContain('code_challenge_method=S256');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getToken with PKCE', () => {
|
||||
const mockPkceTokenCall = (
|
||||
options: {
|
||||
expectClientSecret?: boolean;
|
||||
expectCodeVerifier?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const { expectClientSecret = false, expectCodeVerifier = true } = options;
|
||||
|
||||
return nock(config.baseUrl)
|
||||
.post('/login/oauth/access_token')
|
||||
.once()
|
||||
.reply(200, function (this: nock.ReplyFnContext, _uri: string, requestBody: string) {
|
||||
// Verify PKCE parameters are included correctly
|
||||
if (expectCodeVerifier) {
|
||||
expect(requestBody).toContain('code_verifier=test_code_verifier');
|
||||
}
|
||||
|
||||
expect(requestBody).toContain('grant_type=authorization_code');
|
||||
|
||||
// For confidential clients, credentials are in Basic Auth header
|
||||
if (expectClientSecret) {
|
||||
expect(this.req.headers.authorization).toMatch(/^Basic /);
|
||||
} else {
|
||||
// For public clients, no authorization header but client_id may be in body
|
||||
expect(this.req.headers.authorization).toBeUndefined();
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
it('should exchange authorization code with PKCE code_verifier for public client (no client_secret)', async () => {
|
||||
const client = createPkceClient(); // No client secret
|
||||
mockPkceTokenCall({ expectClientSecret: false });
|
||||
|
||||
const uri = `${config.redirectUri}?code=${config.code}&state=${config.state}`;
|
||||
const token = await client.code.getToken(uri, {
|
||||
body: { code_verifier: 'test_code_verifier' },
|
||||
});
|
||||
|
||||
expect(token).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(token.accessToken).toEqual(config.accessToken);
|
||||
expect(token.refreshToken).toEqual(config.refreshToken);
|
||||
});
|
||||
|
||||
it('should exchange authorization code with PKCE code_verifier for confidential client (with client_secret)', async () => {
|
||||
const client = createPkceClient(config.clientSecret);
|
||||
mockPkceTokenCall({ expectClientSecret: true });
|
||||
|
||||
const uri = `${config.redirectUri}?code=${config.code}&state=${config.state}`;
|
||||
const token = await client.code.getToken(uri, {
|
||||
body: { code_verifier: 'test_code_verifier' },
|
||||
});
|
||||
|
||||
expect(token).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(token.accessToken).toEqual(config.accessToken);
|
||||
expect(token.refreshToken).toEqual(config.refreshToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#refresh for PKCE flows', () => {
|
||||
const mockRefreshCall = async (
|
||||
options: {
|
||||
expectClientSecret?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const { expectClientSecret = false } = options;
|
||||
|
||||
const nockScope = nock(config.baseUrl)
|
||||
.post('/login/oauth/access_token')
|
||||
.once()
|
||||
.reply(200, function (this: nock.ReplyFnContext, _uri: string, requestBody: string) {
|
||||
// Verify refresh token parameters
|
||||
expect(requestBody).toContain(`refresh_token=${config.refreshToken}`);
|
||||
expect(requestBody).toContain('grant_type=refresh_token');
|
||||
|
||||
// For confidential clients, credentials are in Basic Auth header
|
||||
if (expectClientSecret) {
|
||||
expect(this.req.headers.authorization).toMatch(/^Basic /);
|
||||
// client_id should not be in body when using Basic Auth
|
||||
expect(requestBody).not.toContain('client_id=');
|
||||
} else {
|
||||
// For public clients, client_id should be in body
|
||||
expect(requestBody).toContain(`client_id=${config.clientId}`);
|
||||
expect(this.req.headers.authorization).toBeUndefined();
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: config.refreshedAccessToken,
|
||||
refresh_token: config.refreshedRefreshToken,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
});
|
||||
|
||||
return await new Promise<{ headers: Headers; body: string }>((resolve) => {
|
||||
nockScope.once('request', (req: { headers: Headers; requestBodyBuffers: Buffer }) => {
|
||||
resolve({
|
||||
headers: req.headers,
|
||||
body: req.requestBodyBuffers.toString('utf-8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
it('should refresh token for PKCE public client without client_secret', async () => {
|
||||
// Create token with public client (no client secret)
|
||||
const publicClient = createPkceClient();
|
||||
const token = new ClientOAuth2Token(publicClient, {
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
|
||||
const requestPromise = mockRefreshCall({ expectClientSecret: false });
|
||||
const refreshedToken = await token.refresh();
|
||||
const { body } = await requestPromise;
|
||||
|
||||
expect(refreshedToken).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(refreshedToken.accessToken).toEqual(config.refreshedAccessToken);
|
||||
expect(refreshedToken.refreshToken).toEqual(config.refreshedRefreshToken);
|
||||
|
||||
// Verify request body doesn't include client_secret but includes client_id
|
||||
expect(body).toContain(`client_id=${config.clientId}`);
|
||||
expect(body).not.toContain('client_secret=');
|
||||
});
|
||||
|
||||
it('should refresh token for PKCE confidential client with client_secret', async () => {
|
||||
// Create token with confidential client (with client secret)
|
||||
const confidentialClient = createPkceClient(config.clientSecret);
|
||||
const token = new ClientOAuth2Token(confidentialClient, {
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
|
||||
const requestPromise = mockRefreshCall({ expectClientSecret: true });
|
||||
const refreshedToken = await token.refresh();
|
||||
const { headers, body } = await requestPromise;
|
||||
|
||||
expect(refreshedToken).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(refreshedToken.accessToken).toEqual(config.refreshedAccessToken);
|
||||
expect(refreshedToken.refreshToken).toEqual(config.refreshedRefreshToken);
|
||||
|
||||
// Should use Basic Auth header for confidential clients
|
||||
expect(headers?.authorization).toBe('Basic YWJjOjEyMw==');
|
||||
expect(body).not.toContain('client_secret=');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user