From 882436004f5b953e667b97b56bf8c28c6b99783c Mon Sep 17 00:00:00 2001 From: Christian Mengler Date: Wed, 1 Mar 2023 13:16:43 +1100 Subject: [PATCH] feat: add OAuth token refresh logic --- README.md | 2 +- common/oauth.secret.example.ts | 3 + companion/api/api.ts | 19 +++++- companion/api/result.ts | 5 ++ companion/index.ts | 106 ++++++++++++++++++++--------- companion/oauth/base64_polyfill.ts | 55 +++++++++++++++ companion/oauth/fitbit_oauth.ts | 30 ++++++++ companion/oauth/url_encode.ts | 9 +++ settings/index.tsx | 41 +++++++---- settings/oauth.secret.example.ts | 2 - 10 files changed, 221 insertions(+), 51 deletions(-) create mode 100644 common/oauth.secret.example.ts create mode 100644 companion/oauth/base64_polyfill.ts create mode 100644 companion/oauth/fitbit_oauth.ts create mode 100644 companion/oauth/url_encode.ts delete mode 100644 settings/oauth.secret.example.ts diff --git a/README.md b/README.md index df9514e..536dc60 100644 --- a/README.md +++ b/README.md @@ -16,5 +16,5 @@ Quick and convenient way to log your weight and daily water consumption. ## Notes -- Requires an application to be registered with Fitbit, set the `CLIENT_ID` and `CLIENT_SECRET` in `settings/oauth.secret.ts`. +- Requires an application to be registered with Fitbit, set the `FITBIT_CLIENT_ID` and `FITBIT_CLIENT_SECRET` in `common/oauth.secret.ts`. - You must have atleast one recent weight log that has been entered via the Fitbit Companion app for the weight logger to initialise the values from. diff --git a/common/oauth.secret.example.ts b/common/oauth.secret.example.ts new file mode 100644 index 0000000..694f3c4 --- /dev/null +++ b/common/oauth.secret.example.ts @@ -0,0 +1,3 @@ +export const FITBIT_CLIENT_ID = ''; +export const FITBIT_CLIENT_SECRET = ''; +export const FITBIT_SCOPES = 'weight nutrition'; diff --git a/companion/api/api.ts b/companion/api/api.ts index ac25f99..69c1ac5 100644 --- a/companion/api/api.ts +++ b/companion/api/api.ts @@ -1,11 +1,11 @@ -import { WaterResult, WeightResult } from './result'; +import { UserResult, WaterResult, WeightResult } from './result'; export default class FitbitApi { static baseUrl: string = 'https://api.fitbit.com/1/user/-'; private accessToken: string; - constructor(accessToken: string) { + constructor(accessToken?: string) { this.setAccessToken(accessToken); } @@ -22,6 +22,21 @@ export default class FitbitApi { this.accessToken = accessToken; } + getUser = async (): Promise => { + try { + const response = await this.request('GET', [`${FitbitApi.baseUrl}/profile.json`]); + + const data = await response.json(); + + return { + success: true, + display_name: data.user.displayName, + }; + } catch (e) { + return { success: false }; + } + }; + getWeight = async (): Promise => { let now = new Date(); let todayDate = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`; //YYYY-MM-DD diff --git a/companion/api/result.ts b/companion/api/result.ts index 5ab16dd..424eb9f 100644 --- a/companion/api/result.ts +++ b/companion/api/result.ts @@ -1,3 +1,8 @@ +export interface UserResult { + success: boolean; + display_name?: string; +} + export interface WeightResult { success: boolean; weight?: number; diff --git a/companion/index.ts b/companion/index.ts index 720644a..4011975 100644 --- a/companion/index.ts +++ b/companion/index.ts @@ -1,8 +1,9 @@ import * as messaging from 'messaging'; import { settingsStorage } from 'settings'; import FitbitApi from './api/api'; +import { getFitbitApiToken } from './oauth/fitbit_oauth'; -const fbApi = new FitbitApi(getAccessToken()); +const fbApi = new FitbitApi(); // Fetch weight data from Fitbit Web API function fetchWeightData() { @@ -29,48 +30,89 @@ function fetchWaterData() { } // User changes settings -settingsStorage.onchange = (evt) => { - // Handle oAuth response from settings - if (evt.key === 'oauth') { - let data = JSON.parse(evt.newValue); - fbApi.setAccessToken(data.access_token); - fetchWeightData(); - fetchWaterData(); - } -}; - -function restoreSettings() { - const accessToken = getAccessToken(); - if (accessToken !== null) { - fetchWeightData(); - fetchWaterData(); - } -} +settingsStorage.onchange = async (evt) => { + // Received new OAuth code + if (evt.key === 'oauth_code' && evt.newValue) { + // Fetch refresh token + let token = await getFitbitApiToken(evt.newValue); + settingsStorage.setItem('refresh_token', token.refresh_token); -function getAccessToken() { - for (let index = 0; index < settingsStorage.length; index++) { - let key = settingsStorage.key(index); - if (key && key === 'oauth') { - return JSON.parse(settingsStorage.getItem(key)).access_token; - } + await initialize(); } -} +}; // Message socket opens -messaging.peerSocket.onopen = () => { - restoreSettings(); +messaging.peerSocket.onopen = async () => { + await initialize(); + + // Generate new access/refresh token every 6 hours (default expiry is 8 hours) + setInterval(() => generateAccessToken(), 6 * 60 * 60 * 1000); }; // Message socket receives data from app -messaging.peerSocket.onmessage = (event) => { - if (event.data.weight != null) { - fbApi.updateWeight(event.data.weight).then(() => { +messaging.peerSocket.onmessage = (evt) => { + if (evt.data.weight != null) { + fbApi.updateWeight(evt.data.weight).then(() => { fetchWeightData(); }); } - if (event.data.water != null) { - fbApi.logWater(event.data.water).then(() => { + if (evt.data.water != null) { + fbApi.logWater(evt.data.water).then(() => { fetchWaterData(); }); } }; + +// Generate access token from stored refresh token +const generateAccessToken = async () => { + const refreshToken = settingsStorage.getItem('refresh_token'); + + if (refreshToken == null) { + // TODO: If no `refreshToken` exists, indicate to app that user is required to login + return; + } + + let token = await getFitbitApiToken(refreshToken, 'refresh_token'); + + if (token.errors) { + //console.log(`[Companion] Error while generating access token ${JSON.stringify(token)}`); + settingsStorage.setItem('refresh_token', ''); + + // TODO: Indicate to app that user is required to login + return; + } + + //console.log(`[Companion] Generated access token ${JSON.stringify(token)}`); + + settingsStorage.setItem('access_token', token.access_token); + settingsStorage.setItem('refresh_token', token.refresh_token); + + fbApi.setAccessToken(token.access_token); +}; + +// Initialise app +const initialize = async () => { + //console.log('Initializing app...'); + + try { + await generateAccessToken(); + } catch (error) { + //console.log(`[Companion] Error while generating access token ${error}`); + + // Re-try initialisation + setTimeout(() => initialize(), 10 * 1000); + return; + } + + // Fetch current user + fbApi.getUser().then((result) => { + if (result.success) { + // Store current user display name + settingsStorage.setItem('current_user_name', result.display_name); + + // Fetch weight and water data + fetchWeightData(); + fetchWaterData(); + } + }); +}; diff --git a/companion/oauth/base64_polyfill.ts b/companion/oauth/base64_polyfill.ts new file mode 100644 index 0000000..c65f799 --- /dev/null +++ b/companion/oauth/base64_polyfill.ts @@ -0,0 +1,55 @@ +var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + +function InvalidCharacterError(message) { + this.message = message; +} +InvalidCharacterError.prototype = new Error(); +InvalidCharacterError.prototype.name = 'InvalidCharacterError'; + +export const btoa = function (input) { + var str = String(input); + for ( + // initialize result and counter + var block, charCode, idx = 0, map = chars, output = ''; + // if the next str index does not exist: + // change the mapping table to "=" + // check if d has no fractional digits + str.charAt(idx | 0) || ((map = '='), idx % 1); + // "8 - idx % 1 * 8" generates the sequence 2, 4, 6, 8 + output += map.charAt(63 & (block >> (8 - (idx % 1) * 8))) + ) { + charCode = str.charCodeAt((idx += 3 / 4)); + if (charCode > 0xff) { + throw new InvalidCharacterError( + "'btoa' failed: The string to be encoded contains characters outside of the Latin1 range." + ); + } + block = (block << 8) | charCode; + } + return output; +}; + +export const atob = function (input) { + var str = String(input).replace(/[=]+$/, ''); // #31: ExtendScript bad parse of /= + if (str.length % 4 == 1) { + throw new InvalidCharacterError("'atob' failed: The string to be decoded is not correctly encoded."); + } + for ( + // initialize result and counters + var bc = 0, bs, buffer, idx = 0, output = ''; + // get next character + (buffer = str.charAt(idx++)); + // character found in table? initialize bit storage and add its ascii value; + ~buffer && + ((bs = bc % 4 ? bs * 64 + buffer : buffer), + // and if not first of each 4 characters, + // convert the first 8 bits to one ascii character + bc++ % 4) + ? (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)))) + : 0 + ) { + // try to find character in table (0-63, not found => -1) + buffer = chars.indexOf(buffer); + } + return output; +}; diff --git a/companion/oauth/fitbit_oauth.ts b/companion/oauth/fitbit_oauth.ts new file mode 100644 index 0000000..5072c61 --- /dev/null +++ b/companion/oauth/fitbit_oauth.ts @@ -0,0 +1,30 @@ +import { FITBIT_CLIENT_ID, FITBIT_CLIENT_SECRET, FITBIT_SCOPES } from '../../common/oauth.secret'; +import { btoa } from './base64_polyfill'; +import { urlEncode } from './url_encode'; + +export const getFitbitApiToken = async (exchangeCode: string, grantType = 'authorization_code') => { + const tokenRequest: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + btoa(FITBIT_CLIENT_ID + ':' + FITBIT_CLIENT_SECRET), + }, + body: urlEncode({ + grant_type: grantType, + client_id: FITBIT_CLIENT_ID, + client_secret: FITBIT_CLIENT_SECRET, + scope: FITBIT_SCOPES, + code: grantType === 'authorization_code' ? exchangeCode : null, + refresh_token: grantType === 'refresh_token' ? exchangeCode : null, + redirect_uri: 'https://app-settings.fitbitdevelopercontent.com/simple-redirect.html', + }), + }; + + return await fetch('https://api.fitbit.com/oauth2/token', tokenRequest) + .then((data) => { + return data.json(); + }) + .catch((err) => { + //console.log('Error on token generation ' + err); + }); +}; diff --git a/companion/oauth/url_encode.ts b/companion/oauth/url_encode.ts new file mode 100644 index 0000000..487eadc --- /dev/null +++ b/companion/oauth/url_encode.ts @@ -0,0 +1,9 @@ +export const urlEncode = (object: object): string => { + let fBody = []; + for (let prop in object) { + let key = encodeURIComponent(prop); + let value = encodeURIComponent(object[prop]); + fBody.push(key + '=' + value); + } + return fBody.join('&'); +}; diff --git a/settings/index.tsx b/settings/index.tsx index 9e86c58..f245d4d 100644 --- a/settings/index.tsx +++ b/settings/index.tsx @@ -1,26 +1,39 @@ -import { CLIENT_ID, CLIENT_SECRET } from './oauth.secret'; +import { FITBIT_CLIENT_ID, FITBIT_CLIENT_SECRET, FITBIT_SCOPES } from '../common/oauth.secret'; -function mySettings(props) { +function mySettings(props: SettingsComponentProps) { + const refreshToken = props.settingsStorage.getItem('refresh_token'); + const currentUserName = props.settingsStorage.getItem('current_user_name'); + const isLoggedIn = refreshToken && currentUserName; return (
- Fitbit Account + Fitbit Log Settings } > - + {isLoggedIn && Logged in as {currentUserName}} + {!isLoggedIn && ( + { + //console.log(`[Settings] Auth code received`); + props.settingsStorage.setItem('current_user_name', ''); + props.settingsStorage.setItem('refresh_token', ''); + props.settingsStorage.setItem('oauth_code', data.code); + }} + /> + )} + {isLoggedIn &&
); diff --git a/settings/oauth.secret.example.ts b/settings/oauth.secret.example.ts deleted file mode 100644 index 6ae2a15..0000000 --- a/settings/oauth.secret.example.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const CLIENT_ID = ''; -export const CLIENT_SECRET = '';