Files
outline/server/utils/passport.ts
2023-11-01 23:14:45 -04:00

131 lines
3.9 KiB
TypeScript

import crypto from "crypto";
import { addMinutes, subMinutes } from "date-fns";
import type { Context } from "koa";
// Allowed for trusted server<->server connections
// eslint-disable-next-line no-restricted-imports
import fetch from "node-fetch";
import {
StateStoreStoreCallback,
StateStoreVerifyCallback,
} from "passport-oauth2";
import { Client } from "@shared/types";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import env from "@server/env";
import { Team } from "@server/models";
import { InternalError, OAuthStateMismatchError } from "../errors";
export class StateStore {
key = "state";
store = (ctx: Context, callback: StateStoreStoreCallback) => {
// token is a short lived one-time pad to prevent replay attacks
const token = crypto.randomBytes(8).toString("hex");
// We expect host to be a team subdomain, custom domain, or apex domain
// that is passed via query param from the auth provider component.
const clientInput = ctx.query.client?.toString();
const client = clientInput === Client.Desktop ? Client.Desktop : Client.Web;
const host = ctx.query.host?.toString() || parseDomain(ctx.hostname).host;
const state = buildState(host, token, client);
ctx.cookies.set(this.key, state, {
expires: addMinutes(new Date(), 10),
domain: getCookieDomain(ctx.hostname, env.isCloudHosted),
});
callback(null, token);
};
verify = (
ctx: Context,
providedToken: string,
callback: StateStoreVerifyCallback
) => {
const state = ctx.cookies.get(this.key);
if (!state) {
return callback(
OAuthStateMismatchError("State not return in OAuth flow"),
false,
state
);
}
const { token } = parseState(state);
// Destroy the one-time pad token and ensure it matches
ctx.cookies.set(this.key, "", {
expires: subMinutes(new Date(), 1),
domain: getCookieDomain(ctx.hostname, env.isCloudHosted),
});
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",
},
});
const text = await response.text();
try {
return JSON.parse(text);
} catch (err) {
throw InternalError(
`Failed to parse response from ${endpoint}. Expected JSON, got: ${text}`
);
}
}
function buildState(host: string, token: string, client?: Client) {
return [host, token, client].join("|");
}
export function parseState(state: string) {
const [host, token, client] = state.split("|");
return { host, token, client };
}
export function getClientFromContext(ctx: Context): Client {
const state = ctx.cookies.get("state");
const client = state ? parseState(state).client : undefined;
return client === Client.Desktop ? Client.Desktop : Client.Web;
}
export async function getTeamFromContext(ctx: Context) {
// "domain" is the domain the user came from when attempting auth
// we use it to infer the team they intend on signing into
const state = ctx.cookies.get("state");
const host = state ? parseState(state).host : ctx.hostname;
const domain = parseDomain(host);
let team;
if (!env.isCloudHosted) {
if (env.ENVIRONMENT === "test") {
team = await Team.findOne({ where: { domain: env.URL } });
} else {
team = await Team.findOne();
}
} else if (ctx.state?.rootShare) {
team = await Team.findByPk(ctx.state.rootShare.teamId);
} else if (domain.custom) {
team = await Team.findOne({ where: { domain: domain.host } });
} else if (domain.teamSubdomain) {
team = await Team.findOne({
where: { subdomain: domain.teamSubdomain },
});
}
return team;
}