Files
outline/server/utils/passport.ts
2022-07-05 11:06:47 +02:00

101 lines
3.0 KiB
TypeScript

import crypto from "crypto";
import { addMinutes, subMinutes } from "date-fns";
import type { Request } from "express";
import fetch from "fetch-with-proxy";
import {
StateStoreStoreCallback,
StateStoreVerifyCallback,
} from "passport-oauth2";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import { AuthRedirectError, OAuthStateMismatchError } from "../errors";
export class StateStore {
key = "state";
store = (req: Request, callback: StateStoreStoreCallback) => {
// token is a short lived one-time pad to prevent replay attacks
// appDomain is the domain the user originated from when attempting auth
// we expect it to be a team subdomain, custom domain, or apex domain
const token = crypto.randomBytes(8).toString("hex");
const appDomain = parseDomain(req.hostname);
const state = buildState(appDomain.host, token);
req.cookies.set(this.key, state, {
httpOnly: false,
expires: addMinutes(new Date(), 10),
domain: getCookieDomain(req.hostname),
});
callback(null, token);
};
verify = (
req: Request,
providedToken: string,
callback: StateStoreVerifyCallback
) => {
const state = req.cookies.get(this.key);
if (!state) {
return callback(
OAuthStateMismatchError("State not return in OAuth flow"),
false,
state
);
}
const { host, token } = parseState(state);
// Oauth callbacks are hard-coded to come to the apex domain, so we
// redirect to the original app domain before attempting authentication.
// If there is an error during auth, the user will end up on the same domain
// that they started from.
const appDomain = parseDomain(host);
if (appDomain.host !== parseDomain(req.hostname).host) {
const reqProtocol = req.protocol;
const requestHost = req.get("host");
const requestPath = req.originalUrl;
const requestUrl = `${reqProtocol}://${requestHost}${requestPath}`;
const url = new URL(requestUrl);
url.host = appDomain.host;
return callback(AuthRedirectError(``, url.toString()), false, token);
}
// Destroy the one-time pad token and ensure it matches
req.cookies.set(this.key, "", {
httpOnly: false,
expires: subMinutes(new Date(), 1),
domain: getCookieDomain(req.hostname),
});
if (!token || token !== providedToken) {
return callback(OAuthStateMismatchError(), false, token);
}
// @ts-expect-error Type in library is wrong
callback(null, true, state);
};
}
export async function request(endpoint: string, accessToken: string) {
const response = await fetch(endpoint, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
return response.json();
}
function buildState(host: string, token: string) {
return [host, token].join("|");
}
export function parseState(state: string) {
const [host, token] = state.split("|");
return { host, token };
}