Skip to content

Commit 8ff0aeb

Browse files
committed
Handle token refreshes completely outside Auth0
1 parent c2aef9e commit 8ff0aeb

File tree

4 files changed

+120
-61
lines changed

4 files changed

+120
-61
lines changed

api/src/functions/auth/refresh-token.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@ export const handler = catchErrors(async (event) => {
2929
headers: { ...headers,
3030
'content-type': 'application/json'
3131
},
32-
body: JSON.stringify({
33-
accessToken: result.access_token,
34-
expiresAt: Date.now() + result.expires_in * 1000
35-
})
32+
body: JSON.stringify(result)
3633
};
3734
});

api/src/user-data-facade.ts

Lines changed: 78 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import _ from 'lodash';
22
import { sql, Transaction } from 'kysely';
33
import log from 'loglevel';
44
import jwt from 'jsonwebtoken';
5+
import { randomBytes } from 'crypto';
56

67
import { reportError } from './errors.ts'
78
import * as auth0 from './auth0.ts';
@@ -184,10 +185,51 @@ export async function loginWithPasswordlessCode(email: string, code: string, use
184185
return auth0LoginResult;
185186
}
186187

187-
export async function refreshToken(refreshToken: string, userIp: string) {
188-
const auth0RefreshResult = await auth0.refreshToken(refreshToken, userIp);
189-
await pullUserIntoDBFromRefresh(refreshToken, auth0RefreshResult, userIp);
190-
return auth0RefreshResult;
188+
export function refreshToken(refreshToken: string, userIp: string) {
189+
return db.transaction().execute(async (trx) => {
190+
// If we're already in the DB, we skip Auth0 entirely:
191+
if (await doesRefreshTokenExist(trx, refreshToken)) {
192+
const newAccessToken = `at-${randomBytes(32).toString('hex')}`;
193+
const expiresAt = Date.now() + (1000 * 60 * 60 * 24);
194+
195+
await Promise.all([
196+
trx.insertInto('access_tokens')
197+
.values({
198+
value: newAccessToken,
199+
refresh_token: refreshToken,
200+
expires_at: new Date(expiresAt)
201+
})
202+
.execute(),
203+
trx.updateTable('refresh_tokens')
204+
.set({
205+
last_used: new Date()
206+
})
207+
.where('value', '=', refreshToken)
208+
.execute()
209+
]);
210+
211+
return {
212+
accessToken: newAccessToken,
213+
expiresAt
214+
};
215+
} else {
216+
// If not, we go to Auth0 as before, and then cache the result:
217+
const auth0RefreshResult = await auth0.refreshToken(refreshToken, userIp);
218+
await pullUserIntoDBFromRefresh(trx, refreshToken, auth0RefreshResult, userIp);
219+
return {
220+
accessToken: auth0RefreshResult.access_token!,
221+
expiresAt: Date.now() + auth0RefreshResult.expires_in! * 1000
222+
};
223+
}
224+
});
225+
}
226+
227+
function doesRefreshTokenExist(trx: Transaction<Database>, refreshToken: string) {
228+
return trx.selectFrom('refresh_tokens')
229+
.where('value', '=', refreshToken)
230+
.selectAll()
231+
.executeTakeFirst()
232+
.then((rt) => !!rt);
191233
}
192234

193235
// On initial login or token refresh, we pull the user data from Auth0 en route, and migrate it
@@ -241,7 +283,7 @@ async function pullUserIntoDBFromLogin(tokens: TokenSet, userIp: string) {
241283
}
242284
}
243285

