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);

View file

@ -1,40 +0,0 @@
package faas
import (
"errors"
"net/http"
)
type AuthLevel uint
const (
AuthGuest = AuthLevel(iota)
AuthUser
AuthAdmin
AuthSuper
)
var (
ErrNotAuthorized = errors.New("common/faas: session not authorized")
)
func assertAuthLevel(err error, requiredAuthLevel AuthLevel, assertedAuthLevel AuthLevel) error {
if requiredAuthLevel == assertedAuthLevel {
return nil
} else {
return err
}
}
// AuthMustMatch will assert the current session's authorization group/level; only can match for Guest, User, and Super.
func AuthMustMatch(request *http.Request, authLevel AuthLevel) error {
_, err := request.Cookie("Authorization")
if errors.Is(err, http.ErrNoCookie) {
// No cookie is present, assert guest.
return assertAuthLevel(ErrNotAuthorized, authLevel, AuthGuest)
} else if err != nil {
return err
}
return nil
}

View file

@ -1,75 +0,0 @@
package faas
import (
"html/template"
"net/http"
"time"
)
var bounceHTML = template.Must(template.New("bounceHTML").Parse(
`<!doctype html>
<meta charset="utf8">
<title>Redirecting...</title>
<meta http-equiv="refresh" content="0;URL='{{.Location}}'">
<style>
body {
background-color: #453E3D;
color: #AB9B9A;
}
a {
color: #AB9B9A;
}
</style>
<p>
Redirecting you to <a href="{{.Location}}">{{.Location}}</a>
</p>
`,
))
type bounceData struct {
Location string
}
// Bounce will do a 303 See Other response with url.
func Bounce(rw http.ResponseWriter, url string) {
rw.Header().Add("location", url)
rw.WriteHeader(303)
bounceHTML.Execute(rw, bounceData{Location: url})
}
// Stash will save the specified URL for later use in Unstash(), e.g. after an OAuth bounce
func Stash(rw http.ResponseWriter, url string) {
if url == "" {
return
}
cookie := http.Cookie{
Name: "rp_stashed_url",
Value: url,
HttpOnly: true,
Expires: time.Now().Add(5 * time.Minute),
}
rw.Header().Add("set-cookie", cookie.String())
}
// Unstash will redirect/Bounce() to a previously stashed URL or the defaultURL, whichever is available.
func Unstash(rw http.ResponseWriter, req *http.Request, defaultURL string) {
redirectURL := defaultURL
cookie, _ := req.Cookie("rp_stashed_url")
if cookie != nil && cookie.Expires.After(time.Now()) && cookie.Value != "" {
redirectURL = cookie.Value
}
unsetter := http.Cookie{
Name: "rp_stashed_url",
Value: "",
MaxAge: -1,
HttpOnly: true,
}
rw.Header().Set("set-cookie", unsetter.String())
Bounce(rw, redirectURL)
}

View file

@ -1,15 +0,0 @@
package faas
import (
"net/http"
"github.com/roleypoly/roleypoly/src/common/types"
)
func Fingerprint(req *http.Request) types.Fingerprint {
return types.Fingerprint{
UserAgent: req.UserAgent(),
ClientIP: req.RemoteAddr,
ForwardedFor: req.Header.Get("x-forwarded-for"),
}
}

View file

