Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions .github/workflows/automated_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
JWT_RESET_SECRET: ${{secrets.JWT_RESET_SECRET}}
JWT_SCHEDULER_SECRET: ${{secrets.JWT_SCHEDULER_SECRET}}
JWT_SECRET: ${{secrets.JWT_SECRET}}
JWT_DASHBOARD_SECRET: Dashboard_tickets_get_their_own_secret
TEST_USER: ${{secrets.TEST_USER}}
TEST_USER_ID: ${{secrets.TEST_USER_ID}}
steps:
Expand Down
5 changes: 5 additions & 0 deletions packages/api/.env.default
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ JWT_INVITE_SECRET=Any_arbitrary_string_will_do
JWT_RESET_SECRET=Production_is_secured_with_a_long_random_string
JWT_FARM_SECRET=Here_we_can_use_friendly_explanations
JWT_SCHEDULER_SECRET=Another_token_was_needed_for_the_scheduler
JWT_DASHBOARD_SECRET=Dashboard_tickets_get_their_own_secret

# Comma-separated exact addresses an Analytics Dashboard ticket may be returned to.
# Contact the Analytics Dashboard team for the value for your environment.
DASHBOARD_ALLOWED_RETURN_TO=?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This being an .env variable was taken from the spec so I maintained it, but these are public URLs and to consider them secrets is a bit of a stretch. Still it will give us some flexibility to adjust without a full release.


# Create your own (free in most cases) Google API key at https://console.cloud.google.com/apis/dashboard
# (Optional) We use google-maps-services-js package so API services on this key can be restricted to APIs listed here:
Expand Down
48 changes: 48 additions & 0 deletions packages/api/src/controllers/loginController.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import parser from 'ua-parser-js';
import UserLogModel from '../models/userLogModel.js';
import EmailModel from '../models/emailTokenModel.js';
import { createToken } from '../util/jwt.js';
import { randomUUID } from 'crypto';

const loginController = {
authenticateUser() {
Expand Down Expand Up @@ -242,6 +243,53 @@ const loginController = {
}
};
},

dashboardIssueTicket() {
return async (req, res) => {
try {
// The signed-in user only. A user_id in the body is ignored, so a ticket can never name
// anyone but the caller.
const { user_id } = req.auth;
const { return_to, farm_id } = req.body;

// The addresses the Analytics Dashboard is served at, and the only ones a ticket may be
// handed to. Read per request so a test can set the variable after importing this module.
// filter(Boolean) drops the empty string an unset or blank variable would otherwise
// produce, so a missing configuration rejects every address instead of matching ''.
const allowedReturnAddresses = (process.env.DASHBOARD_ALLOWED_RETURN_TO ?? '')
.split(',')
.map((address) => address.trim())
.filter(Boolean);

// Exact string match: a prefix, suffix or substring of an allowed address is not a match.
if (!allowedReturnAddresses.includes(return_to)) {
return res.status(400).send({ message: 'return_to is not an allowed address.' });
}

if (farm_id) {
const userFarm = await UserFarmModel.query()
.where({ user_id, farm_id, status: 'Active' })
.first();
if (!userFarm) {
return res.sendStatus(403);
}
}

const ticket = await createToken('dashboard', {
user_id,
farm_id: farm_id ?? null,
jti: randomUUID(),
});

// The validated return_to is echoed back so the web app navigates to an address the
// server approved rather than to its own copy of the value.
return res.status(200).send({ ticket, return_to });
} catch (error) {
console.error(error);
return res.status(500).json({ error });
}
};
},
};

