2021-02-14 18:21:34 +00:00
|
|
|
import {stringifyParams} from 'query'
|
2021-02-20 18:31:18 +00:00
|
|
|
import globalStore from 'store'
|
|
|
|
import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth'
|
|
|
|
import {setLogin} from 'reducers/login'
|
|
|
|
import config from 'config.json'
|
2021-02-14 18:21:34 +00:00
|
|
|
|
2021-02-10 21:28:36 +00:00
|
|
|
class API {
|
2021-02-20 18:31:18 +00:00
|
|
|
constructor(store) {
|
|
|
|
this.store = store
|
|
|
|
this._getValidAccessTokenPromise = null
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return an access token, if it is (still) valid. If not, and a refresh
|
|
|
|
* token exists, use the refresh token to issue a new access token. If that
|
|
|
|
* fails, or neither is available, return `null`. This should usually result
|
|
|
|
* in a redirect to login.
|
|
|
|
*/
|
|
|
|
async getValidAccessToken() {
|
|
|
|
// prevent multiple parallel refresh processes
|
|
|
|
if (this._getValidAccessTokenPromise) {
|
|
|
|
return await this._getValidAccessTokenPromise
|
|
|
|
} else {
|
|
|
|
this._getValidAccessTokenPromise = this._getValidAccessToken()
|
|
|
|
const result = await this._getValidAccessTokenPromise
|
|
|
|
this._getValidAccessTokenPromise = null
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async _getValidAccessToken() {
|
|
|
|
let {auth} = this.store.getState()
|
|
|
|
|
|
|
|
if (!auth) {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
const {tokenType, accessToken, refreshToken, expiresAt} = auth
|
|
|
|
|
|
|
|
// access token is valid
|
|
|
|
if (accessToken && expiresAt > new Date().getTime()) {
|
|
|
|
return `${tokenType} ${accessToken}`
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!refreshToken) {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
// Try to use the refresh token
|
|
|
|
const url = new URL(config.auth.tokenEndpoint)
|
|
|
|
url.searchParams.append('refresh_token', refreshToken)
|
|
|
|
url.searchParams.append('grant_type', 'refresh_token')
|
|
|
|
url.searchParams.append('scope', config.auth.scope)
|
|
|
|
const response = await window.fetch(url.toString())
|
|
|
|
const json = await response.json()
|
|
|
|
|
|
|
|
if (response.status === 200 && json != null && json.error == null) {
|
|
|
|
auth = this.getAuthFromTokenResponse(json)
|
|
|
|
this.store.dispatch(setAuth(auth))
|
|
|
|
return `${auth.tokenType} ${auth.accessToken}`
|
|
|
|
} else {
|
|
|
|
console.warn('Could not use refresh token, error response:', json)
|
|
|
|
this.store.dispatch(resetAuth())
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async exchangeAuthorizationCode(code) {
|
|
|
|
const url = new URL(config.auth.tokenEndpoint)
|
|
|
|
url.searchParams.append('code', code)
|
|
|
|
url.searchParams.append('grant_type', 'authorization_code')
|
|
|
|
url.searchParams.append('client_id', config.auth.clientId)
|
|
|
|
url.searchParams.append('redirect_uri', config.auth.redirectUri)
|
|
|
|
const response = await window.fetch(url.toString())
|
|
|
|
const json = await response.json()
|
|
|
|
|
|
|
|
if (json.error) {
|
|
|
|
return json
|
|
|
|
}
|
|
|
|
|
|
|
|
const auth = api.getAuthFromTokenResponse(json)
|
|
|
|
this.store.dispatch(setAuth(auth))
|
|
|
|
|
|
|
|
const {user} = await this.get('/user')
|
|
|
|
this.store.dispatch(setLogin(user))
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
getLoginUrl() {
|
|
|
|
const loginUrl = new URL(config.auth.authorizationEndpoint)
|
|
|
|
loginUrl.searchParams.append('client_id', config.auth.clientId)
|
|
|
|
loginUrl.searchParams.append('scope', config.auth.scope)
|
|
|
|
loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri)
|
|
|
|
loginUrl.searchParams.append('response_type', 'code')
|
|
|
|
|
|
|
|
// TODO: Implement PKCE
|
|
|
|
|
|
|
|
return loginUrl.toString()
|
2021-02-10 21:28:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async fetch(url, options = {}) {
|
2021-02-20 18:31:18 +00:00
|
|
|
const accessToken = await this.getValidAccessToken()
|
|
|
|
|
2021-02-10 21:28:36 +00:00
|
|
|
const response = await window.fetch('/api' + url, {
|
|
|
|
...options,
|
|
|
|
headers: {
|
|
|
|
...(options.headers || {}),
|
2021-02-20 18:31:18 +00:00
|
|
|
Authorization: accessToken,
|
2021-02-10 21:28:36 +00:00
|
|
|
},
|
|
|
|
})
|
|
|
|
|
2021-02-20 18:31:18 +00:00
|
|
|
if (response.status === 401) {
|
|
|
|
// Unset login, since 401 means that we're not logged in. On the next
|
|
|
|
// request with `getValidAccessToken()`, this will be detected and the
|
|
|
|
// refresh token is used (if still valid).
|
|
|
|
this.store.dispatch(invalidateAccessToken())
|
|
|
|
|
|
|
|
throw new Error('401 Unauthorized')
|
|
|
|
}
|
|
|
|
|
2021-02-14 18:21:34 +00:00
|
|
|
if (response.status === 200) {
|
|
|
|
return await response.json()
|
|
|
|
} else {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async post(url, {body: body_, ...options}) {
|
2021-02-17 20:50:18 +00:00
|
|
|
let body = body_
|
|
|
|
let headers = {...(options.headers || {})}
|
|
|
|
|
|
|
|
if (!(typeof body === 'string' || body instanceof FormData)) {
|
2021-02-20 18:31:18 +00:00
|
|
|
body = JSON.stringify(body)
|
|
|
|
headers['Content-Type'] = 'application/json'
|
2021-02-17 20:50:18 +00:00
|
|
|
}
|
|
|
|
|
2021-02-14 18:27:16 +00:00
|
|
|
return await this.fetch(url, {
|
|
|
|
...options,
|
|
|
|
body,
|
|
|
|
method: 'post',
|
2021-02-20 18:31:18 +00:00
|
|
|
headers,
|
2021-02-14 18:21:34 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
async get(url, {query, ...options} = {}) {
|
|
|
|
const queryString = query ? stringifyParams(query) : null
|
|
|
|
return await this.fetch(url + (queryString ? '?' + queryString : ''), {method: 'get', ...options})
|
|
|
|
}
|
|
|
|
|
|
|
|
async delete(url, options = {}) {
|
2021-02-14 18:27:16 +00:00
|
|
|
return await this.get(url, {...options, method: 'delete'})
|
2021-02-10 21:28:36 +00:00
|
|
|
}
|
2021-02-20 18:31:18 +00:00
|
|
|
|
|
|
|
getAuthFromTokenResponse(tokenResponse) {
|
|
|
|
return {
|
|
|
|
tokenType: tokenResponse.token_type,
|
|
|
|
accessToken: tokenResponse.access_token,
|
|
|
|
refreshToken: tokenResponse.refresh_token,
|
|
|
|
expiresAt: new Date().getTime() + tokenResponse.expires_in * 1000,
|
|
|
|
scope: tokenResponse.scope,
|
|
|
|
}
|
|
|
|
}
|
2021-02-10 21:28:36 +00:00
|
|
|
}
|
|
|
|
|
2021-02-20 18:31:18 +00:00
|
|
|
const api = new API(globalStore)
|
2021-02-10 21:28:36 +00:00
|
|
|
|
|
|
|
export default api
|