Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ubisoft OAuth integration #56

Merged
merged 27 commits into from
Jul 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
aab5d38
Add trackmania login button and URL
MKuijpers Jul 25, 2021
3efcd85
Add basic TM token exchange and connect front-end
MKuijpers Jul 25, 2021
8254741
Add session secrets and storage of sessions
MKuijpers Jul 26, 2021
f3f6030
Add session deletion and proper logout requests
MKuijpers Jul 26, 2021
a5fa7eb
Use HttpOnly cookie for session
MKuijpers Jul 27, 2021
2dd5c7d
Rename sessionSecret to sessionId
MKuijpers Jul 27, 2021
7cab746
Store user info in AuthContext
MKuijpers Jul 27, 2021
64a3bd9
Logout and update user using AuthContext
MKuijpers Jul 28, 2021
d00a24e
Add user auth middleware on server
MKuijpers Jul 28, 2021
8b4010f
Remove unnecessary TODOs
MKuijpers Jul 28, 2021
a83c9e9
Remove Check Auth button
MKuijpers Jul 28, 2021
aa18353
Move UserDisplay to common components
MKuijpers Jul 28, 2021
e3ff8a4
User router.back() instead of redirecting to the homepage
MKuijpers Jul 28, 2021
06b57bd
Add login button on map page
MKuijpers Jul 28, 2021
860e8b2
Add proper redirects after login
davidbmaier Jul 28, 2021
1535b73
Add and check random state in auth URL
MKuijpers Jul 28, 2021
01fc4c4
Remove state after retrieving it + add log for invalid state
MKuijpers Jul 28, 2021
2d01b33
Open ubisoft auth in new window
MKuijpers Jul 28, 2021
ba2ee69
Move auth popup window code into UserDisplay component
MKuijpers Jul 28, 2021
70090f4
Extract and clean window popup code
MKuijpers Jul 28, 2021
50ad48e
General code cleanup based on PR comments
MKuijpers Jul 29, 2021
fc3a3c7
Update depricated client check
MKuijpers Jul 29, 2021
2ddc7cb
Replace deprecated querystring with URLSearchParams
MKuijpers Jul 29, 2021
24a28ab
Change 'next(); return;' to 'return next();'
MKuijpers Jul 29, 2021
bf5053c
Remove HttpOnly and check sessionId cookie for /me requests
MKuijpers Jul 29, 2021
aa5d2b2
Refactor auth flow code and simplify window opening
MKuijpers Jul 29, 2021
7f15824
Add return at the end of async function (thanks linter)
MKuijpers Jul 29, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/.env.local.template
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
NEXT_PUBLIC_API_URL=http://localhost
NEXT_PUBLIC_ANALYTICS_ID=

NEXT_PUBLIC_CLIENT_ID=<CLIENT_ID from api.trackmania.com>
45 changes: 45 additions & 0 deletions app/components/common/UserDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React, { useContext } from 'react';
import { Button, message } from 'antd';
import { AuthContext } from '../../lib/contexts/AuthContext';

const LoginButton = ({ onClick } :{onClick: () => void}) => (
<Button type="primary" onClick={onClick}>
Login with Ubisoft
</Button>
);

const LogoutButton = ({ onClick } :{onClick: () => void}) => (
<Button
type="primary"
danger
style={{ marginLeft: '10px' }}
onClick={onClick}
>
Logout
</Button>
);

const UserDisplay = () => {
const { user, startAuthFlow, logoutUser } = useContext(AuthContext);

const onLogout = async () => {
try {
logoutUser();
} catch (e) {
message.error('Something went wrong while logging out.');
}
};

return user === undefined
? <LoginButton onClick={startAuthFlow} />
: (
<div className="flex flex-row items-center">
{`Welcome, ${user.displayName}!`}
<LogoutButton
onClick={onLogout}
/>
</div>
);
};

export default UserDisplay;
6 changes: 3 additions & 3 deletions app/components/landing/InfoCard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/* eslint-disable react/no-array-index-key */
/* eslint-disable max-len */
import React, { useState } from 'react';
import {
Card,
} from 'antd';
import { Card } from 'antd';
import DiscordButton from '../common/DiscordButton';
import UserDisplay from '../common/UserDisplay';

type InfoTab = 'welcome'|'howDoesThisWork'|'getInvolved';

