Skip to content

Commit

Permalink
Merge pull request #1 from transferwise/DEVEX-39_oauth-connect
Browse files Browse the repository at this point in the history
Add OAuth connect sample
  • Loading branch information
taaviaasver authored Oct 3, 2023
2 parents 973e1bb + d8a4637 commit fc9c73e
Show file tree
Hide file tree
Showing 24 changed files with 1,835 additions and 2 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# sample app data store
storage.json
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
# wise-platform-samples
Code samples to get started with common Wise API use cases
# Wise Platform samples

Code samples to get started with common Wise API use cases.

Each sample includes server and client side code (powered by [Next.js](https://nextjs.org/)).
All calls to Wise API are done on the server side.

Start by picking a sample and look into `pages/index.tsx` (landing page).
1 change: 1 addition & 0 deletions common/const/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEFAULT_REDIRECT_URI = 'http://localhost:3000/wise-redirect';
35 changes: 35 additions & 0 deletions common/db/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import JSONdb from '../lib/Simple-JSONdb';

import type { Config } from '../types/Config';
import type { Tokens } from '../types/Tokens';

const ONE_HOUR_IN_MS = 60 * 60 * 1000;

// Instead of proper database we have a simple JSON file
export const store = new JSONdb('../storage.json');

export const getSelectedWiseProfileId = () => store.get('selectedProfileId') as string;

export const getWiseEnvironmentConfig = () => store.get('config') as Config;

export const getWiseTokens = () => {
const selectedProfileId = getSelectedWiseProfileId();
if (!selectedProfileId) {
throw Error('No selectedProfileId');
}
return store.get(selectedProfileId) as Tokens;
};

export const getWiseAccessToken = () => {
return getWiseTokens().accessToken;
};

export const getWiseRefreshToken = () => {
return getWiseTokens().refreshToken;
};

export const isWiseTokenAboutToExpire = ()=> {
const tokens = getWiseTokens();
// Token already expired or will expire within 1 hour
return Date.now() > parseInt(tokens.accessTokenExpiresAt, 10) - ONE_HOUR_IN_MS;
};
189 changes: 189 additions & 0 deletions common/lib/Simple-JSONdb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* In order to keep each example app very light we're cloning source of
* Simple-JSONdb here. This way we don't need to install it for every new sample app we create.
*
* https://github.com/nmaggioni/Simple-JSONdb/
*/
const fs = require("fs");

/**
* Default configuration values.
* @type {{asyncWrite: boolean, syncOnWrite: boolean, jsonSpaces: number}}
*/
const defaultOptions = {
asyncWrite: false,
syncOnWrite: true,
jsonSpaces: 4,
stringify: JSON.stringify,
parse: JSON.parse
};

/**
* Validates the contents of a JSON file.
* @param {string} fileContent
* @returns {boolean} `true` if content is ok, throws error if not.
*/
let validateJSON = function(fileContent) {
try {
this.options.parse(fileContent);
} catch (e) {
console.error('Given filePath is not empty and its content is not valid JSON.');
throw e;
}
return true;
};

/**
* Main constructor, manages existing storage file and parses options against default ones.
* @param {string} filePath The path of the file to use as storage.
* @param {object} [options] Configuration options.
* @param {boolean} [options.asyncWrite] Enables the storage to be asynchronously written to disk. Disabled by default (synchronous behaviour).
* @param {boolean} [options.syncOnWrite] Makes the storage be written to disk after every modification. Enabled by default.
* @param {boolean} [options.syncOnWrite] Makes the storage be written to disk after every modification. Enabled by default.
* @param {number} [options.jsonSpaces] How many spaces to use for indentation in the output json files. Default = 4
* @constructor
*/
function JSONdb(filePath, options) {
// Mandatory arguments check
if (!filePath || !filePath.length) {
throw new Error('Missing file path argument.');
} else {
this.filePath = filePath;
}

// Options parsing
if (options) {
for (let key in defaultOptions) {
if (!options.hasOwnProperty(key)) options[key] = defaultOptions[key];
}
this.options = options;
} else {
this.options = defaultOptions;
}


// Storage initialization
this.storage = {};

// File existence check
let stats;
try {
stats = fs.statSync(filePath);
} catch (err) {
if (err.code === 'ENOENT') {
/* File doesn't exist */
return;
} else if (err.code === 'EACCES') {
throw new Error(`Cannot access path "${filePath}".`);
} else {
// Other error
throw new Error(`Error while checking for existence of path "${filePath}": ${err}`);
}
}
/* File exists */
try {
fs.accessSync(filePath, fs.constants.R_OK | fs.constants.W_OK);
} catch (err) {
throw new Error(`Cannot read & write on path "${filePath}". Check permissions!`);
}
if (stats.size > 0) {
let data;
try {
data = fs.readFileSync(filePath);
} catch (err) {
throw err; // TODO: Do something meaningful
}
if (validateJSON.bind(this)(data)) this.storage = this.options.parse(data);
}
}

/**
* Creates or modifies a key in the database.
* @param {string} key The key to create or alter.
* @param {object} value Whatever to store in the key. You name it, just keep it JSON-friendly.
*/
JSONdb.prototype.set = function(key, value) {
this.storage[key] = value;
if (this.options && this.options.syncOnWrite) this.sync();
};

/**
* Extracts the value of a key from the database.
* @param {string} key The key to search for.
* @returns {object|undefined} The value of the key or `undefined` if it doesn't exist.
*/
JSONdb.prototype.get = function(key) {
return this.storage.hasOwnProperty(key) ? this.storage[key] : undefined;
};

/**
* Checks if a key is contained in the database.
* @param {string} key The key to search for.
* @returns {boolean} `True` if it exists, `false` if not.
*/
JSONdb.prototype.has = function(key) {
return this.storage.hasOwnProperty(key);
};

/**
* Deletes a key from the database.
* @param {string} key The key to delete.
* @returns {boolean|undefined} `true` if the deletion succeeded, `false` if there was an error, or `undefined` if the key wasn't found.
*/
JSONdb.prototype.delete = function(key) {
let retVal = this.storage.hasOwnProperty(key) ? delete this.storage[key] : undefined;
if (this.options && this.options.syncOnWrite) this.sync();
return retVal;
};

/**
* Deletes all keys from the database.
* @returns {object} The JSONdb instance itself.
*/
JSONdb.prototype.deleteAll = function() {
for (var key in this.storage) {
//noinspection JSUnfilteredForInLoop
this.delete(key);
}
return this;
};

/**
* Writes the local storage object to disk.
*/
JSONdb.prototype.sync = function() {
if (this.options && this.options.asyncWrite) {
fs.writeFile(this.filePath, this.options.stringify(this.storage, null, this.options.jsonSpaces), (err) => {
if (err) throw err;
});
} else {
try {
fs.writeFileSync(this.filePath, this.options.stringify(this.storage, null, this.options.jsonSpaces));
} catch (err) {
if (err.code === 'EACCES') {
throw new Error(`Cannot access path "${this.filePath}".`);
} else {
throw new Error(`Error while writing to path "${this.filePath}": ${err}`);
}
}
}
};

/**
* If no parameter is given, returns **a copy** of the local storage. If an object is given, it is used to replace the local storage.
* @param {object} storage A JSON object to overwrite the local storage with.
* @returns {object} Clone of the internal JSON storage. `Error` if a parameter was given and it was not a valid JSON object.
*/
JSONdb.prototype.JSON = function(storage) {
if (storage) {
try {
JSON.parse(this.options.stringify(storage));
this.storage = storage;
} catch (err) {
throw new Error('Given parameter is not a valid JSON object.');
}
}
return JSON.parse(this.options.stringify(this.storage));
};

module.exports = JSONdb;
47 changes: 47 additions & 0 deletions common/server/exchangeAuthCodeForToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { getWiseEnvironmentConfig } from '../db';
import { store } from '../db';
import { toUrlencoded } from '../utils/toUrlencoded';

// Obtain Wise access token and refresh token.
// https://docs.wise.com/api-docs/api-reference/user-tokens#authzcode
export const exchangeAuthCodeForToken = async (
authCode: string,
profileId: string
) => {
const config = getWiseEnvironmentConfig();
const headers = new Headers();
headers.set(
'Content-Type',
'application/x-www-form-urlencoded;charset=UTF-8'
);
headers.set(
'Authorization',
`Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString(
'base64'
)}`
);

const body = toUrlencoded({
grant_type: 'authorization_code',
client_id: config.clientId,
code: authCode.toString(),
redirect_uri: config.redirectUri,
});

const response = await fetch(`${config.host}/oauth/token`, {
method: 'POST',
headers,
body,
});
const result = await response.json();
// Stores Wise profileId and tokens in our demo database (storage.json).
// In a production environment, you would associate these tokens with a logged-in user
// in your system.
store.set('selectedProfileId', profileId);
store.set(profileId, {
accessToken: result.access_token,
refreshToken: result.refresh_token,
// Turns expires_in (seconds) into JS timestamp (milliseconds) so we can use it later
accessTokenExpiresAt: Date.now() + result.expires_in * 1000,
});
};
12 changes: 12 additions & 0 deletions common/server/fetchProfileDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getSelectedWiseProfileId, getWiseEnvironmentConfig, getWiseAccessToken } from '../db';

export const fetchProfileDetails = async () => {
const config = getWiseEnvironmentConfig();
const selectedProfileId = getSelectedWiseProfileId();
const oauthToken = getWiseAccessToken();
const headers = new Headers();
headers.set('Authorization', `Bearer ${oauthToken}`);
const response = await fetch(`${config.host}/v2/profiles/${selectedProfileId}`, { headers });
const result = await response.json();
return result;
};
29 changes: 29 additions & 0 deletions common/server/refreshWiseToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getSelectedWiseProfileId, getWiseEnvironmentConfig, getWiseRefreshToken } from '../db';
import { store } from '../db';
import { toUrlencoded } from '../utils/toUrlencoded';

export const refreshWiseToken = async () => {
const config = getWiseEnvironmentConfig();
const refreshToken = getWiseRefreshToken();
const selectedProfileId = getSelectedWiseProfileId();
const headers = new Headers();
headers.set('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
headers.set(
'Authorization',
`Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`,
);
const body = toUrlencoded({
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const response = await fetch(`${config.host}/oauth/token`, { method: 'POST', headers, body });
const result = await response.json();
if (result.access_token) {
store.set(selectedProfileId, {
accessToken: result.access_token,
refreshToken: result.refresh_token,
// Turns expires_in (seconds) into JS timestamp (milliseconds) so we can use it later
accessTokenExpiresAt: Date.now() + result.expires_in * 1000,
});
}
};
Loading

0 comments on commit fc9c73e

Please sign in to comment.