From f178ffbcceb0add1617020f9143c910a97b80484 Mon Sep 17 00:00:00 2001 From: Kurt Berglund Date: Wed, 28 Sep 2016 17:18:10 +0000 Subject: [PATCH] PR 4: Merge feature/refresh to master - Partial refresh token support - Automatically refresh stale account tokens --- server/src/accounts.ts | 94 +++++++++++++++++++++++++++++++- server/src/app.ts | 31 ++++++----- server/src/db/accounts.ts | 5 ++ server/src/routes/authOptions.ts | 1 + server/src/routes/calendar.ts | 60 +++++++++++--------- server/views/index.hjs | 12 ++-- server/views/profile.hjs | 2 +- 7 files changed, 155 insertions(+), 50 deletions(-) diff --git a/server/src/accounts.ts b/server/src/accounts.ts index 2ca515f2aac..0d58b4b8e5f 100644 --- a/server/src/accounts.ts +++ b/server/src/accounts.ts @@ -2,6 +2,10 @@ import * as accounts from './db/accounts'; import * as users from './db/users'; import { Promise } from 'es6-promise'; import * as _ from 'lodash'; +import * as moment from 'moment'; +import * as request from 'request'; +import * as nconf from 'nconf'; +var google = require('googleapis'); // re-export the database user details export import IUserDetails = users.IUserDetails; @@ -14,6 +18,15 @@ export interface IUser { accounts: accounts.IAccount[] } +/** + * Wrapper structure to store access tokens + */ +export interface ITokens { + access: string; + expiration: string; + refresh: string +} + /** * Gets or creates a new user */ @@ -21,6 +34,7 @@ export function createOrGetUser( provider: string, providerId: string, accessToken: string, + expiration: string, refreshToken: string, details: IUserDetails): Promise { @@ -31,13 +45,14 @@ export function createOrGetUser( // Create a user first and then link this account to it let newUserP = users.putUser(details); userIdP = newUserP.then((newUser) => { - return accounts.linkAccount(provider, providerId, accessToken, refreshToken, newUser.id).then((account) => newUser.id); + return accounts.linkAccount(provider, providerId, accessToken, expiration, refreshToken, newUser.id).then((account) => newUser.id); }) } else { // Get the user but also go and update the refresh and access token at this point account.accessToken = accessToken; account.refreshToken = refreshToken; + account.expiration = expiration; var updateAccountP = accounts.updateAccount(account); @@ -58,6 +73,7 @@ export function linkAccount( provider: string, providerId: string, accessToken: string, + expiration: string, refreshToken: string, userId: string): Promise { @@ -68,7 +84,7 @@ export function linkAccount( throw { msg: "Account already linked" }; } else { - var linkP = accounts.linkAccount(provider, providerId, accessToken, refreshToken, userId); + var linkP = accounts.linkAccount(provider, providerId, accessToken, expiration, refreshToken, userId); return linkP.then(() => getUser(userId)); } }) @@ -100,4 +116,78 @@ export function getUser(userId: string): Promise { } }); }); +} + +export function getTokenExpiration(expires: number): string { + var expiration = moment().add(expires, "seconds"); + return expiration.utc().toISOString(); +} + +function refreshTokens(account: accounts.IAccount): Promise { + var udpatedAccountP = new Promise((resolve, reject) => { + // TODO should consolidate the account specific behavior behind an interface + if (account.provider === "microsoft") { + var microsoftConfiguration = nconf.get("login:microsoft"); + request.post('https://login.microsoftonline.com/common/oauth2/v2.0/token', { json: true, form: { + grant_type: 'refresh_token', + client_id: microsoftConfiguration.clientId, + client_secret: microsoftConfiguration.secret, + refresh_token: account.refreshToken + }}, (error, response, body) => { + if (error) { + reject(error); + } + else { + console.log(body); + account.accessToken = body.access_token; + account.expiration = getTokenExpiration(body.expires_in); + resolve(account); + } + }); + } + else if (account.provider === 'google') { + var googleConfig = nconf.get("login:google"); + var oauth2Client = new google.auth.OAuth2(googleConfig.clientId, googleConfig.secret, '/auth/google');; + + // Retrieve tokens via token exchange explained above or set them: + oauth2Client.setCredentials({ access_token: account.accessToken, refresh_token: account.refreshToken }); + oauth2Client.refreshAccessToken((error, tokens) => { + if (error) { + reject(error); + } + else { + account.accessToken = tokens.access_token; + account.expiration = moment(tokens.expiry_date).utc().toISOString(); + account.refreshToken = tokens.refresh_token; + resolve(account); + } + }); + } + else { + throw { error: "Unknown Provider" }; + } + }); + + // Get the updated account information and then use it to update the DB and then return the tokens back + return udpatedAccountP.then((updatedAccount: accounts.IAccount) => { + return accounts.updateAccount(updatedAccount).then(() => { + return { access: updatedAccount.accessToken, expiration: updatedAccount.expiration, refresh: updatedAccount.refreshToken } + }); + }); +} + +/** + * Retrieves the access tokens for the given account + */ +export function getTokens(account: accounts.IAccount): Promise { + var now = moment(); + var expiration = moment(account.expiration); + + var diff = expiration.diff(now); + if (now.isAfter(expiration)) { + return refreshTokens(account); + } + else { + return Promise.resolve({ access: account.accessToken, expiration: account.expiration, refresh: account.refreshToken }); + } } \ No newline at end of file diff --git a/server/src/app.ts b/server/src/app.ts index 7aceab54b49..dcf92eea664 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -17,6 +17,7 @@ import * as calendarRoute from './routes/calendar'; import * as passport from 'passport'; import * as connectRedis from 'connect-redis'; +import * as moment from 'moment'; import * as nconf from 'nconf'; import * as redis from 'redis'; import * as request from 'request'; @@ -81,11 +82,13 @@ function completeAuthentication( provider: string, providerId: string, accessToken: string, + expires: number, refreshToken: string, details: accounts.IUserDetails, done: (error: any, user?: any) => void) { - var userP = accounts.createOrGetUser(provider, providerId, accessToken, refreshToken, details); + let expiration = accounts.getTokenExpiration(expires); + var userP = accounts.createOrGetUser(provider, providerId, accessToken, expiration, refreshToken, details); userP.then( (user) => { done(null, user); @@ -99,11 +102,13 @@ function connectAccount( provider: string, providerId: string, accessToken: string, + expires: number, refreshToken: string, userId: string, done: (error: any, user?: any) => void) { - let linkP = accounts.linkAccount(provider, providerId, accessToken, refreshToken, userId); + let expiration = accounts.getTokenExpiration(expires); + let linkP = accounts.linkAccount(provider, providerId, accessToken, expiration, refreshToken, userId); linkP.then( (user) => { done(null, user); @@ -122,13 +127,13 @@ passport.use( callbackURL: '/auth/google/callback', passReqToCallback: true }, - (req, accessToken, refreshToken, params, profile, done) => { + (req, accessToken, refreshToken, params, profile, done) => { if (!req.user) { - console.log("Google / Not logged in"); completeAuthentication( 'google', profile.id, - accessToken, + accessToken, + params.expires_in, refreshToken, { displayName: profile.displayName, @@ -137,8 +142,7 @@ passport.use( done); } else { - console.log("Google / Logged in"); - connectAccount('google', profile.id, accessToken, refreshToken, req.user.user.id, done); + connectAccount('google', profile.id, accessToken, params.expires_in,refreshToken, req.user.user.id, done); } })); @@ -154,9 +158,8 @@ passport.use( passReqToCallback: true }, (req, iss, sub, profile, jwtClaims, accessToken, refreshToken, params, done) => { - if (!req.user) { - console.log("Microsoft / Not Logged In") - + console.log(params); + if (!req.user) { // use request to load in the user profile request.get('https://graph.microsoft.com/v1.0/me', { auth: { 'bearer': accessToken }, json: true }, (error, response, body) => { console.log('User profile information'); @@ -166,7 +169,8 @@ passport.use( 'microsoft', sub, accessToken, - refreshToken, + params.expires_in, + refreshToken, { displayName: body.displayName, name: { @@ -177,9 +181,8 @@ passport.use( done); }); } - else { - console.log("Microsoft / Logged In"); - connectAccount('microsoft', sub, accessToken, refreshToken, req.user.user.id, done); + else { + connectAccount('microsoft', sub, accessToken, params.expires_in, refreshToken, req.user.user.id, done); } })); diff --git a/server/src/db/accounts.ts b/server/src/db/accounts.ts index 19ad4deee56..0385523ba89 100644 --- a/server/src/db/accounts.ts +++ b/server/src/db/accounts.ts @@ -18,6 +18,9 @@ export interface IAccount { // Access information for the account accessToken: string, + // Access token expiration time + expiration: string, + // Used to refresh access to the account refreshToken: string, @@ -42,6 +45,7 @@ export function linkAccount( provider: string, providerId: string, accessToken: string, + expiration: string, refreshToken: string, userId: string) { @@ -50,6 +54,7 @@ export function linkAccount( provider: provider, providerId: providerId, accessToken: accessToken, + expiration: expiration, refreshToken: refreshToken, userId: userId }; diff --git a/server/src/routes/authOptions.ts b/server/src/routes/authOptions.ts index 9d09d1666d9..24898fab7c1 100644 --- a/server/src/routes/authOptions.ts +++ b/server/src/routes/authOptions.ts @@ -14,6 +14,7 @@ export let microsoft = { 'offline_access', 'profile', 'email', + 'User.Read', 'User.ReadBasic.All', 'Calendars.ReadWrite', 'Contacts.Read' diff --git a/server/src/routes/calendar.ts b/server/src/routes/calendar.ts index 2d2f596832c..9585ba800a2 100644 --- a/server/src/routes/calendar.ts +++ b/server/src/routes/calendar.ts @@ -1,6 +1,7 @@ import * as express from 'express'; import * as request from 'request'; import * as moment from 'moment'; +import * as accounts from '../accounts'; import { Promise } from 'es6-promise'; import { IUser } from '../accounts'; import * as nconf from 'nconf'; @@ -24,27 +25,30 @@ router.get('/', (req: express.Request, response: express.Response) => { for (let account of user.accounts) { if (account.provider === 'microsoft') { var microsoftCalendarP = new Promise((resolve, reject) => { - let url = `https://graph.microsoft.com/v1.0/me/calendar/calendarView?StartDateTime=${now.toISOString()}&endDateTime=${nextWeek.toISOString()}`; - request.get( - url, - { auth: { 'bearer': account.accessToken }, json: true }, (error, response, body) => { - if (error) { - reject(error); - } - else { - var microsoftResults = body.value.map((item) => ({ - summary: item.subject, - start: item.start.dateTime, - end: item.end.dateTime - })); - resolve({ provider: 'Microsoft', items: microsoftResults }); - } - }); - }) + return accounts.getTokens(account).then((tokens) => { + let url = `https://graph.microsoft.com/v1.0/me/calendar/calendarView?StartDateTime=${now.toISOString()}&endDateTime=${nextWeek.toISOString()}`; + request.get( + url, + { auth: { 'bearer': tokens.access }, json: true }, (error, response, body) => { + if (error) { + reject(error); + } + else { + var microsoftResults = body.value.map((item) => ({ + summary: item.subject, + start: item.start.dateTime, + end: item.end.dateTime + })); + resolve({ provider: 'Microsoft', items: microsoftResults }); + } + }); + }) + }) resultPromises.push(microsoftCalendarP); } - else if (account.provider === 'google') { - var googleCalendarP = new Promise((resolve, reject) => { + else if (account.provider === 'google') { + var googleCalendarP = new Promise((resolve, reject) => { + return accounts.getTokens(account).then((tokens) => { let calendar = google.calendar('v3'); var OAuth2 = google.auth.OAuth2; var googleConfig = nconf.get("login:google"); @@ -52,8 +56,8 @@ router.get('/', (req: express.Request, response: express.Response) => { // Retrieve tokens via token exchange explained above or set them: oauth2Client.setCredentials({ - access_token: account.accessToken, - refresh_token: account.refreshToken + access_token: tokens.access, + refresh_token: tokens.refresh }); calendar.events.list({ @@ -72,18 +76,18 @@ router.get('/', (req: express.Request, response: express.Response) => { summary: item.summary, start: item.start.dateTime, end: item.end.dateTime - })); - console.log(JSON.stringify(googleCalendarItems, null, 2)); + })); resolve({ provider: 'Google', items: googleCalendarItems }); } - }); - }); + }); + }); + }); + resultPromises.push(googleCalendarP); } } - Promise.all(resultPromises).then((results) => { - console.log(results.length); + Promise.all(resultPromises).then((results) => { response.render( 'calendar', { @@ -91,6 +95,8 @@ router.get('/', (req: express.Request, response: express.Response) => { partials: defaultPartials, viewModel: results }); + }, (error) => { + response.status(400).json(error); }); }); diff --git a/server/views/index.hjs b/server/views/index.hjs index d38d34f7217..3c810f21419 100644 --- a/server/views/index.hjs +++ b/server/views/index.hjs @@ -4,14 +4,14 @@

{{ title }}

Welcome to {{ title }}

-
-

Data Sources

- - + {{#user}} +

Data Sources

+ + {{/user}} {{^user}} {{Disconnect {{/details.connected}} {{^details.connected}} - Connect + Connect {{/details.connected}}