Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: OAuth 2.0 functionality #290

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
31787ae
feat: add clientSecret and redirectUri
Jul 6, 2020
8a3e48e
chore: add types to CommandOptions
Jul 7, 2020
a334a20
chore: remove promise value from redirectUri
Jul 7, 2020
438cd91
feat: new oauth endpoint with generateUrlMethod()
Jul 7, 2020
3889280
feat: Add getToken & setToken methods for OAuth
Jul 10, 2020
612b7a1
fix: Oauth --> OAuth
Jul 10, 2020
47ee0b8
chore: remove comment line
Jul 10, 2020
9c4066b
feat: add generateAuthorizeUrl() to OAuth
Jul 13, 2020
a747827
chore: remove useless error
Jul 13, 2020
98a23b0
fix: add input as param instead of descriptor in getToken()
Jul 13, 2020
b3fe128
chore: configure request - info --> getToken
Jul 13, 2020
a98f43a
feat: add tests for getToken and setToken
Jul 15, 2020
efcfe27
feat: rename rename OAuthOnAuthorizeToken to OAuthTokenInput; fix flo…
Jul 15, 2020
68ba371
feat: add tests for checking if proper Input is used
Jul 15, 2020
0c43ea9
chore: improve tests
Jul 15, 2020
9f02dc9
feat: check if Content-Type is not application/x-www-form-urlencoded
Jul 20, 2020
c45bc8e
chore: remove setToken from OAuth endpoint
Jul 20, 2020
1f97cc9
feat: add options as this.options and add setToken() as client method
Jul 20, 2020
369209c
chore: add Organization.features
Jul 20, 2020
56ad9b6
chore: remove useless assignment
Jul 21, 2020
7cdd1d4
fix: corrent link when generating url
Jul 21, 2020
274e7b7
chore: remove support for promises in clientSecret
Jul 22, 2020
6900756
chore: remove support for promises in redirectUri
Jul 22, 2020
3b5069e
chore: edit rule when headers are present but Content-Type isn’t
Jul 22, 2020
7071e1c
fix: typo (double check)
Jul 22, 2020
de85e68
fix: typo in authorize_url
Jul 22, 2020
81c2c03
fix: remove state as required
Jul 22, 2020
341b32c
fix: lint error for checking content-type statement in apiRequest
Jul 22, 2020
f6b8697
feat: add scope as a variable for future proof
Jul 22, 2020
0d56460
chore: don't return anything in Client.setToken()
Jul 22, 2020
49fe426
chore: add options param as optional in Endpoint; use client.options …
Jul 22, 2020
abbc152
chore: use input.scope or "all" when choosing scope value in url
Jul 24, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions abstract-sdk.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1855,8 +1855,10 @@ type CommandOptions = {
apiUrl: string | Promise<string>,
analyticsCallback: AnalyticsCallback,
assetUrl: string | Promise<string>,
clientSecret?: string | Promise<string>,
berezovskyicom marked this conversation as resolved.
Show resolved Hide resolved
previewUrl: string | Promise<string>,
shareId?: () => Promise<string | ShareDescriptor | ShareUrlDescriptor | void>,
redirectUri?: string | Promise<string>,
berezovskyicom marked this conversation as resolved.
Show resolved Hide resolved
transportMode: ("api" | "cli")[],
webUrl: string | Promise<string>
};
Expand Down
61 changes: 35 additions & 26 deletions src/Client.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Files from "./endpoints/Files";
import Layers from "./endpoints/Layers";
import Memberships from "./endpoints/Memberships";
import Notifications from "./endpoints/Notifications";
import OAuth from "./endpoints/OAuth";
import Organizations from "./endpoints/Organizations";
import Pages from "./endpoints/Pages";
import Previews from "./endpoints/Previews";
Expand All @@ -26,6 +27,7 @@ import Webhooks from "./endpoints/Webhooks";
import type { CommandOptions, AnalyticsCallback } from "./types";

