Skip to content

Commit

Permalink
Move twitch login to backend API call, rather than exposing access to…
Browse files Browse the repository at this point in the history
…ken in URL in browser

Fixes #311
  • Loading branch information
saebyn committed Jan 7, 2025
1 parent 05bee89 commit 581af06
Show file tree
Hide file tree
Showing 36 changed files with 466 additions and 355 deletions.
1 change: 0 additions & 1 deletion .env.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ VITE_REDIRECT_URI="http://localhost:5173/auth-callback"
VITE_LOGOUT_URI="http://localhost:5173/login"

VITE_TWITCH_CLIENT_ID=???
VITE_TWITCH_REDIRECT_URI="http://localhost:5173/twitch-callback"

VITE_API_URL="http://localhost:5173/api/"
WEBSOCKET_URL="ws://localhost:5173"
Expand Down
15 changes: 13 additions & 2 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"script": "lint:vscode",
"problemMatcher": {
"owner": "biome",
"fileLocation": ["absolute"],
"fileLocation": [
"absolute"
],
"pattern": {
"regexp": "^::(error|warning) title=(.+),file=(.+),line=(\\d+),endLine=(\\d+),col=(\\d+),endColumn=(\\d+)::(.+)$",
"severity": 1,
Expand All @@ -28,6 +30,15 @@
"problemMatcher": [],
"label": "npm: format",
"detail": "biome format"
},
{
"type": "npm",
"script": "typecheck",
"problemMatcher": [
"$tsc"
],
"label": "npm: typecheck",
"detail": "tsc --project tsconfig.json --noEmit"
}
]
}
}
8 changes: 6 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Route, RouterProvider, createBrowserRouter } from 'react-router-dom';
import episodes from '@/resources/episodes';
import streamPlans, { StreamPlansCalendar } from '@/resources/stream_plans';
import streams from '@/resources/streams';
import twitch_streams from '@/resources/twitch_streams';
import twitch from '@/resources/twitch';
import video_clips from '@/resources/video_clips';