244-
async function pullUserIntoDBFromRefresh(refreshToken: string, refreshResult: TokenSet, userIp: string) {
286+
async function pullUserIntoDBFromRefresh(trx: Transaction<Database>, refreshToken: string, refreshResult: TokenSet, userIp: string) {
245287
try {
246288
// Insert refresh token, access token & user, if each is not already present. Go from the user down?
247289
// We need to do a query to get the user data from auth0, if we don't have it already... Awkward.
@@ -250,58 +292,38 @@ async function pullUserIntoDBFromRefresh(refreshToken: string, refreshResult: To
250292
throw new Error('No access token present after refresh');
251293
}
252294

253-
await db.transaction().execute(async (trx) => {
254-
const refreshTokenExists = await trx.selectFrom('refresh_tokens')
255-
.where('value', '=', refreshToken)
256-
.selectAll()
257-
.executeTakeFirst()
258-
.then((rt) => !!rt);
259-
260-
if (refreshTokenExists) {
261-
return trx.insertInto('access_tokens')
262-
.values({
263-
value: refreshResult.access_token,
264-
refresh_token: refreshToken,
265-
expires_at: new Date(Date.now() + (refreshResult.expires_in * 1000))
266-
})
267-
.execute();
268-
}
269-
270-
// If the refresh token didn't exist, we need to fetch the user data given the access token,
271-
// and then create the RT to reference it (and maybe create the user too, if required).
272-
const auth0User = await auth0.getUserInfoFromToken(refreshResult.access_token);
273-
if (!auth0User?.sub || !auth0User?.email) {
274-
console.warn(`Returned user data:`, auth0User);
275-
throw new Error('Could not get user info from access token during refresh');
276-
}
277-
278-
// Create user (or just get id) in our DB, in case it doesn't exist:
279-
const user = await trx.insertInto('users')
280-
.values({
281-
email: auth0User.email,
282-
auth0_user_id: auth0User.sub,
283-
last_ip: userIp,
284-
logins_count: 1,
285-
app_metadata: {},
295+
const auth0User = await auth0.getUserInfoFromToken(refreshResult.access_token);
296+
if (!auth0User?.sub || !auth0User?.email) {
297+
console.warn(`Returned user data:`, auth0User);
298+
throw new Error('Could not get user info from access token during refresh');
299+
}
300+
301+
// Create user (or just get id) in our DB, in case it doesn't exist:
302+
const user = await trx.insertInto('users')
303+
.values({
304+
email: auth0User.email,
305+
auth0_user_id: auth0User.sub,
306+
last_ip: userIp,
307+
logins_count: 1,
308+
app_metadata: {},
309+
})
310+
.onConflict((oc) => oc
311+
.column('auth0_user_id')
312+
.doUpdateSet({
313+
last_ip: (eb) => eb.ref('excluded.last_ip')
286314
})
287-
.onConflict((oc) => oc
288-
.column('auth0_user_id')
289-
.doUpdateSet({
290-
last_ip: (eb) => eb.ref('excluded.last_ip')
291-
})
292-
)
293-
.returning('id')
294-
.executeTakeFirstOrThrow();
295-
296-
// Store the refresh & access tokens again with the full info this time round:
297-
await storeRefreshAndAccessTokens(
298-
trx,
299-
user.id,
300-
refreshToken,
301-
refreshResult.access_token!,
302-
refreshResult.expires_in!
303-
);
304-
});
315+
)
316+
.returning('id')
317+
.executeTakeFirstOrThrow();
318+
319+
// Store the refresh & access tokens again with the full info this time round:
320+
await storeRefreshAndAccessTokens(
321+
trx,
322+
user.id,
323+
refreshToken,
324+
refreshResult.access_token!,
325+
refreshResult.expires_in!
326+
);
305327
} catch (err: any) {
306328
log.error('Error pulling user into DB during token refresh:', err);
307329
reportError(err);

api/test/auth.spec.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { expect } from 'chai';
44
import jwt from 'jsonwebtoken';
55

66
import { DestroyableServer } from 'destroyable-server';
7-
import { startAPI, privateKey, givenUser, givenAuthToken } from './test-setup/setup.ts';
7+
import { startAPI, privateKey, givenUser, givenAuthToken, givenRefreshToken } from './test-setup/setup.ts';
88
import { AUTH0_PORT, auth0Server, givenAuth0Token } from './test-setup/auth0.ts';
99
import { testDB } from './test-setup/database.ts';
1010

@@ -309,6 +309,41 @@ describe("API auth endpoints", () => {
309309
expect(dbAccessTokens[0].value).to.equal('at');
310310
expect(dbAccessTokens[0].refresh_token).to.equal('rt');
311311
});
312+
313+
it("skips Auth0 entirely if the user is already in the DB", async () => {
314+
const refreshToken = 'rt';
315+
await givenUser('auth0|userid', '[email protected]');
316+
await givenRefreshToken(refreshToken, 'auth0|userid');
317+
318+
const tokenEndpoint = await auth0Server.forPost('/oauth/token')
319+
.withForm({
320+
refresh_token: refreshToken,
321+
grant_type: 'refresh_token'
322+
})
323+
.thenCallback(() => {
324+
throw new Error('Should not be called');
325+
});
326+
327+
const response = await fetch(`${apiAddress}/api/auth/refresh-token`, {
328+
method: 'POST',
329+
headers: { 'content-type': 'application/json' },
330+
body: JSON.stringify({ refreshToken })
331+
});
332+
333+
expect(response.status).to.equal(200);
334+
expect(await tokenEndpoint.getSeenRequests()).to.have.length(0);
335+
336+
const result = await response.json();
337+
expect(result.accessToken).to.match(/^at-.{64}$/);
338+
expect(result.expiresAt).to.be.greaterThan(Date.now());
339+
expect(result.expiresAt).to.be.lessThan(Date.now() + 100_000_000);
340+
341+
// The resulting token should appear in the DB:
342+
const dbAccessTokens = (await testDB.query('SELECT * FROM access_tokens')).rows;
343+
expect(dbAccessTokens).to.have.length(1);
344+
expect(dbAccessTokens[0].value).to.equal(result.accessToken);
345+
expect(dbAccessTokens[0].refresh_token).to.equal(refreshToken);
346+
});
312347
});
313348

314349
});

api/test/test-setup/setup.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ export function freshAuthToken() {
100100
return crypto.randomBytes(20).toString('hex');
101101
}
102102

103+
export async function givenRefreshToken(refreshToken: string, auth0UserId: string) {
104+
const dbUserId = (await testDB.query(`SELECT id FROM users WHERE auth0_user_id = $1`, [auth0UserId])).rows[0].id;
105+
await testDB.query('INSERT INTO refresh_tokens (value, user_id) VALUES ($1, $2)', [refreshToken, dbUserId]);
106+
}
107+
103108
export async function givenAuthToken(authToken: string, auth0UserId: string, email?: string) {
104109
const auth0Endpoint = await auth0.givenAuth0Token(authToken, auth0UserId, email);
105110

0 commit comments

Comments
 (0)