Skip to content

Commit 20a1af7

Browse files
authored
Merge pull request #35 from michiganhackers/pr-13
Update Access Tokens before Expiration
2 parents e22a094 + d7fc0a6 commit 20a1af7

File tree

8 files changed

+226
-13
lines changed

8 files changed

+226
-13
lines changed

src/app/api/sessionDB/create/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ export async function POST(req: Request) {
1010
const data = await req.json();
1111
const accessToken : string = data.accessToken;
1212
const refreshToken : string = data.refreshToken;
13+
const expires_in : number = data.expires_in;
14+
15+
const now : any = Date.now();
16+
const expiration = new Date(now + (expires_in * 1000)).toISOString(); // Time of expiration, expressed in "YYYY-MM-DDT00:00:00Z" (ISO format)
1317

1418
let sid;
1519
try {
16-
sid = await CreateSession(accessToken, refreshToken);
20+
sid = await CreateSession(accessToken, refreshToken, expiration);
1721
}
1822
catch (error) {
1923
return NextResponse.json(

src/app/api/spotify/getToken/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export async function GET(req: Request) {
2626
// const state = reqUrl.searchParams.get('state');
2727

2828
var accessToken, refreshToken : string;
29+
var expires_in : Number;
2930
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
3031
method: 'POST',
3132
headers: {
@@ -45,12 +46,14 @@ export async function GET(req: Request) {
4546
const data = await tokenResponse.json();
4647
accessToken = data.access_token;
4748
refreshToken = data.refresh_token;
49+
expires_in = data.expires_in;
4850

4951
const createResponse = await fetch(process.env.APP_SERVER + '/api/sessionDB/create', {
5052
method: 'POST',
5153
body: JSON.stringify({
5254
accessToken: accessToken,
53-
refreshToken: refreshToken
55+
refreshToken: refreshToken,
56+
expires_in
5457
})
5558
})
5659

src/database/db.ts

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import postgres from 'postgres'
22
import 'dotenv/config'
3-
3+
import { cloneElement } from 'react'
44

55
// const sql = postgres(process.env.PG_URI!)
66
const sql = postgres({
@@ -78,7 +78,8 @@ export async function VerifyGuestCode(guestCode : string) : Promise<any> {
7878

7979
export async function CreateSession(
8080
accessToken : string,
81-
refreshToken : string) : Promise<any> {
81+
refreshToken : string,
82+
expiration : string) : Promise<any> {
8283

8384
// Get all currently used session codes
8485
const codes : any[] = await sql`
@@ -107,8 +108,8 @@ export async function CreateSession(
107108
} while (!is_unique)
108109

109110
await sql`
110-
INSERT INTO sessions (session_id, access_token, refresh_token)
111-
VALUES (${code}, ${accessToken}, ${refreshToken})
111+
INSERT INTO sessions (session_id, access_token, refresh_token, expiration)
112+
VALUES (${code}, ${accessToken}, ${refreshToken}, ${expiration})
112113
`
113114
return code;
114115
}
@@ -247,3 +248,79 @@ export async function DeleteSession(sid : string) : Promise<void> {
247248
throw new Error("sid does not exist")
248249
}
249250
}
251+
252+
253+
export async function GetRefreshToken(sid : string) : Promise<any> {
254+
255+
const token = await sql`
256+
SELECT refresh_token FROM sessions
257+
WHERE session_id = ${sid}
258+
`
259+
260+
if(token.length < 1) {
261+
throw Error("Undefined session id")
262+
}
263+
264+
console.log("refreshtoken: ", token[0].refresh_token)
265+
266+
return token[0].refresh_token;
267+
}
268+
269+
export async function UpdateTokens(
270+
accessToken : string,
271+
refreshToken : string,
272+
expiration : string,
273+
sid : string) : Promise<any> {
274+
275+
if(!IsValidSid(sid)) {
276+
throw new Error(`Invalid sid: ${sid}`);
277+
}
278+
279+
const token = await sql`
280+
SELECT access_token FROM sessions
281+
WHERE session_id = ${sid}
282+
`
283+
284+
console.log("old token: ", token[0].access_token)
285+
286+
await sql`
287+
UPDATE sessions
288+
SET access_token = ${accessToken}, refresh_token = ${refreshToken}, expiration = ${expiration}
289+
WHERE session_id = ${sid}
290+
`;
291+
292+
return sid;
293+
}
294+
295+
296+
export async function GetExpiration(sid: string) : Promise<string> {
297+
if(!IsValidSid(sid)) {
298+
throw new Error(`Invalid sid: ${sid}`);
299+
}
300+
301+
const expiration = await sql`
302+
SELECT expiration
303+
FROM sessions
304+
WHERE session_id = ${sid}
305+
`
306+
307+
console.log("Expiration: " + expiration[0].expiration)
308+
309+
return expiration[0].expiration
310+
}
311+
312+
313+
/**** HELPER DB FUNCTIONS BELOW ****/
314+
315+
export async function IsValidSid(sid: string) : Promise<boolean> {
316+
// Get all currently used session codes
317+
const codes : any[] = await sql`
318+
SELECT session_id FROM sessions
319+
`
320+
321+
if(!codes.find(code => code.session_id === sid)) {
322+
return false;
323+
}
324+
325+
return true;
326+
}

src/database/schema.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CREATE TABLE sessions (
44
host_id INT,
55
access_token varchar(255),
66
refresh_token varchar(255),
7+
expiration varchar(255),
78
PRIMARY KEY (session_id)
89
);
910

src/socket/WebSocketController.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { DeleteSession, GetAccessToken, GetQueue, ReplaceQueue } from "../database/db"
1+
import { setMaxIdleHTTPParsers } from "http";
2+
import { DeleteSession, GetAccessToken, GetExpiration, GetQueue, IsValidSid, ReplaceQueue } from "../database/db"
3+
import { updateTokens } from "./refreshTokens";
4+
import { sleep } from "../../src/utils"
25

36
// Goals for this class:
47
// - Create an instance of WebSocketController at top level of server.ts
@@ -27,7 +30,7 @@ export class WebSocketController {
2730

2831

2932
// Adds new {sid, intervalID} to checkQueueUpdatesIntervals
30-
public addSessionInterval(sid : string) : void {
33+
public async addSessionInterval(sid : string) : Promise<void> {
3134
// Check if session already exists, if it does, simply increment user cound
3235
if(this.checkQueueUpdatesIntervals.has(sid)) {
3336
this.incrementUserCount(sid);
@@ -36,6 +39,9 @@ export class WebSocketController {
3639

3740
this.currentSongProgress.set(sid, { progress: 0, lastUpdated: 0, isPlaying: false, duration: 0 });
3841

42+
// Set an interval to trigger before the expiration to create a new access token
43+
this.createRefreshTokenTimeout(sid);
44+
3945
// Calls checkQueueUpdates every 5 seconds
4046
let intervalID = setInterval(async () => {
4147
const existing = this.currentSongProgress.get(sid);
@@ -87,7 +93,7 @@ export class WebSocketController {
8793
console.log("Terminating session interval and database information");
8894
this.destroySession(sid);
8995
}
90-
}, 60000)
96+
}, 600000) // 10 minutes
9197
}
9298
}
9399

@@ -214,5 +220,42 @@ export class WebSocketController {
214220
} catch (error) {
215221
console.error(`Failed to sync song progress for session ${sid}:`, error);
216222
}
217-
}
223+
}
224+
225+
// To be called every expires_in seconds, which is returned from original
226+
// access_token request
227+
private async updateAccessToken(sid : string) : Promise<void> {
228+
let failCount = 0;
229+
// Attempt to update token up to 3 times
230+
while(failCount < 3 && !(await updateTokens(sid))) {
231+
failCount++;
232+
sleep(1)
233+
}
234+
if (failCount == 3) // If updateTokens fails 3 times, assume new token cannot be acquired
235+
return;
236+
237+
await this.createRefreshTokenTimeout(sid); // Repeat cycle
238+
}
239+
240+
241+
private async createRefreshTokenTimeout(sid: string) : Promise<void> {
242+
const now : any = new Date();
243+
let expiration_str : string;
244+
245+
if(await IsValidSid(sid)) {
246+
expiration_str = await GetExpiration(sid);
247+
}
248+
else {
249+
console.error("@@@ Failed to validate session: ", sid);
250+
return; // Session has ended
251+
}
252+
253+
const expiration : any = new Date(expiration_str)
254+
const expires_in : number = (expiration - now) - 10000; // Difference in milliseconds (minus 10 seconds)
255+
if(expires_in >= 0) {
256+
setTimeout(async () => {
257+
this.updateAccessToken(sid);
258+
}, expires_in)
259+
}
260+
}
218261
};

src/socket/refreshTokens.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { NextResponse } from 'next/server'
2+
import 'dotenv/config'
3+
import { GetRefreshToken, UpdateTokens } from '../database/db';
4+
5+
const client_id = process.env.SPOTIFY_CLIENT_ID;
6+
const client_secret = process.env.SPOTIFY_CLIENT_SECRET;
7+
const basic = btoa(`${client_id}:${client_secret}`);
8+
9+
// RETURNS:
10+
// true -- successful update of access token
11+
// false -- failed update of access token
12+
export async function updateTokens(sid : string) : Promise<boolean> {
13+
14+
const url = "https://accounts.spotify.com/api/token";
15+
let refreshToken;
16+
try {
17+
refreshToken = await GetRefreshToken(sid);
18+
}
19+
catch (err) {
20+
console.error("@@@ Failed to obtain refreshToken:\n", err);
21+
return false;
22+
}
23+
24+
const payload = {
25+
method: 'POST',
26+
headers: {
27+
'Content-Type': 'application/x-www-form-urlencoded',
28+
'Authorization': 'Basic ' + basic
29+
},
30+
body: new URLSearchParams({
31+
grant_type: 'refresh_token',
32+
refresh_token: refreshToken,
33+
}),
34+
}
35+
36+
try {
37+
// Fetch the response from Spotify API
38+
const response = await fetch(url, payload);
39+
40+
// Check for a successful response
41+
if (!response.ok) {
42+
console.error(`@@@ Failed to refresh token with Spotify API: ${response.status}`);
43+
return false;
44+
}
45+
46+
// Parse the response JSON
47+
const data = await response.json();
48+
49+
// Extract new tokens from the response
50+
const newAccessToken = data.access_token;
51+
const expires_in : number = data.expires_in;
52+
let newRefreshToken : string;
53+
if('refresh_token' in data) {
54+
newRefreshToken = data.refresh_token;
55+
}
56+
else {
57+
newRefreshToken = refreshToken;
58+
}
59+
60+
const now : any = Date.now();
61+
const expiration = new Date(now + (expires_in * 1000)).toISOString(); // Time of expiration, expressed in "YYYY-MM-DDT00:00:00Z" (ISO format)
62+
63+
// Update the tokens in the database
64+
let confirm;
65+
try {
66+
confirm = await UpdateTokens(newAccessToken, newRefreshToken, expiration, sid);
67+
}
68+
catch (err) {
69+
console.error("@@@ Failed to update tokens in DB:\n", err);
70+
return false;
71+
}
72+
73+
return true; // No need to return anything unless you want to send a response
74+
} catch (error) {
75+
console.error('@@@ Error during token update process:\n', error);
76+
return false;
77+
}
78+
}

src/socket/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const controller = new WebSocketController(io);
1313

1414
// let checkQueueUpdatesIntervals = new Map<string, any>();
1515
// var checkQueueUpdatesInterval : any;
16-
io.on("connection", (socket) => {
16+
io.on("connection", async (socket) => {
1717

1818
// Add user to the room (session) in which they want to connect
1919
const sid : string = socket.handshake.auth.token;
@@ -23,7 +23,7 @@ io.on("connection", (socket) => {
2323
socket.join(sid);
2424

2525
if(isHost === "true")
26-
controller.addSessionInterval(sid);
26+
await controller.addSessionInterval(sid);
2727
else
2828
controller.incrementUserCount(sid);
2929

src/utils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,11 @@ export function handleSpotifyAuth(client_id : string | undefined, redirect_uri :
2121
//songID vs song_id => when fetching straight from spotify its the former, from database its the latter
2222
export function getValue(data: any, key: string) {
2323
return data[key] ?? data[key.replace(/([A-Z])/g, '_$1').toLowerCase()];
24-
}
24+
}
25+
26+
27+
// General purpose sleep function
28+
export function sleep(seconds : number) {
29+
const ms : number = seconds * 1000;
30+
return new Promise(resolve => setTimeout(resolve, ms));
31+
}

0 commit comments

Comments
 (0)