Skip to content

Commit

Permalink
feat: Improve auth handlers (#55)
Browse files Browse the repository at this point in the history
* feat: Implement LinkedIn refresh

* chore: Clean up RedditAuth

* feat: Implement twitter refresh

fix: #39

* chore: Typo and docs

---------

Co-authored-by: pike <pike@SilverAir>
  • Loading branch information
commonpike and pike authored Dec 10, 2023
1 parent 7eb60ed commit 180014c
Show file tree
Hide file tree
Showing 8 changed files with 273 additions and 115 deletions.
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

0 comments on commit 180014c

Please sign in to comment.