diff --git a/static/SugoiAuthProvider.mjs b/static/SugoiAuthProvider.mjs index 833595a..580445c 100644 --- a/static/SugoiAuthProvider.mjs +++ b/static/SugoiAuthProvider.mjs @@ -1,4 +1,4 @@ -import TwitchAuth from "./TwitchAuth.mjs"; +import * as TwitchAuth from "./TwitchAuth.mjs"; function getTwurpleProxy(token){ return new Proxy(token,{ @@ -8,33 +8,57 @@ function getTwurpleProxy(token){ }) } +/** + * @param {import("./TwitchAuth.mjs").TwitchToken} token + * @param {...string} scopes + */ +function hasScopes(token,...scopes){ + for(const scope of scopes){ + if(!token.scope.includes(scope)){ + return false + } + } + return true +} + export default class SugoiAuthProvider { - /** @type {TwitchAuth} */ - #auth; + /** @type {TwitchToken} */ + #token; /** @type {String} */ clientId; constructor(client_id){ - this.#auth=new TwitchAuth(client_id) this.clientId=client_id } /** + * get a new token * @param {String[]} scopes * @returns {Promise} */ - addUser(...scopes){ - return this.#auth.getToken(...scopes).then(getTwurpleProxy) + async addUser(...scopes){ + if(hasScopes(this.#token,scopes)){ + return this.#token + } + return TwitchAuth.getUserToken(this.clientId,...scopes).then(getTwurpleProxy) } + /** + * use an existing token + * @param {import("./TwitchAuth.mjs").TwitchToken} token + * @returns {import("./TwitchAuth.mjs").TwitchToken} + */ addUserForToken(token){ - return this.#auth.setToken(token).then(getTwurpleProxy) + if(token.refresh_token){ + return TwitchAuth.refreshToken(token.refresh_token).then(getTwurpleProxy) + } + return TwitchAuth.validateToken(token.access_token).then(getTwurpleProxy) } removeUser(){ - return this.#auth.resetLocalToken() + this.#token=null } /** @@ -43,22 +67,12 @@ export default class SugoiAuthProvider { * @returns {Promise} */ getAccessTokenForUser(user,...scopeSets){ - const scopes=new Set() - for(const scopeSet of scopeSets){ - for(const scope of scopeSet){ - scopes.add(scope) + for(const scopes of scopeSets){ + if(hasScopes(this.#token,scopes)){ + return this.#token } } - return this.#auth.getToken(...scopes).then(getTwurpleProxy) - .then(token=>{ - if(token.user_id!=user){ - throw 'got access token for wrong user' - } - return token - }).catch(error=>{ - console.warn(error) - return null - }) + return this.addUser(...scopeSets[0]) } /** @@ -66,15 +80,7 @@ export default class SugoiAuthProvider { * @returns {Promise} */ getAnyAccessToken(user){ - return this.#auth.getToken().then(getTwurpleProxy) - .then(token=>{ - if(token.user_id!=user){ - throw 'got access token for wrong user' - } - return token.then(getTwurpleProxy) - }).catch(error=>{ - return this.#auth.getAppToken().then(getTwurpleProxy) - }) + return this.#token || TwitchAuth.getAppToken(this.clientId) } /** @@ -82,7 +88,7 @@ export default class SugoiAuthProvider { * @returns {String[]} */ getCurrentScopesForUser(user){ - return this.#auth.getLocalToken().scope + return this.#token.scope } /** @@ -90,10 +96,6 @@ export default class SugoiAuthProvider { * @returns {Promise} */ refreshAccessTokenForUser(user){ - const token=this.#auth.getLocalToken() - if(token.user_id!=user){ - throw 'got access token for wrong user' - } - return this.#auth.getFreshToken(token).then(getTwurpleProxy) + return TwitchAuth.refreshToken(this.#token.refresh_token) } } \ No newline at end of file diff --git a/static/SugoiAuthProvider2.mjs b/static/SugoiAuthProvider2.mjs deleted file mode 100644 index e2abfba..0000000 --- a/static/SugoiAuthProvider2.mjs +++ /dev/null @@ -1,161 +0,0 @@ -import TwitchAuth from "./TwitchAuth-localforage.mjs"; - -/** - * Represents the data of an OAuth access token returned by Twitch, together with the ID of the user it represents, if it's not an app access token - */ -export class AccessTokenMaybeWithUserId { - - /** - * @type {import("./TwitchAuth-localforage.mjs").TwitchToken} - */ - #token; - - /** - * Create a Twrple-compatible "AccessTokenMaybeWithUserID" object from a TwitchToken - * @param {import("./TwitchAuth-localforage.mjs").TwitchToken} token - */ - constructor(token){ - this.#token=token - } - - /** - * The access token which is necessary for every request to the Twitch API - * @type {string} - */ - get accessToken(){return this.#token.access_token} - - /** - * The time, in seconds from the obtainment date, when the access token expires - * @type {number | null} - */ - get expiresIn(){return this.#token.expires_in} - - /** - * The date when the token was obtained, in epoch milliseconds - * @type {number} - */ - get obtainmentTimestamp(){return this.#token.obtainment_timestamp} - - /** - * The refresh token which is necessary to refresh the access token once it expires - * @type {string | null} - */ - get refreshToken(){return this.#token.refresh_token} - - /** - * The scope the access token is valid for, i.e. what the token enables you to do - * @type {string[]} - */ - get scope(){return this.#token.scope} - - /** - * The ID of the user represented by the token, or undefined if it's an app access token - */ - get userId(){return this.#token.user_id} -} - -export default class SugoiAuthProvider { - - /** - * @type {string} - */ - clientId; - /** - * @type {TwitchAuth} - */ - #auth; - /** - * @type {Map} - */ - #cache; - - constructor(clientId){ - this.clientId=clientId - this.#auth=new TwitchAuth(clientId) - this.#cache=new Map - } - - /** - * Fetches a token for the user - * @param {string | number} user The user to fetch a token for - * @param {...string[]} scopeSets zero or more scope arrays in order of preference - * @returns {Promise} - */ - async getAccessTokenForUser(user,...scopeSets){ - let token=await this.#auth.getLocalToken(user) - if(token){ - if(scopeSets.length===0){ - this.#cache.set(user,token) - return new AccessTokenMaybeWithUserId(token) - } - scopeSets:for(const scopeSet of scopeSets){ - for(const scope of scopeSet){ - if(!token.scope.includes(scope)){ - continue scopeSets; - } - } - this.#cache.set(user,token) - return new AccessTokenMaybeWithUserId(token) - } - } - token=await this.#auth.getToken(scopeSets[0]) - this.#cache.set(user,token) - return new AccessTokenMaybeWithUserId(token) - } - - /** - * Fetches an app token - * @param {boolean} forceNew Whether to always get a new token, even if the old one is still deemed valid internally - * @returns {Promise} - */ - async getAppAccessToken(forceNew=false){ - if(forceNew){ - let token=TwitchAuth.getAppToken(this.clientId) - token=this.#auth.setToken(token) - return new AccessTokenMaybeWithUserId(token) - } - const token=await this.#auth.getAppToken() - return new AccessTokenMaybeWithUserId(token) - } - - /** - * Fetches any token to use with a request that supports both user and app tokens - * @param {string | number} user The user to fetch a token for - * @returns {Promise} - */ - async getAnyAccessToken(user=undefined){ - let token; - if(user){//only call this function for a user token, because the next function will already call this for no user token - token=this.#auth.getLocalToken(user) - } - if(!token){ - token=this.getAppAccessToken() - } - return token - } - - /** - * Gets the scopes that are currently available using the access token for a user. - * The underlying local token storage is async, but this interface must be sync, - * so this function only works for already retreived tokens via in-memory caching. - * @param {string | number} user The user id to get scopes for - * @returns {string[]} - */ - getCurrentScopesForUser(user){ - if(this.#cache.has(user)){ - return this.#cache.get(user).scope - }else{ - return [] - } - } - /** - * Requests that the provider fetches a new token from Twitch for the given user - * @param {string | number} user The user id to fetch a token for - */ - async refreshAccessTokenForUser(user){ - let token=await this.#auth.getLocalToken(user) - token=await TwitchAuth.refreshToken(this.clientId,token.refresh_token) - this.#cache.set(user,token) - return new AccessTokenMaybeWithUserId(token) - } -} \ No newline at end of file diff --git a/static/TwitchAuth-localforage.mjs b/static/TwitchAuth-localforage.mjs deleted file mode 100644 index 2f79c9b..0000000 --- a/static/TwitchAuth-localforage.mjs +++ /dev/null @@ -1,302 +0,0 @@ -let localStorage=globalThis.localStorage -import('https://cdn.jsdelivr.net/npm/localforage@1/+esm') -.then(module=>module.default) -.then(localforage=>{localStorage=localforage.createInstance({name:import.meta.url})}) -.catch(e=>console.warn('localforage failed, using localStorage',e)) - -/** - * @typedef {Object} TwitchToken - * @property {String} access_token - * @property {Number} expires_in - * @property {String} token_type - * @property {String} [refresh_token] - * @property {Array} [scope] - * @property {Number} [obtainment_timestamp] - * @property {Number} [user_id] - */ - -/** - * @typedef {Object} AuthCode - * @property {String} code - * @property {String} scope - */ - -/** - * Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. - * This version of fetch retries requests on "Failed to fetch" errors to account for OBS startup bottlenecking - * @param {RequestInfo | URL} input - * @param {RequestInit} init - * @returns {Promise} - */ -function fetch(input,init=undefined){ - return window.fetch(input,init).catch(async error=>{ - if(error.message==="Failed to fetch"){ - await new Promise(function(resolve,reject){ - setTimeout(resolve,1000) - }) - return fetch(input,init) - }else{ - throw error - } - }) -} - -/** - * Twitch auth token management supporting app tokens and refresh tokens with the accompanying proxy server - */ -export default class TwitchAuth { - - static #redirect_uri=new URL('/code.html',import.meta.url) - static #proxy_uri=new URL('/oauth2/token',import.meta.url) - - /** - * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#client-credentials-grant-flow - * @param {String} client_id - * @returns {Promise} - */ - static getAppToken(client_id){ - const searchParams=new URLSearchParams({ - client_id:client_id, - grant_type:'client_credentials' - }) - return fetch(this.#proxy_uri,{ - method:'POST', - body:searchParams.toString(), - headers:{'content-type':'application/x-www-form-urlencoded'} - }).then(async function(response){ - if(!response.ok){ - throw await response.json() - } - return await response.json() - }) - } - - /** - * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#get-the-user-to-authorize-your-app - * @param {String} client_id - * @param {Array} scopes - * @returns {Promise} - */ - static requestAuthCode(client_id,...scopes){ - console.debug('requesting authorization code') - const url=new URL(this.#redirect_uri) - url.searchParams.append('client_id',client_id) - url.searchParams.append('scope',scopes.join(' ')) - if(!open(url,'_blank')){ - throw new Error('failed to open authorization window') - } - return new Promise(function(resolve,reject){ - addEventListener("message",function onMessage(event){ - if(!(event.origin===new URL(import.meta.url).origin)){ - return - } - removeEventListener('message',onMessage) - if('code' in event.data){ - resolve(event.data) - }else{ - reject(event.data) - } - }) - }) - } - - /** - * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#use-the-authorization-code-to-get-a-token - * @param {String} client_id - * @param {String} code - * @returns {Promise} - */ - static exchangeCode(client_id,code){ - console.debug('exchanging authorization code') - const searchParams=new URLSearchParams({ - client_id:client_id, - code:code, - grant_type:'authorization_code' - }) - return fetch(this.#proxy_uri,{ - method:'POST', - body:searchParams.toString()+'&redirect_uri='+this.#redirect_uri, - headers:{'content-type':'application/x-www-form-urlencoded'} - }).then(async function(response){ - if(!response.ok){ - throw await response.json() - } - return await response.json() - }) - } - - /** - * https://dev.twitch.tv/docs/authentication/validate-tokens/#how-to-validate-a-token - * @param {String} access_token - * @returns {Promise} - */ - static validateToken(access_token){ - console.debug('validating token') - return fetch('https://id.twitch.tv/oauth2/validate',{ - headers:{authorization:'OAuth '+access_token} - }).then(async response=>{ - if(!response.ok){ - throw await response.json() - } - return await response.json() - }) - } - - /** - * https://dev.twitch.tv/docs/authentication/refresh-tokens/#how-to-use-a-refresh-token - * @param {String} client_id - * @param {String} refresh_token - * @returns {Promise} - */ - static refreshToken(client_id,refresh_token){ - console.debug('refreshing token') - const searchParams=new URLSearchParams({ - client_id:client_id, - grant_type:'refresh_token', - refresh_token:refresh_token - }) - return fetch(this.#proxy_uri,{ - method:'POST', - body:searchParams.toString(), - headers:{'content-type':'application/x-www-form-urlencoded'} - }).then(async function(response){ - if(!response.ok){ - throw await response.json() - } - return await response.json() - }) - } - - /** - * validate, update, and return an existing token - * @param {TwitchToken} token - * @returns {Promise} - */ - static getTokenWithValidation(token){ - return this.validateToken(token.access_token) - .then(validation=>{ - Object.assign(validation,token) - validation.obtainment_timestamp=Date.now() - return validation - }) - } - - constructor(client_id){ - this.client_id=client_id - } - - /** - * get the cached token - * @returns {Promise} - */ - async getLocalToken(user_id){ - if(!user_id){ - user_id=await localStorage.key(0) - } - const data=await localStorage.getItem(user_id)||globalThis.localStorage.getItem(import.meta.url) - if(!data){ - return null - } - return JSON.parse(data) - } - - async getAppToken(){ - let token=await this.getLocalToken(this.client_id) - if(!token){ - token=TwitchAuth.getAppToken(this.client_id) - token=await this.setToken(token) - } - return token - } - - /** - * Remove twitch tokens from browser storage. - * If localforage didn't fail to load, erases all tokens. - * Otherwise, only the legacy token key and app token key are erased by default. - * Additional options are availible in this case to specify extra steps. - * @param {boolean} clearLocalStorage If not using localforage, set this to true to clear localStorage entirely, erasing all tokens and possibly unrelated data - * @param {...string} user_ids If not using localforage, the user ids for which to remove their tokens - * @returns {Promise} resolves after all tokens have been removed - */ - async resetLocalTokens(clearLocalStorage=false,...user_ids){ - if(localStorage!==globalThis.localStorage){ - globalThis.localStorage.removeItem(import.meta.url) - return localStorage.clear() - }else{ - if(clearLocalStorage){ - localStorage.clear() - }else{ - localStorage.removeItem(import.meta.url) - localStorage.removeItem(this.client_id) - for(const user_id of user_ids){ - localStorage.removeItem(user_id) - } - } - } - } - - /** - * Do whatever it takes to get a token - * @param {Array} scopes - * @returns {Promise} - */ - async getToken(...scopes){ - const token=await this.getLocalToken() - if(!token){ - console.debug('no local token, requesting new token') - return this.getNewToken(...scopes) - } - scopes=scopes.join(' ').split(' ') - for(const scope of scopes){ - if(scope===''){ - continue - } - if(!token.scope.includes(scope)){ - console.debug('token is missing '+scope+', requesting new token') - return this.getNewToken(...scopes) - } - } - const expiry=token.obtainment_timestamp+(token.expires_in*1000)-(60*1000) - if(expiry} scopes - * @returns {Promise} - */ - getNewToken(...scopes){ - return TwitchAuth.requestAuthCode(this.client_id,...scopes) - .then(response=>TwitchAuth.exchangeCode(this.client_id,response.code)) - .then(this.setToken) - } - - /** - * Non-interactively request a new token, falling back to interactive mode - * @param {TwitchToken} token - * @returns {Promise} - */ - getFreshToken(token){ - return TwitchAuth.refreshToken(this.client_id,token.refresh_token) - .then(this.setToken).catch((error)=>{ - console.warn(error) - return this.getNewToken(...token.scope) - }) - } - - /** - * validate, update, store, and return an existing token - * @param {TwitchToken} token - * @returns {Promise} - */ - setToken(token){ - return TwitchAuth.getTokenWithValidation(token) - .then(token=>{ - localStorage.setItem(token.user_id||this.client_id,JSON.stringify(token)) - return token - }) - } -} \ No newline at end of file diff --git a/static/TwitchAuth.mjs b/static/TwitchAuth.mjs index 68fbbf5..a42f839 100644 --- a/static/TwitchAuth.mjs +++ b/static/TwitchAuth.mjs @@ -1,12 +1,17 @@ +import fetch_retry from 'https://cdn.jsdelivr.net/npm/fetch-retry/+esm' +const fetch = fetch_retry(globalThis.fetch, { retries: 10, retryDelay: attempts => attempts * 1000 }) + /** * @typedef {Object} TwitchToken * @property {String} access_token * @property {Number} expires_in + * @property {Number} obtainment_timestamp * @property {String} token_type - * @property {String} [refresh_token] - * @property {Array} [scope] - * @property {Number} [obtainment_timestamp] * @property {Number} [user_id] + * @property {Array} [scope] + * @property {String} [refresh_token] + * @property {String} [login] + * @property {String} [client_id] */ /** @@ -15,252 +20,173 @@ * @property {String} scope */ +const redirect_uri = location.href.split('?')[0] +const proxy_uri = new URL('/oauth2/token', import.meta.url) + /** - * @param {RequestInfo | URL} input - * @param {RequestInit} init - * @returns {Promise} + * Timestamp and return the token + * @param {TwitchToken} token + * @returns {TwitchToken} */ -function fetch(input,init=undefined){ - return window.fetch(input,init).catch(async error=>{ - if(error.message==="Failed to fetch"){ - await new Promise(function(resolve,reject){ - setTimeout(resolve,1000) - }) - return fetch(input,init) - }else{ - throw error +function stamp(token) { + token.obtainment_timestamp = Date.now() + return token +} + +/** + * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#client-credentials-grant-flow + * @param {String} client_id + * @returns {Promise} + */ +export function getAppToken(client_id) { + const searchParams = new URLSearchParams({ + client_id: client_id, + grant_type: 'client_credentials' + }) + return fetch(proxy_uri, { + method: 'POST', + body: searchParams.toString(), + headers: { 'content-type': 'application/x-www-form-urlencoded' } + }).then(async function (response) { + if (!response.ok) { + throw await response.json() } + const token = await response.json() + return stamp(token) }) } -export default class TwitchAuth { +/** + * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow + * @param {String} client_id + * @param {Array|String} scopes + * @returns {Promise} + */ +export function requestAccessToken(client_id, ...scopes) { + console.debug('requesting access token') + const url = new URL('https://id.twitch.tv/oauth2/authorize') + url.searchParams.append('response_type','token') + url.searchParams.append('client_id', client_id) + url.searchParams.append('scope', scopes.join(' ')) + location.assign(url+'&redirect_uri='+redirect_uri) +} - static #redirect_uri=new URL('/code.html',import.meta.url) - static #proxy_uri=new URL('/oauth2/token',import.meta.url) +/** + * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#get-the-user-to-authorize-your-app + * @param {String} client_id + * @param {Array|String} scopes + * @returns {Promise} + */ +export function requestAuthCode(client_id, ...scopes) { + console.debug('requesting authorization code') + const url = new URL('https://id.twitch.tv/oauth2/authorize') + url.searchParams.append('response_type','code') + url.searchParams.append('client_id', client_id) + url.searchParams.append('scope', scopes.join(' ')) + location.assign(url+'&redirect_uri='+redirect_uri) +} - /** - * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#client-credentials-grant-flow - * @param {String} client_id - * @returns {Promise} - */ - static getAppToken(client_id){ - const searchParams=new URLSearchParams({ - client_id:client_id, - grant_type:'client_credentials' - }) - return fetch(this.#proxy_uri,{ - method:'POST', - body:searchParams.toString(), - headers:{'content-type':'application/x-www-form-urlencoded'} - }).then(async function(response){ - if(!response.ok){ - throw await response.json() - } - return await response.json() - }) - } - - /** - * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#get-the-user-to-authorize-your-app - * @param {String} client_id - * @param {Array} scopes - * @returns {Promise} - */ - static requestAuthCode(client_id,...scopes){ - console.debug('requesting authorization code') - const url=new URL(this.#redirect_uri) - url.searchParams.append('client_id',client_id) - url.searchParams.append('scope',scopes.join(' ')) - if(!open(url,'_blank')){ - throw new Error('failed to open authorization window') +/** + * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#use-the-authorization-code-to-get-a-token + * @param {String} client_id + * @param {String} code + * @returns {Promise} + */ +export function exchangeCode(client_id, code) { + console.debug('exchanging authorization code') + const searchParams = new URLSearchParams({ + client_id: client_id, + code: code, + grant_type: 'authorization_code' + }) + return fetch(proxy_uri, { + method: 'POST', + body: searchParams.toString() + '&redirect_uri=' + redirect_uri, + headers: { 'content-type': 'application/x-www-form-urlencoded' } + }).then(async function (response) { + if (!response.ok) { + throw await response.json() } - return new Promise(function(resolve,reject){ - addEventListener("message",function onMessage(event){ - if(!(event.origin===new URL(import.meta.url).origin)){ - return - } - removeEventListener('message',onMessage) - if('code' in event.data){ - resolve(event.data) - }else{ - reject(event.data) - } - }) - }) - } + const token = await response.json() + return stamp(token) + }) +} - /** - * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#use-the-authorization-code-to-get-a-token - * @param {String} client_id - * @param {String} code - * @returns {Promise} - */ - static exchangeCode(client_id,code){ - console.debug('exchanging authorization code') - const searchParams=new URLSearchParams({ - client_id:client_id, - code:code, - grant_type:'authorization_code' - }) - return fetch(this.#proxy_uri,{ - method:'POST', - body:searchParams.toString()+'&redirect_uri='+this.#redirect_uri, - headers:{'content-type':'application/x-www-form-urlencoded'} - }).then(async function(response){ - if(!response.ok){ - throw await response.json() - } - return await response.json() - }) - } - - /** - * https://dev.twitch.tv/docs/authentication/validate-tokens/#how-to-validate-a-token - * @param {String} access_token - * @returns {Promise} - */ - static validateToken(access_token){ - console.debug('validating token') - return fetch('https://id.twitch.tv/oauth2/validate',{ - headers:{authorization:'OAuth '+access_token} - }).then(async response=>{ - if(!response.ok){ - throw await response.json() - } - return await response.json() - }) - } - - /** - * https://dev.twitch.tv/docs/authentication/refresh-tokens/#how-to-use-a-refresh-token - * @param {String} client_id - * @param {String} refresh_token - * @returns {Promise} - */ - static refreshToken(client_id,refresh_token){ - console.debug('refreshing token') - const searchParams=new URLSearchParams({ - client_id:client_id, - grant_type:'refresh_token', - refresh_token:refresh_token - }) - return fetch(this.#proxy_uri,{ - method:'POST', - body:searchParams.toString(), - headers:{'content-type':'application/x-www-form-urlencoded'} - }).then(async function(response){ - if(!response.ok){ - throw await response.json() - } - return await response.json() - }) - } - - /** - * validate, update, and return an existing token - * @param {TwitchToken} token - * @returns {Promise} - */ - static getTokenWithValidation(token){ - return this.validateToken(token.access_token) - .then(validation=>{ - Object.assign(validation,token) - validation.obtainment_timestamp=Date.now() - return validation - }) - } - - constructor(client_id){ - this.client_id=client_id - } - - /** - * get the cached token - * @returns {TwitchToken} - */ - getLocalToken(){ - const data=localStorage.getItem(import.meta.url) - if(!data){ - return null +/** + * https://dev.twitch.tv/docs/authentication/validate-tokens/#how-to-validate-a-token + * @param {String} access_token + * @returns {TwitchToken} + */ +export function validateToken(access_token) { + console.debug('validating token') + return fetch('https://id.twitch.tv/oauth2/validate', { + headers: { authorization: 'OAuth ' + access_token } + }).then(async response => { + if (!response.ok) { + throw await response.json() } - return JSON.parse(data) - } + /** @type {TwitchToken} */ + const token=await response.json() + token.access_token=access_token + token.scope=token.scopes + delete token.scopes + token.token_type='bearer' + return stamp(token) + }) +} - resetLocalToken(){ - return localStorage.removeItem(import.meta.url) - } - - /** - * Do whatever it takes to get a token - * @param {Array} scopes - * @returns {Promise} - */ - async getToken(...scopes){ - const token=this.getLocalToken() - if(!token){ - console.debug('no local token, requesting new token') - return this.getNewToken(...scopes) +/** + * https://dev.twitch.tv/docs/authentication/refresh-tokens/#how-to-use-a-refresh-token + * @param {String} client_id + * @param {String} refresh_token + * @returns {Promise} + */ +export function refreshToken(client_id, refresh_token) { + console.debug('refreshing token') + const searchParams = new URLSearchParams({ + client_id: client_id, + grant_type: 'refresh_token', + refresh_token: refresh_token + }) + return fetch(proxy_uri, { + method: 'POST', + body: searchParams.toString(), + headers: { 'content-type': 'application/x-www-form-urlencoded' } + }).then(async function (response) { + if (!response.ok) { + throw await response.json() } - scopes=scopes.join(' ').split(' ') - for(const scope of scopes){ - if(scope===''){ - continue - } - if(!token.scope.includes(scope)){ - console.debug('token is missing '+scope+', requesting new token') - return this.getNewToken(...scopes) - } - } - const expiry=token.obtainment_timestamp+(token.expires_in*1000)-(60*1000) - if(expiry} scopes - * @returns {Promise} - */ - getNewToken(...scopes){ - return TwitchAuth.requestAuthCode(this.client_id,...scopes) - .then(response=>TwitchAuth.exchangeCode(this.client_id,response.code)) - .then(this.setToken) +/** + * This function checks the url search parameters and hash for an auth code, + * refresh token, access token, or error message, in that order, + * and if it finds none of those, starts the auth code grant flow. + * https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow + * @param {String} client_id + * @returns {TwitchToken} + */ +export function getUserToken(client_id,...scopes){ + const params=new URLSearchParams(location.search+'&'+location.hash.substring(1)) + if(params.has('code')){ + const code=params.get('code') + history.replaceState(null,'',redirect_uri) + return exchangeCode(client_id,code) } + if(params.has('refresh_token')){ + return refreshToken(client_id,params.get('refresh_token')) + } + if(params.has('access_token')){ + return validateToken(params.get('access_token')) + } + if(params.has('error')){ + const error_message=params.get('error')+': '+params.get('error_description') + history.replaceState(null,'',redirect_uri) + throw new Error(error_message) + } + return requestAuthCode(client_id,...scopes) +} - /** - * Non-interactively request a new token, falling back to interactive mode - * @param {TwitchToken} token - * @returns {Promise} - */ - getFreshToken(token){ - return TwitchAuth.refreshToken(this.client_id,token.refresh_token) - .then(this.setToken).catch((error)=>{ - console.warn(error) - return this.getNewToken(...token.scope) - }) - } - - /** - * validate, update, store, and return an existing token - * @param {TwitchToken} token - * @returns {Promise} - */ - setToken(token){ - return TwitchAuth.getTokenWithValidation(token) - .then(token=>{ - localStorage.setItem(import.meta.url,JSON.stringify(token)) - return token - }) - } - - /** - * Get a new app token with validation - * @returns {Promise} - */ - getAppToken(){ - return TwitchAuth.getAppToken(this.client_id) - .then(TwitchAuth.getTokenWithValidation) - } -} \ No newline at end of file +export default getUserToken \ No newline at end of file