port full auth flow to cf workers

This commit is contained in:
41666 2020-12-05 03:09:20 -05:00
parent 9eeb946389
commit aad0987dce
50 changed files with 551 additions and 1167 deletions

View file

@ -6,5 +6,8 @@ declare global {
const UI_PUBLIC_URI: string;
const API_PUBLIC_URI: string;
const ROOT_USERS: string;
const KV_PREFIX: string;
const KV_SESSIONS: KVNamespace;
const KV_GUILDS: KVNamespace;
const KV_GUILD_DATA: KVNamespace;
}

View file

@ -0,0 +1,31 @@
import { SessionData } from 'roleypoly/common/types';
import { getSessionID, respond } from '../utils/api-tools';
import { Sessions } from '../utils/kv';
const NotAuthenticated = (extra?: string) =>
respond(
{
err: extra || 'not authenticated',
},
{ status: 403 }
);
export const GetSession = async (request: Request): Promise<Response> => {
const sessionID = getSessionID(request);
if (!sessionID) {
return NotAuthenticated('missing auth header');
}
console.log(sessionID);
const sessionData = await Sessions.get<SessionData>(sessionID.id);
if (!sessionData) {
return NotAuthenticated('session not found');
}
const { tokens, ...withoutTokens } = sessionData;
return respond({
...withoutTokens,
});
};

View file

