├── LICENSE ├── README.md ├── mod.ts ├── spotifyStrategy.ts └── types.ts /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dashport-spotifystrategy 2 | Spotify OAuth 2.0 strategy for [Dashport](https://github.com/oslabs-beta/dashport) module for Deno 3 | ``` 4 | import SpotifyStrategy from 'https://deno.land/x/dashport_spotify/mod.ts' 5 | ``` 6 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import SpotifyStrategy from './spotifyStrategy.ts'; 2 | export default SpotifyStrategy; 3 | -------------------------------------------------------------------------------- /spotifyStrategy.ts: -------------------------------------------------------------------------------- 1 | import { OakContext, Options, AuthData, TokenData} from './types.ts'; 2 | import { Base64 } from 'https://deno.land/x/bb64/mod.ts'; 3 | 4 | /** 5 | * Creates an instance of `SpotifyStrategy`. 6 | * 7 | * * Options: 8 | * 9 | * - client_id: string Required 10 | * - client_secret: string Required 11 | * - redirect_uri: string Required 12 | * - state: string; 13 | * - scope: string; 14 | * - client_secret: string; 15 | * 16 | */ 17 | export default class SpotifyStrategy { 18 | name: string = 'spotify' 19 | options: Options; 20 | uriFromParams: string; 21 | authURL: string = 'https://accounts.spotify.com/authorize?'; 22 | tokenURL: string = 'https://accounts.spotify.com/api/token?'; 23 | authDataURL: string = 'https://api.spotify.com/v1/me?'; 24 | /** 25 | * @constructor 26 | * @param {Object} options 27 | * @api public 28 | */ 29 | constructor (options: Options) { 30 | if (!options.client_id || !options.redirect_uri || !options.state || !options.client_secret) { 31 | throw new Error('ERROR in SpotifyStrategy constructor: Missing required arguments'); 32 | } 33 | 34 | this.options = options; 35 | 36 | // PRE STEP 1: 37 | // Constructs the second half of the authURL for developer's first endpoint from the info put into 'options' 38 | this.uriFromParams = this.constructURI(this.options, ['client_secret']); 39 | } 40 | 41 | constructURI(options: any, skip?: string[]): any { 42 | const paramArray: string[][] = Object.entries(options); 43 | let paramString: string = ''; 44 | 45 | for (let i = 0; i < paramArray.length; i++) { 46 | let [key, value] = paramArray[i]; 47 | 48 | if (skip && skip.includes(key)) continue; 49 | // adds the key and '=' for every member of options not in the skip array 50 | paramString += (key + '='); 51 | // adds the value and '&' for every member of options not in the skip array 52 | paramString += (value + '&'); 53 | } 54 | 55 | // removes the '&' that was just placed at the end of the string 56 | if (paramString[paramString.length - 1] === '&') { 57 | paramString = paramString.slice(0, -1); 58 | } 59 | 60 | return paramString; 61 | } 62 | 63 | // parses an encoded URI 64 | parseCode(encodedCode: string): string { 65 | const replacements: { [name: string] : string } = { 66 | "%24": "$", 67 | "%26": "&", 68 | "%2B": "+", 69 | "%2C": ",", 70 | "%2F": "/", 71 | "%3A": ":", 72 | "%3B": ";", 73 | "%3D": "=", 74 | "%3F": "?", 75 | "%40": "@" 76 | } 77 | 78 | const toReplaceArray: string[] = Object.keys(replacements); 79 | 80 | for(let i = 0; i < toReplaceArray.length; i++) { 81 | while (encodedCode.includes(toReplaceArray[i])) { 82 | encodedCode = encodedCode.replace(toReplaceArray[i], replacements[toReplaceArray[i]]); 83 | } 84 | } 85 | 86 | return encodedCode; 87 | } 88 | 89 | // ENTRY POINT 90 | async router(ctx: OakContext, next: Function) { 91 | // GO_Step 1 Request Permission 92 | if (!ctx.request.url.search) return await this.authorize(ctx, next); 93 | // GO_Step 3 Exchange code for Token 94 | if (ctx.request.url.search.slice(1, 5) === 'code') return this.getAuthToken(ctx, next); 95 | } 96 | 97 | // STEP 1: sends the programatically constructed uri to an oauth 2.0 server 98 | async authorize(ctx: OakContext, next: Function) { 99 | return await ctx.response.redirect(this.authURL + this.uriFromParams); 100 | } 101 | 102 | // STEP 2: client says yes or no 103 | 104 | // STEP 3: handle oauth 2.0 server response containing auth code 105 | // STEP 3.5: request access token in exchange for auth code 106 | async getAuthToken(ctx: OakContext, next: Function) { 107 | // the URI sent back from the endpoint you provided in step 1 108 | const OGURI: string = ctx.request.url.search; 109 | 110 | if (OGURI.includes('error')) { 111 | return new Error('ERROR in getAuthToken: Received an error from auth token code request.'); 112 | } 113 | 114 | // EXTRACT THE AUTH CODE 115 | // splits the string at the '=,' storing the first part in URI1[0] and the part wanted in URI1[1] 116 | let URI1: string[] = OGURI.split('='); 117 | // splits the string at the ampersand(&), storing the string with the access_token in URI2[0] 118 | // and the other parameters at URI2[n] 119 | const URI2: string[] = URI1[1].split('&'); 120 | // PARSE THE URI 121 | const code: string = this.parseCode(URI2[0]); 122 | 123 | // STEP 3.5 124 | const bodyOptions = { 125 | grant_type: 'authorization_code', 126 | code: code, 127 | redirect_uri: this.options.redirect_uri 128 | } 129 | const b64 = Base64.fromString(this.options.client_id + ':' + this.options.client_secret).toString(); 130 | const tokenOptions: any = { 131 | method: 'POST', 132 | headers: { 133 | 'Authorization': `Basic ${b64}`, 134 | 'Content-Type': 'application/x-www-form-urlencoded' 135 | }, 136 | body: this.constructURI(bodyOptions) 137 | } 138 | 139 | // SEND A FETCH REQ FOR TOKEN 140 | try { 141 | let data: any = await fetch(this.tokenURL, tokenOptions); 142 | data = await data.json(); 143 | 144 | if (data.type === 'oAuthException') { 145 | return new Error('ERROR in getAuthToken: Token request threw OAuth exception.'); 146 | } 147 | 148 | // PASSES TOKEN ON TO STEP 4 149 | return this.getAuthData(data); 150 | } catch(err) { 151 | return new Error(`ERROR in getAuthToken: Unable to obtain token - ${err}`); 152 | } 153 | } 154 | 155 | // STEP 4 get the access token from the returned data 156 | // STEP 4.5 exchange access token for user info 157 | async getAuthData(parsed: TokenData){ 158 | const authData: AuthData = { 159 | tokenData: { 160 | access_token: parsed.access_token, 161 | token_type: parsed.token_type, 162 | scope: parsed.scope, 163 | expires_in: parsed.expires_in, 164 | refresh_token: parsed.refresh_token 165 | }, 166 | userInfo: { 167 | provider: '', 168 | providerUserId: '' 169 | } 170 | } 171 | 172 | // STEP 4.5: request user info 173 | const authOptions: any = { 174 | access_token: authData.tokenData.access_token, 175 | token_type: authData.tokenData.token_type, 176 | scope: authData.tokenData.scope, 177 | expires_in: authData.tokenData.expires_in, 178 | refresh_token: authData.tokenData.refresh_token, 179 | }; 180 | 181 | try { 182 | let data: any = await fetch(this.authDataURL + this.constructURI(authOptions)); 183 | data = await data.json(); 184 | 185 | authData.userInfo = { 186 | provider: this.name, 187 | providerUserId: data.id, 188 | }; 189 | 190 | return authData; 191 | } catch(err) { 192 | return new Error(`ERROR in getAuthData: Unable to obtain auth data - ${err}`); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Should contain the same properties and methods defined by Oak 3 | * https://github.com/oakserver/oak 4 | */ 5 | export interface OakContext { 6 | app: any; 7 | cookies: any; 8 | request: any; 9 | respond: any; 10 | response: any; 11 | socket: any; 12 | state: any; 13 | assert: Function; 14 | send: Function; 15 | sendEvents: Function; 16 | throw: Function; 17 | upgrade: Function; 18 | params: any; 19 | locals?: any; 20 | } 21 | 22 | /** 23 | * Different OAuths will return different user information in different 24 | * structures. Dashport strategies should break down and reconstruct the user 25 | * info into the standardized UserProfile below 26 | */ 27 | export interface UserProfile { 28 | // the provider the user is authenticated with 29 | provider: string; 30 | // the unique id a user has with that specific provider 31 | providerUserId: string; 32 | // the display name or username for this specific user 33 | displayName?: string; 34 | name?: { 35 | familyName?: string; 36 | givenName?: string; 37 | middleName?: string; 38 | }; 39 | emails?: Array; 40 | } 41 | 42 | /** 43 | * At the bare minimum, OAuth 2.0 providers will require a client ID, client 44 | * secret, and redirect URI. The remaining options depend on the OAuth 2.0 45 | * provider, such as scope 46 | */ 47 | export interface Options { 48 | client_id: string; 49 | client_secret: string; 50 | redirect_uri: string; 51 | [option: string]: string; 52 | } 53 | 54 | /** 55 | * All OAuth 2.0 providers will provide access tokens 56 | */ 57 | export interface TokenData { 58 | access_token: string; 59 | expires_in?: number; 60 | scope?: string; 61 | token_type?: string; 62 | id_token?: string; 63 | refresh_token?: string; 64 | } 65 | 66 | /** 67 | * The form the information from strategies should come back in 68 | */ 69 | export interface AuthData { 70 | tokenData: TokenData; 71 | userInfo: UserProfile; 72 | } 73 | --------------------------------------------------------------------------------