export default class Client {
options: CommandOptions;
activities: Activities;
assets: Assets;
branches: Branches;
Expand All @@ -40,6 +42,7 @@ export default class Client {
layers: Layers;
memberships: Memberships;
notifications: Notifications;
oauth: OAuth;
organizations: Organizations;
pages: Pages;
previews: Previews;
Expand All @@ -54,7 +57,7 @@ export default class Client {
_analyticsCallback: ?AnalyticsCallback;

constructor(options: $Shape<CommandOptions> = {}) {
options = {
this.options = {
accessToken: process.env.ABSTRACT_TOKEN,
apiUrl: "https://api.goabstract.com",
objectUrl: "https://objects.goabstract.com",
Expand All @@ -65,31 +68,37 @@ export default class Client {
...options
};

this._analyticsCallback = options.analyticsCallback;
this.activities = new Activities(this, options);
this.assets = new Assets(this, options);
this.branches = new Branches(this, options);
this.changesets = new Changesets(this, options);
this.collectionLayers = new CollectionLayers(this, options);
this.collections = new Collections(this, options);
this.comments = new Comments(this, options);
this.commits = new Commits(this, options);
this.data = new Data(this, options);
this.descriptors = new Descriptors(this, options);
this.files = new Files(this, options);
this.layers = new Layers(this, options);
this.memberships = new Memberships(this, options);
this.notifications = new Notifications(this, options);
this.organizations = new Organizations(this, options);
this.pages = new Pages(this, options);
this.previews = new Previews(this, options);
this.projects = new Projects(this, options);
this.reviewRequests = new ReviewRequests(this, options);
this.sections = new Sections(this, options);
this.shares = new Shares(this, options);
this.stars = new Stars(this, options);
this.users = new Users(this, options);
this.webhooks = new Webhooks(this, options);
this._analyticsCallback = this.options.analyticsCallback;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should go ahead and just change Endpoint to only accept one argument, this – we can read this.options in the constructor and avoid passing it in essentially twice here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! I left Endpoint options param as optional, because tests were behaving weird.

this.activities = new Activities(this, this.options);
this.assets = new Assets(this, this.options);
this.branches = new Branches(this, this.options);
this.changesets = new Changesets(this, this.options);
this.collectionLayers = new CollectionLayers(this, this.options);
this.collections = new Collections(this, this.options);
this.comments = new Comments(this, this.options);
this.commits = new Commits(this, this.options);
this.data = new Data(this, this.options);
this.descriptors = new Descriptors(this, this.options);
this.files = new Files(this, this.options);
this.layers = new Layers(this, this.options);
this.memberships = new Memberships(this, this.options);
this.notifications = new Notifications(this, this.options);
this.oauth = new OAuth(this, this.options);
this.organizations = new Organizations(this, this.options);
this.pages = new Pages(this, this.options);
this.previews = new Previews(this, this.options);
this.projects = new Projects(this, this.options);
this.reviewRequests = new ReviewRequests(this, this.options);
this.sections = new Sections(this, this.options);
this.shares = new Shares(this, this.options);
this.stars = new Stars(this, this.options);
this.users = new Users(this, this.options);
this.webhooks = new Webhooks(this, this.options);
}

setToken(accessToken: string) {
this.options.accessToken = accessToken;
return this.options;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you have a reason for returning options here in mind? Might be confusing, could be better to return this to allow for chaining or nothing at all…

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 100% agree. Returning nothing lgtm. Will fix that in new commit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed and edited tests.

}

unwrap(value: any) {
Expand Down
10 changes: 9 additions & 1 deletion src/endpoints/Endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,15 @@ export default class Endpoint {
const { customHostname, raw, onProgress } = apiOptions;
const hostname = customHostname || (await this.options.apiUrl);

fetchOptions.body = fetchOptions.body && JSON.stringify(fetchOptions.body);
if (
fetchOptions.headers &&
fetchOptions.headers["Content-Type"] !==
"application/x-www-form-urlencoded"
) {
fetchOptions.body =
fetchOptions.body && JSON.stringify(fetchOptions.body);
}

berezovskyicom marked this conversation as resolved.
Show resolved Hide resolved
fetchOptions.headers = await this._getFetchHeaders(fetchOptions.headers);
const args = [`${hostname}/${url}`, fetchOptions];

Expand Down
68 changes: 68 additions & 0 deletions src/endpoints/OAuth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @flow
import { BaseError } from "../errors";
import type {
OAuthAuthorizeInput,
OAuthTokenInput,
TokenResponseData
} from "../types";
import Endpoint from "../endpoints/Endpoint";

export default class OAuth extends Endpoint {
name = "oauth";

getToken(input: OAuthTokenInput) {
const clientId = input.clientId || this.options.clientId;
const clientSecret = input.clientSecret || this.options.clientSecret;
const redirectUri = input.redirectUri || this.options.redirectUri;
const { authorizationCode } = input;

const body = new URLSearchParams();

if (!clientId || !clientSecret || !redirectUri || !authorizationCode) {
throw new Error("OAuthTokenInput required");
}

body.append("client_id", clientId);
body.append("client_secret", clientSecret);
body.append("redirect_uri", redirectUri);

body.append("code", authorizationCode);
body.append("grant_type", "authorization_code");

return this.configureRequest<Promise<TokenResponseData>>("getToken", {
api: async () => {
const response = await this.apiRequest(
`auth/tokens`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body
},
{
customHostname: "https://auth.goabstract.com"
}
);

return response.access_token;
}
});
}

generateAuthorizeUrl(input: OAuthAuthorizeInput): string {
const clientId = input.clientId || this.options.clientId;
const state = input.state;
const redirectUri = input.redirectUri || this.options.redirectUri;

if (!clientId || !state || !redirectUri) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is state strictly required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope. Will fix that 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

throw new BaseError(
"Client credentials are missing. Please doublecheck clientId, redirectUri and state"
berezovskyicom marked this conversation as resolved.
Show resolved Hide resolved
);
}

return `https://app.abstract.com/signin/auth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri
)}&response_type=code&scope=all&state=${state}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we allow input.scope here to future proof and just set it to "all" as the default?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed.

}
}
25 changes: 25 additions & 0 deletions src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,12 @@ export type CommandOptions = {
accessToken?: AccessTokenOption,
analyticsCallback: AnalyticsCallback,
apiUrl: string | Promise<string>,
clientId?: string,
clientSecret?: string,
objectUrl: string | Promise<string>,
previewUrl: string | Promise<string>,
shareId?: () => Promise<string | ShareDescriptor | ShareUrlDescriptor | void>,
redirectUri?: string,
transportMode: ("api" | "cli")[],
webUrl: string | Promise<string>
};
Expand Down Expand Up @@ -1672,3 +1675,25 @@ export type ReviewRequest = {
status: ReviewStatus,
statusChangedAt: string
};

export type OAuthAuthorizeInput = {
clientId: string,
redirectUri: string,
state: string
};

export type OAuthTokenInput = {
redirectUri: string,
clientSecret: string,
clientId: string,
authorizationCode: string
};

export type TokenResponseData = {
access_token: string,
client_id: string,
created_at: string,
id: string,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this id?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to back end, it's not used anywhere right now and not much needed for client. Looks like an id in database.

scope: string,
user_id: string
};
9 changes: 9 additions & 0 deletions src/util/testing.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ export function mockAPI(
(nock("http://apiurl"): any)[method](url).reply(code, response);
}

export function mockAuth(
url: string,
response: Object,
code: number = 200,
method: string = "get"
) {
(nock("https://auth.goabstract.com"): any)[method](url).reply(code, response);
}

export function mockPreviewAPI(
url: string,
response: Object,
Expand Down
9 changes: 8 additions & 1 deletion tests/Client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const Client = require("../src/Client").default;

describe("Client", () => {
test("no transports specified", async () => {
expect.assertions(1);
expect.assertions(2);

try {
await API_CLIENT.organizations.list({
Expand Down Expand Up @@ -237,6 +237,13 @@ describe("Client", () => {
);
});
});

describe("setToken", () => {
expect(API_CLIENT.setToken("token")).toHaveProperty(
"accessToken",
"token"
);
});
});

test("undefined request options", async () => {
Expand Down
100 changes: 100 additions & 0 deletions tests/endpoints/OAuth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { mockAuth, API_CLIENT } from "../../src/util/testing";

describe("oauth", () => {
describe("getToken", () => {
const [clientId, clientSecret, redirectUri, authorizationCode] = [
"client_id",
"client_secret",
"redirect_uri",
"authorization_code"
];
test("api - with data", async () => {
mockAuth(
"/auth/tokens",
{
access_token: "access_token",
client_id: "client_id",
created_at: "created_at",
id: "id",
scope: "scope",
user_id: "user_id"
},
200,
"post"
);

const response = await API_CLIENT.oauth.getToken({
clientId: "client_id",
clientSecret: "client_secret",
redirectUri: "redirect_uri",
authorizationCode: "authorization_code"
});

expect(response).toEqual("access_token");
});

test("api - without clientId", async () => {
expect(() =>
API_CLIENT.oauth.getToken({
clientSecret,
redirectUri,
authorizationCode
})
).toThrowError();
});

test("api - without clientSecret", async () => {
expect(() =>
API_CLIENT.oauth.getToken({
clientId,
redirectUri,
authorizationCode
})
).toThrowError();
});

test("api - without redirectUri", async () => {
expect(() =>
API_CLIENT.oauth.getToken({
clientId,
clientSecret,
authorizationCode
})
).toThrowError();
});

test("api - without authorizationCode", async () => {
expect(() =>
API_CLIENT.oauth.getToken({
clientId,
clientSecret,
redirectUri
})
).toThrowError();
});
});

describe("generateAuthUrl", () => {
test("options are passed", () => {
const [clientId, redirectUri, state] = [
"clientId",
"redirectUri",
"state"
];

const url = API_CLIENT.oauth.generateAuthorizeUrl({
clientId,
redirectUri,
state
});

expect(url).toEqual(
`https://app.abstract.com/signin/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=all&state=${state}`
);
});

test("options are not passed", () => {
expect(() => API_CLIENT.oauth.generateAuthorizeUrl({})).toThrowError();
});
});
});