indieauth-helper
Version:
A simple helper class for creating IndieAuth clients
488 lines (444 loc) • 15.5 kB
JavaScript
const axios = require('axios')
const relScraper = require('rel-parser')
const CryptoJS = require('crypto-js')
const { parse: qsParse, stringify: qsStringify } = require('qs')
const defaultSettings = {
me: '',
authEndpoint: '',
tokenEndpoint: '',
}
/**
* Creates an error object
* @param {string} message A human readable error message
* @param {int} status A http response status from the indieAuth endpoint
* @param {object} error A full error object if available
* @return {object} A consistently formatted error object
*/
const indieAuthError = (message, status = null, error = null) => {
if (error && error.message && error.error) {
// Don't want to have nested errors.
error = error.error
message = error.message
}
return { message, status, error }
}
/**
* A indieAuth helper class
*/
class IndieAuth {
/**
* Micropub class constructor
* @param {object} userSettings Settings supplied for this indieAuth client
*/
constructor(userSettings = {}) {
this.options = Object.assign({}, defaultSettings, userSettings)
// Bind all the things
this.checkRequiredOptions = this.checkRequiredOptions.bind(this)
this.getAuthUrl = this.getAuthUrl.bind(this)
this.getRelsFromUrl = this.getRelsFromUrl.bind(this)
this.verifyCode = this.verifyCode.bind(this)
this.getToken = this.getToken.bind(this)
this.generateState = this.generateState.bind(this)
this.autoGenerateState = this.autoGenerateState.bind(this)
this.validateState = this.validateState.bind(this)
}
/**
* Checks to see if the given options are set
* @param {array} requirements An array of option keys to check
* @return {object} An object with boolean pass property and array missing property listing missing options
*/
checkRequiredOptions(requirements) {
let missing = []
let pass = true
for (const optionName of requirements) {
const option = this.options[optionName]
if (!option) {
pass = false
missing.push(optionName)
}
}
if (!pass) {
throw indieAuthError('Missing required options: ' + missing.join(', '))
}
return true
}
/**
* Canonicalize the given url according to the rules at
* https://indieauth.spec.indieweb.org/#url-canonicalization
* @param {string} url The url to canonicalize
* @return {string} The canonicalized url.
*/
getCanonicalUrl(url) {
return new URL(url).href
}
/**
* Fetch a URL, keeping track of 301 redirects to update
* https://indieauth.spec.indieweb.org/#redirect-examples
* @param {string} url The url to scrape
* @return {Promise} Passes the axios response object and the "final" url.
*/
async getUrlWithRedirects(url) {
const request = {
url,
method: 'GET',
responseType: 'text',
maxRedirects: 0,
headers: {
accept: 'text/html,application/xhtml+xml'
}
}
const getRedirectUrl = (to, from) => {
if (!to.startsWith('http')) {
return new URL(to, from).toString()
} else {
return to
}
}
try {
const res = await axios(request)
return { url, response: res }
} catch (err) {
if (err.response) {
const res = err.response
if (res.status === 301 || res.status === 308) {
// Permanent redirect means we use this new url as canonical so, recurse on the new url!
const redirectUrl = getRedirectUrl(res.headers.location, url)
const {
response: redirectRes,
url: followUrl,
} = await this.getUrlWithRedirects(redirectUrl)
return { url: followUrl, response: redirectRes }
} else if (res.status === 302 || res.status === 307) {
// Temporary redirect means we use the new url for discovery, but don't treat it as canonical
const redirectUrl = getRedirectUrl(res.headers.location, url)
const tmp = await this.getUrlWithRedirects(redirectUrl)
if (tmp.response.status > 199 && tmp.response.status < 300) {
return { response: tmp.response, url }
}
throw indieAuthError(
'Error following redirects for ' + url,
tmp && tmp.response && tmp.response.status
? tmp.response.status
: null,
tmp.response
)
}
}
throw indieAuthError(
'Error getting ' + url,
err && err.response ? err.response.status : null,
err
)
}
}
/**
* Get the various endpoints needed from the given url
* @param {string} url The url to scrape
* @param {array} extraRels An array of extra rels to try and parse from the url. Everything is normalized to lowercase
* @return {Promise} Passes an object of endpoints on success: `authorization_endpoint`, `token_endpoint` and any extras.
* If a requested rel was not found it will have a null value.
* Note: Will only pass the first value of any rel if there are multiple results.
*/
async getRelsFromUrl(url, extraRels = []) {
url = this.getCanonicalUrl(url)
try {
const toFind = ['authorization_endpoint', 'token_endpoint', ...extraRels]
const { response: res, url: finalUrl } = await this.getUrlWithRedirects(
url
)
this.options.me = finalUrl
// Get rel links
const rels = await relScraper(finalUrl, res.data, res.headers)
const foundRels = {}
if (rels) {
for (const key of toFind) {
foundRels[key] = rels[key] && rels[key][0] ? rels[key][0] : null
}
}
if (!foundRels.authorization_endpoint) {
throw indieAuthError('No authorization endpoint found')
}
this.options.authEndpoint = foundRels.authorization_endpoint
if (foundRels.token_endpoint) {
this.options.tokenEndpoint = foundRels.token_endpoint
}
return foundRels
} catch (err) {
throw indieAuthError(
'Error getting rels from url',
err && err.response ? err.response.status : null,
err
)
}
}
/**
* Exchanges a code for an access token
* @param {string} code A code received from the auth endpoint
* @return {Promise} Promise which resolves with the access token on success
*/
async getToken(code) {
this.checkRequiredOptions([
'me',
'clientId',
'redirectUri',
'tokenEndpoint',
])
try {
const data = {
grant_type: 'authorization_code',
me: this.options.me,
code: code,
client_id: this.options.clientId,
redirect_uri: this.options.redirectUri,
}
// Add PKCE code verifier if it is set
if (this.options.codeVerifier) {
data.code_verifier = this.options.codeVerifier
}
const request = {
url: this.options.tokenEndpoint,
method: 'POST',
data: qsStringify(data),
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
accept: 'application/json, application/x-www-form-urlencoded',
},
}
// This could maybe use the postMicropub method
const res = await axios(request)
let result = res.data
// Parse the response from the indieauth server
if (typeof result === 'string') {
result = qsParse(result)
}
if (result.error_description) {
throw indieAuthError(result.error_description)
} else if (result.error) {
throw indieAuthError(result.error)
}
if (!result.me || !result.scope || !result.access_token) {
throw indieAuthError(
'The token endpoint did not return the expected parameters'
)
}
// Check "me" values have the same hostname
let urlResult = new URL(result.me)
let urlOptions = new URL(this.options.me)
if (urlResult.hostname != urlOptions.hostname) {
throw indieAuthError('The me values do not share the same hostname')
}
// Successfully got the token
return result.access_token
} catch (err) {
throw indieAuthError(
'Error requesting token endpoint',
err.response.status,
err
)
}
}
/**
* Get the authentication url based on the set options
* @param {string} responseType The response type expected from the auth endpoint. Usually `code` or `id`. Defaults to `id`
* @param {array} scopes An array of scopes to send to the auth endpoint.
* @return {Promise} Passes the authentication url on success
*/
async getAuthUrl(responseType = 'id', scopes = []) {
this.autoGenerateState()
this.checkRequiredOptions(['me', 'state'])
if (responseType === 'code' && scopes.length === 0) {
// If doing code auth you also need scopes.
throw indieAuthError(
'You need to provide some scopes when using response type "code"'
)
}
try {
if (!this.options.authEndpoint) {
await this.getRelsFromUrl(this.options.me)
}
this.checkRequiredOptions([
'me',
'state',
'clientId',
'redirectUri',
'authEndpoint',
])
const authUrl = new URL(this.options.authEndpoint)
authUrl.searchParams.append('me', this.options.me)
authUrl.searchParams.append('client_id', this.options.clientId)
authUrl.searchParams.append('redirect_uri', this.options.redirectUri)
authUrl.searchParams.append('response_type', responseType)
authUrl.searchParams.append('state', this.options.state)
if (scopes.length) {
authUrl.searchParams.append('scope', scopes.join(' '))
}
// Add a PKCE code challenge if a code verifier is set.
if (responseType === 'code' && this.options.codeVerifier) {
const codeChallenge = encodeURIComponent(
CryptoJS.SHA256(this.options.codeVerifier)
)
authUrl.searchParams.append('code_challenge_method', 'S256')
authUrl.searchParams.append('code_challenge', codeChallenge)
}
return authUrl.toString()
} catch (err) {
throw indieAuthError('Error getting auth url', null, err)
}
}
/**
* Verify that a code is valid with the auth endpoint.
* @param {string} code The code to verify
* @returns {Promise} If the code is valid then the promise is resolved with the `me` value
*/
async verifyCode(code) {
this.checkRequiredOptions(['me', 'clientId', 'redirectUri', 'authEndpoint'])
const data = {
code,
client_id: this.options.clientId,
redirect_uri: this.options.redirectUri,
}
const request = {
url: this.options.authEndpoint,
method: 'POST',
data: qsStringify(data),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
Accept: 'application/json, application/x-www-form-urlencoded',
},
}
try {
const res = await axios(request)
if (res.status < 200 || res.status > 299) {
throw new Error('Error verifying code', res.status, res)
}
let { data } = res
if (typeof data === 'string') {
data = qsParse(data)
}
if (data.error_description) {
throw indieAuthError(data.error_description)
} else if (data.error) {
throw indieAuthError(data.error)
}
if (!data.me) {
throw indieAuthError(
'The auth endpoint did not return the "me" parameter while verifying the code'
)
}
// Check me is the same (removing any trailing slashes)
if (
data.me &&
data.me.replace(/\/+$/, '') !== this.options.me.replace(/\/+$/, '')
) {
throw indieAuthError('The me values did not match')
}
if (!this.options.me) {
this.options.me = data.me
}
return data.me
} catch (err) {
throw indieAuthError('Error verifying authorization code')
}
}
/**
* Verify the stored access token
* @param {string} The token to verify
* @return {Promise} A promise that resolves true or rejects
*/
async verifyToken(token) {
this.checkRequiredOptions(['indieAuthEndpoint'])
try {
const request = {
url: this.options.indieAuthEndpoint,
method: 'GET',
headers: {
Authorization: 'Bearer ' + token,
},
}
const res = await axios(request)
if (res.status === 200) {
return true
}
throw res
} catch (err) {
throw indieAuthError(
'Error verifying token',
err && err.response ? err.response.status : null,
err
)
}
}
/**
* Generates a unique, encrypted state value that doesn't need to be cached
* @returns {string} A state string
*/
generateState() {
this.checkRequiredOptions(['secret', 'me', 'clientId'])
let state = {
date: Date.now(),
me: this.options.me,
clientId: this.options.clientId,
}
state = CryptoJS.AES.encrypt(
JSON.stringify(state),
this.options.secret
).toString()
return state
}
/**
* Uses `generateState` to set the `this.options.state` property if required
*/
autoGenerateState() {
try {
if (!this.options.state) {
this.checkRequiredOptions(['secret', 'me', 'clientId'])
this.options.state = this.generateState()
}
return
} catch (err) {
// Something is missing to generate the state, but no need to throw an error.
return
}
}
/**
* Validates a state string that was generated with the `generateState` method
* @param {string} state The state string
* @returns {object|false} If successful it will return the validated state option. Which has `date`, `me` and `clientId` properties. Returns false on failure.
*/
validateState(state) {
this.checkRequiredOptions(['secret', 'clientId'])
try {
state = JSON.parse(
CryptoJS.AES.decrypt(state, this.options.secret).toString(
CryptoJS.enc.Utf8
)
)
if (
state.clientId === this.options.clientId &&
state.date > Date.now() - 1000 * 60 * 10 &&
state.me
) {
if (!this.options.me) {
this.options.me = state.me
}
return state
} else {
throw 'State is invalid'
}
} catch (err) {
return false
}
}
/**
* Generates a cryptographically random string
* @param {int} length The character count of the random string. Defaults to 100. (NOTE: Will round up to an even number)
* @returns {string} The random string
*/
generateRandomString(length = 100) {
// Create a random word array and convert it to a string
// 1 word = 2 characters
const wordArray = CryptoJS.lib.WordArray.random(Math.round(length / 2))
return wordArray.toString()
}
}
module.exports = IndieAuth