From 7ddda30b31da9c0945b0b2bdb9ec67bd8f565e11 Mon Sep 17 00:00:00 2001 From: Kaushik Date: Tue, 12 Jul 2022 23:50:12 +0530 Subject: [PATCH] authorization flow added --- constants.js | 21 ++++++ controllers/auth.js | 130 ++++++++++++++++++++++++++++++++++++ routes/auth.js | 24 +++++++ utils/generateRandString.js | 14 ++++ 4 files changed, 189 insertions(+) create mode 100644 constants.js create mode 100644 controllers/auth.js create mode 100644 routes/auth.js create mode 100644 utils/generateRandString.js diff --git a/constants.js b/constants.js new file mode 100644 index 0000000..7d15c54 --- /dev/null +++ b/constants.js @@ -0,0 +1,21 @@ +module.exports = { + scopes: { + ImageUpload: 'ugc-image-upload', + ControlPlayback: 'user-modify-playback-state', + ViewPlaybackState: 'user-read-playback-state', + ViewCurrentlyPlaying: 'user-read-currently-playing', + ModifyFollow: 'user-follow-modify', + ViewFollow: 'user-follow-read', + ViewRecentlyPlayed: 'user-read-recently-played', + ViewPlaybackPosition: 'user-read-playback-position', + ViewTop: 'user-top-read', + IncludeCollaborative: 'playlist-read-collaborative', + ModifyPublicPlaylists: 'playlist-modify-public', + ViewPrivatePlaylists: 'playlist-read-private', + ModifyPrivatePlaylists: 'playlist-modify-private', + ControlRemotePlayback: 'app-remote-control', + ModifyLibrary: 'user-library-modify', + ViewLibrary: 'user-library-read', + }, + stateKey: 'spotify_auth_state' +} \ No newline at end of file diff --git a/controllers/auth.js b/controllers/auth.js new file mode 100644 index 0000000..c080cd8 --- /dev/null +++ b/controllers/auth.js @@ -0,0 +1,130 @@ +require('dotenv').config(); + +const typedefs = require("../typedefs"); +const { scopes, stateKey } = require('../constants'); + +const generateRandString = require('../utils/generateRandString'); + +/** + * Stateful redirect to Spotify login with credentials + * @param {typedefs.Req} req + * @param {typedefs.Res} res + */ +const login = (_req, res) => { + try { + const state = generateRandString(16); + res.cookie(stateKey, state); + + const scope = Object.values(scopes).join(' '); + return res.redirect( + 'https://accounts.spotify.com/authorize?' + + new URLSearchParams({ + response_type: 'code', + client_id: process.env.CLIENT_ID, + scope: scope, + redirect_uri: process.env.REDIRECT_URI, + state: state + }).toString() + ); + } catch (error) { + console.error(error); + return res.status(500).send({ message: "Server Error. Try again." }); + } +} + +/** + * Exchange authorization code for refresh and access tokens + * @param {typedefs.Req} req + * @param {typedefs.Res} res + */ +const callback = async (req, res) => { + try { + const code = req.query.code || null; + const state = req.query.state || null; + const error = req.query.error || null; + const storedState = req.cookies ? req.cookies[stateKey] : null; + + // check state + if (state === null || state !== storedState) { + console.error('state mismatch'); + return res.redirect(409, '/'); + } else if (error !== null) { + console.error(error); + return res.status(401).send(`Error: ${error}`); + } else { + // get auth tokens + res.clearCookie(stateKey); + const authOptions = { + url: 'https://accounts.spotify.com/api/token', + form: { + code: code, + redirect_uri: process.env.REDIRECT_URI, + grant_type: 'authorization_code' + }, + headers: { + 'Authorization': 'Basic ' + (Buffer.from(process.env.CLIENT_ID + ':' + process.env.CLIENT_SECRET).toString('base64')) + }, + responseType: 'json' + }; + + const { got } = await import("got"); + + const response = await got.post(authOptions); + if (response.statusCode === 200) { + const access_token = response.body.access_token; + const refresh_token = response.body.refresh_token; + + return res.status(200).send({ + message: "Auth tokens obtained.", + refresh_token, + access_token, + }); + } + } + } catch (error) { + console.error(error); + return res.status(500).send({ message: "Server Error. Try again." }); + } +} + +/** + * Request new access token using refresh token + * @param {typedefs.Req} req + * @param {typedefs.Res} res + */ +const refresh = async (req, res) => { + try { + const refresh_token = req.query.refresh_token; + const authOptions = { + url: 'https://accounts.spotify.com/api/token', + headers: { + 'Authorization': 'Basic ' + (Buffer.from(process.env.CLIENT_ID + ':' + process.env.CLIENT_SECRET).toString('base64')) + }, + form: { + grant_type: 'refresh_token', + refresh_token, + }, + responseType: 'json' + }; + + const { got } = await import("got"); + + const response = await got.post(authOptions); + if (response.statusCode === 200) { + const access_token = response.body.access_token; + return res.status(200).send({ + message: "New access token obtained.", + access_token, + }); + } + } catch (error) { + console.error(error); + return res.status(500).send({ message: "Server Error. Try again." }); + } +}; + +module.exports = { + login, + callback, + refresh +}; \ No newline at end of file diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..e6de74b --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,24 @@ +const router = require('express').Router(); + +const { login, callback, refresh } = require('../controllers/auth'); +const validator = require("../validators"); + +router.get( + "/login", + validator.validate, + login +); + +router.get( + "/callback", + validator.validate, + callback +); + +router.get( + "/refresh", + validator.validate, + refresh +) + +module.exports = router; diff --git a/utils/generateRandString.js b/utils/generateRandString.js new file mode 100644 index 0000000..a4faabe --- /dev/null +++ b/utils/generateRandString.js @@ -0,0 +1,14 @@ +/** + * Generates a random string containing numbers and letters + * @param {number} length The length of the string + * @return {string} The generated string + */ +module.exports = (length) => { + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ''; + + for (let i = 0; i < length; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +}; \ No newline at end of file