Refactor/oauth2 (#5571)

* get-token

* named export

* another pass

* exporting const breaks types in a good way

* flatten optional params

* remove silly db functions

* remove junk function in db models

* flatten grant auth code

* simplify responseToObject

* improve response to object typing

* use union type for auth keys

* remove authkey constants

* some hacking

* another pass

* unpacking

* obscure auth object

* fix auth code types

* fix grant implicit types

* remove oauth2 token from redux store

* fix oauth method args

* extract network from grant auth code

* extract send from grant password

* extract grant implicit

* extract client creds

* flatten param generator

* extract nuller

* flatten refresh token

* flatten refresh token

* fix types

* remove tests

* fix test flake

* fix gql body test import

* fix typos

* fix grant auth

* fix token clear

* fix token state

* fix auth header logic

* fix types

* fix

* simplification pass

* fix code verifier

* flatten implicit grant type

* fix typo

* use consistent url parsers

* flatten grant auth code

* fix typo

* type oauth2

* improve helper and tidy

* fix type

* tidy

* tidy

* tidy up

* remove some constants
This commit is contained in:
Jack Kavanagh 2023-01-12 12:36:51 +01:00 committed by GitHub
parent d86330445e
commit c4d2939e7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 576 additions and 2083 deletions

View File

@ -4,7 +4,6 @@ import { Cookie as ToughCookie } from 'tough-cookie';
import * as models from '../models';
import type { Request } from '../models/request';
import { newBodyRaw } from '../models/request';
import type { Response } from '../models/response';
import { isWorkspace } from '../models/workspace';
import { getAuthHeader } from '../network/authentication';
@ -423,7 +422,7 @@ function getResponseCookies(response: Response) {
try {
cookie = ToughCookie.parse(harCookie.value || '');
} catch (error) {}
} catch (error) { }
if (cookie === null || cookie === undefined) {
return accumulator;
@ -520,10 +519,11 @@ function getRequestQueryString(renderedRequest: RenderedRequest): HarQueryString
function getRequestPostData(renderedRequest: RenderedRequest): HarPostData | undefined {
let body;
if (renderedRequest.body.fileName) {
try {
body = newBodyRaw(fs.readFileSync(renderedRequest.body.fileName, 'base64'));
body = {
text: fs.readFileSync(renderedRequest.body.fileName, 'base64'),
};
} catch (error) {
console.warn('[code gen] Failed to read file', error);
return;

View File

@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { globalBeforeEach } from '../../__jest__/before-each';
import { CONTENT_TYPE_GRAPHQL } from '../../common/constants';
import { newBodyGraphQL, updateMimeType } from '../../ui/components/panes/request-pane';
import * as models from '../index';
import { newBodyGraphQL } from '../request';
describe('init()', () => {
beforeEach(globalBeforeEach);
@ -87,7 +87,7 @@ describe('updateMimeType()', () => {
parentId: 'fld_1',
});
expect(request).not.toBeNull();
const newRequest = await models.request.updateMimeType(request, 'text/html');
const newRequest = await updateMimeType(request, 'text/html');
expect(newRequest.headers).toEqual([
{
name: 'Content-Type',
@ -116,7 +116,7 @@ describe('updateMimeType()', () => {
],
});
expect(request).not.toBeNull();
const newRequest = await models.request.updateMimeType(request, 'text/html');
const newRequest = await updateMimeType(request, 'text/html');
expect(newRequest.headers).toEqual([
{
name: 'content-tYPE',
@ -145,7 +145,7 @@ describe('updateMimeType()', () => {
],
});
expect(request).not.toBeNull();
const newRequest = await models.request.updateMimeType(request, 'text/html');
const newRequest = await updateMimeType(request, 'text/html');
expect(newRequest.headers).toEqual([
{
name: 'content-tYPE',
@ -166,7 +166,7 @@ describe('updateMimeType()', () => {
],
});
expect(request).not.toBeNull();
const newRequest = await models.request.updateMimeType(request, null);
const newRequest = await updateMimeType(request, null);
expect(newRequest.body).toEqual({});
expect(newRequest.headers).toEqual([]);
});
@ -180,7 +180,7 @@ describe('updateMimeType()', () => {
},
});
expect(request).not.toBeNull();
const newRequest = await models.request.updateMimeType(request, 'application/json', false, {
const newRequest = await updateMimeType(request, 'application/json', false, {
text: 'Saved Data',
});
expect(newRequest.body.text).toEqual('Saved Data');
@ -195,7 +195,7 @@ describe('updateMimeType()', () => {
},
});
expect(request).not.toBeNull();
const newRequest = await models.request.updateMimeType(request, 'application/json', false, {});
const newRequest = await updateMimeType(request, 'application/json', false, {});
expect(newRequest.body.text).toEqual('My Data');
});
});

View File

@ -9,19 +9,12 @@ import {
AUTH_NTLM,
AUTH_OAUTH_1,
AUTH_OAUTH_2,
CONTENT_TYPE_FILE,
CONTENT_TYPE_FORM_DATA,
CONTENT_TYPE_FORM_URLENCODED,
CONTENT_TYPE_GRAPHQL,
CONTENT_TYPE_JSON,
CONTENT_TYPE_OTHER,
getContentTypeFromHeaders,
HAWK_ALGORITHM_SHA256,
METHOD_GET,
METHOD_POST,
} from '../common/constants';
import { database as db } from '../common/database';
import { getContentTypeHeader } from '../common/misc';
import { SIGNATURE_METHOD_HMAC_SHA1 } from '../network/o-auth-1/constants';
import { GRANT_TYPE_AUTHORIZATION_CODE } from '../network/o-auth-2/constants';
import { deconstructQueryStringToParams } from '../utils/url/querystring';
@ -38,7 +31,30 @@ export const canDuplicate = true;
export const canSync = true;
export type RequestAuthentication = Record<string, any>;
export interface AuthTypeOAuth2 {
type: 'oauth2';
grantType: 'authorization_code' | 'client_credentials' | 'password' | 'implicit' | 'refresh_token';
accessTokenUrl?: string;
authorizationUrl?: string;
clientId?: string;
clientSecret?: string;
audience?: string;
scope?: string;
resource?: string;
username?: string;
password?: string;
redirectUrl?: string;
credentialsInBody?: boolean;
state?: string;
code?: string;
accessToken?: string;
refreshToken?: string;
tokenPrefix?: string;
usePkce?: boolean;
pkceMethod?: string;
responseType?: string;
origin?: string;
}
export interface RequestHeader {
name: string;
value: string;
@ -206,65 +222,6 @@ export function newAuth(type: string, oldAuth: RequestAuthentication = {}): Requ
}
}
export function newBodyNone(): RequestBody {
return {};
}
export function newBodyRaw(rawBody: string, contentType?: string): RequestBody {
if (typeof contentType !== 'string') {
return {
text: rawBody,
};
}
const mimeType = contentType.split(';')[0];
return {
mimeType,
text: rawBody,
};
}
export function newBodyGraphQL(rawBody: string): RequestBody {
try {
// Only strip the newlines if rawBody is a parsable JSON
JSON.parse(rawBody);
return {
mimeType: CONTENT_TYPE_GRAPHQL,
text: rawBody.replace(/\\\\n/g, ''),
};
} catch (error) {
if (error instanceof SyntaxError) {
return {
mimeType: CONTENT_TYPE_GRAPHQL,
text: rawBody,
};
} else {
throw error;
}
}
}
export function newBodyFormUrlEncoded(parameters: RequestBodyParameter[] | null): RequestBody {
return {
mimeType: CONTENT_TYPE_FORM_URLENCODED,
params: parameters || [],
};
}
export function newBodyFile(path: string): RequestBody {
return {
mimeType: CONTENT_TYPE_FILE,
fileName: path,
};
}
export function newBodyForm(parameters: RequestBodyParameter[]): RequestBody {
return {
mimeType: CONTENT_TYPE_FORM_DATA,
params: parameters || [],
};
}
export function migrate(doc: Request): Request {
doc = migrateBody(doc);
doc = migrateWeirdUrls(doc);
@ -292,105 +249,6 @@ export function update(request: Request, patch: Partial<Request>) {
return db.docUpdate<Request>(request, patch);
}
export function updateMimeType(
request: Request,
mimeType: string,
doCreate = false,
savedBody: RequestBody = {},
) {
let headers = request.headers ? [...request.headers] : [];
const contentTypeHeader = getContentTypeHeader(headers);
// GraphQL uses JSON content-type
const contentTypeHeaderValue = mimeType === CONTENT_TYPE_GRAPHQL ? CONTENT_TYPE_JSON : mimeType;
// GraphQL must be POST
if (mimeType === CONTENT_TYPE_GRAPHQL) {
request.method = METHOD_POST;
}
// Check if we are converting to/from variants of XML or JSON
let leaveContentTypeAlone = false;
if (contentTypeHeader && mimeType) {
const current = contentTypeHeader.value;
if (current.includes('xml') && mimeType.includes('xml')) {
leaveContentTypeAlone = true;
} else if (current.includes('json') && mimeType.includes('json')) {
leaveContentTypeAlone = true;
}
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// 1. Update Content-Type header //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
const hasBody = typeof mimeType === 'string';
if (!hasBody) {
headers = headers.filter(h => h !== contentTypeHeader);
} else if (mimeType === CONTENT_TYPE_OTHER) {
// Leave headers alone
} else if (mimeType && contentTypeHeader && !leaveContentTypeAlone) {
contentTypeHeader.value = contentTypeHeaderValue;
} else if (mimeType && !contentTypeHeader) {
headers.push({
name: 'Content-Type',
value: contentTypeHeaderValue,
});
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// 2. Make a new request body //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~ //
let body;
const oldBody = Object.keys(savedBody).length === 0 ? request.body : savedBody;
if (mimeType === CONTENT_TYPE_FORM_URLENCODED) {
// Urlencoded
body = oldBody.params
? newBodyFormUrlEncoded(oldBody.params)
// @ts-expect-error -- TSCONVERSION
: newBodyFormUrlEncoded(deconstructQueryStringToParams(oldBody.text));
} else if (mimeType === CONTENT_TYPE_FORM_DATA) {
// Form Data
body = oldBody.params
? newBodyForm(oldBody.params)
// @ts-expect-error -- TSCONVERSION
: newBodyForm(deconstructQueryStringToParams(oldBody.text));
} else if (mimeType === CONTENT_TYPE_FILE) {
// File
body = newBodyFile('');
} else if (mimeType === CONTENT_TYPE_GRAPHQL) {
if (contentTypeHeader) {
contentTypeHeader.value = CONTENT_TYPE_JSON;
}
body = newBodyGraphQL(oldBody.text || '');
} else if (typeof mimeType !== 'string') {
// No body
body = newBodyNone();
} else {
// Raw Content-Type (ex: application/json)
body = newBodyRaw(oldBody.text || '', mimeType);
}
// ~~~~~~~~~~~~~~~~~~~~~~~~ //
// 2. create/update request //
// ~~~~~~~~~~~~~~~~~~~~~~~~ //
if (doCreate) {
const newRequest: Request = Object.assign({}, request, {
headers,
body,
});
return create(newRequest);
} else {
return update(request, {
headers,
body,
});
}
}
export async function duplicate(request: Request, patch: Partial<Request> = {}) {
// Only set name and "(Copy)" if the patch does
// not define it and the request itself has a name.
@ -450,13 +308,20 @@ function migrateBody(request: Request) {
if (wasFormUrlEncoded) {
// Convert old-style form-encoded request bodies to new style
const body = typeof request.body === 'string' ? request.body : '';
request.body = newBodyFormUrlEncoded(deconstructQueryStringToParams(body, false));
request.body = {
mimeType: CONTENT_TYPE_FORM_URLENCODED,
params: deconstructQueryStringToParams(typeof request.body === 'string' ? request.body : '', false),
};
} else if (!request.body && !contentType) {
request.body = {};
} else {
const body: string = typeof request.body === 'string' ? request.body : '';
request.body = newBodyRaw(body, contentType);
const rawBody: string = typeof request.body === 'string' ? request.body : '';
request.body = typeof contentType !== 'string' ? {
text: rawBody,
} : {
mimeType: contentType.split(';')[0],
text: rawBody,
};
}
return request;

View File

@ -157,7 +157,7 @@ export async function getLatestForRequest(
return response || null;
}
export async function create(patch: Record<string, any> = {}, maxResponses = 20) {
export async function create(patch: Partial<Response> = {}, maxResponses = 20): Promise<Response> {
if (!patch.parentId) {
throw new Error('New Response missing `parentId`');
}
@ -168,17 +168,12 @@ export async function create(patch: Record<string, any> = {}, maxResponses = 20)
const requestVersion = request ? await models.requestVersion.create(request) : null;
patch.requestVersionId = requestVersion ? requestVersion._id : null;
// Filter responses by environment if setting is enabled
const query: Record<string, any> = {
const shouldQueryByEnvId = (await models.settings.getOrCreate()).filterResponsesByEnv && patch.hasOwnProperty('environmentId');
const query = {
parentId,
...(shouldQueryByEnvId ? { environmentId: patch.environmentId } : {}),
};
if (
(await models.settings.getOrCreate()).filterResponsesByEnv &&
patch.hasOwnProperty('environmentId')
) {
query.environmentId = patch.environmentId;
}
// Delete all other responses before creating the new one
const allResponses = await db.findMostRecentlyModified<Response>(type, query, Math.max(1, maxResponses));
const recentIds = allResponses.map(r => r._id);

View File

@ -11,12 +11,12 @@ import {
AUTH_OAUTH_2,
} from '../common/constants';
import type { RenderedRequest } from '../common/render';
import { RequestAuthentication, RequestParameter } from '../models/request';
import { AuthTypeOAuth2, RequestAuthentication, RequestParameter } from '../models/request';
import { COOKIE, HEADER, QUERY_PARAMS } from './api-key/constants';
import { getBasicAuthHeader } from './basic-auth/get-header';
import { getBearerAuthHeader } from './bearer-auth/get-header';
import getOAuth1Token from './o-auth-1/get-token';
import getOAuth2Token from './o-auth-2/get-token';
import { getOAuth2Token } from './o-auth-2/get-token';
interface Header {
name: string;
@ -64,7 +64,7 @@ export async function getAuthHeader(renderedRequest: RenderedRequest, url: strin
// pretending we are fetching a token for the original request. This makes sure
// the same tokens are used for schema fetching. See issue #835 on GitHub.
const tokenId = requestId.match(/\.graphql$/) ? requestId.replace(/\.graphql$/, '') : requestId;
const oAuth2Token = await getOAuth2Token(tokenId, authentication);
const oAuth2Token = await getOAuth2Token(tokenId, authentication as AuthTypeOAuth2);
if (oAuth2Token) {
const token = oAuth2Token.accessToken;

View File

@ -1,307 +0,0 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import fs from 'fs';
import path from 'path';
import { globalBeforeEach } from '../../../__jest__/before-each';
import { getTempDir } from '../../../common/electron-helpers';
import * as network from '../../network';
import getToken from '../grant-authorization-code';
// Mock some test things
const AUTHORIZE_URL = 'https://foo.com/authorizeAuthCode';
const ACCESS_TOKEN_URL = 'https://foo.com/access_token';
const CLIENT_ID = 'client_123';
const CLIENT_SECRET = 'secret_12345456677756343';
const REDIRECT_URI = 'https://foo.com/redirect';
const SCOPE = 'scope_123';
const STATE = 'state_123';
const AUDIENCE = 'https://foo.com/resource';
const RESOURCE = 'foo.com';
describe('authorization_code', () => {
beforeEach(globalBeforeEach);
it('gets token with JSON and basic auth', async () => {
window.main = { authorizeUserInWindow: () => Promise.resolve(`${REDIRECT_URI}?code=code_123&state=${STATE}`) };
const bodyPath = path.join(getTempDir(), 'foo.response');
fs.writeFileSync(
bodyPath,
JSON.stringify({
access_token: 'token_123',
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
}),
);
network.sendWithSettings = jest.fn(() => ({
bodyPath,
bodyCompression: '',
parentId: 'req_1',
statusCode: 200,
headers: [
{
name: 'Content-Type',
value: 'application/json',
},
],
}));
const result = await getToken(
'req_1',
AUTHORIZE_URL,
ACCESS_TOKEN_URL,
false,
CLIENT_ID,
CLIENT_SECRET,
REDIRECT_URI,
SCOPE,
STATE,
AUDIENCE,
RESOURCE,
);
// Check the request to fetch the token
expect(network.sendWithSettings.mock.calls).toEqual([
[
'req_1',
{
url: ACCESS_TOKEN_URL,
method: 'POST',
body: {
mimeType: 'application/x-www-form-urlencoded',
params: [
{
name: 'grant_type',
value: 'authorization_code',
},
{
name: 'code',
value: 'code_123',
},
{
name: 'redirect_uri',
value: REDIRECT_URI,
},
{
name: 'state',
value: STATE,
},
{
name: 'audience',
value: AUDIENCE,
},
{
name: 'resource',
value: RESOURCE,
},
],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
{
name: 'Authorization',
value: 'Basic Y2xpZW50XzEyMzpzZWNyZXRfMTIzNDU0NTY2Nzc3NTYzNDM=',
},
],
},
],
]);
// Check the expected value
expect(result).toEqual({
access_token: 'token_123',
id_token: null,
refresh_token: null,
expires_in: null,
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
error: null,
error_uri: null,
error_description: null,
xResponseId: expect.stringMatching(/^res_/),
});
});
it('gets token with urlencoded and body auth', async () => {
window.main = { authorizeUserInWindow: () => Promise.resolve(`${REDIRECT_URI}?code=code_123&state=${STATE}`) };
const bodyPath = path.join(getTempDir(), 'foo.response');
fs.writeFileSync(
bodyPath,
JSON.stringify({
access_token: 'token_123',
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
}),
);
network.sendWithSettings = jest.fn(() => ({
bodyPath,
bodyCompression: '',
parentId: 'req_1',
statusCode: 200,
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
],
}));
const result = await getToken(
'req_1',
AUTHORIZE_URL,
ACCESS_TOKEN_URL,
true,
CLIENT_ID,
CLIENT_SECRET,
REDIRECT_URI,
SCOPE,
STATE,
AUDIENCE,
RESOURCE,
);
// Check the request to fetch the token
expect(network.sendWithSettings.mock.calls).toEqual([
[
'req_1',
{
url: ACCESS_TOKEN_URL,
method: 'POST',
body: {
mimeType: 'application/x-www-form-urlencoded',
params: [
{
name: 'grant_type',
value: 'authorization_code',
},
{
name: 'code',
value: 'code_123',
},
{
name: 'redirect_uri',
value: REDIRECT_URI,
},
{
name: 'state',
value: STATE,
},
{
name: 'audience',
value: AUDIENCE,
},
{
name: 'resource',
value: RESOURCE,
},
{
name: 'client_id',
value: CLIENT_ID,
},
{
name: 'client_secret',
value: CLIENT_SECRET,
},
],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
],
},
],
]);
// Check the expected value
expect(result).toEqual({
access_token: 'token_123',
id_token: null,
refresh_token: null,
expires_in: null,
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
error: null,
error_uri: null,
error_description: null,
xResponseId: expect.stringMatching(/^res_/),
});
});
it('uses PKCE', async () => {
window.main = { authorizeUserInWindow: () => Promise.resolve(`${REDIRECT_URI}?code=code_123&state=${STATE}`) };
const bodyPath = path.join(getTempDir(), 'foo.response');
fs.writeFileSync(
bodyPath,
JSON.stringify({
access_token: 'token_123',
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
}),
);
network.sendWithSettings = jest.fn(() => ({
bodyPath,
bodyCompression: '',
parentId: 'req_1',
statusCode: 200,
headers: [
{
name: 'Content-Type',
value: 'application/json',
},
],
}));
const result = await getToken(
'req_1',
AUTHORIZE_URL,
ACCESS_TOKEN_URL,
false,
CLIENT_ID,
CLIENT_SECRET,
REDIRECT_URI,
SCOPE,
STATE,
AUDIENCE,
RESOURCE,
true,
);
// Check the request to fetch the token
expect(network.sendWithSettings.mock.calls[0][1].body.params).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'code_verifier',
}),
]),
);
// Check the expected value
expect(result).toEqual({
access_token: 'token_123',
id_token: null,
refresh_token: null,
expires_in: null,
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
error: null,
error_uri: null,
error_description: null,
xResponseId: expect.stringMatching(/^res_/),
});
});
});

View File

@ -1,218 +0,0 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import fs from 'fs';
import path from 'path';
import { globalBeforeEach } from '../../../__jest__/before-each';
import { getTempDir } from '../../../common/electron-helpers';
import * as network from '../../network';
import getToken from '../grant-client-credentials';
// Mock some test things
const ACCESS_TOKEN_URL = 'https://foo.com/access_token';
const CLIENT_ID = 'client_123';
const CLIENT_SECRET = 'secret_12345456677756343';
const SCOPE = 'scope_123';
const AUDIENCE = 'https://foo.com/userinfo';
const RESOURCE = 'https://foo.com/resource';
describe('client_credentials', () => {
beforeEach(globalBeforeEach);
it('gets token with JSON and basic auth', async () => {
const bodyPath = path.join(getTempDir(), 'foo.response');
fs.writeFileSync(
bodyPath,
JSON.stringify({
access_token: 'token_123',
refresh_token: 'token_456',
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
}),
);
network.sendWithSettings = jest.fn(() => ({
bodyPath,
bodyCompression: '',
parentId: 'req_1',
statusCode: 200,
headers: [
{
name: 'Content-Type',
value: 'application/json',
},
],
}));
const result = await getToken(
'req_1',
ACCESS_TOKEN_URL,
false,
CLIENT_ID,
CLIENT_SECRET,
SCOPE,
AUDIENCE,
RESOURCE,
);
// Check the request to fetch the token
expect(network.sendWithSettings.mock.calls).toEqual([
[
'req_1',
{
url: ACCESS_TOKEN_URL,
method: 'POST',
body: {
mimeType: 'application/x-www-form-urlencoded',
params: [
{
name: 'grant_type',
value: 'client_credentials',
},
{
name: 'scope',
value: SCOPE,
},
{
name: 'audience',
value: AUDIENCE,
},
{
name: 'resource',
value: RESOURCE,
},
],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
{
name: 'Authorization',
value: 'Basic Y2xpZW50XzEyMzpzZWNyZXRfMTIzNDU0NTY2Nzc3NTYzNDM=',
},
],
},
],
]);
// Check the expected value
expect(result).toEqual({
access_token: 'token_123',
id_token: null,
refresh_token: 'token_456',
expires_in: null,
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
error: null,
error_uri: null,
error_description: null,
xResponseId: expect.stringMatching(/^res_/),
});
});
it('gets token with urlencoded and body auth', async () => {
const bodyPath = path.join(getTempDir(), 'req_1.response');
fs.writeFileSync(
bodyPath,
JSON.stringify({
access_token: 'token_123',
refresh_token: 'token_456',
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
}),
);
network.sendWithSettings = jest.fn(() => ({
bodyPath,
bodyCompression: '',
parentId: 'req_1',
statusCode: 200,
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
],
}));
const result = await getToken(
'req_1',
ACCESS_TOKEN_URL,
true,
CLIENT_ID,
CLIENT_SECRET,
SCOPE,
AUDIENCE,
RESOURCE,
);
// Check the request to fetch the token
expect(network.sendWithSettings.mock.calls).toEqual([
[
'req_1',
{
url: ACCESS_TOKEN_URL,
method: 'POST',
body: {
mimeType: 'application/x-www-form-urlencoded',
params: [
{
name: 'grant_type',
value: 'client_credentials',
},
{
name: 'scope',
value: SCOPE,
},
{
name: 'audience',
value: AUDIENCE,
},
{
name: 'resource',
value: RESOURCE,
},
{
name: 'client_id',
value: CLIENT_ID,
},
{
name: 'client_secret',
value: CLIENT_SECRET,
},
],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
],
},
],
]);
// Check the expected value
expect(result).toEqual({
access_token: 'token_123',
id_token: null,
refresh_token: 'token_456',
expires_in: null,
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
resource: RESOURCE,
error: null,
error_uri: null,
error_description: null,
xResponseId: expect.stringMatching(/^res_/),
});
});
});

View File

@ -1,33 +0,0 @@
import { beforeEach, describe, expect, it } from '@jest/globals';
import { globalBeforeEach } from '../../../__jest__/before-each';
import getToken from '../grant-implicit';
// Mock some test things
const AUTHORIZE_URL = 'https://foo.com/authorizeAuthCode';
const CLIENT_ID = 'client_123';
const REDIRECT_URI = 'https://foo.com/redirect';
const AUDIENCE = 'https://foo.com/userinfo';
const SCOPE = 'scope_123';
const STATE = 'state_123';
describe('implicit', () => {
beforeEach(globalBeforeEach);
it('works in default case', async () => {
window.main = { authorizeUserInWindow: () => Promise.resolve(`${REDIRECT_URI}#access_token=token_123&state=${STATE}&foo=bar`) };
const result = await getToken(AUTHORIZE_URL, CLIENT_ID, REDIRECT_URI, SCOPE, STATE, AUDIENCE);
expect(result).toEqual({
access_token: 'token_123',
id_token: null,
token_type: null,
expires_in: null,
scope: null,
state: STATE,
error: null,
error_description: null,
error_uri: null,
});
});
});

View File

@ -1,223 +0,0 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import fs from 'fs';
import path from 'path';
import { globalBeforeEach } from '../../../__jest__/before-each';
import { getTempDir } from '../../../common/electron-helpers';
import * as network from '../../network';
import getToken from '../grant-password';
// Mock some test things
const ACCESS_TOKEN_URL = 'https://foo.com/access_token';
const CLIENT_ID = 'client_123';
const CLIENT_SECRET = 'secret_12345456677756343';
const USERNAME = 'user';
const PASSWORD = 'password';
const SCOPE = 'scope_123';
const AUDIENCE = 'https://foo.com/userinfo';
describe('password', () => {
beforeEach(globalBeforeEach);
it('gets token with JSON and basic auth', async () => {
const bodyPath = path.join(getTempDir(), 'foo.response');
fs.writeFileSync(
bodyPath,
JSON.stringify({
access_token: 'token_123',
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
}),
);
network.sendWithSettings = jest.fn(() => ({
bodyPath,
bodyCompression: '',
parentId: 'req_1',
statusCode: 200,
headers: [
{
name: 'Content-Type',
value: 'application/json',
},
],
}));
const result = await getToken(
'req_1',
ACCESS_TOKEN_URL,
false,
CLIENT_ID,
CLIENT_SECRET,
USERNAME,
PASSWORD,
SCOPE,
AUDIENCE,
);
// Check the request to fetch the token
expect(network.sendWithSettings.mock.calls).toEqual([
[
'req_1',
{
url: ACCESS_TOKEN_URL,
method: 'POST',
body: {
mimeType: 'application/x-www-form-urlencoded',
params: [
{
name: 'grant_type',
value: 'password',
},
{
name: 'username',
value: USERNAME,
},
{
name: 'password',
value: PASSWORD,
},
{
name: 'scope',
value: SCOPE,
},
{
name: 'audience',
value: AUDIENCE,
},
],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
{
name: 'Authorization',
value: 'Basic Y2xpZW50XzEyMzpzZWNyZXRfMTIzNDU0NTY2Nzc3NTYzNDM=',
},
],
},
],
]);
// Check the expected value
expect(result).toEqual({
access_token: 'token_123',
id_token: null,
expires_in: null,
token_type: 'token_type',
refresh_token: null,
scope: SCOPE,
audience: AUDIENCE,
error: null,
error_uri: null,
error_description: null,
xResponseId: expect.stringMatching(/^res_/),
});
});
it('gets token with urlencoded and body auth', async () => {
const bodyPath = path.join(getTempDir(), 'foo.response');
fs.writeFileSync(
bodyPath,
JSON.stringify({
access_token: 'token_123',
token_type: 'token_type',
scope: SCOPE,
audience: AUDIENCE,
}),
);
network.sendWithSettings = jest.fn(() => ({
bodyPath,
bodyCompression: '',
parentId: 'req_1',
statusCode: 200,
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
],
}));
const result = await getToken(
'req_1',
ACCESS_TOKEN_URL,
true,
CLIENT_ID,
CLIENT_SECRET,
USERNAME,
PASSWORD,
SCOPE,
AUDIENCE,
);
// Check the request to fetch the token
expect(network.sendWithSettings.mock.calls).toEqual([
[
'req_1',
{
url: ACCESS_TOKEN_URL,
method: 'POST',
body: {
mimeType: 'application/x-www-form-urlencoded',
params: [
{
name: 'grant_type',
value: 'password',
},
{
name: 'username',
value: USERNAME,
},
{
name: 'password',
value: PASSWORD,
},
{
name: 'scope',
value: SCOPE,
},
{
name: 'audience',
value: AUDIENCE,
},
{
name: 'client_id',
value: CLIENT_ID,
},
{
name: 'client_secret',
value: CLIENT_SECRET,
},
],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
],
},
],
]);
// Check the expected value
expect(result).toEqual({
access_token: 'token_123',
id_token: null,
expires_in: null,
token_type: 'token_type',
refresh_token: null,
scope: SCOPE,
audience: AUDIENCE,
error: null,
error_uri: null,
error_description: null,
xResponseId: expect.stringMatching(/^res_/),
});
});
});

View File

@ -3,63 +3,11 @@ import { mocked } from 'jest-mock';
import { globalBeforeEach } from '../../../__jest__/before-each';
import * as models from '../../../models';
import { authorizeUserInWindow, ChromiumVerificationResult, responseToObject } from '../misc';
import { authorizeUserInWindow, ChromiumVerificationResult } from '../misc';
import { createBWRedirectMock } from './helpers';
const MOCK_AUTHORIZATION_URL = 'https://foo.com';
describe('responseToObject()', () => {
beforeEach(globalBeforeEach);
it('works in the general case', () => {
const body = JSON.stringify({
str: 'hi',
num: 10,
});
const keys = ['str', 'num'];
expect(responseToObject(body, keys)).toEqual({
str: 'hi',
num: 10,
});
});
it('skips things not in keys', () => {
const body = JSON.stringify({
str: 'hi',
num: 10,
other: 'thing',
});
const keys = ['str'];
expect(responseToObject(body, keys)).toEqual({
str: 'hi',
});
});
it('works with things not found', () => {
const body = JSON.stringify({});
const keys = ['str'];
expect(responseToObject(body, keys)).toEqual({
str: null,
});
});
it('works with default values', () => {
const body = JSON.stringify({
str: 'hi',
num: 10,
});
const keys = ['str', 'missing'];
const defaults = {
missing: 'found it!',
str: 'should not see this',
};
expect(responseToObject(body, keys, defaults)).toEqual({
str: 'hi',
missing: 'found it!',
});
});
});
describe('authorizeUserInWindow()', () => {
beforeEach(globalBeforeEach);

View File

@ -7,31 +7,32 @@ export const RESPONSE_TYPE_CODE = 'code';
export const RESPONSE_TYPE_ID_TOKEN = 'id_token';
export const RESPONSE_TYPE_TOKEN = 'token';
export const RESPONSE_TYPE_ID_TOKEN_TOKEN = 'id_token token';
export const P_ACCESS_TOKEN = 'access_token';
export const P_ID_TOKEN = 'id_token';
export const P_CLIENT_ID = 'client_id';
export const P_CLIENT_SECRET = 'client_secret';
export const P_AUDIENCE = 'audience';
export const P_RESOURCE = 'resource';
export const P_CODE_CHALLENGE = 'code_challenge';
export const P_CODE_CHALLENGE_METHOD = 'code_challenge_method';
export const P_CODE_VERIFIER = 'code_verifier';
export const P_CODE = 'code';
export const P_NONCE = 'nonce';
export const P_ERROR = 'error';
export const P_ERROR_DESCRIPTION = 'error_description';
export const P_ERROR_URI = 'error_uri';
export const P_EXPIRES_IN = 'expires_in';
export const P_GRANT_TYPE = 'grant_type';
export const P_PASSWORD = 'password';
export const P_REDIRECT_URI = 'redirect_uri';
export const P_REFRESH_TOKEN = 'refresh_token';
export const P_RESPONSE_TYPE = 'response_type';
export const P_SCOPE = 'scope';
export const P_STATE = 'state';
export const P_TOKEN_TYPE = 'token_type';
export const P_USERNAME = 'username';
export const X_RESPONSE_ID = 'xResponseId';
export const X_ERROR = 'xError';
export type AuthKeys =
'access_token' |
'id_token' |
'client_id' |
'client_secret' |
'audience' |
'resource' |
'code_challenge' |
'code_challenge_method' |
'code_verifier' |
'code' |
'nonce' |
'error' |
'error_description' |
'error_uri' |
'expires_in' |
'grant_type' |
'password' |
'redirect_uri' |
'refresh_token' |
'response_type' |
'scope' |
'state' |
'token_type' |
'username' |
'xError' |
'xResponseId';
export const PKCE_CHALLENGE_S256 = 'S256';
export const PKCE_CHALLENGE_PLAIN = 'plain';

View File

@ -1,229 +1,306 @@
import crypto from 'crypto';
import querystring from 'querystring';
import { escapeRegex } from '../../common/misc';
import * as models from '../../models';
import type { OAuth2Token } from '../../models/o-auth-2-token';
import type { RequestAuthentication } from '../../models/request';
import type { AuthTypeOAuth2, RequestHeader, RequestParameter } from '../../models/request';
import type { Response } from '../../models/response';
import { invariant } from '../../utils/invariant';
import { setDefaultProtocol } from '../../utils/url/protocol';
import { getBasicAuthHeader } from '../basic-auth/get-header';
import { sendWithSettings } from '../network';
import {
AuthKeys,
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_CLIENT_CREDENTIALS,
GRANT_TYPE_IMPLICIT,
GRANT_TYPE_PASSWORD,
P_ACCESS_TOKEN,
P_ERROR,
P_ERROR_DESCRIPTION,
P_ERROR_URI,
P_EXPIRES_IN,
P_ID_TOKEN,
P_REFRESH_TOKEN,
X_ERROR,
X_RESPONSE_ID,
PKCE_CHALLENGE_S256,
RESPONSE_TYPE_CODE,
RESPONSE_TYPE_ID_TOKEN,
RESPONSE_TYPE_ID_TOKEN_TOKEN,
} from './constants';
import getAccessTokenAuthorizationCode from './grant-authorization-code';
import getAccessTokenClientCredentials from './grant-client-credentials';
import getAccessTokenImplicit from './grant-implicit';
import getAccessTokenPassword from './grant-password';
import refreshAccessToken from './refresh-token';
/** Get an OAuth2Token object and also handle storing/saving/refreshing */
import { getOAuthSession } from './misc';
export default async function(
// NOTE
// 1. return valid access token from insomnia db
// 2. send refresh token in order to save and return valid access token
// 3. run a given grant type and save and return valid access token
export const getOAuth2Token = async (
requestId: string,
authentication: RequestAuthentication,
authentication: AuthTypeOAuth2,
forceRefresh = false,
): Promise<OAuth2Token | null> {
switch (authentication.grantType) {
case GRANT_TYPE_AUTHORIZATION_CODE:
return _getOAuth2AuthorizationCodeHeader(requestId, authentication, forceRefresh);
case GRANT_TYPE_CLIENT_CREDENTIALS:
return _getOAuth2ClientCredentialsHeader(requestId, authentication, forceRefresh);
case GRANT_TYPE_IMPLICIT:
return _getOAuth2ImplicitHeader(requestId, authentication, forceRefresh);
case GRANT_TYPE_PASSWORD:
return _getOAuth2PasswordHeader(requestId, authentication, forceRefresh);
default:
return null;
}
}
async function _getOAuth2AuthorizationCodeHeader(
requestId: string,
authentication: RequestAuthentication,
forceRefresh: boolean,
): Promise<OAuth2Token | null> {
const oAuth2Token = await _getAccessToken(requestId, authentication, forceRefresh);
): Promise<OAuth2Token | null> => {
const oAuth2Token = await getExisingAccessTokenAndRefreshIfExpired(requestId, authentication, forceRefresh);
if (oAuth2Token) {
return oAuth2Token;
}
const validGrantType = ['implicit', 'authorization_code', 'password', 'client_credentials'].includes(authentication.grantType);
invariant(validGrantType, `Invalid grant type ${authentication.grantType}`);
if (authentication.grantType === 'implicit') {
invariant(authentication.authorizationUrl, 'Missing authorization URL');
const hasNonce = !authentication.responseType || authentication.responseType === RESPONSE_TYPE_ID_TOKEN_TOKEN || authentication.responseType === RESPONSE_TYPE_ID_TOKEN;
const implicitUrl = new URL(authentication.authorizationUrl);
[
{ name: 'response_type', value: authentication.responseType },
{ name: 'client_id', value: authentication.clientId },
...insertAuthKeyIf('redirect_uri', authentication.redirectUrl),
...insertAuthKeyIf('scope', authentication.scope),
...insertAuthKeyIf('state', authentication.state),
...insertAuthKeyIf('audience', authentication.audience),
...(hasNonce ? [{
name: 'nonce', value: Math.floor(Math.random() * 9999999999999) + 1 + '',
}] : []),
].forEach(p => p.value && implicitUrl.searchParams.append(p.name, p.value));
const redirectedTo = await window.main.authorizeUserInWindow({
url: implicitUrl.toString(),
urlSuccessRegex: /(access_token=|id_token=)/,
urlFailureRegex: /(error=)/,
sessionId: getOAuthSession(),
});
console.log('[oauth2] Detected redirect ' + redirectedTo);
const results = await getAccessTokenAuthorizationCode(
requestId,
authentication.authorizationUrl,
authentication.accessTokenUrl,
authentication.credentialsInBody,
authentication.clientId,
authentication.clientSecret,
authentication.redirectUrl,
authentication.scope,
authentication.state,
authentication.audience,
authentication.resource,
authentication.usePkce,
authentication.pkceMethod,
authentication.origin,
);
return _updateOAuth2Token(requestId, results);
}
const hash = new URL(redirectedTo).hash.slice(1);
invariant(hash, 'No hash found in redirect URL');
const data = Object.fromEntries(new URLSearchParams(hash));
const old = await models.oAuth2Token.getOrCreateByParentId(requestId);
return models.oAuth2Token.update(old, transformNewAccessTokenToOauthModel({
...data,
access_token: data.access_token || data.id_token,
}));
}
invariant(authentication.accessTokenUrl, 'Missing access token URL');
let params: RequestHeader[] = [];
if (authentication.grantType === 'authorization_code') {
invariant(authentication.redirectUrl, 'Missing redirect URL');
invariant(authentication.authorizationUrl, 'Invalid authorization URL');
async function _getOAuth2ClientCredentialsHeader(
requestId: string,
authentication: RequestAuthentication,
forceRefresh: boolean,
): Promise<OAuth2Token | null> {
const oAuth2Token = await _getAccessToken(requestId, authentication, forceRefresh);
if (oAuth2Token) {
return oAuth2Token;
const codeVerifier = authentication.usePkce ? encodePKCE(crypto.randomBytes(32)) : '';
const codeChallenge = authentication.pkceMethod !== PKCE_CHALLENGE_S256 ? codeVerifier : encodePKCE(crypto.createHash('sha256').update(codeVerifier).digest());
const authCodeUrl = new URL(authentication.authorizationUrl);
[
{ name: 'response_type', value: RESPONSE_TYPE_CODE },
{ name: 'client_id', value: authentication.clientId },
...insertAuthKeyIf('redirect_uri', authentication.redirectUrl),
...insertAuthKeyIf('scope', authentication.scope),
...insertAuthKeyIf('state', authentication.state),
...insertAuthKeyIf('audience', authentication.audience),
...insertAuthKeyIf('resource', authentication.resource),
...(codeChallenge ? [
{ name: 'code_challenge', value: codeChallenge },
{ name: 'code_challenge_method', value: authentication.pkceMethod },
] : []),
].forEach(p => p.value && authCodeUrl.searchParams.append(p.name, p.value));
const redirectedTo = await window.main.authorizeUserInWindow({
url: authCodeUrl.toString(),
urlSuccessRegex: new RegExp(`${escapeRegex(authentication.redirectUrl)}.*(code=)`, 'i'),
urlFailureRegex: new RegExp(`${escapeRegex(authentication.redirectUrl)}.*(error=)`, 'i'),
sessionId: getOAuthSession(),
});
console.log('[oauth2] Detected redirect ' + redirectedTo);
const redirectParams = Object.fromEntries(new URL(redirectedTo).searchParams);
if (redirectParams.error) {
const code = redirectParams.error;
const msg = redirectParams.error_description;
const uri = redirectParams.error_uri;
throw new Error(`OAuth 2.0 Error ${code}\n\n${msg}\n\n${uri}`);
}
params = [
{ name: 'grant_type', value: GRANT_TYPE_AUTHORIZATION_CODE },
{ name: 'code', value: redirectParams.code },
...insertAuthKeyIf('redirect_uri', authentication.redirectUrl),
...insertAuthKeyIf('state', authentication.state),
...insertAuthKeyIf('audience', authentication.audience),
...insertAuthKeyIf('resource', authentication.resource),
...insertAuthKeyIf('code_verifier', codeVerifier),
];
} else if (authentication.grantType === 'password') {
params = [
{ name: 'grant_type', value: 'password' },
...insertAuthKeyIf('username', authentication.username),
...insertAuthKeyIf('password', authentication.password),
...insertAuthKeyIf('scope', authentication.scope),
...insertAuthKeyIf('audience', authentication.audience),
];
} else if (authentication.grantType === 'client_credentials') {
params = [
{ name: 'grant_type', value: 'client_credentials' },
...insertAuthKeyIf('scope', authentication.scope),
...insertAuthKeyIf('audience', authentication.audience),
...insertAuthKeyIf('resource', authentication.resource),
];
}
const headers = authentication.origin ? [{ name: 'Origin', value: authentication.origin }] : [];
if (authentication.credentialsInBody) {
params = [
...params,
...insertAuthKeyIf('client_id', authentication.clientId),
...insertAuthKeyIf('client_secret', authentication.clientSecret),
];
} else {
headers.push(getBasicAuthHeader(authentication.clientId, authentication.clientSecret));
}
const results = await getAccessTokenClientCredentials(
requestId,
authentication.accessTokenUrl,
authentication.credentialsInBody,
authentication.clientId,
authentication.clientSecret,
authentication.scope,
authentication.audience,
authentication.resource,
);
return _updateOAuth2Token(requestId, results);
}
const response = await sendAccessTokenRequest(requestId, authentication, params, headers);
const old = await models.oAuth2Token.getOrCreateByParentId(requestId);
return models.oAuth2Token.update(old, transformNewAccessTokenToOauthModel(
oauthResponseToAccessToken(authentication.accessTokenUrl, response)
));
};
// 1. get token from db and return if valid
// 2. if expired, and no refresh token return null
// 3. run refresh token query and return new token or null if it fails
async function _getOAuth2ImplicitHeader(
async function getExisingAccessTokenAndRefreshIfExpired(
requestId: string,
authentication: RequestAuthentication,
authentication: AuthTypeOAuth2,
forceRefresh: boolean,
): Promise<OAuth2Token | null> {
const oAuth2Token = await _getAccessToken(requestId, authentication, forceRefresh);
if (oAuth2Token) {
return oAuth2Token;
}
const results = await getAccessTokenImplicit(
requestId,
authentication.authorizationUrl,
authentication.clientId,
authentication.responseType,
authentication.redirectUrl,
authentication.scope,
authentication.state,
authentication.audience,
);
return _updateOAuth2Token(requestId, results);
}
async function _getOAuth2PasswordHeader(
requestId: string,
authentication: RequestAuthentication,
forceRefresh: boolean,
): Promise<OAuth2Token | null> {
const oAuth2Token = await _getAccessToken(requestId, authentication, forceRefresh);
if (oAuth2Token) {
return oAuth2Token;
}
const results = await getAccessTokenPassword(
requestId,
authentication.accessTokenUrl,
authentication.credentialsInBody,
authentication.clientId,
authentication.clientSecret,
authentication.username,
authentication.password,
authentication.scope,
authentication.audience,
);
return _updateOAuth2Token(requestId, results);
}
async function _getAccessToken(
requestId: string,
authentication: RequestAuthentication,
forceRefresh: boolean,
): Promise<OAuth2Token | null> {
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// See if we have a token already //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
const token: OAuth2Token | null = await models.oAuth2Token.getByParentId(requestId);
if (!token) {
return null;
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// Check if the token needs refreshing //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// Refresh tokens are part of Auth Code, Password
const expiresAt = token.expiresAt || Infinity;
const isExpired = Date.now() > expiresAt;
if (!isExpired && !forceRefresh) {
return token;
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// Refresh the token if necessary //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// We've expired, but don't have a refresh token, so tell caller to fetch new
// access token
if (!token.refreshToken) {
return null;
}
const refreshResults = await refreshAccessToken(
requestId,
authentication.accessTokenUrl,
authentication.credentialsInBody,
authentication.clientId,
authentication.clientSecret,
token.refreshToken,
authentication.scope,
authentication.origin,
);
let params = [
{ name: 'grant_type', value: 'refresh_token' },
{ name: 'refresh_token', value: token.refreshToken },
...insertAuthKeyIf('scope', authentication.scope),
];
const headers = [];
if (authentication.credentialsInBody) {
params = [
...params,
...insertAuthKeyIf('client_id', authentication.clientId),
...insertAuthKeyIf('client_secret', authentication.clientSecret),
];
} else {
headers.push(getBasicAuthHeader(authentication.clientId, authentication.clientSecret));
}
const response = await sendAccessTokenRequest(requestId, authentication, params, []);
// If we didn't receive an access token it means the refresh token didn't succeed,
// so we tell caller to fetch brand new access and refresh tokens.
if (!refreshResults.access_token) {
const statusCode = response.statusCode || 0;
const bodyBuffer = models.response.getBodyBuffer(response);
if (statusCode === 401) {
// If the refresh token was rejected due an unauthorized request, we will
// return a null access_token to trigger an authentication request to fetch
// brand new refresh and access tokens.
const old = await models.oAuth2Token.getOrCreateByParentId(requestId);
models.oAuth2Token.update(old, transformNewAccessTokenToOauthModel({ access_token: null }));
return null;
}
const isSuccessful = statusCode >= 200 && statusCode < 300;
const hasBodyAndIsError = bodyBuffer && statusCode === 400;
if (isSuccessful) {
if (hasBodyAndIsError) {
const body = tryToParse(bodyBuffer.toString());
// If the refresh token was rejected due an oauth2 invalid_grant error, we will
// return a null access_token to trigger an authentication request to fetch
// brand new refresh and access tokens.
if (body?.error === 'invalid_grant') {
console.log(`[oauth2] Refresh token rejected due to invalid_grant error: ${body.error_description}`);
const old = await models.oAuth2Token.getOrCreateByParentId(requestId);
models.oAuth2Token.update(old, transformNewAccessTokenToOauthModel({ access_token: null }));
return null;
}
}
// ~~~~~~~~~~~~~ //
// Update the DB //
// ~~~~~~~~~~~~~ //
return _updateOAuth2Token(requestId, refreshResults);
throw new Error(`[oauth2] Failed to refresh token url=${authentication.accessTokenUrl} status=${statusCode}`);
}
invariant(bodyBuffer, `[oauth2] No body returned from ${authentication.accessTokenUrl}`);
const data = tryToParse(bodyBuffer.toString());
if (!data) {
return null;
}
const old = await models.oAuth2Token.getOrCreateByParentId(requestId);
return models.oAuth2Token.update(old, transformNewAccessTokenToOauthModel({
...data,
refresh_token: data.refresh_token || token.refreshToken,
}));
}
async function _updateOAuth2Token(
requestId: string,
authResults: Record<string, any>,
): Promise<OAuth2Token> {
const oAuth2Token = await models.oAuth2Token.getOrCreateByParentId(requestId);
// Calculate expiry date
const expiresIn = authResults[P_EXPIRES_IN];
const expiresAt = expiresIn ? Date.now() + expiresIn * 1000 : null;
return models.oAuth2Token.update(oAuth2Token, {
expiresAt,
refreshToken: authResults[P_REFRESH_TOKEN] || null,
accessToken: authResults[P_ACCESS_TOKEN] || null,
identityToken: authResults[P_ID_TOKEN] || null,
error: authResults[P_ERROR] || null,
errorDescription: authResults[P_ERROR_DESCRIPTION] || null,
errorUri: authResults[P_ERROR_URI] || null,
export const oauthResponseToAccessToken = (accessTokenUrl: string, response: Response) => {
const bodyBuffer = models.response.getBodyBuffer(response);
if (!bodyBuffer) {
return {
xResponseId: response._id,
xError: `No body returned from ${accessTokenUrl}`,
};
}
if (response.statusCode < 200 || response.statusCode >= 300) {
return {
xResponseId: response._id,
xError: `Failed to fetch token url=${accessTokenUrl} status=${response.statusCode}`,
};
}
const body = bodyBuffer.toString('utf8');
const data = tryToParse(body);
return {
...data,
xResponseId: response._id,
};
};
const transformNewAccessTokenToOauthModel = (accessToken: Partial<Record<AuthKeys, string | null>>): Partial<OAuth2Token> => {
const expiry = accessToken.expires_in ? +accessToken.expires_in : 0;
return {
// Calculate expiry date
expiresAt: accessToken.expires_in ? Date.now() + expiry * 1000 : null,
refreshToken: accessToken.refresh_token || undefined,
accessToken: accessToken.access_token || undefined,
identityToken: accessToken.id_token || undefined,
error: accessToken.error || undefined,
errorDescription: accessToken.error_description || undefined,
errorUri: accessToken.error_uri || undefined,
// Special Cases
xResponseId: authResults[X_RESPONSE_ID] || null,
xError: authResults[X_ERROR] || null,
xResponseId: accessToken.xResponseId || null,
xError: accessToken.xError || null,
};
};
const sendAccessTokenRequest = async (requestId: string, authentication: AuthTypeOAuth2, params: RequestParameter[], headers: RequestHeader[]) => {
invariant(authentication.accessTokenUrl, 'Missing access token URL');
const responsePatch = await sendWithSettings(requestId, {
headers: [
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
{ name: 'Accept', value: 'application/x-www-form-urlencoded, application/json' },
...headers,
],
url: setDefaultProtocol(authentication.accessTokenUrl),
method: 'POST',
body: {
mimeType: 'application/x-www-form-urlencoded',
params,
},
});
}
return await models.response.create(responsePatch);
};
export const encodePKCE = (buffer: Buffer) => {
return buffer.toString('base64')
// The characters + / = are reserved for PKCE as per the RFC,
// so we replace them with unreserved characters
// Docs: https://tools.ietf.org/html/rfc7636#section-4.2
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};
const tryToParse = (body: string): Record<string, any> | null => {
try {
return JSON.parse(body);
} catch (err) { }
try {
// NOTE: parse does not return a JS Object, so
// we cannot use hasOwnProperty on it
return querystring.parse(body);
} catch (err) { }
return null;
};
const insertAuthKeyIf = (name: AuthKeys, value?: string) => value ? [{ name, value }] : [];

View File

@ -1,297 +0,0 @@
import crypto from 'crypto';
import { parse as urlParse } from 'url';
import { escapeRegex } from '../../common/misc';
import * as models from '../../models/index';
import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../utils/url/querystring';
import { getBasicAuthHeader } from '../basic-auth/get-header';
import { sendWithSettings } from '../network';
import * as c from './constants';
import { getOAuthSession, responseToObject } from './misc';
export default async function(
requestId: string,
authorizeUrl: string,
accessTokenUrl: string,
credentialsInBody: boolean,
clientId: string,
clientSecret: string,
redirectUri = '',
scope = '',
state = '',
audience = '',
resource = '',
usePkce = false,
pkceMethod = c.PKCE_CHALLENGE_S256,
origin = '',
): Promise<Record<string, any>> {
if (!authorizeUrl) {
throw new Error('Invalid authorization URL');
}
if (!accessTokenUrl) {
throw new Error('Invalid access token URL');
}
let codeVerifier = '';
let codeChallenge = '';
if (usePkce) {
// @ts-expect-error -- TSCONVERSION
codeVerifier = _base64UrlEncode(crypto.randomBytes(32));
if (pkceMethod === c.PKCE_CHALLENGE_S256) {
// @ts-expect-error -- TSCONVERSION
codeChallenge = _base64UrlEncode(crypto.createHash('sha256').update(codeVerifier).digest());
} else {
codeChallenge = codeVerifier;
}
}
const authorizeResults = await _authorize(
authorizeUrl,
clientId,
redirectUri,
scope,
state,
audience,
resource,
codeChallenge,
pkceMethod
);
// Handle the error
if (authorizeResults[c.P_ERROR]) {
const code = authorizeResults[c.P_ERROR];
const msg = authorizeResults[c.P_ERROR_DESCRIPTION];
const uri = authorizeResults[c.P_ERROR_URI];
throw new Error(`OAuth 2.0 Error ${code}\n\n${msg}\n\n${uri}`);
}
return _getToken(
requestId,
accessTokenUrl,
credentialsInBody,
clientId,
clientSecret,
// @ts-expect-error -- unsound typing
authorizeResults[c.P_CODE],
redirectUri,
state,
audience,
resource,
codeVerifier,
origin,
);
}
async function _authorize(
url: string,
clientId: string,
redirectUri = '',
scope = '',
state = '',
audience = '',
resource = '',
codeChallenge = '',
pkceMethod = '',
) {
const params = [
{
name: c.P_RESPONSE_TYPE,
value: c.RESPONSE_TYPE_CODE,
},
{
name: c.P_CLIENT_ID,
value: clientId,
},
];
// Add optional params
redirectUri &&
params.push({
name: c.P_REDIRECT_URI,
value: redirectUri,
});
scope &&
params.push({
name: c.P_SCOPE,
value: scope,
});
state &&
params.push({
name: c.P_STATE,
value: state,
});
audience &&
params.push({
name: c.P_AUDIENCE,
value: audience,
});
resource &&
params.push({
name: c.P_RESOURCE,
value: resource,
});
if (codeChallenge) {
params.push({
name: c.P_CODE_CHALLENGE,
value: codeChallenge,
});
params.push({
name: c.P_CODE_CHALLENGE_METHOD,
value: pkceMethod,
});
}
// Add query params to URL
const qs = buildQueryStringFromParams(params);
const finalUrl = joinUrlAndQueryString(url, qs);
const urlSuccessRegex = new RegExp(`${escapeRegex(redirectUri)}.*(code=)`, 'i');
const urlFailureRegex = new RegExp(`${escapeRegex(redirectUri)}.*(error=)`, 'i');
const sessionId = getOAuthSession();
const redirectedTo = await window.main.authorizeUserInWindow({ url: finalUrl, urlSuccessRegex, urlFailureRegex, sessionId });
console.log('[oauth2] Detected redirect ' + redirectedTo);
const { query } = urlParse(redirectedTo);
return responseToObject(query, [
c.P_CODE,
c.P_STATE,
c.P_ERROR,
c.P_ERROR_DESCRIPTION,
c.P_ERROR_URI,
]);
}
async function _getToken(
requestId: string,
url: string,
credentialsInBody: boolean,
clientId: string,
clientSecret: string,
code: string,
redirectUri = '',
state = '',
audience = '',
resource = '',
codeVerifier = '',
origin = '',
): Promise<Record<string, any>> {
const params = [
{
name: c.P_GRANT_TYPE,
value: c.GRANT_TYPE_AUTHORIZATION_CODE,
},
{
name: c.P_CODE,
value: code,
},
];
// Add optional params
redirectUri &&
params.push({
name: c.P_REDIRECT_URI,
value: redirectUri,
});
state &&
params.push({
name: c.P_STATE,
value: state,
});
audience &&
params.push({
name: c.P_AUDIENCE,
value: audience,
});
resource &&
params.push({
name: c.P_RESOURCE,
value: resource,
});
codeVerifier &&
params.push({
name: c.P_CODE_VERIFIER,
value: codeVerifier,
});
const headers = [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
];
if (credentialsInBody) {
params.push({
name: c.P_CLIENT_ID,
value: clientId,
});
params.push({
name: c.P_CLIENT_SECRET,
value: clientSecret,
});
} else {
headers.push(getBasicAuthHeader(clientId, clientSecret));
}
if (origin) {
headers.push({ name: 'Origin', value: origin });
}
const responsePatch = await sendWithSettings(requestId, {
headers,
url,
method: 'POST',
body: models.request.newBodyFormUrlEncoded(params),
});
const response = await models.response.create(responsePatch);
// @ts-expect-error -- TSCONVERSION
const bodyBuffer = models.response.getBodyBuffer(response);
if (!bodyBuffer) {
return {
[c.X_ERROR]: `No body returned from ${url}`,
[c.X_RESPONSE_ID]: response._id,
};
}
// @ts-expect-error -- TSCONVERSION
const statusCode = response.statusCode || 0;
if (statusCode < 200 || statusCode >= 300) {
return {
[c.X_ERROR]: `Failed to fetch token url=${url} status=${statusCode}`,
[c.X_RESPONSE_ID]: response._id,
};
}
const results = responseToObject(bodyBuffer.toString('utf8'), [
c.P_ACCESS_TOKEN,
c.P_ID_TOKEN,
c.P_REFRESH_TOKEN,
c.P_EXPIRES_IN,
c.P_TOKEN_TYPE,
c.P_SCOPE,
c.P_AUDIENCE,
c.P_RESOURCE,
c.P_ERROR,
c.P_ERROR_URI,
c.P_ERROR_DESCRIPTION,
]);
results[c.X_RESPONSE_ID] = response._id;
return results;
}
function _base64UrlEncode(str: string) {
// @ts-expect-error -- TSCONVERSION appears to be genuine
return str.toString('base64')
// The characters + / = are reserved for PKCE as per the RFC,
// so we replace them with unreserved characters
// Docs: https://tools.ietf.org/html/rfc7636#section-4.2
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}

View File

@ -1,107 +0,0 @@
import * as models from '../../models/index';
import { setDefaultProtocol } from '../../utils/url/protocol';
import { getBasicAuthHeader } from '../basic-auth/get-header';
import { sendWithSettings } from '../network';
import * as c from './constants';
import { responseToObject } from './misc';
export default async function(
requestId: string,
accessTokenUrl: string,
credentialsInBody: boolean,
clientId: string,
clientSecret: string,
scope = '',
audience = '',
resource = '',
): Promise<Record<string, any>> {
const params = [
{
name: c.P_GRANT_TYPE,
value: c.GRANT_TYPE_CLIENT_CREDENTIALS,
},
];
// Add optional params
scope &&
params.push({
name: c.P_SCOPE,
value: scope,
});
audience &&
params.push({
name: c.P_AUDIENCE,
value: audience,
});
resource &&
params.push({
name: c.P_RESOURCE,
value: resource,
});
const headers = [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
];
if (credentialsInBody) {
params.push({
name: c.P_CLIENT_ID,
value: clientId,
});
params.push({
name: c.P_CLIENT_SECRET,
value: clientSecret,
});
} else {
headers.push(getBasicAuthHeader(clientId, clientSecret));
}
const url = setDefaultProtocol(accessTokenUrl);
const responsePatch = await sendWithSettings(requestId, {
headers,
url,
method: 'POST',
body: models.request.newBodyFormUrlEncoded(params),
});
const response = await models.response.create(responsePatch);
// @ts-expect-error -- TSCONVERSION
const bodyBuffer = models.response.getBodyBuffer(response);
if (!bodyBuffer) {
return {
[c.X_ERROR]: `No body returned from ${url}`,
[c.X_RESPONSE_ID]: response._id,
};
}
// @ts-expect-error -- TSCONVERSION
const statusCode = response.statusCode || 0;
if (statusCode < 200 || statusCode >= 300) {
return {
[c.X_ERROR]: `Failed to fetch token url=${url} status=${statusCode}`,
[c.X_RESPONSE_ID]: response._id,
};
}
const results = responseToObject(bodyBuffer.toString('utf8'), [
c.P_ACCESS_TOKEN,
c.P_ID_TOKEN,
c.P_REFRESH_TOKEN,
c.P_TOKEN_TYPE,
c.P_EXPIRES_IN,
c.P_SCOPE,
c.P_AUDIENCE,
c.P_RESOURCE,
c.P_ERROR,
c.P_ERROR_URI,
c.P_ERROR_DESCRIPTION,
]);
results[c.X_RESPONSE_ID] = response._id;
return results;
}

View File

@ -1,87 +0,0 @@
import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../utils/url/querystring';
import * as c from './constants';
import { getOAuthSession, responseToObject } from './misc';
export default async function(
_requestId: string,
authorizationUrl: string,
clientId: string,
responseType: string = c.RESPONSE_TYPE_TOKEN,
redirectUri = '',
scope = '',
state = '',
audience = '',
): Promise<Record<string, any>> {
const params = [
{
name: c.P_RESPONSE_TYPE,
value: responseType,
},
{
name: c.P_CLIENT_ID,
value: clientId,
},
];
// Add optional params
if (
responseType === c.RESPONSE_TYPE_ID_TOKEN_TOKEN ||
responseType === c.RESPONSE_TYPE_ID_TOKEN
) {
const nonce = Math.floor(Math.random() * 9999999999999) + 1;
params.push({
name: c.P_NONCE,
// @ts-expect-error -- TSCONVERSION
value: nonce,
});
}
redirectUri &&
params.push({
name: c.P_REDIRECT_URI,
value: redirectUri,
});
scope &&
params.push({
name: c.P_SCOPE,
value: scope,
});
state &&
params.push({
name: c.P_STATE,
value: state,
});
audience &&
params.push({
name: c.P_AUDIENCE,
value: audience,
});
// Add query params to URL
const qs = buildQueryStringFromParams(params);
const finalUrl = joinUrlAndQueryString(authorizationUrl, qs);
const urlSuccessRegex = /(access_token=|id_token=)/;
const urlFailureRegex = /(error=)/;
const sessionId = getOAuthSession();
const redirectedTo = await window.main.authorizeUserInWindow({ url: finalUrl, urlSuccessRegex, urlFailureRegex, sessionId });
const fragment = redirectedTo.split('#')[1];
if (fragment) {
const results = responseToObject(fragment, [
c.P_ACCESS_TOKEN,
c.P_ID_TOKEN,
c.P_TOKEN_TYPE,
c.P_EXPIRES_IN,
c.P_SCOPE,
c.P_STATE,
c.P_ERROR,
c.P_ERROR_DESCRIPTION,
c.P_ERROR_URI,
]);
results[c.P_ACCESS_TOKEN] = results[c.P_ACCESS_TOKEN] || results[c.P_ID_TOKEN];
return results;
} else {
// Bad redirect
return {};
}
}

View File

@ -1,110 +0,0 @@
import * as models from '../../models/index';
import { setDefaultProtocol } from '../../utils/url/protocol';
import { getBasicAuthHeader } from '../basic-auth/get-header';
import * as network from '../network';
import * as c from './constants';
import { responseToObject } from './misc';
export default async function(
requestId: string,
accessTokenUrl: string,
credentialsInBody: boolean,
clientId: string,
clientSecret: string,
username: string,
password: string,
scope = '',
audience = '',
): Promise<Record<string, any>> {
const params = [
{
name: c.P_GRANT_TYPE,
value: c.GRANT_TYPE_PASSWORD,
},
{
name: c.P_USERNAME,
value: username,
},
{
name: c.P_PASSWORD,
value: password,
},
];
// Add optional params
scope &&
params.push({
name: c.P_SCOPE,
value: scope,
});
audience &&
params.push({
name: c.P_AUDIENCE,
value: audience,
});
const headers = [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
];
if (credentialsInBody) {
params.push({
name: c.P_CLIENT_ID,
value: clientId,
});
params.push({
name: c.P_CLIENT_SECRET,
value: clientSecret,
});
} else {
headers.push(getBasicAuthHeader(clientId, clientSecret));
}
const url = setDefaultProtocol(accessTokenUrl);
const responsePatch = await network.sendWithSettings(requestId, {
url,
headers,
method: 'POST',
body: models.request.newBodyFormUrlEncoded(params),
});
const response = await models.response.create(responsePatch);
// @ts-expect-error -- TSCONVERSION
const bodyBuffer = models.response.getBodyBuffer(response);
if (!bodyBuffer) {
return {
[c.X_ERROR]: `No body returned from ${url}`,
[c.X_RESPONSE_ID]: response._id,
};
}
// @ts-expect-error -- TSCONVERSION
const statusCode = response.statusCode || 0;
if (statusCode < 200 || statusCode >= 300) {
return {
[c.X_ERROR]: `Failed to fetch token url=${url} status=${statusCode}`,
[c.X_RESPONSE_ID]: response._id,
};
}
const results = responseToObject(bodyBuffer.toString(), [
c.P_ACCESS_TOKEN,
c.P_ID_TOKEN,
c.P_TOKEN_TYPE,
c.P_EXPIRES_IN,
c.P_REFRESH_TOKEN,
c.P_SCOPE,
c.P_AUDIENCE,
c.P_ERROR,
c.P_ERROR_URI,
c.P_ERROR_DESCRIPTION,
]);
results[c.X_RESPONSE_ID] = response._id;
return results;
}

View File

@ -1,5 +1,4 @@
import electron from 'electron';
import querystring from 'querystring';
import { v4 as uuidv4 } from 'uuid';
import * as models from '../../models/index';
@ -22,45 +21,6 @@ export function initNewOAuthSession() {
return authWindowSessionId;
}
export function responseToObject(body: string | null, keys: string[], defaults: Record<string, string | string[]> = {}) {
let data: querystring.ParsedUrlQuery | null = null;
try {
// TODO: remove non-null assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data = JSON.parse(body!);
} catch (err) {}
if (!data) {
try {
// NOTE: parse does not return a JS Object, so
// we cannot use hasOwnProperty on it
// TODO: remove non-null assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data = querystring.parse(body!);
} catch (err) {}
}
// Shouldn't happen but we'll check anyway
if (!data) {
data = {};
}
const results: Record<string, string | string[] | null | undefined> = {};
for (const key of keys) {
if (data[key] !== undefined) {
results[key] = data[key];
} else if (defaults?.hasOwnProperty(key)) {
results[key] = defaults[key];
} else {
results[key] = null;
}
}
return results;
}
export function authorizeUserInWindow({
url,
urlSuccessRegex = /(code=).*/,

View File

@ -1,114 +0,0 @@
import * as models from '../../models/index';
import { setDefaultProtocol } from '../../utils/url/protocol';
import { getBasicAuthHeader } from '../basic-auth/get-header';
import { sendWithSettings } from '../network';
import * as c from './constants';
import { responseToObject } from './misc';
export default async function(
requestId: string,
accessTokenUrl: string,
credentialsInBody: boolean,
clientId: string,
clientSecret: string,
refreshToken: string,
scope: string,
origin: string,
): Promise<Record<string, any>> {
const params = [
{
name: c.P_GRANT_TYPE,
value: c.GRANT_TYPE_REFRESH,
},
{
name: c.P_REFRESH_TOKEN,
value: refreshToken,
},
];
// Add optional params
scope &&
params.push({
name: c.P_SCOPE,
value: scope,
});
const headers = [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
},
];
if (credentialsInBody) {
params.push({
name: c.P_CLIENT_ID,
value: clientId,
});
params.push({
name: c.P_CLIENT_SECRET,
value: clientSecret,
});
} else {
headers.push(getBasicAuthHeader(clientId, clientSecret));
}
if (origin) {
headers.push({ name: 'Origin', value: origin });
}
const url = setDefaultProtocol(accessTokenUrl);
const response = await sendWithSettings(requestId, {
headers,
url,
method: 'POST',
body: models.request.newBodyFormUrlEncoded(params),
});
const statusCode = response.statusCode || 0;
const bodyBuffer = models.response.getBodyBuffer(response);
if (statusCode === 401) {
// If the refresh token was rejected due an unauthorized request, we will
// return a null access_token to trigger an authentication request to fetch
// brand new refresh and access tokens.
return responseToObject(null, [c.P_ACCESS_TOKEN]);
} else if (statusCode < 200 || statusCode >= 300) {
if (bodyBuffer && statusCode === 400) {
const response = responseToObject(bodyBuffer.toString(), [c.P_ERROR, c.P_ERROR_DESCRIPTION]);
// If the refresh token was rejected due an oauth2 invalid_grant error, we will
// return a null access_token to trigger an authentication request to fetch
// brand new refresh and access tokens.
if (response[c.P_ERROR] === 'invalid_grant') {
return responseToObject(null, [c.P_ACCESS_TOKEN]);
}
}
throw new Error(`[oauth2] Failed to refresh token url=${url} status=${statusCode}`);
}
if (!bodyBuffer) {
throw new Error(`[oauth2] No body returned from ${url}`);
}
return responseToObject(
bodyBuffer.toString(),
[
c.P_ACCESS_TOKEN,
c.P_ID_TOKEN,
c.P_REFRESH_TOKEN,
c.P_EXPIRES_IN,
c.P_TOKEN_TYPE,
c.P_SCOPE,
c.P_ERROR,
c.P_ERROR_URI,
c.P_ERROR_DESCRIPTION,
],
{
// Refresh token is optional, so we'll default it to the existing value
[c.P_REFRESH_TOKEN]: refreshToken,
},
);
}

View File

@ -1,12 +1,11 @@
import React, { ChangeEvent, FC, ReactNode, useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import React, { ChangeEvent, FC, ReactNode, useEffect, useMemo, useState } from 'react';
import { convertEpochToMilliseconds, toKebabCase } from '../../../../common/misc';
import accessTokenUrls from '../../../../datasets/access-token-urls';
import authorizationUrls from '../../../../datasets/authorization-urls';
import * as models from '../../../../models';
import type { OAuth2Token } from '../../../../models/o-auth-2-token';
import type { Request } from '../../../../models/request';
import type { AuthTypeOAuth2, Request } from '../../../../models/request';
import {
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_CLIENT_CREDENTIALS,
@ -18,11 +17,10 @@ import {
RESPONSE_TYPE_ID_TOKEN_TOKEN,
RESPONSE_TYPE_TOKEN,
} from '../../../../network/o-auth-2/constants';
import getAccessToken from '../../../../network/o-auth-2/get-token';
import { getOAuth2Token } from '../../../../network/o-auth-2/get-token';
import { initNewOAuthSession } from '../../../../network/o-auth-2/misc';
import { useNunjucks } from '../../../context/nunjucks/use-nunjucks';
import { useActiveRequest } from '../../../hooks/use-active-request';
import { selectActiveOAuth2Token } from '../../../redux/selectors';
import { Link } from '../../base/link';
import { showModal } from '../../modals';
import { ResponseDebugModal } from '../../modals/response-debug-modal';
@ -105,12 +103,12 @@ const getFields = (authentication: Request['authentication']) => {
options={pkceMethodOptions}
/>;
const authorizationUrl = <AuthInputRow label='Authorization URL' property='authorizationUrl' key='authorizationUrl' getAutocompleteConstants={getAuthorizationUrls} />;
const accessTokenUrl = <AuthInputRow label='Access Token URL' property='accessTokenUrl' key='accessTokenUrl' getAutocompleteConstants={getAccessTokenUrls} />;
const accessTokenUrl = <AuthInputRow label='Access Token URL' property='accessTokenUrl' key='accessTokenUrl' getAutocompleteConstants={getAccessTokenUrls} />;
const redirectUri = <AuthInputRow label='Redirect URL' property='redirectUrl' key='redirectUrl' help='This can be whatever you want or need it to be. Insomnia will automatically detect a redirect in the client browser window and extract the code from the redirected URL.' />;
const state = <AuthInputRow label='State' property='state' key='state' />;
const scope = <AuthInputRow label='Scope' property='scope' key='scope' />;
const username = <AuthInputRow label='Username' property='username' key='username' />;
const password = <AuthInputRow label='Password' property='password' key='password' mask/>;
const password = <AuthInputRow label='Password' property='password' key='password' mask />;
const tokenPrefix = <AuthInputRow label='Header Prefix' property='tokenPrefix' key='tokenPrefix' help='Change Authorization header prefix from "Bearer" to something else. Use "NO_PREFIX" to send raw token without prefix.' />;
const responseType = <AuthSelectRow
label='Response Type'
@ -266,7 +264,7 @@ export const OAuth2Auth: FC = () => {
{advanced}
{
<tr>
<td/>
<td />
<td className="wide">
<div className="pad-top text-right">
<button className="btn btn--clicky" onClick={initNewOAuthSession}>
@ -334,9 +332,8 @@ const renderAccessTokenExpiry = (token?: Pick<OAuth2Token, 'accessToken' | 'expi
);
};
const OAuth2TokenInput: FC<{label: string; property: keyof Pick<OAuth2Token, 'accessToken' | 'refreshToken' | 'identityToken'>}> = ({ label, property }) => {
const OAuth2TokenInput: FC<{ token: OAuth2Token | null; label: string; property: keyof Pick<OAuth2Token, 'accessToken' | 'refreshToken' | 'identityToken'> }> = ({ token, label, property }) => {
const { activeRequest } = useActiveRequest();
const token = useSelector(selectActiveOAuth2Token);
const onChange = async ({ currentTarget: { value } }: ChangeEvent<HTMLInputElement>) => {
if (token) {
@ -348,9 +345,9 @@ const OAuth2TokenInput: FC<{label: string; property: keyof Pick<OAuth2Token, 'ac
const expiryLabel = useMemo(() => {
if (property === 'identityToken') {
return renderIdentityTokenExpiry(token);
return token && renderIdentityTokenExpiry(token);
} else if (property === 'accessToken') {
return renderAccessTokenExpiry(token);
return token && renderAccessTokenExpiry(token);
} else {
return null;
}
@ -372,9 +369,7 @@ const OAuth2TokenInput: FC<{label: string; property: keyof Pick<OAuth2Token, 'ac
);
};
const OAuth2Error: FC = () => {
const token = useSelector(selectActiveOAuth2Token);
const OAuth2Error: FC<{ token: OAuth2Token | null }> = ({ token }) => {
const debug = () => {
if (!token || !token.xResponseId) {
return;
@ -420,42 +415,20 @@ const OAuth2Error: FC = () => {
return debugButton;
};
const useActiveOAuth2Token = () => {
const token = useSelector(selectActiveOAuth2Token);
const OAuth2Tokens: FC = () => {
const { activeRequest: { authentication, _id: requestId } } = useActiveRequest();
const [token, setToken] = useState<OAuth2Token | null>(null);
useEffect(() => {
const fn = async () => {
const token = await models.oAuth2Token.getByParentId(requestId);
setToken(token);
};
fn();
}, [requestId]);
const { handleRender } = useNunjucks();
const clearTokens = useCallback(async () => {
if (token) {
await models.oAuth2Token.remove(token);
}
}, [token]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const refreshToken = useCallback(async () => {
setError('');
setLoading(true);
try {
const renderedAuthentication = await handleRender(authentication);
await getAccessToken(requestId, renderedAuthentication, true);
setLoading(false);
} catch (err) {
// Clear existing tokens if there's an error
await clearTokens();
setError(err.message);
setLoading(false);
}
}, [authentication, clearTokens, handleRender, requestId]);
return { error, loading, token, clearTokens, refreshToken };
};
const OAuth2Tokens: FC = () => {
const { token, clearTokens, refreshToken, loading, error } = useActiveOAuth2Token();
return (
<div className='notice subtle text-left'>
{error && (
@ -463,20 +436,47 @@ const OAuth2Tokens: FC = () => {
{error}
</p>
)}
<OAuth2Error />
<OAuth2TokenInput label='Refresh Token' property='refreshToken' />
<OAuth2TokenInput label='Identity Token' property='identityToken' />
<OAuth2TokenInput label='Access Token' property='accessToken' />
<OAuth2Error token={token} />
<OAuth2TokenInput token={token} label='Refresh Token' property='refreshToken' />
<OAuth2TokenInput token={token} label='Identity Token' property='identityToken' />
<OAuth2TokenInput token={token} label='Access Token' property='accessToken' />
<div className='pad-top text-right'>
{token ? (
<button className="btn btn--clicky" onClick={clearTokens}>
<button
className="btn btn--clicky"
disabled={!token}
onClick={() => {
if (token) {
setToken(null);
models.oAuth2Token.remove(token);
}
}}
>
Clear
</button>
) : null}
&nbsp;&nbsp;
<button
className="btn btn--clicky"
onClick={refreshToken}
onClick={async () => {
setError('');
setLoading(true);
try {
const renderedAuthentication = await handleRender(authentication) as AuthTypeOAuth2;
const t = await getOAuth2Token(requestId, renderedAuthentication, true);
setToken(t);
setLoading(false);
} catch (err) {
// Clear existing tokens if there's an error
if (token) {
setToken(null);
models.oAuth2Token.remove(token);
}
setError(err.message);
setLoading(false);
}
}}
disabled={loading}
>
{loading

View File

@ -16,12 +16,6 @@ import type {
Request,
RequestBodyParameter,
} from '../../../../models/request';
import {
newBodyFile,
newBodyForm,
newBodyFormUrlEncoded,
newBodyRaw,
} from '../../../../models/request';
import type { Workspace } from '../../../../models/workspace';
import { NunjucksEnabledProvider } from '../../../context/nunjucks/nunjucks-enabled-context';
import { AskModal } from '../../modals/ask-modal';
@ -46,24 +40,51 @@ export const BodyEditor: FC<Props> = ({
environmentId,
}) => {
const handleRawChange = useCallback((rawValue: string) => {
models.request.update(request, { body: newBodyRaw(rawValue, request.body.mimeType || '') });
models.request.update(request, {
body: typeof request.body.mimeType !== 'string' ? {
text: rawValue,
} : {
mimeType: request.body.mimeType.split(';')[0],
text: rawValue,
},
});
}, [request]);
const handleGraphQLChange = useCallback((content: string) => {
models.request.update(request, { body: newBodyRaw(content, CONTENT_TYPE_GRAPHQL) });
models.request.update(request, {
body: typeof CONTENT_TYPE_GRAPHQL !== 'string' ? {
text: content,
} : {
mimeType: CONTENT_TYPE_GRAPHQL.split(';')[0],
text: content,
},
});
}, [request]);
const handleFormUrlEncodedChange = useCallback((parameters: RequestBodyParameter[]) => {
models.request.update(request, { body: newBodyFormUrlEncoded(parameters) });
const handleFormUrlEncodedChange = useCallback((params: RequestBodyParameter[]) => {
models.request.update(request, {
body: {
mimeType: CONTENT_TYPE_FORM_URLENCODED,
params,
},
});
}, [request]);
const handleFormChange = useCallback((parameters: RequestBodyParameter[]) => {
models.request.update(request, { body: newBodyForm(parameters) });
models.request.update(request, {
body: {
mimeType: CONTENT_TYPE_FORM_DATA,
params: parameters || [],
},
});
}, [request]);
const handleFileChange = async (path: string) => {
const headers = clone(request.headers);
const body = newBodyFile(path);
const body = {
mimeType: CONTENT_TYPE_FILE,
fileName: path,
};
const newRequest = await models.request.update(request, { body });
let contentTypeHeader = getContentTypeHeader(headers);

View File

@ -21,6 +21,7 @@ import type { ResponsePatch } from '../../../../main/network/libcurl-promise';
import * as models from '../../../../models';
import type { Request } from '../../../../models/request';
import * as network from '../../../../network/network';
import { invariant } from '../../../../utils/invariant';
import { jsonPrettify } from '../../../../utils/prettify/json';
import { selectSettings } from '../../../redux/selectors';
import { Dropdown } from '../../base/dropdown/dropdown';
@ -34,11 +35,6 @@ import { HelpTooltip } from '../../help-tooltip';
import { Toolbar } from '../../key-value-editor/key-value-editor';
import { useDocBodyKeyboardShortcuts } from '../../keydown-binder';
import { TimeFromNow } from '../../time-from-now';
const explorerContainer = document.querySelector('#graphql-explorer-container');
if (!explorerContainer) {
throw new Error('Failed to find #graphql-explorer-container');
}
const isOperationDefinition = (def: DefinitionNode): def is OperationDefinitionNode => def.kind === Kind.OPERATION_DEFINITION;
@ -361,6 +357,8 @@ export const GraphQLEditor: FC<Props> = ({
// Create portal for GraphQL Explorer
let graphQLExplorerPortal: React.ReactPortal | null = null;
const explorerContainer = document.querySelector('#graphql-explorer-container');
invariant(explorerContainer, 'Failed to find #graphql-explorer-container');
if (explorerContainer) {
graphQLExplorerPortal = ReactDOM.createPortal(
<GraphQLExplorer

View File

@ -2,14 +2,15 @@ import React, { FC, useCallback, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { getContentTypeFromHeaders } from '../../../common/constants';
import { CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, CONTENT_TYPE_GRAPHQL, CONTENT_TYPE_JSON, CONTENT_TYPE_OTHER, getContentTypeFromHeaders, METHOD_POST } from '../../../common/constants';
import { database } from '../../../common/database';
import { getContentTypeHeader } from '../../../common/misc';
import * as models from '../../../models';
import { queryAllWorkspaceUrls } from '../../../models/helpers/query-all-workspace-urls';
import { update } from '../../../models/helpers/request-operations';
import type { Request } from '../../../models/request';
import { Request, RequestBody } from '../../../models/request';
import type { Settings } from '../../../models/settings';
import type { Workspace } from '../../../models/workspace';
import { create, Workspace } from '../../../models/workspace';
import { deconstructQueryStringToParams, extractQueryStringFromUrl } from '../../../utils/url/querystring';
import { useActiveRequestSyncVCSVersion, useGitVCSVersion } from '../../hooks/use-vcs-version';
import { selectActiveEnvironment, selectActiveRequestMeta } from '../../redux/selectors';
@ -64,7 +65,139 @@ interface Props {
workspace: Workspace;
setLoading: (l: boolean) => void;
}
export function newBodyGraphQL(rawBody: string): RequestBody {
try {
// Only strip the newlines if rawBody is a parsable JSON
JSON.parse(rawBody);
return {
mimeType: CONTENT_TYPE_GRAPHQL,
text: rawBody.replace(/\\\\n/g, ''),
};
} catch (error) {
if (error instanceof SyntaxError) {
return {
mimeType: CONTENT_TYPE_GRAPHQL,
text: rawBody,
};
} else {
throw error;
}
}
}
export function updateMimeType(
request: Request,
mimeType: string,
doCreate = false,
savedBody: RequestBody = {},
) {
let headers = request.headers ? [...request.headers] : [];
const contentTypeHeader = getContentTypeHeader(headers);
// GraphQL uses JSON content-type
const contentTypeHeaderValue = mimeType === CONTENT_TYPE_GRAPHQL ? CONTENT_TYPE_JSON : mimeType;
// GraphQL must be POST
if (mimeType === CONTENT_TYPE_GRAPHQL) {
request.method = METHOD_POST;
}
// Check if we are converting to/from variants of XML or JSON
let leaveContentTypeAlone = false;
if (contentTypeHeader && mimeType) {
const current = contentTypeHeader.value;
if (current.includes('xml') && mimeType.includes('xml')) {
leaveContentTypeAlone = true;
} else if (current.includes('json') && mimeType.includes('json')) {
leaveContentTypeAlone = true;
}
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// 1. Update Content-Type header //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
const hasBody = typeof mimeType === 'string';
if (!hasBody) {
headers = headers.filter(h => h !== contentTypeHeader);
} else if (mimeType === CONTENT_TYPE_OTHER) {
// Leave headers alone
} else if (mimeType && contentTypeHeader && !leaveContentTypeAlone) {
contentTypeHeader.value = contentTypeHeaderValue;
} else if (mimeType && !contentTypeHeader) {
headers.push({
name: 'Content-Type',
value: contentTypeHeaderValue,
});
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~ //
// 2. Make a new request body //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~ //
let body;
const oldBody = Object.keys(savedBody).length === 0 ? request.body : savedBody;
if (mimeType === CONTENT_TYPE_FORM_URLENCODED) {
// Urlencoded
body = oldBody.params
? {
mimeType: CONTENT_TYPE_FORM_URLENCODED,
params: oldBody.params,
} : {
mimeType: CONTENT_TYPE_FORM_URLENCODED,
params: oldBody.text ? deconstructQueryStringToParams(oldBody.text) : [],
};
} else if (mimeType === CONTENT_TYPE_FORM_DATA) {
// Form Data
body = oldBody.params
? {
mimeType: CONTENT_TYPE_FORM_DATA,
params: oldBody.params || [],
} : {
mimeType: CONTENT_TYPE_FORM_DATA,
params: oldBody.text ? deconstructQueryStringToParams(oldBody.text) : [],
};
} else if (mimeType === CONTENT_TYPE_FILE) {
// File
body = {
mimeType: CONTENT_TYPE_FILE,
fileName: '',
};
} else if (mimeType === CONTENT_TYPE_GRAPHQL) {
if (contentTypeHeader) {
contentTypeHeader.value = CONTENT_TYPE_JSON;
}
body = newBodyGraphQL(oldBody.text || '');
} else if (typeof mimeType !== 'string') {
// No body
body = {};
} else {
// Raw Content-Type (ex: application/json)
body = typeof mimeType !== 'string' ? {
text: oldBody.text || '',
} : {
mimeType: mimeType.split(';')[0],
text: oldBody.text || '',
};
}
// ~~~~~~~~~~~~~~~~~~~~~~~~ //
// 2. create/update request //
// ~~~~~~~~~~~~~~~~~~~~~~~~ //
if (doCreate) {
const newRequest: Request = Object.assign({}, request, {
headers,
body,
});
return create(newRequest);
} else {
return update(request, {
headers,
body,
});
}
}
export const RequestPane: FC<Props> = ({
environmentId,
request,
@ -89,11 +222,11 @@ export const RequestPane: FC<Props> = ({
}, [handleEditDescription]);
const handleUpdateSettingsUseBulkHeaderEditor = useCallback(() => {
models.settings.update(settings, { useBulkHeaderEditor:!settings.useBulkHeaderEditor });
models.settings.update(settings, { useBulkHeaderEditor: !settings.useBulkHeaderEditor });
}, [settings]);
const handleUpdateSettingsUseBulkParametersEditor = useCallback(() => {
models.settings.update(settings, { useBulkParametersEditor:!settings.useBulkParametersEditor });
models.settings.update(settings, { useBulkParametersEditor: !settings.useBulkParametersEditor });
}, [settings]);
const handleImportQueryFromUrl = useCallback(() => {
@ -122,7 +255,7 @@ export const RequestPane: FC<Props> = ({
modified: Date.now(),
url,
parameters,
// Hack to force the ui to refresh. More info on use-vcs-version
// Hack to force the ui to refresh. More info on use-vcs-version
}, true);
}
}, [request]);
@ -159,7 +292,7 @@ export const RequestPane: FC<Props> = ({
// Clear saved value in requestMeta
await models.requestMeta.update(requestMeta, { savedRequestBody });
// @ts-expect-error -- TSCONVERSION mimeType can be null when no body is selected but the updateMimeType logic needs to be reexamined
return models.request.updateMimeType(request, mimeType, false, requestMeta.savedRequestBody);
return updateMimeType(request, mimeType, false, requestMeta.savedRequestBody);
}
const numParameters = request.parameters.filter(p => !p.disabled).length;
const numHeaders = request.headers.filter(h => !h.disabled).length;

View File

@ -370,15 +370,6 @@ export const selectActiveCookieJar = createSelector(
},
);
export const selectActiveOAuth2Token = createSelector(
selectEntitiesLists,
selectActiveWorkspaceMeta,
(entities, workspaceMeta) => {
const id = workspaceMeta?.activeRequestId || 'n/a';
return entities.oAuth2Tokens.find(t => t.parentId === id);
},
);
export const selectUnseenWorkspaces = createSelector(
selectEntitiesLists,
entities => {
@ -431,7 +422,7 @@ export const selectActiveRequestResponses = createSelector(
(activeRequest, entities, activeEnvironment, settings) => {
const requestId = activeRequest ? activeRequest._id : 'n/a';
const responses: (Response | WebSocketResponse)[] = (activeRequest && isWebSocketRequest(activeRequest)) ? entities.webSocketResponses : entities.responses;
const responses: (Response | WebSocketResponse)[] = (activeRequest && isWebSocketRequest(activeRequest)) ? entities.webSocketResponses : entities.responses;
// Filter responses down if the setting is enabled
return responses.filter(response => {