obs-portal/frontend/src/api.js

307 lines
9.5 KiB
JavaScript

import {stringifyParams} from 'query'
import globalStore from 'store'
import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth'
import {setLogin} from 'reducers/login'
import configPromise from 'config'
import {create as createPkce} from 'pkce'
import download from 'downloadjs'
function getFileNameFromContentDispostionHeader(contentDisposition: string): string | undefined {
const standardPattern = /filename=(["']?)(.+)\1/i
const wrongPattern = /filename=([^"'][^;"'\n]+)/i
if (standardPattern.test(contentDisposition)) {
return contentDisposition.match(standardPattern)[2]
}
if (wrongPattern.test(contentDisposition)) {
return contentDisposition.match(wrongPattern)[1]
}
}
class RequestError extends Error {
constructor(message, errors) {
super(message)
this.errors = errors
}
}
class API {
constructor(store) {
this.store = store
this._getValidAccessTokenPromise = null
}
/**
* Fetches or directly returns from cache the metadata information from the
* authorization server, according to https://tools.ietf.org/html/rfc8414.
* Also validates compatibility with this metadata server, i.e. checking that
* it supports PKCE.
*/
async getAuthorizationServerMetadata() {
const config = await configPromise
const url = new URL(config.auth.server)
const pathSuffix = url.pathname.replace(/^\/+|\/+$/, '')
url.pathname = '/.well-known/oauth-authorization-server' + (pathSuffix ? '/' + pathSuffix : '')
const response = await window.fetch(url.toString())
const metadata = await response.json()
const {
authorization_endpoint: authorizationEndpoint,
token_endpoint: tokenEndpoint,
response_types_supported: responseTypesSupported,
code_challenge_methods_supported: codeChallengeMethodsSupported,
} = metadata
if (!authorizationEndpoint) {
throw new Error('No authorization endpoint')
}
if (!authorizationEndpoint.startsWith(config.auth.server)) {
throw new Error('Invalid authorization endpoint')
}
if (!tokenEndpoint) {
throw new Error('No token endpoint')
}
if (!tokenEndpoint.startsWith(config.auth.server)) {
throw new Error('Invalid token endpoint')
}
if (!Array.isArray(responseTypesSupported) || !responseTypesSupported.includes('code')) {
throw new Error('Authorization code flow not supported or no support advertised.')
}
if (!Array.isArray(codeChallengeMethodsSupported) || !codeChallengeMethodsSupported.includes('S256')) {
throw new Error('PKCE with S256 not supported or no support advertised.')
}
return {authorizationEndpoint, tokenEndpoint}
}
/**
* 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 {tokenEndpoint} = await this.getAuthorizationServerMetadata()
const config = await configPromise
const url = new URL(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 codeVerifier = localStorage.getItem('codeVerifier')
if (!codeVerifier) {
throw new Error('No code verifier found')
}
const {tokenEndpoint} = await this.getAuthorizationServerMetadata()
const config = await configPromise
const url = new URL(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)
url.searchParams.append('code_verifier', codeVerifier)
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
}
async logout() {
// 1. Tell the store to forget that we're logged in.
this.store.dispatch(resetAuth())
// 2. Log out session in API.
const {tokenEndpoint} = await this.getAuthorizationServerMetadata()
const url = new URL(tokenEndpoint.replace(/\/token$/, '/logout'))
url.searchParams.append('redirectTo', window.location.href) // bring us back to the current page
window.location.href = url.toString()
}
async makeLoginUrl() {
const {authorizationEndpoint} = await this.getAuthorizationServerMetadata()
const config = await configPromise
const {codeVerifier, codeChallenge} = createPkce()
localStorage.setItem('codeVerifier', codeVerifier)
const loginUrl = new URL(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')
loginUrl.searchParams.append('code_challenge', codeChallenge)
loginUrl.searchParams.append('code_challenge_method', 'S256')
// TODO: Implement PKCE
return loginUrl.toString()
}
async fetch(url, options = {}) {
const accessToken = await this.getValidAccessToken()
const config = await configPromise
const {returnResponse = false, ...fetchOptions} = options
const response = await window.fetch(config.apiUrl + '/api' + url, {
...fetchOptions,
headers: {
...(fetchOptions.headers || {}),
Authorization: accessToken,
},
})
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')
}
if (returnResponse) {
if (response.status === 200) {
return response
} else if (response.status === 204) {
return null
} else {
throw new RequestError('Error code ' + response.status)
}
}
let json
try {
json = await response.json()
} catch (err) {
json = null
}
if (response.status === 200) {
return json
} else if (response.status === 204) {
return null
} else {
throw new RequestError('Error code ' + response.status, json?.errors)
}
}
async post(url, {body: body_, ...options}) {
let body = body_
let headers = {...(options.headers || {})}
if (!(typeof body === 'string' || body instanceof FormData)) {
body = JSON.stringify(body)
headers['Content-Type'] = 'application/json'
}
return await this.fetch(url, {
method: 'post',
...options,
body,
headers,
})
}
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 = {}) {
return await this.get(url, {...options, method: 'delete'})
}
async put(url, options = {}) {
return await this.post(url, {...options, method: 'put'})
}
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,
}
}
async downloadFile(url, options = {}) {
const res = await this.fetch(url, {returnResponse: true, ...options})
const blob = await res.blob()
const filename = getFileNameFromContentDispostionHeader(res.headers.get('content-disposition'))
const contentType = res.headers.get('content-type')
// Apparently this workaround is needed for some browsers
const newBlob = new Blob([blob], {type: contentType})
download(newBlob, filename, contentType)
}
}
const api = new API(globalStore)
export default api