@ -34,3 +34,16 @@ export type PresentableGuild = {
export type GuildEnumeration = {
guildsList: PresentableGuild[];
};
export enum UserGuildPermissions {
User,
Manager,
Admin,
}
export type GuildSlug = {
id: string;
name: string;
icon: string;
permissionLevel: UserGuildPermissions;
};

View file

@ -0,0 +1,18 @@
import { GuildSlug } from './Guild';
import { DiscordUser } from './User';
export type AuthTokenResponse = {
access_token: string;
token_type: 'Bearer';
expires_in: number;
refresh_token: string;
scope: string;
};
export type SessionData = {
/** sessionID is a KSUID */
sessionID: string;
tokens: AuthTokenResponse;
user: DiscordUser;
guilds: GuildSlug[];
};

View file

@ -2,3 +2,4 @@ export * from './Role';
export * from './Category';
export * from './Guild';
export * from './User';
export * from './Session';

View file

@ -1,8 +1,12 @@
import { Role } from 'roleypoly/common/types';
import { Role } from '../types';
export const evaluatePermission = (haystack: number, needle: number): boolean => {
return (haystack & needle) === needle;
};
export const hasPermission = (roles: Role[], permission: number): boolean => {
const aggregateRoles = roles.reduce((acc, role) => acc | role.permissions, 0);
return (aggregateRoles & permission) === permission;
return evaluatePermission(aggregateRoles, permission);
};
export const hasPermissionOrAdmin = (roles: Role[], permission: number): boolean =>

View file

@ -0,0 +1 @@
export const isBrowser = () => typeof window !== 'undefined';

View file

@ -5,3 +5,6 @@ export const initialsFromName = (name: string) =>
.map((x) => x[0])
.join('')
.toUpperCase();
export const avatarHash = (id: string, hash: string) =>
`https://cdn.discordapp.com/icons/${id}/${hash}.webp?size=256`;

View file

@ -125,8 +125,8 @@ export const Bi: Variant = {
matchDay(new Date('2021-Sep-16'), new Date('2021-Sep-24'), currentDate),
sharedProps: {
circleFill: '#D60270',
circleOuterFill: '#9B4F96',
typeFill: '#0038A8',
circleOuterFill: '#0038A8',
typeFill: '#9B4F96',
},
tooltip: 'Being bi is a lot like a riding a bicycle since they can go both ways.',
Logomark: (props: DynamicLogoProps) => <Logomark {...props} {...Bi.sharedProps} />,
@ -276,7 +276,7 @@ export const BicycleDay: Variant = {
export const Christmas: Variant = {
// Dec 20-27
activeIf: (currentDate?: Date) =>
matchDay(new Date('2021-Dec-20'), new Date('2021-Dec-28'), currentDate),
true || matchDay(new Date('2021-Dec-20'), new Date('2021-Dec-28'), currentDate),
sharedProps: {
circleFill: palette.green200,
circleOuterFill: palette.red200,
@ -284,10 +284,14 @@ export const Christmas: Variant = {
},
tooltip: 'Have yourself a merry little Christmas~',
Logomark: (props: DynamicLogoProps) => (
<Logomark {...props} {...Christmas.sharedProps} />
<SparkleOverlay strokeColor={'white'}>
<Logomark {...props} {...Christmas.sharedProps} />
</SparkleOverlay>
),
Logotype: (props: DynamicLogoProps) => (
<Logotype {...props} {...Christmas.sharedProps} />
<SparkleOverlay strokeColor={'white'}>
<Logotype {...props} {...Christmas.sharedProps} />
</SparkleOverlay>
),
};

View file

@ -2,52 +2,37 @@ import Link from 'next/link';
import * as React from 'react';
import { GoStar, GoZap } from 'react-icons/go';
import ReactTooltip from 'react-tooltip';
import { hasPermission, permissions } from 'roleypoly/common/utils/hasPermission';
import { GuildSlug, UserGuildPermissions } from 'roleypoly/common/types';
import { sortBy } from 'roleypoly/common/utils/sortBy';
import { GuildEnumeration, PresentableGuild, Role } from 'roleypoly/common/types';
import { NavSlug } from 'roleypoly/design-system/molecules/nav-slug';
import { GuildNavItem } from './GuildNav.styled';
type Props = {
guildEnumeration: GuildEnumeration;
guilds: GuildSlug[];
};
const tooltipId = 'guildnav';
const Badges = (props: { guild: PresentableGuild }) => {
const Badges = (props: { guild: GuildSlug }) => {
return React.useMemo(() => {
if (!props.guild.member) {
return null;
}
const roles = props.guild.member.rolesList
.map((id) => {
if (!props.guild.roles) {
return undefined;
}
return props.guild.roles.rolesList.find((role) => role.id === id);
})
.filter((x) => !!x) as Role[];
if (hasPermission(roles, permissions.ADMINISTRATOR)) {
if (props.guild.permissionLevel === UserGuildPermissions.Admin) {
return <GoStar data-tip="Administrator" data-for={tooltipId} />;
}
if (hasPermission(roles, permissions.MANAGE_ROLES)) {
if (props.guild.permissionLevel === UserGuildPermissions.Manager) {
return <GoZap data-tip="Role Editor" data-for={tooltipId} />;
}
return null;
}, [props.guild]);
}, [props.guild.permissionLevel]);
};
export const GuildNav = (props: Props) => (
<div>
{sortBy(props.guildEnumeration.guildsList, 'id').map((guild) => (
{sortBy(props.guilds, 'id').map((guild) => (
<Link href={`/s/${guild.id}`} passHref>
<GuildNavItem>
<NavSlug guild={guild.guild || null} key={guild.id} />
<NavSlug guild={guild || null} key={guild.id} />
<Badges guild={guild} />
</GuildNavItem>
</Link>

View file

@ -1,16 +1,23 @@
import * as React from 'react';
import { Guild } from 'roleypoly/common/types';
import { GoOrganization } from 'react-icons/go';
import { GuildSlug } from 'roleypoly/common/types';
import { Avatar, utils } from 'roleypoly/design-system/atoms/avatar';
import { SlugContainer, SlugName } from './NavSlug.styled';
import { GoOrganization } from 'react-icons/go';
type Props = {
guild: Guild | null;
guild: GuildSlug | null;
};
export const NavSlug = (props: Props) => (
<SlugContainer>
<Avatar src={props.guild?.icon} deliberatelyEmpty={!props.guild} size={35}>
<Avatar
src={
(props.guild && utils.avatarHash(props.guild.id, props.guild.icon)) ||
undefined
}
deliberatelyEmpty={!props.guild}
size={35}
>
{props.guild ? utils.initialsFromName(props.guild.name) : <GoOrganization />}
</Avatar>
<SlugName>{props.guild?.name || <>Your Guilds</>}</SlugName>

View file

@ -1,27 +1,27 @@
import * as React from 'react';
import * as Masthead from 'roleypoly/design-system/organisms/masthead';
import { RoleypolyUser, GuildEnumeration } from 'roleypoly/common/types';
import { Footer } from 'roleypoly/design-system/molecules/footer';
import { Content, GlobalStyles } from './AppShell.styled';
import { GlobalStyleColors } from 'roleypoly/design-system/atoms/colors';
import { Scrollbars } from 'react-custom-scrollbars';
import { DiscordUser, GuildSlug } from 'roleypoly/common/types';
import { GlobalStyleColors } from 'roleypoly/design-system/atoms/colors';
import { Footer } from 'roleypoly/design-system/molecules/footer';
import * as Masthead from 'roleypoly/design-system/organisms/masthead';
import { Content, GlobalStyles } from './AppShell.styled';
type AppShellProps = {
export type AppShellProps = {
children: React.ReactNode;
user: RoleypolyUser | null;
user?: DiscordUser;
showFooter?: boolean;
small?: boolean;
activeGuildId?: string | null;
guildEnumeration?: GuildEnumeration;
guilds?: GuildSlug[];
};
export const AppShell = (props: AppShellProps) => (
<>
<GlobalStyles />
<GlobalStyleColors />
{props.user !== null ? (
{props.user ? (
<Masthead.Authed
guildEnumeration={props.guildEnumeration || { guildsList: [] }}
guilds={props.guilds || []}
activeGuildId={props.activeGuildId || null}
user={props.user}
/>

View file

@ -1,7 +1,13 @@
import Link from 'next/link';
import * as React from 'react';
import { GoOrganization } from 'react-icons/go';
import { GuildEnumeration, RoleypolyUser } from 'roleypoly/common/types';
import { Guilds } from 'roleypoly/backend-worker/utils/kv';
import {
DiscordUser,
GuildEnumeration,
GuildSlug,
RoleypolyUser,
} from 'roleypoly/common/types';
import { guildEnum } from 'roleypoly/common/types/storyData';
import { DynamicLogomark } from 'roleypoly/design-system/atoms/branding';
import { Popover } from 'roleypoly/design-system/atoms/popover';
@ -20,9 +26,9 @@ import {
} from './Masthead.styled';
type Props = {
user: RoleypolyUser;
user?: DiscordUser;
activeGuildId: string | null;
guildEnumeration: GuildEnumeration;
guilds: GuildSlug[];
};
export const Authed = (props: Props) => {
@ -47,9 +53,9 @@ export const Authed = (props: Props) => {
>
<NavSlug
guild={
guildEnum.guildsList.find(
(g) => g.id === props.activeGuildId
)?.guild || null
props.guilds.find(
(guild) => guild.id === props.activeGuildId
) || null
}
/>
</InteractionBase>
@ -65,7 +71,7 @@ export const Authed = (props: Props) => {
active={serverPopoverState}
onExit={() => setServerPopoverState(false)}
>
<GuildNav guildEnumeration={props.guildEnumeration} />
<GuildNav guilds={props.guilds} />
</Popover>
</MastheadLeft>
<MastheadRight>
@ -76,9 +82,7 @@ export const Authed = (props: Props) => {
}}
hide={!userPopoverState}
>
{props.user.discorduser && (
<UserAvatarGroup user={props.user.discorduser} />
)}
{props.user && <UserAvatarGroup user={props.user} />}
</InteractionBase>
<Popover
headContent={<></>}
@ -87,9 +91,7 @@ export const Authed = (props: Props) => {
active={userPopoverState}
onExit={() => setUserPopoverState(false)}
>
{props.user.discorduser && (
<UserPopover user={props.user.discorduser} />
)}
{props.user && <UserPopover user={props.user} />}
</Popover>
</MastheadRight>
</MastheadAlignment>

View file

@ -6,7 +6,7 @@ import * as React from 'react';
export type AuthLoginProps = PreauthProps;
export const AuthLogin = (props: AuthLoginProps) => (
<AppShell showFooter user={null}>
<AppShell showFooter user={undefined}>
<Hero topSpacing={100} bottomSpacing={175}>
<Preauth {...props} />
</Hero>

View file

@ -1,15 +1,15 @@
import * as React from 'react';
import { AppShell } from 'roleypoly/design-system/organisms/app-shell';
import { HelpPageBase } from 'roleypoly/design-system/molecules/help-page-base';
import { RoleypolyUser } from 'roleypoly/common/types';
import { DiscordUser } from 'roleypoly/common/types';
type HelpPageProps = {
user: RoleypolyUser | null;
user: DiscordUser | null;
children: React.ReactNode;
};
export const HelpPageTemplate = (props: HelpPageProps) => (
<AppShell user={props.user || null}>
<AppShell user={props.user || undefined}>
<HelpPageBase>{props.children}</HelpPageBase>
</AppShell>
);

View file

@ -3,7 +3,7 @@ import { AppShell } from 'roleypoly/design-system/organisms/app-shell';
import { Landing } from 'roleypoly/design-system/organisms/landing';
export const LandingTemplate = () => (
<AppShell showFooter user={null}>
<AppShell showFooter>
<Landing />
</AppShell>
);

View file

@ -6,11 +6,7 @@ import {
RolePickerProps,
} from 'roleypoly/design-system/organisms/role-picker';
export type RolePickerTemplateProps = RolePickerProps & {
user: RoleypolyUser;
guildEnumeration?: GuildEnumeration;
activeGuildId?: string;
};
export type RolePickerTemplateProps = RolePickerProps & AppShellProps;
export const RolePickerTemplate = (props: RolePickerTemplateProps) => {
const { user, ...pickerProps } = props;
@ -18,7 +14,8 @@ export const RolePickerTemplate = (props: RolePickerTemplateProps) => {
<AppShell
guildEnumeration={props.guildEnumeration}
activeGuildId={props.activeGuildId}
user={user}
user={user.discorduser}
guilds={user.guilds}
small
>
<RolePicker {...pickerProps} />

View file

@ -1,10 +0,0 @@
# Bot Join Bounce Service
This function sends a user to the necessary Discord.com Bot OAuth flow.
Two cases may be present:
- General: The user will be sent to allow any of their relevant servers to join
- This flow would be triggered from a generalized button
- Specific: The user will be sent to join one of their servers.
- This flow would be triggered from server picker

View file

@ -1,45 +0,0 @@
package botjoin
import (
"bytes"
"net/http"
"regexp"
"text/template"
"github.com/roleypoly/roleypoly/src/common"
"github.com/roleypoly/roleypoly/src/common/faas"
)
var (
validGuildID = regexp.MustCompile(`^[0-9]+$`)
redirectPathTemplate = template.Must(
template.New("redirect").Parse(
`https://discord.com/api/oauth2/authorize?client_id={{.ClientID}}&scope=bot&permissions={{.Permissions}}{{if .GuildID}}&guild_id={{.GuildID}}&disable_guild_select=true{{end}}`,
),
)
clientID = common.Getenv("BOT_CLIENT_ID").String()
)
type redirectPathData struct {
ClientID string
Permissions int
GuildID string
}
func BotJoin(rw http.ResponseWriter, r *http.Request) {
guildID := r.URL.Query().Get("guild")
if !validGuildID.MatchString(guildID) {
guildID = ""
}
pathData := redirectPathData{
ClientID: clientID,
Permissions: 268435456, // MANAGE_ROLES
GuildID: guildID,
}
pathBuffer := bytes.Buffer{}
redirectPathTemplate.Execute(&pathBuffer, pathData)
faas.Bounce(rw, pathBuffer.String())
}

View file

@ -1,36 +0,0 @@
package botjoin_test
import (
"net/http/httptest"
"testing"
"github.com/onsi/gomega"
botjoin "github.com/roleypoly/roleypoly/src/functions/bot-join"
)
func TestGeneral(t *testing.T) {
O := gomega.NewWithT(t)
req := httptest.NewRequest("GET", "/bot-join", nil)
resp := httptest.NewRecorder()
botjoin.BotJoin(resp, req)
result := resp.Result()
O.Expect(result.StatusCode).Should(gomega.BeIdenticalTo(303))
O.Expect(result.Header.Get("location")).ShouldNot(gomega.ContainSubstring("guild_id"))
}
func TestGeneralSpecific(t *testing.T) {
O := gomega.NewWithT(t)
req := httptest.NewRequest("GET", "/bot-join?guild=386659935687147521", nil)
resp := httptest.NewRecorder()
botjoin.BotJoin(resp, req)
result := resp.Result()
O.Expect(result.StatusCode).Should(gomega.BeIdenticalTo(303))
O.Expect(result.Header.Get("location")).Should(gomega.ContainSubstring("guild_id=386659935687147521"))
}

View file

@ -1,59 +0,0 @@
package createsession
import (
"context"
"encoding/json"
"net/http"
"time"
"cloud.google.com/go/firestore"
"k8s.io/klog"
"github.com/roleypoly/roleypoly/src/common"
"github.com/roleypoly/roleypoly/src/common/types"
)
var (
firestoreClient *firestore.Client
projectID = common.Getenv("GCP_PROJECT_ID").String()
firestoreEndpoint = common.Getenv("FIRESTORE_ENDPOINT").String()
)
func init() {
var err error // shadow avoidance
ctx := context.Background()
firestoreClient, err = firestore.NewClient(ctx, projectID)
if err != nil {
panic(err)
}
}
func CreateSession(rw http.ResponseWriter, req *http.Request) {
requestData := types.CreateSessionRequest{}
json.NewDecoder(req.Body).Decode(&requestData)
klog.Info("Creating session...")
sessionData := types.SessionData{
AccessTokens: requestData.AccessTokenResponse,
Fingerprint: requestData.Fingerprint,
UserData: types.UserData{},
}
ctx, ctxCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer ctxCancel()
docRef, _, err := firestoreClient.Collection("sessions").Add(ctx, sessionData)
if err != nil {
rw.WriteHeader(500)
klog.Error("error: create session in firestore: ", err)
}
klog.Info("Created session ", docRef.ID)
responseData := types.CreateSessionResponse{
SessionID: docRef.ID,
}
json.NewEncoder(rw).Encode(responseData)
}

View file

@ -1,10 +0,0 @@
package helloworld
import (
"fmt"
"net/http"
)
func HelloWorld(rw http.ResponseWriter, req *http.Request) {
fmt.Fprintln(rw, "Hello "+req.RemoteAddr+"!")
}

View file

@ -1,43 +0,0 @@
package loginbounce
import (
"bytes"
"net/http"
"text/template"
"github.com/segmentio/ksuid"
"github.com/roleypoly/roleypoly/src/common"
"github.com/roleypoly/roleypoly/src/common/faas"
)
var (
redirectPathTemplate = template.Must(
template.New("redirect").Parse(
`https://discord.com/api/oauth2/authorize?client_id={{.ClientID}}&response_type=code&scope=identify%20guilds&redirect_uri={{urlquery .RedirectURI}}&state={{.State}}`,
),
)
clientID = common.Getenv("BOT_CLIENT_ID").String()
redirectURI = common.Getenv("OAUTH_REDIRECT_URI").String()
)
type redirectPathData struct {
ClientID string
RedirectURI string
State string
}
func LoginBounce(rw http.ResponseWriter, r *http.Request) {
faas.Stash(rw, r.URL.Query().Get("redirect_url"))
pathData := redirectPathData{
ClientID: clientID,
RedirectURI: redirectURI,
State: ksuid.New().String(),
}
pathBuffer := bytes.Buffer{}
redirectPathTemplate.Execute(&pathBuffer, pathData)
faas.Bounce(rw, pathBuffer.String())
}

View file

@ -1,24 +0,0 @@
package loginbounce_test
import (
"net/http/httptest"
"testing"
"github.com/onsi/gomega"
loginbounce "github.com/roleypoly/roleypoly/src/functions/login-bounce"
)
func TestBounce(t *testing.T) {
O := gomega.NewWithT(t)
req := httptest.NewRequest("GET", "/login-bounce?redirect_url=https://localhost:6600/test", nil)
rw := httptest.NewRecorder()
loginbounce.LoginBounce(rw, req)
resp := rw.Result()
O.Expect(resp.StatusCode).Should(gomega.BeIdenticalTo(303))
O.Expect(resp.Header.Get("location")).Should(gomega.ContainSubstring("identify,guild"))
O.Expect(resp.Header.Get("set-cookie")).Should(gomega.ContainSubstring("https://localhost:6600/test"))
}

View file

@ -1,76 +0,0 @@
package loginhandler
import (
"bytes"
"encoding/json"
"net/http"
"net/url"
"time"
"github.com/roleypoly/roleypoly/src/common"
"github.com/roleypoly/roleypoly/src/common/faas"
"github.com/roleypoly/roleypoly/src/common/types"
"github.com/segmentio/ksuid"
"k8s.io/klog"
)
var (
// HTTPClient is an overridable HTTP client with a 15 second timeout
HTTPClient = http.Client{Timeout: 15 * time.Second}
uiPath = common.Getenv("UI_PUBLIC_URI").SafeURL()
apiPath = common.Getenv("API_PUBLIC_URI").SafeURL()
clientID = common.Getenv("BOT_CLIENT_ID").String()
clientSecret = common.Getenv("BOT_CLIENT_SECRET").String()
redirectURI = common.Getenv("OAUTH_REDIRECT_URI").String()
)
func LoginHandler(rw http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
state := query.Get("state")
stateID, err := ksuid.Parse(state)
if err != nil || stateID.IsNil() || stateID.Time().Add(5*time.Minute).Before(time.Now()) {
faas.Bounce(rw, uiPath+"/machinery/error?error_code=auth_failure")
return
}
code := query.Get("code")
body := url.Values{}
body.Set("client_id", clientID)
body.Set("client_secret", clientSecret)
body.Set("grant_type", "authorization_code")
body.Set("code", code)
body.Set("redirect_uri", redirectURI)
body.Set("scope", "identify guilds")
response, err := HTTPClient.PostForm("https://discord.com/api/v8/oauth2/token", body)
if err != nil {
klog.Error("token fetch failed: ", err)
faas.Bounce(rw, uiPath+"/machinery/error?error_code=auth_failure")
return
}
tokens := types.AccessTokenResponse{}
json.NewDecoder(response.Body).Decode(&tokens)
sessionRequest := types.CreateSessionRequest{
AccessTokenResponse: tokens,
Fingerprint: faas.Fingerprint(req),
}
buf := bytes.Buffer{}
json.NewEncoder(&buf).Encode(sessionRequest)
response, err = HTTPClient.Post(apiPath+"/create-session", "application/json", &buf)
if err != nil {
klog.Error("create session failed: ", err)
faas.Bounce(rw, uiPath+"/machinery/error?error_code=auth_failure")
return
}
session := types.CreateSessionResponse{}
json.NewDecoder(response.Body).Decode(&session)
faas.Bounce(rw, uiPath+"/machinery/new-session?session_id="+session.SessionID)
}

View file

@ -1,10 +0,0 @@
package sessiondata
import (
"fmt"
"net/http"
)
func SessionData(rw http.ResponseWriter, r *http.Request) {
fmt.Fprintln(rw, "Hello world!")
}

View file

@ -1,11 +1,36 @@
import { AppProps } from 'next/app';
import NextApp, { AppContext, AppProps } from 'next/app';
import * as React from 'react';
import { InjectTypekitFont } from 'roleypoly/design-system/atoms/fonts';
import nookies from 'nookies';
import { AuthProvider } from 'roleypoly/providers/auth/AuthContext';
const App = (props: AppProps) => (
type Props = AppProps & {
sessionKey: string | null;
};
const App = (props: Props) => (
<>
<InjectTypekitFont />
<props.Component {...props.pageProps} />
<AuthProvider sessionKey={props.sessionKey}>
<props.Component {...props.pageProps} />
</AuthProvider>
</>
);
export default App;
export const getInitialProps = async (context: AppContext) => {
let sessionKey: string | null = null;
if (context.ctx.req) {
const key = nookies.get(context.ctx)['rp_session_key'];
if (key) {
sessionKey = key;
}
} else {
sessionKey = sessionStorage.getItem('session_key');
}
const pageProps = await NextApp.getInitialProps(context);
return { ...pageProps, sessionKey };
};

View file

@ -1,5 +1,9 @@
import { NextPageContext } from 'next';
import * as React from 'react';
import nookies from 'nookies';
import { AppShell } from 'roleypoly/design-system/organisms/app-shell';
import { Hero } from 'roleypoly/design-system/atoms/hero';
import { AccentTitle } from 'roleypoly/design-system/atoms/typography';
type Props = {
sessionID: string;
@ -9,19 +13,33 @@ const NewSession = (props: Props) => {
const { sessionID } = props;
React.useEffect(() => {
sessionStorage.setItem('session_key', sessionID);
location.href = '/';
}, [sessionID]);
return <div>Logging you in...</div>;
return (
<AppShell>
<Hero>
<AccentTitle>Logging you in...</AccentTitle>
</Hero>
</AppShell>
);
};
NewSession.getInitialProps = (context: NextPageContext): Props => {
const sessionID = context.query.session_id;
export const getServerSideProps = (context: NextPageContext): { props: Props } => {
const sessionID = context.query.session_id as string;
if (!sessionID) {
throw new Error("I shouldn't be here today.");
}
return { sessionID: sessionID as string };
nookies.set(context, 'rp_session_key', sessionID, {
httpOnly: true,
maxAge: 60 * 60 * 6,
path: '/',
sameSite: 'strict',
});
return { props: { sessionID } };
};
export default NewSession;

View file

@ -0,0 +1,40 @@
import * as React from 'react';
type AuthContextType = {
sessionKey: string | null;
setSessionKey: (value: string | null) => void;
};
type Props = {
sessionKey: string | null;
children: React.ReactNode;
};
const AuthContext = React.createContext<AuthContextType>({
sessionKey: null,
setSessionKey: () => {},
});
export const AuthProvider = (props: Props) => {
const [sessionKey, setSessionKey] = React.useState(props.sessionKey);
return (
<AuthContext.Provider value={{ sessionKey, setSessionKey }}>
{props.children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const authCtx = React.useContext(AuthContext);
if (!authCtx) {
throw new Error('useAuth used without AuthProvider');
}
return authCtx;
};
export const isAuthenticated = () => {
const authCtx = useAuth();
return authCtx.sessionKey !== null;
};

View file

@ -0,0 +1 @@
export * from './AuthContext';