mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-16 17:49:09 +00:00
port full auth flow to cf workers
This commit is contained in:
parent
9eeb946389
commit
aad0987dce
50 changed files with 551 additions and 1167 deletions
5
src/backend-worker/bindings.d.ts
vendored
5
src/backend-worker/bindings.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
|
31
src/backend-worker/handlers/get-session.ts
Normal file
31
src/backend-worker/handlers/get-session.ts
Normal 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,
|
||||
});
|
||||
};
|
|
@ -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 }));
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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));
|
||||
|
|
55
src/backend-worker/utils/api-tools.ts
Normal file
55
src/backend-worker/utils/api-tools.ts
Normal 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 };
|
||||
};
|
11
src/backend-worker/utils/config.ts
Normal file
11
src/backend-worker/utils/config.ts
Normal 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;
|
26
src/backend-worker/utils/kv.ts
Normal file
26
src/backend-worker/utils/kv.ts
Normal 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);
|
Loading…
Add table
Add a link
Reference in a new issue