Expand Down Expand Up @@ -89,6 +88,7 @@ const InfoCard = (): JSX.Element => {
<Card
className="w-full dojo-info-card"
title="Welcome to TMDojo!"
extra={<UserDisplay />}
tabList={tabList}
activeTabKey={infoTab}
onTabChange={(key) => {
Expand Down
13 changes: 8 additions & 5 deletions app/components/maps/MapHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PageHeader, Button } from 'antd';

import { MapInfo } from '../../lib/api/apiRequests';
import { cleanTMFormatting } from '../../lib/utils/formatting';
import UserDisplay from '../common/UserDisplay';

interface Props {
mapInfo: MapInfo;
Expand All @@ -28,10 +29,11 @@ const MapHeader = ({ mapInfo }: Props): JSX.Element => {
<PageHeader
onBack={() => router.push('/')}
title="Replay viewer"
subTitle={cleanTMFormatting(mapInfo.name || '')}
// anchors need duplicate links for keyboard accessibility
extra={(
<>
subTitle={(
<div className="flex flex-row gap-4 items-baseline">
{cleanTMFormatting(mapInfo.name || '')}

{/* anchors need duplicate links for keyboard accessibility */}
<Link href={tmioURL}>
<a target="_blank" rel="noreferrer" href={tmioURL}>
<Button key="tm.io" type="primary">
Expand All @@ -49,8 +51,9 @@ const MapHeader = ({ mapInfo }: Props): JSX.Element => {
) : (
<TmxButton />
)}
</>
</div>
)}
extra={<UserDisplay />}
/>
);
};
Expand Down
73 changes: 73 additions & 0 deletions app/lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import axios from 'axios';

const getRedirectUri = () => {
if (typeof window === 'undefined') {
// Avoid some nextjs compilation errors regarding window being undefined
return undefined;
}
return `${window.location.origin}/auth_redirect`;
};

export const generateAuthUrl = (state: string): string => {
const url = 'https://api.trackmania.com/oauth/authorize';

const params = {
response_type: 'code',
client_id: process.env.NEXT_PUBLIC_CLIENT_ID,
redirect_uri: getRedirectUri(),
state,
};

return axios.getUri({ url, params });
};

interface AuthorizationResponse {
displayName: string;
accountId: string;
}
export const authorizeWithAccessCode = async (accessCode: string): Promise<AuthorizationResponse> => {
const url = `${process.env.NEXT_PUBLIC_API_URL}/authorize`;

const params = {
code: accessCode,
redirect_uri: getRedirectUri(),
};

// TODO: use custom axios instance with default config for withCredentials
const { data } = await axios.post(url, params, { withCredentials: true });

return data;
};

export interface UserInfo {
displayName: string;
accountId: string;
}
export const fetchLoggedInUser = async (): Promise<UserInfo | undefined> => {
const url = `${process.env.NEXT_PUBLIC_API_URL}/me`;

const hasSessionCookie = document.cookie
.split(';')
.filter((cookie) => cookie.trim().startsWith('sessionId='))
.length > 0;

if (!hasSessionCookie) {
return undefined;
}

try {
// TODO: use custom axios instance with default config for withCredentials
const { data } = await axios.post(url, {}, { withCredentials: true });
return data;
} catch (e) {
// TODO: find solution for being logged out on a server error?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure we handle this well everywhere - especially since this currently is what determines if the user is logged in.
Also, we might want to find a way to store our login state somewhere persistent - so we know if sending this request even makes sense (so we can avoid useless calls that end in 401s anyway).

return undefined;
}
};

export const logout = async (): Promise<void> => {
const url = `${process.env.NEXT_PUBLIC_API_URL}/logout`;

// TODO: use custom axios instance with default config for withCredentials
await axios.post(url, {}, { withCredentials: true });
};
118 changes: 118 additions & 0 deletions app/lib/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useRouter } from 'next/router';
import React, {
createContext, useCallback, useEffect, useState,
} from 'react';
import {
authorizeWithAccessCode, fetchLoggedInUser, generateAuthUrl, logout, UserInfo,
} from '../api/auth';
import openAuthWindow from '../utils/authPopup';

export interface AuthContextProps {
user?: UserInfo,
setUser: (user?: UserInfo) => void,
logoutUser: () => Promise<void>
startAuthFlow: () => void
}

export const AuthContext = createContext<AuthContextProps>({
user: undefined,
setUser: (user?: UserInfo) => {},
logoutUser: async () => {},
startAuthFlow: () => {},
});

export const AuthProvider = ({ children }: any): JSX.Element => {
const [user, setUser] = useState<UserInfo>();
const { asPath } = useRouter();

useEffect(() => {
updateLoggedInUser();
}, [asPath]);

const updateLoggedInUser = async () => {
const me = await fetchLoggedInUser();
if (me === undefined) {
setUser(undefined);
} else if (me?.accountId !== user?.accountId) {
setUser(me);
}
};

const startAuthFlow = () => {
// Generate and store random string as state
const state = Math.random().toString(36).substring(2); // 11 random lower-case alpha-numeric characters
localStorage.setItem('state', state);

// Remove any existing event listeners
window.removeEventListener('message', receiveAuthEvent);

openAuthWindow(generateAuthUrl(state), 'Login with Ubisoft');

// Add the listener for receiving a message from the popup
window.addEventListener('message', receiveAuthEvent, false);
};

const receiveAuthEvent = useCallback(async (event: any) => {
if (event.origin !== window.origin) {
return;
}

const { data } = event;
const { source, code, state } = data;
if (source !== 'ubi-login-redirect') {
return;
}

// We received a message from the auth window, remove this listener
window.removeEventListener('message', receiveAuthEvent);

if (code === undefined || code === null || typeof code !== 'string') {
return;
}
if (state === undefined || state === null || typeof state !== 'string') {
return;
}

const storedState = localStorage.getItem('state');
localStorage.removeItem('state');
if (storedState !== state) {
console.log(`Stored state (${storedState}) did not match incoming state (${state})`);
return;
}

try {
const userInfo = await authorizeWithAccessCode(code);
setUser(userInfo);
} catch (e) {
console.log(e);
}
}, [setUser]);

const logoutUser = async () => {
try {
await logout();
setUser(undefined);
} catch (e) {
// If error code is Unauthorized (so no user is logged in), set user to undefined
// This should only happen when manually deleting the session cookie
if (e.response.status === 401) {
setUser(undefined);
} else {
throw e;
}
}
};

return (
<AuthContext.Provider
value={{
user,
setUser,
logoutUser,
startAuthFlow,
}}
>
{children}
</AuthContext.Provider>
);
};
19 changes: 19 additions & 0 deletions app/lib/utils/authPopup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// https://dev.to/dinkydani21/how-we-use-a-popup-for-google-and-outlook-oauth-oci
let windowReference: Window | null = null;
let previousUrl: string | undefined;

const openAuthWindow = (
url: string,
name: string,
) => {
const windowFeatures = 'toolbar=no, menubar=no, width=600, height=700, top=100, left=100';

if (windowReference === null || windowReference.closed || previousUrl !== url) {
windowReference = window.open(url, name, windowFeatures);
windowReference?.focus();
}

previousUrl = url;
};

export default openAuthWindow;
44 changes: 23 additions & 21 deletions app/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import Head from 'next/head';
import HeadTitle from '../components/common/HeadTitle';
import { SettingsProvider } from '../lib/contexts/SettingsContext';
import { AuthProvider } from '../lib/contexts/AuthContext';
import '../styles/globals.css';

interface Props {
Expand All @@ -13,31 +13,33 @@ interface Props {
const ANALYTICS_ID = process.env.NEXT_PUBLIC_ANALYTICS_ID;

const App = ({ Component, pageProps }: Props): React.ReactElement => (
<SettingsProvider>
<Head>
{
ANALYTICS_ID && (
<>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${ANALYTICS_ID}`} />
<script
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: `
<AuthProvider>
<SettingsProvider>
<Head>
{
ANALYTICS_ID && (
<>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${ANALYTICS_ID}`} />
<script
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${ANALYTICS_ID}');
`,
}}
/>
</>
)
}
<link rel="icon" href="/favicon.ico" />
<title>TMDojo</title>
</Head>
<Component {...pageProps} />
</SettingsProvider>
}}
/>
</>
)
}
<link rel="icon" href="/favicon.ico" />
<title>TMDojo</title>
</Head>
<Component {...pageProps} />
</SettingsProvider>
</AuthProvider>
);

export default App;
Loading