UNPKG

cloudworker-proxy

Version:
333 lines (288 loc) 9.3 kB
const cookie = require('cookie'); const get = require('lodash.get'); const set = require('lodash.set'); const shortid = require('shortid'); const KvStorage = require('../services/kv-storage'); const jwtRefresh = require('./jwt-refresh'); const aes = require('../encryption/aes'); const _ = { get, set, }; function getCookie({ cookieHeader = '', cookieName }) { const cookies = cookie.parse(cookieHeader); return cookies[cookieName]; } /** * Very simplistic form serializer that works for this case but probably nothing else :) * @param {*} obj */ function serializeFormData(obj) { return Object.keys(obj) .map((key) => `${key}=${encodeURIComponent(obj[key])}`) .join('&'); } function isBrowser(accept = '') { return accept.split(',').indexOf('text/html') !== -1; } module.exports = function oauth2Handler({ cookieName = 'proxy', cookieHttpOnly = true, allowPublicAccess = false, kvAccountId, kvNamespace, kvAuthEmail, kvAuthKey, kvTtl = 2592000, // A month oauth2AuthDomain, oauth2ClientId, oauth2ClientSecret, oauth2Audience, oauth2Scopes = [], oauth2CallbackPath = '/callback', oauth2CallbackType = 'cookie', oauth2LogoutPath = '/logout', oauth2LoginPath = '/login', oauth2ServerTokenPath = '/oauth/token', oauth2ServerAuthorizePath = '', oauth2ServerLogoutPath, }) { const kvStorage = new KvStorage({ accountId: kvAccountId, namespace: kvNamespace, authEmail: kvAuthEmail, authKey: kvAuthKey, ttl: kvTtl, }); const authDomain = oauth2AuthDomain; const callbackPath = oauth2CallbackPath; const callbackType = oauth2CallbackType; const serverTokenPath = oauth2ServerTokenPath; const serverAuthorizePath = oauth2ServerAuthorizePath; const serverLogoutPath = oauth2ServerLogoutPath; const clientId = oauth2ClientId; const clientSecret = oauth2ClientSecret; const audience = oauth2Audience; const logoutPath = oauth2LogoutPath; const loginPath = oauth2LoginPath; const scopes = oauth2Scopes; const scope = scopes.join('%20'); async function getTokenFromCode(code, redirectUrl) { const tokenUrl = `${authDomain}${serverTokenPath}`; const response = await fetch(tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', }, body: serializeFormData({ code, grant_type: 'authorization_code', client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUrl, }), }); if (!response.ok) { throw new Error('Authentication failed'); } const body = await response.json(); return { ...body, expires: Date.now() + body.expires_in * 1000, }; } async function handleLogout(ctx) { const sessionCookie = getCookie({ cookieHeader: ctx.request.headers.cookie, cookieName, }); if (sessionCookie) { const domain = ctx.request.hostname.match(/[^.]+\.[^.]+$/i)[0]; // Remove the cookie ctx.set( 'Set-Cookie', cookie.serialize(cookieName, '', { domain: `.${domain}`, path: '/', maxAge: 0, }), ); } const returnToPath = getRedirectTo(ctx); if (oauth2ServerLogoutPath) { const returnTo = encodeURIComponent( `${ctx.request.protocol}://${ctx.request.host}${returnToPath}`, ); // Bounce to remove cookie at the oauth server ctx.set( 'Location', `${authDomain}${serverLogoutPath}?client_id=${clientId}&returnTo=${returnTo}`, ); } else { ctx.set('Location', returnToPath); } ctx.status = 302; } async function handleCallback(ctx) { const redirectUrl = ctx.request.href.split('?')[0]; const body = await getTokenFromCode(ctx.request.query.code, redirectUrl); const key = shortid.generate(); const salt = await aes.getSalt(); const sessionToken = `${key}.${salt}`; const aesKey = await aes.deriveAesGcmKey(key, salt); const data = await aes.encrypt(aesKey, JSON.stringify(body)); await kvStorage.put(key, data); const domain = ctx.request.hostname.match(/[^.]+\.[^.]+$/i)[0]; ctx.status = 302; if (callbackType === 'query') { ctx.set('Location', `${ctx.request.query.state}?auth=${sessionToken}`); } else { ctx.set( 'Set-Cookie', cookie.serialize(cookieName, sessionToken, { httpOnly: cookieHttpOnly, domain: `.${domain}`, path: '/', maxAge: 60 * 60 * 24 * 365, // 1 year }), ); ctx.set('Location', ctx.request.query.state); } } /** * Try to set a bearer based on the session cookie * @param {*} ctx * @param {*} sessionToken */ async function getSession(ctx, sessionToken) { const [key, salt] = sessionToken.split('.'); const data = await kvStorage.get(key); if (data) { const aesKey = await aes.deriveAesGcmKey(key, salt); const authData = await aes.decrypt(aesKey, data); let tokens = JSON.parse(authData); if (tokens.expires < Date.now()) { tokens = await jwtRefresh({ refresh_token: tokens.refresh_token, clientId, authDomain, clientSecret, }); const encryptedAuthData = await aes.encrypt(aesKey, JSON.stringify(tokens)); await kvStorage.put(key, encryptedAuthData); } ctx.state.accessToken = tokens.access_token; if (ctx.state.accessToken) { ctx.request.headers.authorization = `bearer ${ctx.state.accessToken}`; } } else { // Remove the cookie if the session can't be found in the kv-store const domain = ctx.request.hostname.match(/[^.]+\.[^.]+$/i)[0]; // Remove the cookie ctx.set( 'Set-Cookie', cookie.serialize(cookieName, '', { domain: `.${domain}`, maxAge: 0, }), ); } } function getRedirectTo(ctx) { const redirectTo = _.get(ctx, 'request.query.redirect-to'); if (redirectTo) { return redirectTo; } const referer = _.get(ctx, 'request.headers.referer'); // TODO: Add a whitelist with regex if (referer) { return referer; } // Default to the root return '/'; } /** * Explicitly logins a user * @param {*} ctx * @param {*} next */ async function handleLogin(ctx) { // Options requests should return a 200 if (ctx.request.method === 'OPTIONS') { ctx.status = 200; } else { const redirectTo = getRedirectTo(ctx); const state = encodeURIComponent(redirectTo || '/'); const encodedRedirectUri = encodeURIComponent( `${ctx.request.protocol}://${ctx.request.host}${callbackPath}`, ); ctx.status = 302; ctx.set( 'location', `${authDomain}${serverAuthorizePath}/authorize?state=${state}&client_id=${clientId}&response_type=code&scope=${scope}&audience=${audience}&redirect_uri=${encodedRedirectUri}`, ); } } /** * Validates the request based on bearer token and cookie * @param {*} ctx * @param {*} next */ async function handleValidate(ctx, next) { // Options requests should not be authenticated. Requests with auth headers are passed through if (ctx.request.method === 'OPTIONS') { await next(ctx); } else if ( _.get(ctx, 'request.headers.authorization', '').toLowerCase().startsWith('bearer ') ) { // If the request has a auth-header, use this and pass on. _.set(ctx, 'state.access_token', ctx.request.headers.authorization.slice(7)); await next(ctx); } else { // Check for the token in the querystring first and fallback to the cookie const sessionToken = _.get(ctx, 'request.query.auth') || getCookie({ cookieHeader: ctx.request.headers.cookie, cookieName, }); // If the client didn't supply a bearer token, try to fetch one based on the cookie if (sessionToken) { await getSession(ctx, sessionToken); } const accessToken = _.get(ctx, 'state.accessToken'); if (accessToken || allowPublicAccess) { await next(ctx); } else if (isBrowser(ctx.request.headers.accept)) { // For now we just code the requested url in the state. Could pass more properties in a serialized object const state = encodeURIComponent(ctx.request.href); const encodedRedirectUri = encodeURIComponent( `${ctx.request.protocol}://${ctx.request.host}${callbackPath}`, ); ctx.status = 302; ctx.set( 'location', `${authDomain}${serverAuthorizePath}/authorize?state=${state}&client_id=${clientId}&response_type=code&scope=${scope}&audience=${audience}&redirect_uri=${encodedRedirectUri}`, ); } else { ctx.status = 403; ctx.body = 'Forbidden'; } } } return async (ctx, next) => { switch (ctx.request.path) { case callbackPath: await handleCallback(ctx); break; case logoutPath: await handleLogout(ctx); break; case loginPath: await handleLogin(ctx); break; default: await handleValidate(ctx, next); } }; };