import { Activities } from './activities.js'
import { parseHandle } from './utils.js'
/**
* Enum of user account access roles than can be granted
* @readonly
* @enum {string}
*/
export const SCOPES = {
viewProfile: 'viewProfile',
viewPublic: 'viewPublic',
viewFriends: 'viewFriends',
postLocation: 'postLocation',
viewPrivate: 'viewPrivate',
creative: 'creative',
addFriends: 'addFriends',
addBlocks: 'addBlocks',
destructive: 'destructive'
}
/**
* User account access roles that can be granted.
* @constant allScopes
* @type {string[]}
*/
export const allScopes = [
'viewProfile',
'viewPublic',
'viewFriends',
'postLocation',
'viewPrivate',
'creative',
'addFriends',
'addBlocks',
'destructive'
]
/**
* User account access levels that can be requested.
* @constant
* @type {string[]}
*/
export const roles = ['public', 'friends', 'modAdditive', 'modFull']
/**
* @typedef {object} TokenResult
* @property {string} token OAuth access token
* @property {string} homeImmer User's home Immers Server origin
* @property {Array<string>} authorizedScopes Scopes granted by user (may differ from requested scopes)
* @property {object} sessionInfo Any other params returned from the authorization server with the token
*/
/**
* Retrieve OAuth access token and authorization details from URL after
* redirect and pass it back to the opening window if in a pop-up. Returns true
* if a token was found and passed from popup to opener. Returns the token response
* data if a token was found but the window is not a popup
* (e.g. to pass on to [ImmersClient.loginWithToken]{@link ImmersClient#loginWithToken}).
* Returns false if no token found.
* @returns {boolean | TokenResult}
*/
export function catchToken () {
const hashParams = new URLSearchParams(window.location.hash.substring(1))
if (hashParams.has('access_token')) {
const token = hashParams.get('access_token')
hashParams.delete('access_token')
const homeImmer = hashParams.get('issuer')
hashParams.delete('issuer')
const authorizedScopes = hashParams.get('scope')?.split(' ') || []
hashParams.delete('scope')
// other params may include: email, provider
const sessionInfo = Object.fromEntries(hashParams)
window.location.hash = ''
// If this is an oauth popup, pass the results back up and close
try {
if (window.opener?.location.origin === window.location.origin) {
window.opener.postMessage({
type: 'ImmersAuth',
token,
homeImmer,
authorizedScopes,
sessionInfo
})
return true
}
} catch {}
return {
token,
homeImmer,
authorizedScopes,
sessionInfo
}
} else if (hashParams.has('error')) {
window.opener?.postMessage({ type: 'ImmersAuth', error: hashParams.get('error') })
}
return false
}
/**
* @typedef {object} AuthResult
* @property {APActor} actor User's ActivityPub profile object
* @property {string} token OAuth access token
* @property {string} homeImmer User's home Immers Server origin
* @property {Array<string>} authorizedScopes Scopes granted by user (may differ from requested scopes)
* @property {object} sessionInfo Any other params returned from the authorization server with the token
*/
/**
* Internal oauth popup handler
* @returns {Promise<AuthResult>}
*/
function oauthPopup (oauthPath, { clientId, redirectURI, preferredScope, handle, deepLink }) {
// center the popup
const width = 800
const height = 800
const left = (window.innerWidth - width) / 2 + window.screenLeft
// we want the window to overlap browser chrome in order to rule out BITB attack
const top = window.screenTop + 5
const features = `toolbar=no, menubar=no, width=${width}, height=${height}, top=${top}, left=${left}`
const authURL = new URL(oauthPath)
const authURLParams = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectURI,
response_type: 'token',
scope: preferredScope,
me: handle,
tab: deepLink
})
authURL.search = authURLParams.toString()
const popup = window.open(authURL, 'immersLoginPopup', features)
if (!popup) {
window.alert('Could not open login window. Please check if popup was blocked and allow it')
} else {
document.body.classList.add('immers-authorizing')
const checkClosed = () => {
if (popup.closed) {
document.body.classList.remove('immers-authorizing')
} else {
window.setTimeout(checkClosed, 100)
}
}
checkClosed()
}
return new Promise((resolve, reject) => {
const handler = ({ data }) => {
if (data?.type !== 'ImmersAuth') {
return
}
// have to close the popup in this thread because, in chrome, having the popup close itself crashes the browser
popup?.close()
if (data.error || !data.token) {
return reject(new Error(data.error ?? 'Not authorized'))
}
const { token, homeImmer, sessionInfo } = data
const authorizedScopes = preprocessScopes(data.authorizedScopes)
window.removeEventListener('message', handler)
tokenToActor(token, homeImmer).then(actor => {
resolve({ actor, token, homeImmer, authorizedScopes, sessionInfo })
})
}
window.addEventListener('message', handler)
})
}
/**
* For a standalone destination without its own Immers Server,
* trigger OAuth flow to a user's home immer via popup window.
* Must be invoked from a trusted user input event handler to allow the popup.
* @param {string} handle User's Immers Handle (username[home.immer] or username@home.immer)
* @param {string} preferredScope Level of access to request (remember the user can alter this before approving)
* @param {string} [tokenCatcherURL=window.location] Redirect URI for OAuth, a page on your origin that runs catchToken on load
* @returns {Promise<AuthResult>}
*/
export async function DestinationOAuthPopup (handle, preferredScope, tokenCatcherURL = window.location) {
const { immer } = parseHandle(handle)
if (!immer) {
throw new Error('Invalid handle')
}
/**
* The proper flow here should be:
* 1. fetch webfinger, get profile IRI
* 2. fetch profile IRI, get profile object
* 3. use object.endpoints.oauthAuthorizationEndpoint to authorize
*
* Unfortunately, #2 will be blocked by CORS which requires an oauth token to open up,
* so we'll just use the hardcoded immers oauth endpoint for now
// find user profile to get OAuth endpoint
let profile
try {
const finger = await window.fetch(`https://${immer}/.well-known/webfinger?resource=acct:${username}@${immer}`)
.then(res => res.json())
} catch (err) {
throw new Error(`Unable to fetch profile: ${err.message}`)
}
*/
return oauthPopup(`https://${immer}/auth/authorize`, {
redirectURI: tokenCatcherURL,
preferredScope,
handle
})
}
/**
* For complete immers, trigger popup window OAuth flow starting at local immer and redirecting
* as necessary. Must be invoked from a trusted user input event handler to allow the popup.
* @param {string} localImmer Origin of the local Immers Server
* @param {string} localImmerId IRI of the local immer Place object
* @param {string} preferredScope Level of access to request (remember the user can alter this before approving)
* @param {string} tokenCatcherURL Redirect URI for OAuth, a page on your origin that runs catchToken on load
* @param {string} [handle] If known, you can provide the user's handle (username[home.immer]) to pre-fill login forms
* @param {'Login'|'Register'|'Reset password'} [deepLink] Set the default tab to be shown on the login page
* @returns {Promise<AuthResult>}
*/
export function ImmerOAuthPopup (localImmer, localImmerId, preferredScope, tokenCatcherURL, handle, deepLink) {
return oauthPopup(`https://${localImmer}/auth/authorize`, {
clientId: localImmerId,
redirectURI: tokenCatcherURL,
preferredScope,
handle,
deepLink
})
}
// TODO logout / upgrade scope
export async function tokenToActor (token, homeImmer) {
const response = await window.fetch(`${homeImmer}/auth/me`, {
headers: {
Accept: Activities.JSONLDMime,
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error(`Error fetching actor ${response.status} ${response.statusText}`)
}
return response.json()
}
export function preprocessScopes (authorizedScopes) {
if (typeof authorizedScopes === 'string') {
authorizedScopes = authorizedScopes.split(' ')
}
return authorizedScopes[0] === '*' ? allScopes : authorizedScopes
}
Source