chore: temporarily loosen CORS, add OAuth state info for backend bouncing

This commit is contained in:
41666 2021-03-13 15:49:36 -05:00
parent ed82a67594
commit 6edfe7455f
8 changed files with 72 additions and 9 deletions

View file

@ -6,6 +6,7 @@ declare global {
const UI_PUBLIC_URI: string; const UI_PUBLIC_URI: string;
const API_PUBLIC_URI: string; const API_PUBLIC_URI: string;
const ROOT_USERS: string; const ROOT_USERS: string;
const ALLOWED_CALLBACK_HOSTS: string;
const KV_SESSIONS: KVNamespace; const KV_SESSIONS: KVNamespace;
const KV_GUILDS: KVNamespace; const KV_GUILDS: KVNamespace;

View file

@ -1,4 +1,5 @@
import KSUID from 'ksuid'; import { StateSession } from '@roleypoly/types';
import { getQuery, isAllowedCallbackHost, setupStateSession } from '../utils/api-tools';
import { Bounce } from '../utils/bounce'; import { Bounce } from '../utils/bounce';
import { apiPublicURI, botClientID } from '../utils/config'; import { apiPublicURI, botClientID } from '../utils/config';
@ -16,9 +17,17 @@ const buildURL = (params: URLParams) =>
)}&state=${params.state}`; )}&state=${params.state}`;
export const LoginBounce = async (request: Request): Promise<Response> => { export const LoginBounce = async (request: Request): Promise<Response> => {
const state = await KSUID.random(); const stateSessionData: StateSession = {};
const { cbh: callbackHost } = getQuery(request);
if (callbackHost && isAllowedCallbackHost(callbackHost)) {
stateSessionData.callbackHost = callbackHost;
}
const state = await setupStateSession(stateSessionData);
const redirectURI = `${apiPublicURI}/login-callback`; const redirectURI = `${apiPublicURI}/login-callback`;
const clientID = botClientID; const clientID = botClientID;
return Bounce(buildURL({ state: state.string, redirectURI, clientID })); return Bounce(buildURL({ state, redirectURI, clientID }));
}; };

View file