async function sendMissingInvitations(user) {
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/routes/loginRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import express from 'express';
const router = express.Router();
import loginController from '../controllers/loginController.js';
import checkGoogleJwt from '../middleware/acl/checkGoogleJwt.js';
import checkJwt from '../middleware/acl/checkJwt.js';

router.post('/google', checkGoogleJwt, loginController.loginWithGoogle());
router.post('/', loginController.authenticateUser());
router.get('/user/:email', loginController.getUserNameByUserEmail());
// This router is mounted before the global checkJwt in server.ts, so the middleware is attached
// here to make the endpoint require a login token.
router.post('/dashboard/ticket', checkJwt, loginController.dashboardIssueTicket());

export default router;
3 changes: 3 additions & 0 deletions packages/api/src/util/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@ import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_EXPIRES_IN = '7d';
const RESET_PASSWORD_TOKEN_EXPIRES_IN = '1d';
const SCHEDULER_TOKEN_EXPIRES_IN = '1d';
const DASHBOARD_TICKET_EXPIRES_IN = '30s';

const tokenType = {
access: process.env.JWT_SECRET,
invite: process.env.JWT_INVITE_SECRET,
passwordReset: process.env.JWT_RESET_SECRET,
farm: process.env.JWT_FARM_SECRET,
scheduler: process.env.JWT_SCHEDULER_SECRET,
dashboard: process.env.JWT_DASHBOARD_SECRET,
};
const expireTime = {
access: ACCESS_TOKEN_EXPIRES_IN,
invite: ACCESS_TOKEN_EXPIRES_IN,
passwordReset: RESET_PASSWORD_TOKEN_EXPIRES_IN,
farm: ACCESS_TOKEN_EXPIRES_IN,
scheduler: SCHEDULER_TOKEN_EXPIRES_IN,
dashboard: DASHBOARD_TICKET_EXPIRES_IN,
};

function createToken(type, payload) {
Expand Down
256 changes: 256 additions & 0 deletions packages/api/tests/dashboardTicket.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/*
* Copyright 2026 LiteFarm.org
* This file is part of LiteFarm.
*
* LiteFarm is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LiteFarm is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
*/

import chai from 'chai';

import chaiHttp from 'chai-http';
chai.use(chaiHttp);

import jwt from 'jsonwebtoken';
import { Response } from 'superagent';
import server from '../src/server.js';
import knex from '../src/util/knex.js';
import { tableCleanup } from './testEnvironment.js';
import mocks from './mock.factories.js';
import { createToken } from '../src/util/jwt.js';
import { Farm, User } from '../src/models/types.js';

jest.mock('jsdom');
jest.mock('../src/jobs/station_sync/mapping.js');
jest.mock('../src/templates/sendEmailTemplate.js', () => ({
sendEmail: jest.fn(),
emails: { INVITATION: { path: 'invitation_to_farm_email' } },
}));
// checkJwt and util/jwt.js are deliberately not mocked: the real middleware and the real signer
// are both under test.

// The allowlist is supplied by DASHBOARD_ALLOWED_RETURN_TO, so these stand in for the real
// deployment addresses. Every near-miss case below is derived from the first entry, and stays
// correct if these change.
const ALLOWED_RETURN_ADDRESSES = [
'https://dashboard.test/auth/finish',
'https://second-host.test/litefarm/auth/finish',
];
const [ALLOWED_RETURN_TO] = ALLOWED_RETURN_ADDRESSES;

interface TicketResponseBody {
ticket: string;
return_to: string;
message?: string;
}

interface DashboardTicket extends jwt.JwtPayload {
user_id: User['user_id'];
farm_id: Farm['farm_id'] | null;
jti: string;
iat: number;
exp: number;
}

type TicketResponse = Omit<Response, 'body'> & { body: TicketResponseBody };

function postRequest(
body: Record<string, unknown>,
{ authorization }: { authorization?: string } = {},
): Promise<TicketResponse> {
const request = chai
.request(server)
.post('/login/dashboard/ticket')
.set('content-type', 'application/json');

if (authorization) {
request.set('Authorization', authorization);
}

return request.send(body) as unknown as Promise<TicketResponse>;
}

function decodeTicket(ticket: string): DashboardTicket {
return jwt.verify(ticket, process.env.JWT_DASHBOARD_SECRET as string) as DashboardTicket;
}

describe('POST /login/dashboard/ticket', () => {
let user: User;
let otherUser: User;
let activeFarm: Farm;
let strangerFarm: Farm;
let inactiveFarm: Farm;
let invitedFarm: Farm;
let authorization: string;

beforeAll(async () => {
[user] = await mocks.usersFactory();
[otherUser] = await mocks.usersFactory();

[activeFarm] = await mocks.farmFactory();
[strangerFarm] = await mocks.farmFactory();
[inactiveFarm] = await mocks.farmFactory();
[invitedFarm] = await mocks.farmFactory();

await mocks.userFarmFactory({
promisedUser: Promise.resolve([user]),
promisedFarm: Promise.resolve([activeFarm]),
});
await mocks.userFarmFactory(
{
promisedUser: Promise.resolve([user]),
promisedFarm: Promise.resolve([inactiveFarm]),
},
mocks.fakeUserFarm({ status: 'Inactive' }),
);
await mocks.userFarmFactory(
{
promisedUser: Promise.resolve([user]),
promisedFarm: Promise.resolve([invitedFarm]),
},
mocks.fakeUserFarm({ status: 'Invited' }),
);

authorization = `Bearer ${await createToken('access', { user_id: user.user_id })}`;
});

beforeEach(() => {
process.env.DASHBOARD_ALLOWED_RETURN_TO = ALLOWED_RETURN_ADDRESSES.join(',');
});

afterAll(async () => {
await tableCleanup(knex);
await knex.destroy();
});

describe('Identity', () => {
test('names the token holder, not a user_id supplied in the body', async () => {
const res = await postRequest(
{ return_to: ALLOWED_RETURN_TO, user_id: otherUser.user_id },
{ authorization },
);

expect(res.status).toBe(200);
expect(decodeTicket(res.body.ticket).user_id).toBe(user.user_id);
});

test('returns 401 without an Authorization header', async () => {
const res = await postRequest({ return_to: ALLOWED_RETURN_TO });

expect(res.status).toBe(401);
expect(res.body.ticket).toBeUndefined();
});
});

describe('Farm membership', () => {
test('issues a ticket for a farm the user is an Active member of', async () => {
const res = await postRequest(
{ return_to: ALLOWED_RETURN_TO, farm_id: activeFarm.farm_id },
{ authorization },
);

expect(res.status).toBe(200);
expect(decodeTicket(res.body.ticket).farm_id).toBe(activeFarm.farm_id);
});

test('issues a ticket with a null farm_id when the body omits farm_id', async () => {
const res = await postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization });

expect(res.status).toBe(200);
expect(decodeTicket(res.body.ticket).farm_id).toBe(null);
});

test.each([
['no userFarm row at all', () => strangerFarm],
['an Inactive userFarm row', () => inactiveFarm],
['an Invited userFarm row', () => invitedFarm],
])('returns 403 for a farm with %s', async (_label, getFarm) => {
const res = await postRequest(
{ return_to: ALLOWED_RETURN_TO, farm_id: getFarm().farm_id },
{ authorization },
);

expect(res.status).toBe(403);
expect(res.body.ticket).toBeUndefined();
});
});

describe('Return address allowlist', () => {
test.each(ALLOWED_RETURN_ADDRESSES)('issues a ticket for %s', async (return_to) => {
const res = await postRequest({ return_to }, { authorization });

expect(res.status).toBe(200);
expect(res.body.return_to).toBe(return_to);
});

test.each([
['absent', undefined],
['an unrelated address', 'https://attacker.example'],
['a prefix of an allowed address', ALLOWED_RETURN_TO.slice(0, -3)],
['a suffix of an allowed address', new URL(ALLOWED_RETURN_TO).pathname],
['a substring of an allowed address', ALLOWED_RETURN_TO.replace('https://', '')],
['an allowed address with an appended segment', `${ALLOWED_RETURN_TO}.attacker.example`],
])('returns 400 when return_to is %s', async (_label, return_to) => {
const res = await postRequest({ return_to }, { authorization });

expect(res.status).toBe(400);
expect(res.body.ticket).toBeUndefined();
});

test.each([
['unset', undefined],
['blank', ''],
['a lone comma', ','],
])(
'rejects an empty return_to when DASHBOARD_ALLOWED_RETURN_TO is %s',
async (_label, allowlist) => {
if (allowlist === undefined) {
delete process.env.DASHBOARD_ALLOWED_RETURN_TO;
} else {
process.env.DASHBOARD_ALLOWED_RETURN_TO = allowlist;
}

const res = await postRequest({ return_to: '' }, { authorization });

expect(res.status).toBe(400);
expect(res.body.ticket).toBeUndefined();
},
);
});

describe('Ticket properties', () => {
test('expires 30 seconds after it is issued', async () => {
const res = await postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization });
const { iat, exp } = decodeTicket(res.body.ticket);

expect(exp - iat).toBe(30);
});

test('does not verify against JWT_SECRET', async () => {
const res = await postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization });

expect(() => jwt.verify(res.body.ticket, process.env.JWT_SECRET as string)).toThrow();
});

test('carries a jti that differs between two tickets', async () => {
const [first, second] = await Promise.all([
postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization }),
postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization }),
]);

const firstJti = decodeTicket(first.body.ticket).jti;
const secondJti = decodeTicket(second.body.ticket).jti;

expect(firstJti).toBeTruthy();
expect(secondJti).toBeTruthy();
expect(firstJti).not.toBe(secondJti);
});
});
});
Loading