Skip to content

Commit 7642e05

Browse files
committed
switch remote auth to competitiongroups token exchange
1 parent fea3281 commit 7642e05

9 files changed

Lines changed: 127 additions & 109 deletions

File tree

netlify/functions/notify-comp-token.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const headers = {
88
};
99

1010
const base64Url = (value) => Buffer.from(value).toString('base64url');
11+
const REMOTE_SCOPE = 'notifycomp.remote';
12+
const PUSH_SCOPE = 'assignment_notifications';
1113

1214
const signJwt = (claims, secret) => {
1315
const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
@@ -42,7 +44,18 @@ exports.handler = async (event) => {
4244
};
4345
}
4446

45-
const { accessToken } = JSON.parse(event.body || '{}');
47+
let body;
48+
try {
49+
body = JSON.parse(event.body || '{}');
50+
} catch {
51+
return {
52+
statusCode: 400,
53+
headers,
54+
body: JSON.stringify({ message: 'Invalid JSON body' }),
55+
};
56+
}
57+
58+
const { accessToken, competitionId, scope } = body;
4659
if (!accessToken) {
4760
return {
4861
statusCode: 400,
@@ -75,14 +88,28 @@ exports.handler = async (event) => {
7588
};
7689
}
7790

91+
const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE;
92+
if (tokenScope === REMOTE_SCOPE && !competitionId) {
93+
return {
94+
statusCode: 400,
95+
headers,
96+
body: JSON.stringify({ message: 'Missing competition ID for remote token' }),
97+
};
98+
}
99+
78100
const now = Math.floor(Date.now() / 1000);
79101
const token = signJwt(
80102
{
81103
aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp',
104+
competitionIds: tokenScope === REMOTE_SCOPE ? [competitionId] : undefined,
82105
exp: now + 10 * 60,
83106
iat: now,
84107
iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com',
108+
name: me.name,
109+
scope: tokenScope,
110+
scopes: [tokenScope],
85111
sub: `wca:${me.id}`,
112+
wcaUserId: me.id,
86113
wcaUserIds: [me.id],
87114
},
88115
secret,

src/lib/notifications/assignmentNotifications.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage';
2+
import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken';
23

34
const NOTIFY_COMP_ORIGIN =
45
import.meta.env.VITE_NOTIFY_COMP_ORIGIN ?? 'https://api.notifycomp.com/api';
@@ -26,17 +27,6 @@ const notifyCompUrl = (path: string) => `${NOTIFY_COMP_ORIGIN}${path}`;
2627
export const isAssignmentNotificationsEnabled = () =>
2728
getLocalStorage(ENABLED_STORAGE_KEY) === 'true';
2829

29-
const getAccessToken = () => {
30-
const expiresAt = Number(getLocalStorage('expirationTime') ?? 0);
31-
const accessToken = getLocalStorage('accessToken');
32-
33-
if (!accessToken || !expiresAt || expiresAt <= Date.now()) {
34-
return null;
35-
}
36-
37-
return accessToken;
38-
};
39-
4030
const toUint8Array = (base64: string) => {
4131
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
4232
const normalized = `${base64}${padding}`.replace(/-/g, '+').replace(/_/g, '/');
@@ -59,7 +49,7 @@ export const getAssignmentNotificationStatus = (): AssignmentNotificationStatus
5949
return 'unsupported';
6050
}
6151

62-
if (!getAccessToken()) {
52+
if (!getStoredWcaAccessToken()) {
6353
return 'reauthorize';
6454
}
6555

@@ -90,7 +80,7 @@ const readErrorMessage = async (response: Response) => {
9080
};
9181

9282
const fetchNotifyCompToken = async () => {
93-
const accessToken = getAccessToken();
83+
const accessToken = getStoredWcaAccessToken();
9484
if (!accessToken) {
9585
throw new Error('Refresh your WCA authorization to enable assignment notifications.');
9686
}

src/lib/notifyCompRemoteAuth.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { deleteLocalStorage, getLocalStorage, setLocalStorage } from './localStorage';
22

33
const REMOTE_JWT_KEY = 'notifyComp.jwt';
4-
const REMOTE_AUTH_PENDING_KEY = 'notifyComp.authPending';
5-
const REMOTE_REDIRECT_PATH_KEY = 'notifyComp.redirectPath';
64

75
interface JwtClaims {
6+
competitionIds?: string[];
87
exp?: number;
9-
name?: string;
108
id?: number;
9+
name?: string;
10+
scope?: string | string[];
11+
scopes?: string[];
12+
wcaUserId?: number;
1113
}
1214

1315
const decodeJwtPayload = (token: string): JwtClaims | null => {
@@ -44,25 +46,19 @@ export const getNotifyCompRemoteClaims = () => {
4446
return token ? decodeJwtPayload(token) : null;
4547
};
4648

49+
export const hasNotifyCompRemoteTokenForCompetition = (competitionId: string) => {
50+
const claims = getNotifyCompRemoteClaims();
51+
if (!claims) {
52+
return false;
53+
}
54+
55+
return claims.competitionIds?.includes(competitionId) ?? false;
56+
};
57+
4758
export const setNotifyCompRemoteToken = (token: string) => {
4859
setLocalStorage(REMOTE_JWT_KEY, token);
4960
};
5061

5162
export const clearNotifyCompRemoteToken = () => {
5263
deleteLocalStorage(REMOTE_JWT_KEY);
5364
};
54-
55-
export const setNotifyCompRemoteAuthPending = (redirectPath: string) => {
56-
setLocalStorage(REMOTE_AUTH_PENDING_KEY, 'true');
57-
setLocalStorage(REMOTE_REDIRECT_PATH_KEY, redirectPath);
58-
};
59-
60-
export const isNotifyCompRemoteAuthPending = () =>
61-
getLocalStorage(REMOTE_AUTH_PENDING_KEY) === 'true';
62-
63-
export const consumeNotifyCompRemoteRedirectPath = () => {
64-
const redirectPath = getLocalStorage(REMOTE_REDIRECT_PATH_KEY) || '/';
65-
deleteLocalStorage(REMOTE_AUTH_PENDING_KEY);
66-
deleteLocalStorage(REMOTE_REDIRECT_PATH_KEY);
67-
return redirectPath;
68-
};

src/lib/remoteConfig.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,3 @@ export const NOTIFYCOMP_API_ORIGIN = NOTIFYCOMP_GRAPHQL_ORIGIN.replace(/\/graphq
99

1010
export const NOTIFYCOMP_WS_ORIGIN =
1111
import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://api.notifycomp.com/api/graphql';
12-
13-
export const NOTIFYCOMP_AUTH_ORIGIN =
14-
import.meta.env.VITE_NOTIFYCOMP_AUTH_ORIGIN || NOTIFYCOMP_API_ORIGIN;

src/lib/wcaAccessToken.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getLocalStorage } from './localStorage';
2+
3+
export const getStoredWcaAccessToken = () => {
4+
const expiresAt = Number(getLocalStorage('expirationTime') ?? 0);
5+
const accessToken = getLocalStorage('accessToken');
6+
7+
if (!accessToken || !expiresAt || expiresAt <= Date.now()) {
8+
return null;
9+
}
10+
11+
return accessToken;
12+
};

src/pages/Competition/Remote/index.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ export default function CompetitionRemote() {
3333

3434
const rooms = useMemo(() => (wcif ? getRooms(wcif) : []), [wcif]);
3535
const roomId = selectedRoomId === 'all' ? undefined : selectedRoomId;
36+
const isRemoteAuthenticated = competitionId
37+
? remoteAuth.isAuthenticatedForCompetition(competitionId)
38+
: false;
3639
const remote = useNotifyCompRemoteActivities({
3740
competitionId: competitionId || '',
38-
enabled: remoteAuth.isAuthenticated,
41+
enabled: isRemoteAuthenticated,
3942
roomId,
4043
});
4144

@@ -120,16 +123,22 @@ export default function CompetitionRemote() {
120123

121124
{remoteAuth.error && <NoteBox prefix="Remote sign in" text={remoteAuth.error} />}
122125

123-
{!remoteAuth.isAuthenticated ? (
126+
{!isRemoteAuthenticated ? (
124127
<div className="space-y-4 rounded border border-tertiary-weak bg-panel p-4 shadow-md shadow-tertiary-dark">
125128
<div className="space-y-2">
126-
<h2 className="type-heading">Remote sign in</h2>
129+
<h2 className="type-heading">Remote authorization</h2>
127130
<p className="type-body-sm text-subtle">
128-
Sign in with NotifyComp Remote to start, stop, reset, or auto-advance activities.
131+
Authorize this competition with your WCA account to start, stop, reset, or
132+
auto-advance activities.
129133
</p>
130134
</div>
131-
<Button type="button" disabled={remoteAuth.authenticating} onClick={remoteAuth.signIn}>
132-
{remoteAuth.authenticating ? 'Signing in...' : 'Sign in for remote control'}
135+
<Button
136+
type="button"
137+
disabled={remoteAuth.authenticating}
138+
onClick={() => {
139+
void remoteAuth.signIn(competitionId);
140+
}}>
141+
{remoteAuth.authenticating ? 'Authorizing...' : 'Authorize remote control'}
133142
</Button>
134143
</div>
135144
) : (

src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@ import { createContext, useContext } from 'react';
33
export interface NotifyCompRemoteAuthContextValue {
44
authenticating: boolean;
55
error: string | null;
6+
isAuthenticatedForCompetition: (competitionId: string) => boolean;
67
isAuthenticated: boolean;
7-
signIn: () => void;
8+
signIn: (competitionId: string) => Promise<void>;
89
signOut: () => void;
910
userName?: string;
1011
}
1112

1213
export const NotifyCompRemoteAuthContext = createContext<NotifyCompRemoteAuthContextValue>({
1314
authenticating: false,
1415
error: null,
16+
isAuthenticatedForCompetition: () => false,
1517
isAuthenticated: false,
16-
signIn: () => {},
18+
signIn: async () => {},
1719
signOut: () => {},
1820
});
1921

src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx

Lines changed: 51 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
import { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react';
2-
import { useLocation, useNavigate } from 'react-router-dom';
1+
import { PropsWithChildren, useCallback, useMemo, useState } from 'react';
32
import {
43
clearNotifyCompRemoteToken,
5-
consumeNotifyCompRemoteRedirectPath,
64
getNotifyCompRemoteClaims,
75
getNotifyCompRemoteToken,
8-
isNotifyCompRemoteAuthPending,
9-
setNotifyCompRemoteAuthPending,
6+
hasNotifyCompRemoteTokenForCompetition,
107
setNotifyCompRemoteToken,
118
} from '@/lib/notifyCompRemoteAuth';
12-
import { NOTIFYCOMP_AUTH_ORIGIN } from '@/lib/remoteConfig';
9+
import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken';
10+
import { useAuth } from '../AuthProvider';
1311
import { NotifyCompRemoteAuthContext } from './NotifyCompRemoteAuthContext';
1412

13+
const NOTIFY_COMP_TOKEN_URL = '/.netlify/functions/notify-comp-token';
14+
const REMOTE_SCOPE = 'notifycomp.remote';
15+
1516
const readErrorMessage = async (response: Response) => {
1617
const text = await response.text();
1718

@@ -27,82 +28,67 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) {
2728
const [token, setToken] = useState(getNotifyCompRemoteToken);
2829
const [authenticating, setAuthenticating] = useState(false);
2930
const [error, setError] = useState<string | null>(null);
30-
const location = useLocation();
31-
const navigate = useNavigate();
32-
33-
const signIn = useCallback(() => {
34-
const redirectPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
35-
setNotifyCompRemoteAuthPending(redirectPath);
36-
setError(null);
37-
38-
const params = new URLSearchParams({
39-
redirect_uri: window.location.href,
40-
});
41-
42-
window.location.href = `${NOTIFYCOMP_AUTH_ORIGIN}/auth/wca?${params.toString()}`;
43-
}, []);
44-
45-
const signOut = useCallback(() => {
46-
clearNotifyCompRemoteToken();
47-
setToken(null);
48-
}, []);
49-
50-
useEffect(() => {
51-
const params = new URLSearchParams(location.search);
52-
const code = params.get('code');
31+
const { signIn: signInWithWca } = useAuth();
32+
33+
const signIn = useCallback(
34+
async (competitionId: string) => {
35+
setError(null);
36+
const accessToken = getStoredWcaAccessToken();
37+
38+
if (!accessToken) {
39+
signInWithWca();
40+
return;
41+
}
42+
43+
setAuthenticating(true);
44+
45+
try {
46+
const response = await fetch(NOTIFY_COMP_TOKEN_URL, {
47+
method: 'POST',
48+
headers: {
49+
'Content-Type': 'application/json',
50+
},
51+
body: JSON.stringify({
52+
accessToken,
53+
competitionId,
54+
scope: REMOTE_SCOPE,
55+
}),
56+
});
5357

54-
if (!code || !isNotifyCompRemoteAuthPending()) {
55-
return;
56-
}
57-
58-
setAuthenticating(true);
59-
setError(null);
60-
61-
const callbackParams = new URLSearchParams({
62-
code,
63-
redirect_uri: window.location.href,
64-
});
65-
66-
fetch(`${NOTIFYCOMP_AUTH_ORIGIN}/auth/wca/callback?${callbackParams.toString()}`)
67-
.then(async (response) => {
6858
if (!response.ok) {
6959
throw new Error(await readErrorMessage(response));
7060
}
7161

72-
return (await response.json()) as { jwt?: string };
73-
})
74-
.then(({ jwt }) => {
75-
if (!jwt) {
76-
throw new Error('NotifyComp did not return a remote session token.');
62+
const payload = (await response.json()) as { token?: string };
63+
if (!payload.token) {
64+
throw new Error('Remote token response was missing a token.');
7765
}
7866

79-
setNotifyCompRemoteToken(jwt);
80-
setToken(jwt);
81-
82-
const nextParams = new URLSearchParams(location.search);
83-
nextParams.delete('code');
84-
const query = nextParams.toString();
85-
const fallbackPath = `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
86-
const redirectPath = consumeNotifyCompRemoteRedirectPath() || fallbackPath;
87-
navigate(redirectPath, { replace: true });
88-
})
89-
.catch((err) => {
90-
setError(err instanceof Error ? err.message : 'Unable to sign in to NotifyComp Remote.');
67+
setNotifyCompRemoteToken(payload.token);
68+
setToken(payload.token);
69+
} catch (err) {
70+
setError(err instanceof Error ? err.message : 'Unable to authorize NotifyComp Remote.');
9171
clearNotifyCompRemoteToken();
9272
setToken(null);
93-
consumeNotifyCompRemoteRedirectPath();
94-
})
95-
.finally(() => {
73+
} finally {
9674
setAuthenticating(false);
97-
});
98-
}, [location, navigate]);
75+
}
76+
},
77+
[signInWithWca],
78+
);
79+
80+
const signOut = useCallback(() => {
81+
clearNotifyCompRemoteToken();
82+
setToken(null);
83+
}, []);
9984

10085
const claims = token ? getNotifyCompRemoteClaims() : null;
10186

10287
const value = useMemo(
10388
() => ({
10489
authenticating,
10590
error,
91+
isAuthenticatedForCompetition: hasNotifyCompRemoteTokenForCompetition,
10692
isAuthenticated: Boolean(token),
10793
signIn,
10894
signOut,

src/vite-env.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ declare const __GIT_TAG__: string;
66

77
interface ImportMetaEnv {
88
readonly VITE_NOTIFY_COMP_ORIGIN?: string;
9-
readonly VITE_NOTIFYCOMP_AUTH_ORIGIN?: string;
109
readonly VITE_NOTIFYCOMP_API_ORIGIN?: string;
1110
readonly VITE_NOTIFYCOMP_WS_ORIGIN?: string;
1211
}

0 commit comments

Comments
 (0)