@ -1,9 +1,17 @@
import { AuthTokenResponse, DiscordUser, GuildSlug, SessionData } from '@roleypoly/types'; import {
AuthTokenResponse,
DiscordUser,
GuildSlug,
SessionData,
StateSession,
} from '@roleypoly/types';
import KSUID from 'ksuid'; import KSUID from 'ksuid';
import { import {
AuthType, AuthType,
discordFetch, discordFetch,
formData, formData,
getStateSession,
isAllowedCallbackHost,
parsePermissions, parsePermissions,
resolveFailures, resolveFailures,
userAgent, userAgent,
@ -21,8 +29,9 @@ const AuthErrorResponse = (extra?: string) =>
export const LoginCallback = resolveFailures( export const LoginCallback = resolveFailures(
AuthErrorResponse, AuthErrorResponse,
async (request: Request): Promise<Response> => { async (request: Request): Promise<Response> => {
const query = new URL(request.url).searchParams; let bounceBaseUrl = uiPublicURI;
const query = new URL(request.url).searchParams;
const stateValue = query.get('state'); const stateValue = query.get('state');
if (stateValue === null) { if (stateValue === null) {
@ -37,6 +46,14 @@ export const LoginCallback = resolveFailures(
if (currentTime > stateExpiry) { if (currentTime > stateExpiry) {
return AuthErrorResponse('state expired'); return AuthErrorResponse('state expired');
} }
const stateSession = await getStateSession<StateSession>(state.string);
if (
stateSession?.callbackHost &&
isAllowedCallbackHost(stateSession.callbackHost)
) {
bounceBaseUrl = stateSession.callbackHost;
}
} catch (e) { } catch (e) {
return AuthErrorResponse('state invalid'); return AuthErrorResponse('state invalid');
} }
@ -90,7 +107,7 @@ export const LoginCallback = resolveFailures(
await Sessions.put(sessionID.string, sessionData, 60 * 60 * 6); await Sessions.put(sessionID.string, sessionData, 60 * 60 * 6);
return Bounce( return Bounce(
uiPublicURI + '/machinery/new-session?session_id=' + sessionID.string bounceBaseUrl + '/machinery/new-session?session_id=' + sessionID.string
); );
} }
); );

View file

@ -3,8 +3,9 @@ import {
permissions as Permissions, permissions as Permissions,
} from '@roleypoly/misc-utils/hasPermission'; } from '@roleypoly/misc-utils/hasPermission';
import { SessionData, UserGuildPermissions } from '@roleypoly/types'; import { SessionData, UserGuildPermissions } from '@roleypoly/types';
import KSUID from 'ksuid';
import { Handler } from '../router'; import { Handler } from '../router';
import { rootUsers, uiPublicURI } from './config'; import { allowedCallbackHosts, apiPublicURI, rootUsers } from './config';
import { Sessions, WrappedKVNamespace } from './kv'; import { Sessions, WrappedKVNamespace } from './kv';
export const formData = (obj: Record<string, any>): string => { export const formData = (obj: Record<string, any>): string => {
@ -17,7 +18,7 @@ export const addCORS = (init: ResponseInit = {}) => ({
...init, ...init,
headers: { headers: {
...(init.headers || {}), ...(init.headers || {}),
'access-control-allow-origin': uiPublicURI, 'access-control-allow-origin': '*',
'access-control-allow-methods': '*', 'access-control-allow-methods': '*',
'access-control-allow-headers': '*', 'access-control-allow-headers': '*',
}, },
@ -159,6 +160,20 @@ export const withSession = (
return await wrappedHandler(session)(request); return await wrappedHandler(session)(request);
}; };
export const setupStateSession = async <T>(data: T): Promise<string> => {
const stateID = (await KSUID.random()).string;
await Sessions.put(`state_${stateID}`, { data }, 60 * 5);
return stateID;
};
export const getStateSession = async <T>(stateID: string): Promise<T | undefined> => {
const stateSession = await Sessions.get<{ data: T }>(`state_${stateID}`);
return stateSession?.data;
};
export const isRoot = (userID: string): boolean => rootUsers.includes(userID); export const isRoot = (userID: string): boolean => rootUsers.includes(userID);
export const onlyRootUsers = (handler: Handler): Handler => export const onlyRootUsers = (handler: Handler): Handler =>
@ -176,3 +191,17 @@ export const onlyRootUsers = (handler: Handler): Handler =>
} }
); );
}); });
export const getQuery = (request: Request): { [x: string]: string } => {
const output: { [x: string]: string } = {};
for (let [key, value] of new URL(request.url).searchParams.entries()) {
output[key] = value;
}
return output;
};
export const isAllowedCallbackHost = (host: string): boolean => {
return host === apiPublicURI || allowedCallbackHosts.includes(host);
};

View file

@ -11,3 +11,4 @@ export const botToken = env('BOT_TOKEN');
export const uiPublicURI = safeURI(env('UI_PUBLIC_URI')); export const uiPublicURI = safeURI(env('UI_PUBLIC_URI'));
export const apiPublicURI = safeURI(env('API_PUBLIC_URI')); export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
export const rootUsers = list(env('ROOT_USERS')); export const rootUsers = list(env('ROOT_USERS'));
export const allowedCallbackHosts = list(env('ALLOWED_CALLBACK_HOSTS'));

View file

@ -10,6 +10,7 @@ module.exports = {
'UI_PUBLIC_URI', 'UI_PUBLIC_URI',
'API_PUBLIC_URI', 'API_PUBLIC_URI',
'ROOT_USERS', 'ROOT_USERS',
'ALLOWED_CALLBACK_HOSTS',
]), ]),
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'], kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
}; };

View file

@ -16,3 +16,7 @@ export type SessionData = {
user: DiscordUser; user: DiscordUser;
guilds: GuildSlug[]; guilds: GuildSlug[];
}; };
export type StateSession = {
callbackHost?: string;
};

View file

@ -11,9 +11,10 @@ const Login = () => {
React.useEffect(() => { React.useEffect(() => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const callbackHost = `${url.protocol}://${url.host}`;
const redirectServerID = url.searchParams.get('r'); const redirectServerID = url.searchParams.get('r');
if (!redirectServerID) { if (!redirectServerID) {
window.location.href = `${apiUrl}/login-bounce`; window.location.href = `${apiUrl}/login-bounce?cbh=${callbackHost}`;
return; return;
} }