|
|
import { |
|
|
getTokenExpiresAtDate, |
|
|
isBrowserEnv, |
|
|
createBrowserSafeString, |
|
|
OAuth2AuthorizationUrl, |
|
|
OAuth2TokenUrl, |
|
|
isWorkerEnv, |
|
|
} from './utils.js'; |
|
|
import { parseResponse } from './response.js'; |
|
|
|
|
|
let fetch; |
|
|
let crypto; |
|
|
let Encoder; |
|
|
|
|
|
|
|
|
const TokenExpirationBuffer = 300 * 1000; |
|
|
const PKCELength = 128; |
|
|
const TokenAccessTypes = ['legacy', 'offline', 'online']; |
|
|
const GrantTypes = ['code', 'token']; |
|
|
const IncludeGrantedScopes = ['none', 'user', 'team']; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default class DropboxAuth { |
|
|
constructor(options) { |
|
|
options = options || {}; |
|
|
|
|
|
if (isBrowserEnv()) { |
|
|
fetch = window.fetch.bind(window); |
|
|
crypto = window.crypto || window.msCrypto; |
|
|
} else if (isWorkerEnv()) { |
|
|
|
|
|
fetch = self.fetch.bind(self); |
|
|
crypto = self.crypto; |
|
|
|
|
|
} else { |
|
|
fetch = require('node-fetch'); |
|
|
crypto = require('crypto'); |
|
|
} |
|
|
|
|
|
if (typeof TextEncoder === 'undefined') { |
|
|
Encoder = require('util').TextEncoder; |
|
|
} else { |
|
|
Encoder = TextEncoder; |
|
|
} |
|
|
|
|
|
this.fetch = options.fetch || fetch; |
|
|
this.accessToken = options.accessToken; |
|
|
this.accessTokenExpiresAt = options.accessTokenExpiresAt; |
|
|
this.refreshToken = options.refreshToken; |
|
|
this.clientId = options.clientId; |
|
|
this.clientSecret = options.clientSecret; |
|
|
|
|
|
this.domain = options.domain; |
|
|
this.domainDelimiter = options.domainDelimiter; |
|
|
this.customHeaders = options.customHeaders; |
|
|
this.dataOnBody = options.dataOnBody; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setAccessToken(accessToken) { |
|
|
this.accessToken = accessToken; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getAccessToken() { |
|
|
return this.accessToken; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setClientId(clientId) { |
|
|
this.clientId = clientId; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getClientId() { |
|
|
return this.clientId; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setClientSecret(clientSecret) { |
|
|
this.clientSecret = clientSecret; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getClientSecret() { |
|
|
return this.clientSecret; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getRefreshToken() { |
|
|
return this.refreshToken; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setRefreshToken(refreshToken) { |
|
|
this.refreshToken = refreshToken; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getAccessTokenExpiresAt() { |
|
|
return this.accessTokenExpiresAt; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setAccessTokenExpiresAt(accessTokenExpiresAt) { |
|
|
this.accessTokenExpiresAt = accessTokenExpiresAt; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setCodeVerifier(codeVerifier) { |
|
|
this.codeVerifier = codeVerifier; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getCodeVerifier() { |
|
|
return this.codeVerifier; |
|
|
} |
|
|
|
|
|
generateCodeChallenge() { |
|
|
const encoder = new Encoder(); |
|
|
const codeData = encoder.encode(this.codeVerifier); |
|
|
let codeChallenge; |
|
|
if (isBrowserEnv() || isWorkerEnv()) { |
|
|
return crypto.subtle.digest('SHA-256', codeData) |
|
|
.then((digestedHash) => { |
|
|
const base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(digestedHash))); |
|
|
codeChallenge = createBrowserSafeString(base64String).substr(0, 128); |
|
|
this.codeChallenge = codeChallenge; |
|
|
}); |
|
|
} |
|
|
const digestedHash = crypto.createHash('sha256').update(codeData).digest(); |
|
|
codeChallenge = createBrowserSafeString(digestedHash); |
|
|
this.codeChallenge = codeChallenge; |
|
|
return Promise.resolve(); |
|
|
} |
|
|
|
|
|
generatePKCECodes() { |
|
|
let codeVerifier; |
|
|
if (isBrowserEnv() || isWorkerEnv()) { |
|
|
const array = new Uint8Array(PKCELength); |
|
|
const randomValueArray = crypto.getRandomValues(array); |
|
|
const base64String = btoa(randomValueArray); |
|
|
codeVerifier = createBrowserSafeString(base64String).substr(0, 128); |
|
|
} else { |
|
|
const randomBytes = crypto.randomBytes(PKCELength); |
|
|
codeVerifier = createBrowserSafeString(randomBytes).substr(0, 128); |
|
|
} |
|
|
this.codeVerifier = codeVerifier; |
|
|
|
|
|
return this.generateCodeChallenge(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getAuthenticationUrl(redirectUri, state, authType = 'token', tokenAccessType = null, scope = null, includeGrantedScopes = 'none', usePKCE = false) { |
|
|
const clientId = this.getClientId(); |
|
|
const baseUrl = OAuth2AuthorizationUrl(this.domain); |
|
|
|
|
|
if (!clientId) { |
|
|
throw new Error('A client id is required. You can set the client id using .setClientId().'); |
|
|
} |
|
|
if (authType !== 'code' && !redirectUri) { |
|
|
throw new Error('A redirect uri is required.'); |
|
|
} |
|
|
if (!GrantTypes.includes(authType)) { |
|
|
throw new Error('Authorization type must be code or token'); |
|
|
} |
|
|
if (tokenAccessType && !TokenAccessTypes.includes(tokenAccessType)) { |
|
|
throw new Error('Token Access Type must be legacy, offline, or online'); |
|
|
} |
|
|
if (scope && !(scope instanceof Array)) { |
|
|
throw new Error('Scope must be an array of strings'); |
|
|
} |
|
|
if (!IncludeGrantedScopes.includes(includeGrantedScopes)) { |
|
|
throw new Error('includeGrantedScopes must be none, user, or team'); |
|
|
} |
|
|
|
|
|
let authUrl; |
|
|
if (authType === 'code') { |
|
|
authUrl = `${baseUrl}?response_type=code&client_id=${clientId}`; |
|
|
} else { |
|
|
authUrl = `${baseUrl}?response_type=token&client_id=${clientId}`; |
|
|
} |
|
|
|
|
|
if (redirectUri) { |
|
|
authUrl += `&redirect_uri=${redirectUri}`; |
|
|
} |
|
|
if (state) { |
|
|
authUrl += `&state=${state}`; |
|
|
} |
|
|
if (tokenAccessType) { |
|
|
authUrl += `&token_access_type=${tokenAccessType}`; |
|
|
} |
|
|
if (scope) { |
|
|
authUrl += `&scope=${scope.join(' ')}`; |
|
|
} |
|
|
if (includeGrantedScopes !== 'none') { |
|
|
authUrl += `&include_granted_scopes=${includeGrantedScopes}`; |
|
|
} |
|
|
if (usePKCE) { |
|
|
return this.generatePKCECodes() |
|
|
.then(() => { |
|
|
authUrl += '&code_challenge_method=S256'; |
|
|
authUrl += `&code_challenge=${this.codeChallenge}`; |
|
|
return authUrl; |
|
|
}); |
|
|
} |
|
|
return Promise.resolve(authUrl); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getAccessTokenFromCode(redirectUri, code) { |
|
|
const clientId = this.getClientId(); |
|
|
const clientSecret = this.getClientSecret(); |
|
|
|
|
|
if (!clientId) { |
|
|
throw new Error('A client id is required. You can set the client id using .setClientId().'); |
|
|
} |
|
|
let path = OAuth2TokenUrl(this.domain, this.domainDelimiter); |
|
|
path += '?grant_type=authorization_code'; |
|
|
path += `&code=${code}`; |
|
|
path += `&client_id=${clientId}`; |
|
|
|
|
|
if (clientSecret) { |
|
|
path += `&client_secret=${clientSecret}`; |
|
|
} else { |
|
|
if (!this.codeVerifier) { |
|
|
throw new Error('You must use PKCE when generating the authorization URL to not include a client secret'); |
|
|
} |
|
|
path += `&code_verifier=${this.codeVerifier}`; |
|
|
} |
|
|
if (redirectUri) { |
|
|
path += `&redirect_uri=${redirectUri}`; |
|
|
} |
|
|
|
|
|
const fetchOptions = { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/x-www-form-urlencoded', |
|
|
}, |
|
|
}; |
|
|
return this.fetch(path, fetchOptions) |
|
|
.then((res) => parseResponse(res)); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
checkAndRefreshAccessToken() { |
|
|
const canRefresh = this.getRefreshToken() && this.getClientId(); |
|
|
const needsRefresh = !this.getAccessTokenExpiresAt() |
|
|
|| (new Date(Date.now() + TokenExpirationBuffer)) >= this.getAccessTokenExpiresAt(); |
|
|
const needsToken = !this.getAccessToken(); |
|
|
if ((needsRefresh || needsToken) && canRefresh) { |
|
|
return this.refreshAccessToken(); |
|
|
} |
|
|
return Promise.resolve(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
refreshAccessToken(scope = null) { |
|
|
const clientId = this.getClientId(); |
|
|
const clientSecret = this.getClientSecret(); |
|
|
|
|
|
if (!clientId) { |
|
|
throw new Error('A client id is required. You can set the client id using .setClientId().'); |
|
|
} |
|
|
if (scope && !(scope instanceof Array)) { |
|
|
throw new Error('Scope must be an array of strings'); |
|
|
} |
|
|
|
|
|
let refreshUrl = OAuth2TokenUrl(this.domain, this.domainDelimiter); |
|
|
const fetchOptions = { |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
method: 'POST', |
|
|
}; |
|
|
|
|
|
if (this.dataOnBody) { |
|
|
const body = { grant_type: 'refresh_token', client_id: clientId, refresh_token: this.getRefreshToken() }; |
|
|
|
|
|
if (clientSecret) { |
|
|
body.client_secret = clientSecret; |
|
|
} |
|
|
if (scope) { |
|
|
body.scope = scope.join(' '); |
|
|
} |
|
|
|
|
|
fetchOptions.body = body; |
|
|
} else { |
|
|
refreshUrl += `?grant_type=refresh_token&refresh_token=${this.getRefreshToken()}`; |
|
|
refreshUrl += `&client_id=${clientId}`; |
|
|
if (clientSecret) { |
|
|
refreshUrl += `&client_secret=${clientSecret}`; |
|
|
} |
|
|
if (scope) { |
|
|
refreshUrl += `&scope=${scope.join(' ')}`; |
|
|
} |
|
|
} |
|
|
|
|
|
return this.fetch(refreshUrl, fetchOptions) |
|
|
.then((res) => parseResponse(res)) |
|
|
.then((res) => { |
|
|
this.setAccessToken(res.result.access_token); |
|
|
this.setAccessTokenExpiresAt(getTokenExpiresAtDate(res.result.expires_in)); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|