Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,14 @@ Other commands and `--arguments`
may help you to, for example, immediately publish
a certain post to a certain platform if you like.



### Refresh tokens

Access and refresh tokens for various platforms may
expire sooner or later. Before you do anything, try
`fairpost.js refresh-platforms`. Eventually, even
refresh tokens may expire, and you will have to run
`fairpost.js setup-platform --platform=bla` again
to get a new pair of tokens.


### Cli
Expand Down
2 changes: 1 addition & 1 deletion docs/Reddit.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

### Get an OAuth2 Access Token for your Reddit account

This token last for 24 hours and should be refreshed.
This token only lasts for 24 hours and should be refreshed.

- call `./fairpost.js setup-platform --platform=reddit`
- follow instructions from the command line
Expand Down
9 changes: 8 additions & 1 deletion src/platforms/LinkedIn/LinkedIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export default class LinkedIn extends Platform {
return this.getProfile();
}

/** @inheritdoc */
async refresh(): Promise<boolean> {
await this.auth.refresh();
return true;
}

async preparePost(folder: Folder): Promise<Post> {
const post = await super.preparePost(folder);
if (post) {
Expand Down Expand Up @@ -280,10 +286,11 @@ export default class LinkedIn extends Platform {
private async uploadImage(leashUrl: string, file: string) {
const rawData = fs.readFileSync(file);
Logger.trace("PUT", leashUrl);
const accessToken = Storage.get("auth", "LINKEDIN_ACCESS_TOKEN");
return await fetch(leashUrl, {
method: "PUT",
headers: {
Authorization: "Bearer " + (await this.auth.getAccessToken()),
Authorization: "Bearer " + accessToken,
},
body: rawData,
}).then((res) => this.api.handleApiResponse(res));
Expand Down
130 changes: 78 additions & 52 deletions src/platforms/LinkedIn/LinkedInAuth.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,46 @@
import Logger from "../../services/Logger";
import OAuth2Service from "../../services/OAuth2Service";
import Storage from "../../services/Storage";
import { strict as assert } from "assert";

export default class LinkedInAuth {
API_VERSION = "v2";
accessToken = "";

/**
* Set up LinkedIn platform
*/
async setup() {
const code = await this.requestCode();
const tokens = await this.exchangeCode(code);
this.accessToken = tokens["access_token"];
Storage.set("auth", "LINKEDIN_ACCESS_TOKEN", this.accessToken);
Storage.set("auth", "LINKEDIN_REFRESH_TOKEN", tokens["refresh_token"]);
}

/**
* Get Linkedin Access token
* @returns The access token
*/
public async getAccessToken(): Promise<string> {
if (this.accessToken) {
return this.accessToken;
}
this.accessToken = Storage.get("auth", "LINKEDIN_ACCESS_TOKEN");
// check if it works here
return this.accessToken;
this.store(tokens);
}

/**
* Refresh LinkedIn Access token
* @returns The access token
* Refresh LinkedIn tokens
*/
public async refreshAccessToken(): Promise<string> {
const result = await this.post("access_token", {
async refresh() {
const tokens = (await this.post("accessToken", {
grant_type: "refresh_token",
refresh_token: Storage.get("settings", "LINKEDIN_REFRESH_TOKEN"),
refresh_token: Storage.get("auth", "LINKEDIN_REFRESH_TOKEN"),
client_id: Storage.get("settings", "LINKEDIN_CLIENT_ID"),
cient_secret: Storage.get("settings", "LINKEDIN_CLIENT_SECRET"),
});
client_secret: Storage.get("settings", "LINKEDIN_CLIENT_SECRET"),
})) as TokenResponse;

if (!result["access_token"]) {
const msg = "Remote response did not return a access_token";
throw Logger.error(msg, result);
if (!isTokenResponse(tokens)) {
throw Logger.error(
"LinkedInAuth.refresh: response is not a TokenResponse",
tokens,
);
}
this.accessToken = result["access_token"];
// now store it
return this.accessToken;
this.store(tokens);
}

protected async requestCode(): Promise<string> {
/**
* Request remote code using OAuth2Service
* @returns - code
*/
private async requestCode(): Promise<string> {
Logger.trace("LinkedInAuth", "requestCode");
const clientId = Storage.get("settings", "LINKEDIN_CLIENT_ID");
const state = String(Math.random()).substring(2);
Expand Down Expand Up @@ -89,38 +81,50 @@ export default class LinkedInAuth {
return result["code"] as string;
}

protected async exchangeCode(code: string): Promise<{
access_token: string;
token_type: "bearer";
expires_in: number;
scope: string;
refresh_token: string;
}> {
Logger.trace("RedditAuth", "exchangeCode", code);
/**
* Exchange remote code for tokens
* @param code - the code to exchange
* @returns - TokenResponse
*/
private async exchangeCode(code: string): Promise<TokenResponse> {
Logger.trace("LinkedInAuth", "exchangeCode", code);
const redirectUri = OAuth2Service.getCallbackUrl();

const result = (await this.post("accessToken", {
const tokens = (await this.post("accessToken", {
grant_type: "authorization_code",
code: code,
client_id: Storage.get("settings", "LINKEDIN_CLIENT_ID"),
client_secret: Storage.get("settings", "LINKEDIN_CLIENT_SECRET"),
redirect_uri: redirectUri,
})) as {
access_token: string;
token_type: "bearer";
expires_in: number;
scope: string;
refresh_token: string;
refresh_token_expires_in: string;
};
})) as TokenResponse;

if (!result["access_token"]) {
const msg = "Remote response did not return a access_token";
throw Logger.error(msg, result);
if (!isTokenResponse(tokens)) {
throw Logger.error("Invalid TokenResponse", tokens);
}

return result;
return tokens;
}

/**
* Save all tokens in auth store
* @param tokens - the tokens to store
*/
private store(tokens: TokenResponse) {
Storage.set("auth", "LINKEDIN_ACCESS_TOKEN", tokens["access_token"]);
const accessExpiry = new Date(
new Date().getTime() + tokens["expires_in"] * 1000,
).toISOString();
Storage.set("auth", "LINKEDIN_ACCESS_EXPIRY", accessExpiry);

Storage.set("auth", "LINKEDIN_REFRESH_TOKEN", tokens["refresh_token"]);
const refreshExpiry = new Date(
new Date().getTime() + tokens["refresh_token_expires_in"] * 1000,
).toISOString();
Storage.set("auth", "LINKEDIN_REFRESH_EXPIRY", refreshExpiry);

Storage.set("auth", "LINKEDIN_SCOPE", tokens["scope"]);
}

// API implementation -------------------

/**
Expand Down Expand Up @@ -158,7 +162,7 @@ export default class LinkedInAuth {
throw Logger.error(
"LinkedInAuth.handleApiResponse",
response.url + ":" + response.status + ", " + response.statusText,
await response.json(),
await response.text(),
);
}
const data = await response.json();
Expand All @@ -179,3 +183,25 @@ export default class LinkedInAuth {
return data;
}
}

interface TokenResponse {
access_token: string;
token_type: "bearer";
expires_in: number;
scope: string;
refresh_token: string;
refresh_token_expires_in: number;
}

function isTokenResponse(tokens: TokenResponse) {
try {
assert("access_token" in tokens);
assert("expires_in" in tokens);
assert("scope" in tokens);
assert("refresh_token" in tokens);
assert("refresh_token_expires_in" in tokens);
} catch (e) {
return false;
}
return true;
}
2 changes: 1 addition & 1 deletion src/platforms/Reddit/Reddit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default class Reddit extends Platform {

/** @inheritdoc */
async refresh(): Promise<boolean> {
await this.auth.refreshAccessToken();
await this.auth.refresh();
return true;
}

Expand Down
94 changes: 63 additions & 31 deletions src/platforms/Reddit/RedditAuth.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,42 @@
import Logger from "../../services/Logger";
import OAuth2Service from "../../services/OAuth2Service";
import Storage from "../../services/Storage";
import { strict as assert } from "assert";

export default class RedditAuth {
API_VERSION = "v1";
accessToken = "";

async setup() {
const code = await this.requestCode();
const tokens = await this.exchangeCode(code);
this.accessToken = tokens["access_token"];
Storage.set("auth", "REDDIT_ACCESS_TOKEN", this.accessToken);
Storage.set("auth", "REDDIT_REFRESH_TOKEN", tokens["refresh_token"]);
this.store(tokens);
}

/**
* Refresh Reddit Access token
*
* Reddits access token expire in 24 hours.
* Refresh this regularly.
* @returns The access token
*/
public async refreshAccessToken(): Promise<string> {
if (this.accessToken) {
return this.accessToken;
}
const result = await this.post("access_token", {
public async refresh() {
const tokens = (await this.post("access_token", {
grant_type: "refresh_token",
refresh_token: Storage.get("auth", "REDDIT_REFRESH_TOKEN"),
});
})) as TokenResponse;

if (!result["access_token"]) {
const msg = "Remote response did not return a access_token";
throw Logger.error(msg, result);
}
const accessToken = result["access_token"];
if (!accessToken) {
throw new Error("RedditAuth: refresh failed - no access token");
if (!isTokenResponse(tokens)) {
throw Logger.error(
"RedditAuth.refresh: response is not a TokenResponse",
tokens,
);
}
Storage.set("auth", "REDDIT_ACCESS_TOKEN", accessToken);
this.store(tokens);
}

/**
* Request remote code using OAuth2Service
* @returns - code
*/
protected async requestCode(): Promise<string> {
Logger.trace("RedditAuth", "requestCode");
const clientId = Storage.get("settings", "REDDIT_CLIENT_ID");
Expand Down Expand Up @@ -78,17 +74,16 @@ export default class RedditAuth {
return result["code"] as string;
}

protected async exchangeCode(code: string): Promise<{
access_token: string;
token_type: "bearer";
expires_in: number;
scope: string;
refresh_token: string;
}> {
/**
* Exchange remote code for tokens
* @param code - the code to exchange
* @returns - TokenResponse
*/
protected async exchangeCode(code: string): Promise<TokenResponse> {
Logger.trace("RedditAuth", "exchangeCode", code);
const redirectUri = OAuth2Service.getCallbackUrl();

const result = (await this.post("access_token", {
const tokens = (await this.post("access_token", {
grant_type: "authorization_code",
code: code,
redirect_uri: redirectUri,
Expand All @@ -100,13 +95,30 @@ export default class RedditAuth {
refresh_token: string;
};

if (!result["access_token"]) {
const msg = "Remote response did not return a access_token";
throw Logger.error(msg, result);
if (!isTokenResponse(tokens)) {
throw Logger.error(
"RedditAuth.exchangeCode: response is not a TokenResponse",
tokens,
);
}

return result;
return tokens;
}

/**
* Save all tokens in auth store
* @param tokens - the tokens to store
*/
private store(tokens: TokenResponse) {
Storage.set("auth", "REDDIT_ACCESS_TOKEN", tokens["access_token"]);
const accessExpiry = new Date(
new Date().getTime() + tokens["expires_in"] * 1000,
).toISOString();
Storage.set("auth", "REDDIT_ACCESS_EXPIRY", accessExpiry);
Storage.set("auth", "REDDIT_REFRESH_TOKEN", tokens["refresh_token"]);
Storage.set("auth", "REDDIT_SCOPE", tokens["scope"]);
}

// API implementation -------------------

/**
Expand Down Expand Up @@ -180,3 +192,23 @@ export default class RedditAuth {
throw Logger.error("RedditAuth.handleApiError", error);
}
}

interface TokenResponse {
access_token: string;
token_type: "bearer";
expires_in: number;
scope: string;
refresh_token: string;
}

function isTokenResponse(tokens: TokenResponse) {
try {
assert("access_token" in tokens);
assert("expires_in" in tokens);
assert("scope" in tokens);
assert("refresh_token" in tokens);
} catch (e) {
return false;
}
return true;
}
Loading