diff --git a/abstract-sdk.d.ts b/abstract-sdk.d.ts index c15e9b45..9455a4c3 100644 --- a/abstract-sdk.d.ts +++ b/abstract-sdk.d.ts @@ -1855,8 +1855,10 @@ type CommandOptions = { apiUrl: string | Promise, analyticsCallback: AnalyticsCallback, assetUrl: string | Promise, + clientSecret?: string, previewUrl: string | Promise, shareId?: () => Promise, + redirectUri?: string, transportMode: ("api" | "cli")[], webUrl: string | Promise }; diff --git a/src/Client.js b/src/Client.js index 564bd1f9..bfef63e0 100644 --- a/src/Client.js +++ b/src/Client.js @@ -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"; @@ -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; @@ -40,6 +42,7 @@ export default class Client { layers: Layers; memberships: Memberships; notifications: Notifications; + oauth: OAuth; organizations: Organizations; pages: Pages; previews: Previews; @@ -54,7 +57,7 @@ export default class Client { _analyticsCallback: ?AnalyticsCallback; constructor(options: $Shape = {}) { - options = { + this.options = { accessToken: process.env.ABSTRACT_TOKEN, apiUrl: "https://api.goabstract.com", objectUrl: "https://objects.goabstract.com", @@ -65,31 +68,36 @@ 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; + this.activities = new Activities(this); + this.assets = new Assets(this); + this.branches = new Branches(this); + this.changesets = new Changesets(this); + this.collectionLayers = new CollectionLayers(this); + this.collections = new Collections(this); + this.comments = new Comments(this); + this.commits = new Commits(this); + this.data = new Data(this); + this.descriptors = new Descriptors(this); + this.files = new Files(this); + this.layers = new Layers(this); + this.memberships = new Memberships(this); + this.notifications = new Notifications(this); + this.oauth = new OAuth(this); + this.organizations = new Organizations(this); + this.pages = new Pages(this); + this.previews = new Previews(this); + this.projects = new Projects(this); + this.reviewRequests = new ReviewRequests(this); + this.sections = new Sections(this); + this.shares = new Shares(this); + this.stars = new Stars(this); + this.users = new Users(this); + this.webhooks = new Webhooks(this); + } + + setToken(accessToken: string) { + this.options.accessToken = accessToken; } unwrap(value: any) { diff --git a/src/endpoints/Endpoint.js b/src/endpoints/Endpoint.js index 0d9d8097..3cb3ddbb 100644 --- a/src/endpoints/Endpoint.js +++ b/src/endpoints/Endpoint.js @@ -37,9 +37,9 @@ export default class Endpoint { client: Client; options: CommandOptions; - constructor(client: Client, options: CommandOptions) { + constructor(client: Client, options?: CommandOptions) { this.client = client; - this.options = options; + this.options = options ? options : this.client.options; } configureRequest(requestName: string, config: RequestConfig): T { @@ -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); + } + fetchOptions.headers = await this._getFetchHeaders(fetchOptions.headers); const args = [`${hostname}/${url}`, fetchOptions]; diff --git a/src/endpoints/OAuth.js b/src/endpoints/OAuth.js new file mode 100644 index 00000000..e8c1b91a --- /dev/null +++ b/src/endpoints/OAuth.js @@ -0,0 +1,69 @@ +// @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>("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; + const scope = input.scope || "all"; + + if (!clientId || !redirectUri) { + throw new BaseError( + "Client credentials are missing. Please double check clientId, redirectUri and state" + ); + } + + return `https://app.abstract.com/signin/auth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent( + redirectUri + )}&response_type=code&scope=${scope}&state=${state}`; + } +} diff --git a/src/types.js b/src/types.js index 44358834..6b0fec88 100644 --- a/src/types.js +++ b/src/types.js @@ -159,9 +159,12 @@ export type CommandOptions = { accessToken?: AccessTokenOption, analyticsCallback: AnalyticsCallback, apiUrl: string | Promise, + clientId?: string, + clientSecret?: string, objectUrl: string | Promise, previewUrl: string | Promise, shareId?: () => Promise, + redirectUri?: string, transportMode: ("api" | "cli")[], webUrl: string | Promise }; @@ -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, + scope: string, + user_id: string +}; diff --git a/src/util/testing.js b/src/util/testing.js index e8ee473f..d3ed58f2 100644 --- a/src/util/testing.js +++ b/src/util/testing.js @@ -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, diff --git a/tests/Client.test.js b/tests/Client.test.js index 72189bb8..edce42cd 100644 --- a/tests/Client.test.js +++ b/tests/Client.test.js @@ -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({ @@ -237,6 +237,11 @@ describe("Client", () => { ); }); }); + + describe("setToken", () => { + API_CLIENT.setToken("token"); + expect(API_CLIENT.options).toMatchObject({ accessToken: "token" }); + }); }); test("undefined request options", async () => { diff --git a/tests/endpoints/OAuth.test.js b/tests/endpoints/OAuth.test.js new file mode 100644 index 00000000..2097ed9b --- /dev/null +++ b/tests/endpoints/OAuth.test.js @@ -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/auth/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(); + }); + }); +});