Skip to content

Commit

Permalink
refresh tokens and leave group
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristopherJMiller committed Jun 2, 2024
1 parent e460708 commit 9e6cb5d
Show file tree
Hide file tree
Showing 9 changed files with 182 additions and 35 deletions.
36 changes: 32 additions & 4 deletions api/src/groups/group.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ export class GroupController {
constructor(
private groupService: GroupsService,
private userService: UsersService,
) {}
) { }

@UseGuards(AuthGuard('jwt'))
@Get('groups/:id')
@ApiParam(groupIdParam)
@ApiBearerAuth()
getById(@Param() params: GetByIdParameter): Promise<Group> {
return this.groupService.findOneBy({ id: params.id });
async getById(@Param() params: GetByIdParameter): Promise<Group> {
const group = await this.groupService.findOneBy({ id: params.id });
// For public route, exclude user information
return { ...group, users: undefined };
}

@Get('groups')
Expand Down Expand Up @@ -97,4 +98,31 @@ export class GroupController {

return this.groupService.updateGroup(group);
}

@UseGuards(AuthGuard('jwt'))
@Post('groups/:id/leave')
@ApiBearerAuth()
@ApiParam(groupIdParam)
async leave(
@Request() req,
@Param() params: GetByIdParameter,
): Promise<Group> {
const id = req.user.sub;

if (!id) {
throw new Error('User session not found, cannot leave group');
}

const user = await this.userService.findOneBy({ id });
const group = await this.groupService.findOneBy({ id: params.id });
const index = group.users.findIndex((user) => user.id === user.id);

if (user && index !== -1) {
group.users.splice(index, 1);
return this.groupService.updateGroup(group);

}

throw new Error('User does not exist in group');
}
}
38 changes: 36 additions & 2 deletions frontend/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import { ReactNode, createContext, useEffect, useState } from 'react';
import { User, profile as getProfile } from '../util/api';
import { useCookies } from 'react-cookie';
import { decodeJwt, JWTPayload } from 'jose';
import Cookies from 'js-cookie';

const MIN_WAIT_MS = 200;

export interface AuthedContext {
loading: boolean;
token?: string;
refreshToken?: string;
profile?: User;
needRefresh: boolean;
reloadAuthState: () => void;
updateToken: (token: string) => void;
updateRefreshToken: (refreshToken: string | undefined) => void;
}