@ -1,5 +1,6 @@
import { v4 as uuidv4 } from 'uuid';
import KSUID from 'ksuid';
import { Bounce } from '../utils/bounce';
import { apiPublicURI, botClientID } from '../utils/config';
type URLParams = {
clientID: string;
@ -14,10 +15,10 @@ const buildURL = (params: URLParams) =>
params.redirectURI
)}&state=${params.state}`;
export const LoginBounce = (request: Request): Response => {
const state = uuidv4();
const redirectURI = `${API_PUBLIC_URI}/login-callback`;
const clientID = BOT_CLIENT_ID;
export const LoginBounce = async (request: Request): Promise<Response> => {
const state = await KSUID.random();
const redirectURI = `${apiPublicURI}/login-callback`;
const clientID = botClientID;
return Bounce(buildURL({ state, redirectURI, clientID }));
return Bounce(buildURL({ state: state.string, redirectURI, clientID }));
};

View file

@ -1,3 +1,134 @@
export const LoginCallback = (request: Request): Response => {
return new Response('login-callback!');
import KSUID from 'ksuid';
import {
DiscordUser,
GuildSlug,
SessionData,
AuthTokenResponse,
} from '../../common/types';
import { formData, resolveFailures, parsePermissions } from '../utils/api-tools';
import { Bounce } from '../utils/bounce';
import { apiPublicURI, botClientID, botClientSecret, uiPublicURI } from '../utils/config';
import { Sessions } from '../utils/kv';
const AuthErrorResponse = (extra?: string) =>
Bounce(
uiPublicURI +
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
);
export const LoginCallback = resolveFailures(
AuthErrorResponse(),
async (request: Request): Promise<Response> => {
const query = new URL(request.url).searchParams;
const stateValue = query.get('state');
if (stateValue === null) {
return AuthErrorResponse('state missing');
}
try {
const state = KSUID.parse(stateValue);
const stateExpiry = state.date.getTime() + 1000 * 60 * 5;
const currentTime = Date.now();
if (currentTime > stateExpiry) {
return AuthErrorResponse('state expired');
}
} catch (e) {
return AuthErrorResponse('state invalid');
}
const code = query.get('code');
if (!code) {
return AuthErrorResponse('code missing');
}
const tokenRequest = {
client_id: botClientID,
client_secret: botClientSecret,
grant_type: 'authorization_code',
redirect_uri: apiPublicURI + '/login-callback',
scope: 'identify guilds',
code,
};
const tokenFetch = await fetch('https://discord.com/api/v8/oauth2/token', {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: formData(tokenRequest),
});
const tokens = (await tokenFetch.json()) as AuthTokenResponse;
if (!tokens.access_token) {
console.info({ tokens });
return AuthErrorResponse('token response invalid');
}
const [sessionID, user, guilds] = await Promise.all([
KSUID.random(),
getUser(tokens.access_token),
getGuilds(tokens.access_token),
]);
const sessionData: SessionData = {
tokens,
sessionID: sessionID.string,
user,
guilds,
};
await Sessions.put(sessionID.string, sessionData, 60 * 60 * 6);
return Bounce(
uiPublicURI + '/machinery/new-session?session_id=' + sessionID.string
);
}
);
const discordFetch = async <T>(url: string, auth: string): Promise<T> => {
const response = await fetch('https://discord.com/api/v8' + url, {
headers: {
authorization: 'Bearer ' + auth,
},
});
return (await response.json()) as T;
};
const getUser = async (accessToken: string): Promise<DiscordUser> => {
const { id, username, discriminator, bot, avatar } = await discordFetch<DiscordUser>(
'/users/@me',
accessToken
);
return { id, username, discriminator, bot, avatar };
};
type UserGuildsPayload = {
id: string;
name: string;
icon: string;
owner: boolean;
permissions: number;
features: string[];
}[];
const getGuilds = async (accessToken: string) => {
const guilds = await discordFetch<UserGuildsPayload>(
'/users/@me/guilds',
accessToken
);
const guildSlugs = guilds.map<GuildSlug>((guild) => ({
id: guild.id,
name: guild.name,
icon: guild.icon,
permissionLevel: parsePermissions(guild.permissions, guild.owner),
}));
return guildSlugs;
};

View file

@ -1,4 +1,5 @@
import { BotJoin } from './handlers/bot-join';
import { GetSession } from './handlers/get-session';
import { LoginBounce } from './handlers/login-bounce';
import { LoginCallback } from './handlers/login-callback';
import { Router } from './router';
@ -8,6 +9,7 @@ const router = new Router();
router.add('GET', 'bot-join', BotJoin);
router.add('GET', 'login-bounce', LoginBounce);
router.add('GET', 'login-callback', LoginCallback);
router.add('GET', 'get-session', GetSession);
addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(router.handle(event.request));

View file

@ -0,0 +1,55 @@
import { UserGuildPermissions } from '../../common/types';
import {
evaluatePermission,
permissions as Permissions,
} from '../../common/utils/hasPermission';
export const formData = (obj: Record<string, any>): string => {
return Object.keys(obj)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
.join('&');
};
export const respond = (obj: Record<string, any>, init?: ResponseInit) =>
new Response(JSON.stringify(obj), init);
export const resolveFailures = (
handleWith: Response,
handler: (request: Request) => Promise<Response> | Response
) => async (request: Request): Promise<Response> | Response => {
try {
return handler(request);
} catch (e) {
console.error(e);
return handleWith;
}
};
export const parsePermissions = (
permissions: number,
owner: boolean = false
): UserGuildPermissions => {
if (owner || evaluatePermission(permissions, Permissions.ADMINISTRATOR)) {
return UserGuildPermissions.Admin;
}
if (evaluatePermission(permissions, Permissions.MANAGE_ROLES)) {
return UserGuildPermissions.Manager;
}
return UserGuildPermissions.User;
};
export const getSessionID = (request: Request): { type: string; id: string } | null => {
const sessionID = request.headers.get('authorization');
if (!sessionID) {
return null;
}
const [type, id] = sessionID.split(' ');
if (type !== 'Bearer') {
return null;
}
return { type, id };
};

View file

@ -0,0 +1,11 @@
const self = (global as any) as Record<string, string>;
const safeURI = (x: string) => x.replace(/\/$/, '');
const list = (x: string) => x.split(',');
export const botClientID = self.BOT_CLIENT_ID;
export const botClientSecret = self.BOT_CLIENT_SECRET;
export const uiPublicURI = safeURI(self.UI_PUBLIC_URI);
export const apiPublicURI = safeURI(self.API_PUBLIC_URI);
export const rootUsers = list(self.ROOT_USERS);
export const kvPrefix = self.KV_PREFIX;

View file

@ -0,0 +1,26 @@
class WrappedKVNamespace {
constructor(private kvNamespace: KVNamespace) {}
async get<T>(key: string): Promise<T | null> {
const data = await this.kvNamespace.get(key, 'text');
if (!data) {
return null;
}
return JSON.parse(data) as T;
}
async put<T>(key: string, value: T, ttlSeconds?: number) {
await this.kvNamespace.put(key, JSON.stringify(value), {
expirationTtl: ttlSeconds,
});
}
list = this.kvNamespace.list;
getWithMetadata = this.kvNamespace.getWithMetadata;
delete = this.kvNamespace.delete;
}
export const Sessions = new WrappedKVNamespace(KV_SESSIONS);
export const GuildData = new WrappedKVNamespace(KV_GUILD_DATA);
export const Guilds = new WrappedKVNamespace(KV_GUILDS);