Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ Required push-notification environment variables:
- `VAPID_PUBLIC_KEY`: public VAPID key returned to competitiongroups.com.
- `VAPID_PRIVATE_KEY`: private VAPID key used to send browser push messages.
- `COMPETITION_GROUPS_JWT_SECRET`: shared HS256 secret used to authenticate
CompetitionGroups subscription requests.
CompetitionGroups subscription requests. Also used for push session tokens
unless `COMPETITION_GROUPS_PUSH_SESSION_SECRET` is configured.

Optional push-notification environment variables:

Expand All @@ -39,6 +40,10 @@ Optional push-notification environment variables:
- `COMPETITION_GROUPS_JWT_ISSUER`: expected `iss` claim when configured.
- `COMPETITION_GROUPS_JWT_AUDIENCE`: expected `aud` claim when configured.
- `COMPETITION_GROUPS_ORIGIN`: origin used when building notification click URLs.
- `COMPETITION_GROUPS_PUSH_SESSION_SECRET`: dedicated HS256 secret for durable
CompetitionGroups push session tokens.
- `COMPETITION_GROUPS_PUSH_SESSION_TTL_SECONDS`: optional push session token
lifetime. When omitted, push sessions last until the client disables them.

The question should be asked: How does the server know who is in what group? The main answer to this is through the [WCIF](https://github.com/thewca/wcif/blob/master/specification.md). But the other question is does this data get saved?
**An argument for saving the data in databases**: potentially faster, could cache, only retreive the data you need.
Expand Down
32 changes: 23 additions & 9 deletions packages/server/auth/Auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,8 @@ const loadAuth = async (
sign: jwtSign,
},
}));
jest.doMock('node-fetch', () => ({
__esModule: true,
default: fetchMock,
jest.doMock('../lib/fetchWithTimeout', () => ({
fetchWithTimeout: fetchMock,
}));
jest.doMock('../mocks/config', () => ({
isMocksMode: jest.fn(() => mocksMode),
Expand All @@ -110,7 +109,7 @@ const loadAuth = async (
})),
}));
jest.doMock('./AuthMiddleware', () => ({
authMiddlewareDecode: jest.fn(
authMiddlewareVerifyIgnoringExpiration: jest.fn(
(req: MockRequest, _res: MockResponse, next: () => void) => next()
),
}));
Expand Down Expand Up @@ -146,6 +145,17 @@ describe('Auth router', () => {
expect(response.send).toHaveBeenCalledWith('public-key');
});

it('redacts client secrets from startup config logs', async () => {
await loadAuth(false);

expect(console.log).toHaveBeenCalledWith(
'Loading values from environment variables',
expect.objectContaining({
CLIENT_SECRET: '[redacted]',
})
);
});

it('redirects mock WCA auth requests back with a mock code', async () => {
const { getRoutes } = await loadAuth(true);
const response = createResponse();
Expand Down Expand Up @@ -233,12 +243,16 @@ describe('Auth router', () => {
'https://wca.example/oauth/token',
expect.objectContaining({ method: 'POST' })
);
expect(fetchMock).toHaveBeenCalledWith('https://wca.example/api/v0/me', {
headers: {
Authorization: 'Bearer access-token',
'Content-Type': 'application/x-www-form-urlencoded',
expect(fetchMock).toHaveBeenCalledWith(
'https://wca.example/api/v0/me',
{
headers: {
Authorization: 'Bearer access-token',
'Content-Type': 'application/x-www-form-urlencoded',
},
},
});
{ retries: 2 }
);
expect(jwtSign).toHaveBeenCalled();
expect(response.json).toHaveBeenCalledWith({ jwt: 'signed-jwt' });
});
Expand Down
106 changes: 57 additions & 49 deletions packages/server/auth/Auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import fs from 'fs';
import express from 'express';
import jwt from 'jsonwebtoken';
import fetch from 'node-fetch';
import { authMiddlewareDecode } from './AuthMiddleware';
import { authMiddlewareVerifyIgnoringExpiration } from './AuthMiddleware';
import { getMockUser, isMocksMode } from '../mocks/config';
import { fetchWithTimeout } from '../lib/fetchWithTimeout';

// TODO Should really be fetched from environment variables
// Depending on how we want to deploy this
Expand All @@ -15,7 +15,7 @@ const { WCA_ORIGIN, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI } = process.env;
console.log('Loading values from environment variables', {
WCA_ORIGIN,
CLIENT_ID,
CLIENT_SECRET,
CLIENT_SECRET: CLIENT_SECRET ? '[redacted]' : undefined,
REDIRECT_URI,
});

Expand Down Expand Up @@ -183,7 +183,7 @@ router.get('/wca/callback', async (req, res) => {
});

try {
const response = await fetch(`${resolvedWcaOrigin}/oauth/token`, {
const response = await fetchWithTimeout(`${resolvedWcaOrigin}/oauth/token`, {
method: 'POST',
body: params,
headers: {
Expand All @@ -197,9 +197,13 @@ router.get('/wca/callback', async (req, res) => {

const wcaToken = (await response.json()) as WcaOauthRes;

const profileRes = await fetch(`${resolvedWcaOrigin}/api/v0/me`, {
headers: createHeaders(wcaToken.access_token),
});
const profileRes = await fetchWithTimeout(
`${resolvedWcaOrigin}/api/v0/me`,
{
headers: createHeaders(wcaToken.access_token),
},
{ retries: 2 }
);

if (!profileRes.ok) {
throw await profileRes.json();
Expand All @@ -215,55 +219,59 @@ router.get('/wca/callback', async (req, res) => {
}
});

router.post('/wca/refresh', authMiddlewareDecode, async (req, res) => {
if (!req.user) {
return res.status(403).send('Unauthenticated');
}

if (isMocksMode()) {
return res.json({ jwt: await resignJWT(req.user) });
}
router.post(
'/wca/refresh',
authMiddlewareVerifyIgnoringExpiration,
async (req, res) => {
if (!req.user) {
return res.status(403).send('Unauthenticated');
}

const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: resolvedClientId,
client_secret: resolvedClientSecret,
refresh_token: req.user.wca.refreshToken,
code: req.user.wca.code,
scope: SCOPE,
redirect_uri: req.get('Referer') ?? resolvedRedirectUri,
});
if (isMocksMode()) {
return res.json({ jwt: await resignJWT(req.user) });
}

try {
const response = await fetch(`${resolvedWcaOrigin}/oauth/token`, {
method: 'POST',
body: params,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: resolvedClientId,
client_secret: resolvedClientSecret,
refresh_token: req.user.wca.refreshToken,
code: req.user.wca.code,
scope: SCOPE,
redirect_uri: req.get('Referer') ?? resolvedRedirectUri,
});

if (!response.ok) {
throw await response.json();
}
try {
const response = await fetchWithTimeout(`${resolvedWcaOrigin}/oauth/token`, {
method: 'POST',
body: params,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});

if (!response.ok) {
throw await response.json();
}

const tokens = (await response.json()) as WcaOauthRes;
const tokens = (await response.json()) as WcaOauthRes;

const token = await resignJWT({
...req.user,
wca: {
...req.user.wca,
accessToken: tokens.access_token,
expiration: new Date(Date.now() + tokens.expires_in * 1000).getTime(),
refreshToken: tokens.refresh_token,
},
});
const token = await resignJWT({
...req.user,
wca: {
...req.user.wca,
accessToken: tokens.access_token,
expiration: new Date(Date.now() + tokens.expires_in * 1000).getTime(),
refreshToken: tokens.refresh_token,
},
});

return res.json({ jwt: token });
} catch (e) {
console.error(e);
res.status(500).json(e);
return res.json({ jwt: token });
} catch (e) {
console.error(e);
res.status(500).json(e);
}
}
});
);

export default router;
57 changes: 56 additions & 1 deletion packages/server/auth/AuthMiddleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ jest.mock('../lib/competitionGroupsToken', () => ({
verifyCompetitionGroupsToken,
}));

import { authMiddlewareDecode, authMiddlewareVerify } from './AuthMiddleware';
import {
authMiddlewareDecode,
authMiddlewareVerify,
authMiddlewareVerifyIgnoringExpiration,
} from './AuthMiddleware';

const createRequest = (authorization?: string) => ({
headers: {
Expand All @@ -35,6 +39,13 @@ const callAuthMiddlewareDecode = authMiddlewareDecode as unknown as (
next: jest.Mock
) => void;

const callAuthMiddlewareVerifyIgnoringExpiration =
authMiddlewareVerifyIgnoringExpiration as unknown as (
req: ReturnType<typeof createRequest>,
res: unknown,
next: jest.Mock
) => void;

describe('AuthMiddleware', () => {
beforeEach(() => {
jwtVerify.mockReset();
Expand All @@ -52,6 +63,17 @@ describe('AuthMiddleware', () => {
expect(jwtVerify).not.toHaveBeenCalled();
});

it('continues without verifying non-Bearer authorization headers', () => {
const request = createRequest('Basic credentials');
const next = jest.fn();

callAuthMiddlewareVerify(request, {}, next);

expect(next).toHaveBeenCalledWith(null);
expect(jwtVerify).not.toHaveBeenCalled();
expect(verifyCompetitionGroupsToken).not.toHaveBeenCalled();
});

it('verifies normal bearer JWT users', () => {
const user = { id: 123, name: 'Test User' };
const request = createRequest('Bearer jwt-token');
Expand All @@ -65,6 +87,39 @@ describe('AuthMiddleware', () => {
expect(next).toHaveBeenCalledWith(null);
});

it('can verify expired app JWTs for refresh without checking expiration', () => {
const user = { id: 123, name: 'Expired User' };
const request = createRequest('Bearer expired-jwt-token');
const next = jest.fn();
jwtVerify.mockReturnValue(user);

callAuthMiddlewareVerifyIgnoringExpiration(request, {}, next);

expect(jwtVerify).toHaveBeenCalledWith(
'expired-jwt-token',
expect.anything(),
{
ignoreExpiration: true,
}
);
expect(request).toMatchObject({ user });
expect(next).toHaveBeenCalledWith(null);
});

it('does not fall back to Competition Groups tokens for refresh JWT verification', () => {
const request = createRequest('Bearer competition-groups-token');
const next = jest.fn();
const jwtError = new Error('invalid jwt');
jwtVerify.mockImplementation(() => {
throw jwtError;
});

callAuthMiddlewareVerifyIgnoringExpiration(request, {}, next);

expect(verifyCompetitionGroupsToken).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(jwtError);
});

it('falls back to Competition Groups tokens for remote-control users', () => {
const request = createRequest('Bearer competition-groups-token');
const next = jest.fn();
Expand Down
Loading
Loading