export interface ContextProps {
Expand All @@ -19,40 +23,65 @@ export interface ContextProps {

export const AuthContext = createContext<AuthedContext>({
loading: true,
needRefresh: false,
reloadAuthState: () => {},
updateToken: () => {},
updateRefreshToken: () => {},
});

export function AuthContextProvider({ children }: ContextProps) {
const [token, setToken] = useState<string | undefined>(undefined);
const [refreshToken, setRefreshToken] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(false);
const [profile, setProfile] = useState<User | undefined>(undefined);
const [cookies, setCookie, removeCookie] = useCookies(['token']);
const [cookies, setCookie, removeCookie] = useCookies(['token', 'refreshToken']);
const [needRefresh, setNeedRefresh] = useState(false);

// Token-Cookie Sync
useEffect(() => {
// Token is not defined yet, but is in cache and exp is not too late
if (cookies.token && !token) {
try {
try {
const res = decodeJwt(cookies.token) as JWTPayload;
if (res && (res.exp ?? 0) >= Date.now() / 1000) {
console.log('Setting token');
setToken(cookies.token);
setRefreshToken(cookies.refreshToken);
setNeedRefresh(false);
setLoading(true);
return;
} else {
console.log('Removing cookie, expired');
Cookies.remove('token');
removeCookie('token');

if (refreshToken) {
setNeedRefresh(true);
}
}
} catch (e) {
console.warn('Failure while syncing token and cookies', e);
Cookies.remove('token');
Cookies.remove('refreshToken');
removeCookie('token');
removeCookie('refreshToken');
setNeedRefresh(false);
}
} else if (!loading && token && !cookies.token) {
console.log('Updated token');
setCookie('token', token, {
sameSite: 'strict',
});

if (refreshToken) {
setCookie('refreshToken', refreshToken, {
sameSite: 'strict',
});
setNeedRefresh(false);
}
} else if (!cookies.token && cookies.refreshToken) {
setRefreshToken(cookies.refreshToken);
setNeedRefresh(true);
}
}, [cookies, removeCookie, setCookie, setLoading, loading, token]);

Expand Down Expand Up @@ -94,6 +123,8 @@ export function AuthContextProvider({ children }: ContextProps) {
const context: AuthedContext = {
profile,
token,
needRefresh,
refreshToken,
loading,
reloadAuthState: () => {
setLoading(true);
Expand All @@ -103,6 +134,9 @@ export function AuthContextProvider({ children }: ContextProps) {
setLoading(true);
setToken(token);
},
updateRefreshToken: (refreshToken) => {
setRefreshToken(refreshToken);
},
};

return (
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/pages/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ import { Link, Outlet, useNavigation } from 'react-router-dom';
import { AuthContext } from '../contexts/AuthContext';

function App() {
const { profile, loading, updateToken } = useContext(AuthContext);
const { beginFlow, completeFlow } = useOIDCProvider(ReallianceProvider);
const { profile, loading, updateToken, updateRefreshToken, needRefresh, refreshToken } = useContext(AuthContext);
const { beginFlow, completeFlow, refreshToken: beginRefreshToken } = useOIDCProvider({ ...ReallianceProvider, redirectUriPath: window.location.pathname });

const navigation = useNavigation();

useEffect(() =>{
if (!loading && needRefresh && refreshToken) {
beginRefreshToken(refreshToken, updateToken, updateRefreshToken)
}
}, [updateToken, updateRefreshToken, beginRefreshToken, needRefresh, refreshToken])

useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);

if (urlParams.get('code') !== null) {
completeFlow(updateToken);
completeFlow(updateToken, updateRefreshToken);
}
}, []);

Expand All @@ -36,7 +42,7 @@ function App() {
);
} else {
return (
<Navbar.Link href="#" onClick={() => beginFlow()}>
<Navbar.Link onClick={() => beginFlow()}>
Login
</Navbar.Link>
);
Expand Down Expand Up @@ -64,7 +70,7 @@ function App() {
<Navbar.Collapse>{profileItem}</Navbar.Collapse>
</Navbar>
<div
className={`mt-3 grow ${
className={`mx-3 mt-3 grow ${
navigation.state === 'loading' ? 'animate-pulse' : ''
}`}
>
Expand Down
39 changes: 23 additions & 16 deletions frontend/src/pages/GroupShow.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,44 @@
import { useLoaderData } from 'react-router-dom';
import { Button } from 'flowbite-react';
import { Group, joinGroup } from '../util/api';
import { useContext, useEffect, useMemo } from 'react';
import { Button, Spinner } from 'flowbite-react';
import { Group, joinGroup, leaveGroup } from '../util/api';
import { useContext, useMemo, useState } from 'react';
import { AuthContext } from '../contexts/AuthContext';
import { ReallianceProvider, useOIDCProvider } from '../util/oidc';

export function GroupShow() {
const { token, profile } = useContext(AuthContext);
const { beginFlow } = useOIDCProvider(ReallianceProvider);
const group = useLoaderData() as Group;
const [joinOverride, setJoinOverride] = useState<boolean | undefined>(undefined);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (!group && !token) {
beginFlow();
}
}, [group, token, beginFlow]);
const loggedIn = token !== undefined;

const joined = useMemo(
() => group?.users?.some((user) => user.id === profile?.id),
[group, profile],
() => joinOverride === undefined ? group?.users?.some((user) => user.id === profile?.id) : joinOverride,
[group, profile, joinOverride],
);

const join = () => {
const joinLabel = joined ? "Leave Group" : loggedIn ? "Join Group" : "Login to Join Group";

const handleJoinClick = async () => {
if (token) {
joinGroup(token, group.id);
setLoading(true);
if (!joined) {
await joinGroup(token, group.id);
setJoinOverride(true);
} else {
await leaveGroup(token, group.id);
setJoinOverride(false);
}
setLoading(false);
}
};

return (
<div className="container max-w-xl mx-auto flex flex-col gap-3">
<h1 className="text-4xl font-bold">{group?.name}</h1>
<Button disabled={joined} onClick={join} outline>
Join
<Button disabled={!loggedIn} onClick={handleJoinClick} outline className="flex flex-row gap-2 items-center">
{loading && <Spinner />}
{joinLabel}
</Button>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/MinecraftConnection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function MinecraftConnection() {
const urlParams = new URLSearchParams(window.location.search);

if (urlParams.get('code') !== null) {
completeFlow(setMsAccessToken);
completeFlow(setMsAccessToken, () => {});
}
}, [completeFlow]);

Expand Down
24 changes: 24 additions & 0 deletions frontend/src/util/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ export const group = (token: string, id: string) =>
},
});

export const groupMembers = (token: string, id: string) =>
GET('/api/groups/{id}/members', {
headers: {
Authorization: `Bearer ${token}`,
},
params: {
path: {
id,
},
},
});

export const groups = () => GET('/api/groups');

export const createGroup = (token: string, body: NewGroup) =>
Expand All @@ -82,3 +94,15 @@ export const joinGroup = (token: string, id: string) =>
},
},
});

export const leaveGroup = (token: string, id: string) =>
POST('/api/groups/{id}/leave', {
headers: {
Authorization: `Bearer ${token}`,
},
params: {
path: {
id,
},
},
});
9 changes: 5 additions & 4 deletions frontend/src/util/group.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LoaderFunctionArgs } from 'react-router-dom';
import { Group, createGroup, group, groups } from './api';
import { Group, createGroup, group, groupMembers, groups } from './api';
import Cookies from 'js-cookie';

export async function loadAllGroups(): Promise<Group[] | undefined> {
Expand Down Expand Up @@ -35,13 +35,14 @@ export async function loadGroup({
const token = Cookies.get('token');

const { data, error } = await group(token ?? '', params.id);
const { data: membersData, error: groupError } = await groupMembers(token ?? '', params.id);

if (error) {
console.warn(error);
if (error || groupError) {
console.warn(error, groupError);
return null;
}

return data;
return { ...data, users: membersData };
}

return null;
Expand Down
35 changes: 32 additions & 3 deletions frontend/src/util/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ const REDIRECT_URI = import.meta.env.PROD

interface OpenIdConnectContext {
beginFlow: () => Promise<void>;
completeFlow: (updateToken: (token: string) => void) => Promise<void>;
completeFlow: (updateToken: (token: string) => void, updateRefreshToken: (token: string | undefined) => void) => Promise<void>;
refreshToken: (refreshToken: string, updateToken: (token: string) => void, updateRefreshToken: (token: string | undefined) => void) => Promise<void>;
}

export const useOIDCProvider = ({
Expand All @@ -61,8 +62,9 @@ export const useOIDCProvider = ({
return {
beginFlow: async () =>
beginAuthFlow(client, scopes, authorizationServer, redirectUriPath),
completeFlow: async (updateToken) =>
onRedirect(client, authorizationServer, redirectUriPath, updateToken),
completeFlow: async (updateToken, updateRefreshToken) =>
onRedirect(client, authorizationServer, redirectUriPath, updateToken, updateRefreshToken),
refreshToken: async (refreshToken, updateToken, updateRefreshToken) => handleRefreshToken(client, authorizationServer, refreshToken, updateToken, updateRefreshToken),
};
};

Expand Down Expand Up @@ -104,6 +106,7 @@ async function onRedirect(
as: Promise<oidc.AuthorizationServer>,
redirectPath: string | undefined,
updateToken: (token: string) => void,
updateRefreshToken: (refreshToken: string | undefined) => void,
) {
const codeVerifier = Cookies.get('codeVerifier');
if (!codeVerifier) {
Expand Down Expand Up @@ -160,5 +163,31 @@ async function onRedirect(
throw new Error();
}


updateToken(result.access_token);
updateRefreshToken(result.refresh_token);
}

async function handleRefreshToken(client: oidc.Client,
as: Promise<oidc.AuthorizationServer>,
refreshToken: string,
updateToken: (token: string) => void,
updateRefreshToken: (refreshToken: string | undefined) => void
) {
const authServer = await as;

const response = await oidc.refreshTokenGrantRequest(authServer, client, refreshToken);
const result = await oidc.processRefreshTokenResponse(
authServer,
client,
response,
);

if (oidc.isOAuth2Error(result)) {
console.error(result);
throw new Error();
}

updateToken(result.access_token);
updateRefreshToken(result.refresh_token);
}
Loading

0 comments on commit 9e6cb5d

Please sign in to comment.