import GlobalStyles from '@mui/material/GlobalStyles';
Expand Down Expand Up @@ -79,7 +79,11 @@ function App() {
</Resource>
<Resource name="episodes" {...episodes} />
<Resource name="video_clips" {...video_clips} />
<Resource name="twitch_streams" {...twitch_streams} />
<Resource
name="twitch"
{...twitch}
options={{ label: 'Twitch Streams' }}
/>
<Resource name="profile" />

<CustomRoutes>
Expand Down
107 changes: 107 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { TwitchAccess } from '@/types';
import { userManager } from '@/utilities/auth';
import type {
TwitchAuthRequest,
TwitchCallbackRequest,
} from 'glowing-telegram-types/src/types';

const { VITE_API_URL: baseApiUrl } = import.meta.env;

export async function authenticatedFetch(
url: string,
options: RequestInit = {},
): Promise<Response> {
const user = await userManager.getUser();

if (!user) {
throw new Error('User not found');
}

const token = user.id_token;

if (token === undefined) {
throw new Error('User not authenticated');
}

return fetch(url, {
...options,
headers: {
Authorization: token,
Accept: 'application/json',
...options.headers,
},
});
}

export async function fetchTwitchAccessToken(): Promise<TwitchAccess> {
const url = new URL('auth/twitch/token', baseApiUrl);

try {
const res = await authenticatedFetch(url.toString());

const data = await res.json();

return {
valid: true,
id: data.broadcaster_id,
accessToken: data.access_token,
};
} catch (error) {
return {
id: 'twitchToken',
valid: false,
};
}
}

export async function generateAuthorizeUri(
provider: 'twitch',
scopes: string[],
): Promise<string> {
const url = new URL(`auth/${provider}/url`, baseApiUrl);

const body: TwitchAuthRequest = {
scopes,
redirect_uri: window.location.href,
};

const res = await authenticatedFetch(url.toString(), {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
});

const data = await res.json();

return data.url;
}

export async function handleOAuthCallback(
provider: 'twitch',
code: string,
state: string,
): Promise<string> {
const body: TwitchCallbackRequest = {
code,
state,
scope: [],
};

const res = await authenticatedFetch(
new URL(`auth/${provider}/callback`, baseApiUrl).toString(),
{
method: 'POST',
body: JSON.stringify(body),
redirect: 'manual',
headers: {
'Content-Type': 'application/json',
},
},
);

const data = await res.json();

return data.url;
}
7 changes: 6 additions & 1 deletion src/components/atoms/TwitchCCLSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ function TwitchCCLSelect({
useEffect(() => {
const abortController = new AbortController();

if (!profile.twitch?.accessToken) {
setError(new Error('Missing Twitch access token'));
return;
}

setLoading(true);

getContentClassificationLabels(getLocale(), profile.twitch.accessToken, {
Expand All @@ -62,7 +67,7 @@ function TwitchCCLSelect({
return () => {
abortController.abort();
};
}, [profile.twitch.accessToken, getLocale]);
}, [profile.twitch?.accessToken, getLocale]);

return (
<FormControl error={Boolean(error)}>
Expand Down
7 changes: 6 additions & 1 deletion src/components/atoms/TwitchCategoryAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ function TwitchCategoryAutocomplete({
return;
}

if (!profile.twitch?.accessToken) {
setError(new Error('Missing Twitch access token'));
return;
}

const abortController = new AbortController();

setLoading(true);
Expand All @@ -61,7 +66,7 @@ function TwitchCategoryAutocomplete({
setLoading(false);
}
}, FETCH_CATEGORIES_DEBOUNCE_TIME),
[profile.twitch.accessToken],
[profile.twitch?.accessToken],
);

useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/atoms/TwitchCategoryAutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function TwitchCategoryAutocompleteInput({
category={field.value === '' ? null : field.value}
onChange={field.onChange}
profile={profile}
label={label}
label={label || ''}
/>
);
}
Expand Down
72 changes: 17 additions & 55 deletions src/components/atoms/TwitchOAuthButton.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { getCsrfToken } from '@/utilities/csrf';
import { generateAuthorizeUri } from '@/utilities/twitch';
import CheckIcon from '@mui/icons-material/Check';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import { LoadingIndicator, useTranslate, useUpdate } from 'react-admin';
import { useDataProvider, useTranslate } from 'react-admin';

interface TwitchOAuthButtonProps {
tokens: {
Expand All @@ -19,64 +16,29 @@ const scopes = [

function TwitchOAuthButton({ tokens }: TwitchOAuthButtonProps) {
const translate = useTranslate();
const [update, { isPending, isError }] = useUpdate();
const dataprovider = useDataProvider();

const csrfToken = getCsrfToken();
const url = generateAuthorizeUri(csrfToken, scopes);

const handleDisconnect = () => {
update('profile', {
id: 'my-profile',
data: { twitch: { accessToken: null } },
});
};

const handleConnect = () => {
const handleConnect = async () => {
const url = await dataprovider.generateAuthorizeUri('twitch', scopes);
window.location.href = url;
};

if (isPending) {
return <LoadingIndicator />;
}

if (isError) {
return (
<Alert severity="error">
{translate('gt.profile.errorSaving', {
_: 'There was an error saving the profile',
})}
</Alert>
);
}

// if the user is connected, show the reauthorize button
return (
<>
{tokens.accessToken && (
<>
<Button
variant="contained"
color="success"
title={translate('gt.profile.reauthorizeTwitch', {
_: 'Click to Re-authorize Twitch',
})}
onClick={handleConnect}
>
<CheckIcon />{' '}
{translate('gt.profile.connectedToTwitch', {
_: 'Connected to Twitch',
})}
</Button>
<Button
color="warning"
variant="contained"
onClick={handleDisconnect}
>
{translate('gt.profile.disconnectTwitch', {
_: 'Click to Disconnect Twitch',
})}
</Button>
</>
<Button
variant="contained"
color="success"
title={translate('gt.profile.reauthorizeTwitch', {
_: 'Click to Re-authorize Twitch',
})}
onClick={handleConnect}
>
<CheckIcon />{' '}
{translate('gt.profile.connectedToTwitch', {
_: 'Connected to Twitch',
})}
</Button>
)}

{!tokens.accessToken && (
Expand Down
50 changes: 0 additions & 50 deletions src/components/atoms/TwitchTokenLivenessChecker.tsx

This file was deleted.

14 changes: 13 additions & 1 deletion src/components/molecules/AdManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,24 @@ function AdManager({ profile }: AdManagerProps) {
useEffect(() => {
const abortController = new AbortController();

if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) {
return;
}

getAdSchedule(profile.twitch.broadcasterId, profile.twitch.accessToken, {
signal: abortController.signal,
})
.then((data) => setAdSchedule(data))
.catch((e) => setError(e));

return () => abortController.abort();
}, [profile.twitch.accessToken, profile.twitch.broadcasterId]);
}, [profile.twitch?.accessToken, profile.twitch?.broadcasterId]);

const handleSnooze = async () => {
if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) {
return;
}

setIsPending(true);
try {
const data = await snoozeNextAd(
Expand All @@ -55,6 +63,10 @@ function AdManager({ profile }: AdManagerProps) {
};

const handleStartCommercial = async () => {
if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) {
return;
}

setIsPending(true);
try {
await startCommercial(
Expand Down
Loading

0 comments on commit 581af06

Please sign in to comment.