mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
fbd4ded5b4
* Gemini first-pass * Prevent post-connect login * stash * stash * Add OIDC logo * Separate security page * test * Update icon * test * ui * Add extra guards for disabling auth provider * refactor * test
249 lines
7.2 KiB
TypeScript
249 lines
7.2 KiB
TypeScript
import crypto from "crypto";
|
|
import { addMinutes, subMinutes } from "date-fns";
|
|
import type { Context } from "koa";
|
|
import type {
|
|
StateStoreStoreCallback,
|
|
StateStoreVerifyCallback,
|
|
} from "passport-oauth2";
|
|
import type { Primitive } from "utility-types";
|
|
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";
|
|
import fetch from "./fetch";
|
|
import { getUserForJWT } from "./jwt";
|
|
|
|
export class StateStore {
|
|
constructor(private pkce = false) {}
|
|
|
|
key = "state";
|
|
|
|
store = (
|
|
ctx: Context,
|
|
verifierOrCallback: StateStoreStoreCallback | string,
|
|
_state?: Record<string, Primitive>,
|
|
_meta?: unknown,
|
|
cb?: StateStoreStoreCallback
|
|
) => {
|
|
// token is a short lived one-time pad to prevent replay attacks
|
|
const token = crypto.randomBytes(8).toString("hex");
|
|
|
|
// Note parameters are based on whether PKCE is in use or not, this is parameters
|
|
// of how the underlying library is architected, see:
|
|
// https://github.com/jaredhanson/passport-oauth2/blob/be9bf58cee75938c645a9609f0cc87c4c724e7c8/lib/strategy.js#L289-L298
|
|
const callback =
|
|
typeof verifierOrCallback === "function" ? verifierOrCallback : cb;
|
|
if (!callback) {
|
|
throw InternalError("Callback is required");
|
|
}
|
|
|
|
const codeVerifier =
|
|
typeof verifierOrCallback === "function" ? undefined : verifierOrCallback;
|
|
|
|
// 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 accessToken = ctx.cookies.get("accessToken");
|
|
const state = buildState({
|
|
host,
|
|
token,
|
|
client,
|
|
codeVerifier,
|
|
accessToken,
|
|
});
|
|
|
|
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("No state was available after OAuth flow"),
|
|
false,
|
|
state
|
|
);
|
|
}
|
|
|
|
const { token, codeVerifier } = 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("Token in state mismatched"),
|
|
false,
|
|
token
|
|
);
|
|
}
|
|
|
|
// @ts-expect-error Type in library is wrong
|
|
callback(null, codeVerifier ?? true, state);
|
|
};
|
|
}
|
|
|
|
export async function request(
|
|
method: "GET" | "POST",
|
|
endpoint: string,
|
|
accessToken: string
|
|
) {
|
|
const response = await fetch(endpoint, {
|
|
method,
|
|
allowPrivateIPAddress: true,
|
|
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,
|
|
token,
|
|
client,
|
|
codeVerifier,
|
|
accessToken,
|
|
}: {
|
|
host: string;
|
|
token: string;
|
|
client?: Client;
|
|
codeVerifier?: string;
|
|
accessToken?: string;
|
|
}) {
|
|
return [host, token, client, codeVerifier, accessToken].join("|");
|
|
}
|
|
|
|
/**
|
|
* Parses the state string into its components.
|
|
*
|
|
* @param state The state string
|
|
* @returns An object containing the parsed components
|
|
*/
|
|
export function parseState(state: string) {
|
|
const [host, token, client, rawCodeVerifier, rawAccessToken] =
|
|
state.split("|");
|
|
const codeVerifier = rawCodeVerifier ? rawCodeVerifier : undefined;
|
|
const accessToken = rawAccessToken ? rawAccessToken : undefined;
|
|
return { host, token, client, codeVerifier, accessToken };
|
|
}
|
|
|
|
/**
|
|
* Returns the client type from the context if available. Used to redirect
|
|
* the user back to the correct client after the OAuth flow.
|
|
*
|
|
* @param ctx The Koa context
|
|
* @returns The client type, defaults to Client.Web
|
|
*/
|
|
export function getClientFromOAuthState(ctx: Context): Client {
|
|
const state = ctx.cookies.get("state");
|
|
const client = state ? parseState(state).client : undefined;
|
|
return client === Client.Desktop ? Client.Desktop : Client.Web;
|
|
}
|
|
|
|
/**
|
|
* Returns the access token from the context if available. This is used
|
|
* to restore the session during the OAuth flow when connecting additional
|
|
* providers to an existing team.
|
|
*
|
|
* @param ctx The Koa context
|
|
* @returns The access token if available, otherwise undefined
|
|
*/
|
|
export function getAccessTokenFromOAuthState(ctx: Context): string | undefined {
|
|
const state = ctx.cookies.get("state");
|
|
return state ? parseState(state).accessToken : undefined;
|
|
}
|
|
|
|
/**
|
|
* Returns the user from the context if they are authenticated. This is used
|
|
* to restore the session during the OAuth flow.
|
|
*
|
|
* @param ctx The Koa context
|
|
* @returns The user if authenticated, otherwise undefined
|
|
*/
|
|
export async function getUserFromOAuthState(ctx: Context) {
|
|
const token = getAccessTokenFromOAuthState(ctx);
|
|
if (!token) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
return await getUserForJWT(token);
|
|
} catch (_err) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
type TeamFromContextOptions = {
|
|
/**
|
|
* Whether to consider the state cookie in the context when determining the team.
|
|
* If true, the state cookie will be parsed to determine the host and infer the team
|
|
* this should only be used in the authentication process.
|
|
*/
|
|
includeStateCookie?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Infers the team from the context based on the hostname or state cookie.
|
|
*
|
|
* @param ctx The Koa context
|
|
* @param options Options for determining the team
|
|
* @returns The inferred team or undefined if not found
|
|
*/
|
|
export async function getTeamFromContext(
|
|
ctx: Context,
|
|
options: TeamFromContextOptions = { includeStateCookie: true }
|
|
) {
|
|
// "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 = options.includeStateCookie
|
|
? ctx.cookies.get("state")
|
|
: undefined;
|
|
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({
|
|
order: [["createdAt", "DESC"]],
|
|
});
|
|
}
|
|
} 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.findBySubdomain(domain.teamSubdomain);
|
|
}
|
|
|
|
return team;
|
|
}
|