From e1b1f6c3156b3f7c38dfa79174bd24cd5409fdbb Mon Sep 17 00:00:00 2001 From: pike Date: Sun, 10 Dec 2023 11:05:47 +0100 Subject: [PATCH 1/4] feat: Implement LinkedIn refresh --- src/platforms/LinkedIn/LinkedIn.ts | 9 +- src/platforms/LinkedIn/LinkedInAuth.ts | 125 +++++++++++++++---------- 2 files changed, 82 insertions(+), 52 deletions(-) diff --git a/src/platforms/LinkedIn/LinkedIn.ts b/src/platforms/LinkedIn/LinkedIn.ts index 98f98e0..f8c3249 100644 --- a/src/platforms/LinkedIn/LinkedIn.ts +++ b/src/platforms/LinkedIn/LinkedIn.ts @@ -45,6 +45,12 @@ export default class LinkedIn extends Platform { return this.getProfile(); } + /** @inheritdoc */ + async refresh(): Promise { + await this.auth.refresh(); + return true; + } + async preparePost(folder: Folder): Promise { const post = await super.preparePost(folder); if (post) { @@ -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)); diff --git a/src/platforms/LinkedIn/LinkedInAuth.ts b/src/platforms/LinkedIn/LinkedInAuth.ts index 14b47c8..6aa8c36 100644 --- a/src/platforms/LinkedIn/LinkedInAuth.ts +++ b/src/platforms/LinkedIn/LinkedInAuth.ts @@ -1,54 +1,43 @@ 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 { - 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 { - 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("Invalid TokenResponse", tokens); } - this.accessToken = result["access_token"]; - // now store it - return this.accessToken; + this.store(tokens); } - protected async requestCode(): Promise { + /** + * Request remote code using OAuth2Service + * @returns - code + */ + private async requestCode(): Promise { Logger.trace("LinkedInAuth", "requestCode"); const clientId = Storage.get("settings", "LINKEDIN_CLIENT_ID"); const state = String(Math.random()).substring(2); @@ -89,38 +78,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; - }> { + /** + * Exchange remote code for tokens + * @param code - the code to exchange + * @returns - TokenResponse + */ + private async exchangeCode(code: string): Promise { Logger.trace("RedditAuth", "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 ------------------- /** @@ -158,7 +159,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(); @@ -179,3 +180,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; +} From 2353636ddc191ac183de4b229b3fdcf47c211c6c Mon Sep 17 00:00:00 2001 From: pike Date: Sun, 10 Dec 2023 12:41:51 +0100 Subject: [PATCH 2/4] chore: Clean up RedditAuth --- docs/Reddit.md | 2 +- src/platforms/LinkedIn/LinkedInAuth.ts | 5 +- src/platforms/Reddit/Reddit.ts | 2 +- src/platforms/Reddit/RedditAuth.ts | 94 +++++++++++++++++--------- 4 files changed, 69 insertions(+), 34 deletions(-) diff --git a/docs/Reddit.md b/docs/Reddit.md index 03723ac..21c74ab 100644 --- a/docs/Reddit.md +++ b/docs/Reddit.md @@ -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 diff --git a/src/platforms/LinkedIn/LinkedInAuth.ts b/src/platforms/LinkedIn/LinkedInAuth.ts index 6aa8c36..d2258d0 100644 --- a/src/platforms/LinkedIn/LinkedInAuth.ts +++ b/src/platforms/LinkedIn/LinkedInAuth.ts @@ -28,7 +28,10 @@ export default class LinkedInAuth { })) as TokenResponse; if (!isTokenResponse(tokens)) { - throw Logger.error("Invalid TokenResponse", tokens); + throw Logger.error( + "LinkedInAuth.refresh: response is not a TokenResponse", + tokens, + ); } this.store(tokens); } diff --git a/src/platforms/Reddit/Reddit.ts b/src/platforms/Reddit/Reddit.ts index 59dd726..4e23a61 100644 --- a/src/platforms/Reddit/Reddit.ts +++ b/src/platforms/Reddit/Reddit.ts @@ -47,7 +47,7 @@ export default class Reddit extends Platform { /** @inheritdoc */ async refresh(): Promise { - await this.auth.refreshAccessToken(); + await this.auth.refresh(); return true; } diff --git a/src/platforms/Reddit/RedditAuth.ts b/src/platforms/Reddit/RedditAuth.ts index 7aaa9c9..0398f2e 100644 --- a/src/platforms/Reddit/RedditAuth.ts +++ b/src/platforms/Reddit/RedditAuth.ts @@ -1,17 +1,15 @@ 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); } /** @@ -19,28 +17,26 @@ export default class RedditAuth { * * Reddits access token expire in 24 hours. * Refresh this regularly. - * @returns The access token */ - public async refreshAccessToken(): Promise { - 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 { Logger.trace("RedditAuth", "requestCode"); const clientId = Storage.get("settings", "REDDIT_CLIENT_ID"); @@ -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 { 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, @@ -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 ------------------- /** @@ -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; +} From 9be27343fd422f543b14bef960ff03a78279a9d6 Mon Sep 17 00:00:00 2001 From: pike Date: Sun, 10 Dec 2023 13:32:04 +0100 Subject: [PATCH 3/4] feat: Implement twitter refresh fix: https://github.com/commonpike/fairpost/issues/39 --- src/platforms/Twitter/Twitter.ts | 13 ++- src/platforms/Twitter/TwitterAuth.ts | 128 +++++++++++++++++++++------ 2 files changed, 114 insertions(+), 27 deletions(-) diff --git a/src/platforms/Twitter/Twitter.ts b/src/platforms/Twitter/Twitter.ts index ca4a061..0840035 100644 --- a/src/platforms/Twitter/Twitter.ts +++ b/src/platforms/Twitter/Twitter.ts @@ -16,25 +16,28 @@ import TwitterAuth from "./TwitterAuth"; */ export default class Twitter extends Platform { id = PlatformId.TWITTER; + auth: TwitterAuth; constructor() { super(); + this.auth = new TwitterAuth(); } /** @inheritdoc */ async setup() { - const auth = new TwitterAuth(); - return await auth.setup(); + return await this.auth.setup(); } /** @inheritdoc */ async test() { + Logger.trace("Twitter.test: get oauth1 api"); const client1 = new TwitterApi({ appKey: Storage.get("settings", "TWITTER_OA1_API_KEY"), appSecret: Storage.get("settings", "TWITTER_OA1_API_KEY_SECRET"), accessToken: Storage.get("settings", "TWITTER_OA1_ACCESS_TOKEN"), accessSecret: Storage.get("settings", "TWITTER_OA1_ACCESS_SECRET"), }); + Logger.trace("Twitter.test: get oauth2 api"); const client2 = new TwitterApi(Storage.get("auth", "TWITTER_ACCESS_TOKEN")); return { oauth1: await client1.v1.verifyCredentials(), @@ -42,6 +45,12 @@ export default class Twitter extends Platform { }; } + /** @inheritdoc */ + async refresh(): Promise { + await this.auth.refresh(); + return true; + } + async preparePost(folder: Folder): Promise { const post = await super.preparePost(folder); if (post) { diff --git a/src/platforms/Twitter/TwitterAuth.ts b/src/platforms/Twitter/TwitterAuth.ts index f6837b6..e814098 100644 --- a/src/platforms/Twitter/TwitterAuth.ts +++ b/src/platforms/Twitter/TwitterAuth.ts @@ -2,54 +2,132 @@ import Logger from "../../services/Logger"; import OAuth2Service from "../../services/OAuth2Service"; import Storage from "../../services/Storage"; import { TwitterApi } from "twitter-api-v2"; +import { strict as assert } from "assert"; export default class TwitterAuth extends OAuth2Service { + client: TwitterApi; + + /** + * Set up Twitter platform + */ async setup() { - const tokens = await this.requestAccessToken(); - Storage.set("auth", "TWITTER_ACCESS_TOKEN", tokens["accessToken"]); - Storage.set("auth", "TWITTER_REFRESH_TOKEN", tokens["refreshToken"]); + const { code, verifier } = await this.requestCode(); + const tokens = await this.exchangeCode(code, verifier); + this.store(tokens); + } + + /** + * Refresh Twitter tokens + */ + async refresh() { + const tokens = (await this.getClient().refreshOAuth2Token( + Storage.get("auth", "TWITTER_REFRESH_TOKEN"), + )) as TokenResponse; + if (!isTokenResponse(tokens)) { + throw Logger.error( + "TwitterAuth.refresh: response is not a TokenResponse", + tokens, + ); + } + this.store(tokens); } - protected async requestAccessToken(): Promise<{ - client: TwitterApi; - scope: string[]; - accessToken: string; - refreshToken?: string; - }> { - const client = new TwitterApi({ + /** + * Get or create a TwitterApi client + * @returns - TwitterApi + */ + private getClient(): TwitterApi { + if (this.client) { + return this.client; + } + this.client = new TwitterApi({ clientId: Storage.get("settings", "TWITTER_CLIENT_ID"), clientSecret: Storage.get("settings", "TWITTER_CLIENT_SECRET"), }); - const { url, codeVerifier, state } = client.generateOAuth2AuthLink( - OAuth2Service.getCallbackUrl(), - { - scope: ["users.read", "tweet.read", "tweet.write", "offline.access"], - }, - ); + return this.client; + } + /** + * Request remote code using OAuth2Service + * @returns - {code, verifier} + */ + private async requestCode(): Promise<{ code: string; verifier: string }> { + const { url, codeVerifier, state } = + this.getClient().generateOAuth2AuthLink(OAuth2Service.getCallbackUrl(), { + scope: ["users.read", "tweet.read", "tweet.write", "offline.access"], + }); const result = await OAuth2Service.requestRemotePermissions("Twitter", url); if (result["error"]) { const msg = result["error_reason"] + " - " + result["error_description"]; - throw Logger.error(msg, result); + throw Logger.error("TwitterApi.requestCode: " + msg, result); } if (result["state"] !== state) { const msg = "Response state does not match request state"; - throw Logger.error(msg, result); + throw Logger.error("TwitterApi.requestCode: " + msg, result); } if (!result["code"]) { const msg = "Remote response did not return a code"; - throw Logger.error(msg, result); + throw Logger.error("TwitterApi.requestCode: " + msg, result); } - - const tokens = await client.loginWithOAuth2({ + return { code: result["code"] as string, - codeVerifier: codeVerifier, + verifier: codeVerifier, + }; + } + + /** + * Exchange remote code for tokens + * @param code - the code to exchange + * @param verifier - the code verifier to use + * @returns - TokenResponse + */ + private async exchangeCode( + code: string, + verifier: string, + ): Promise { + const tokens = (await this.getClient().loginWithOAuth2({ + code: code, + codeVerifier: verifier, redirectUri: OAuth2Service.getCallbackUrl(), - }); - if (!tokens["accessToken"]) { - throw Logger.error("An accessToken was not returned"); + })) as TokenResponse; + if (!isTokenResponse(tokens)) { + throw Logger.error( + "TitterAuth.requestAccessToken: reponse is not a valid TokenResponse", + ); } return tokens; } + + /** + * Save all tokens in auth store + * @param tokens - the tokens to store + */ + private store(tokens: TokenResponse) { + Storage.set("auth", "TWITTER_ACCESS_TOKEN", tokens["accessToken"]); + const accessExpiry = new Date( + new Date().getTime() + tokens["expiresIn"] * 1000, + ).toISOString(); + Storage.set("auth", "TWITTER_ACCESS_EXPIRY", accessExpiry); + + Storage.set("auth", "TWITTER_REFRESH_TOKEN", tokens["refreshToken"]); + } +} + +interface TokenResponse { + client: TwitterApi; + accessToken: string; + expiresIn: number; + refreshToken: string; +} + +function isTokenResponse(tokens: TokenResponse) { + try { + assert("accessToken" in tokens); + assert("expiresIn" in tokens); + assert("refreshToken" in tokens); + } catch (e) { + return false; + } + return true; } From ea71a882107083b62c3538ea6d5a63566273a97a Mon Sep 17 00:00:00 2001 From: pike Date: Sun, 10 Dec 2023 13:47:16 +0100 Subject: [PATCH 4/4] chore: Typo and docs --- README.md | 10 ++++++++-- src/platforms/LinkedIn/LinkedInAuth.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0732bbf..19c2b42 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/platforms/LinkedIn/LinkedInAuth.ts b/src/platforms/LinkedIn/LinkedInAuth.ts index d2258d0..884c471 100644 --- a/src/platforms/LinkedIn/LinkedInAuth.ts +++ b/src/platforms/LinkedIn/LinkedInAuth.ts @@ -87,7 +87,7 @@ export default class LinkedInAuth { * @returns - TokenResponse */ private async exchangeCode(code: string): Promise { - Logger.trace("RedditAuth", "exchangeCode", code); + Logger.trace("LinkedInAuth", "exchangeCode", code); const redirectUri = OAuth2Service.getCallbackUrl(); const tokens = (await this.post("accessToken", {