Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ DB_PASS:
# Secret key used by the Ruby API to decode our custom LTI tokens.
# Must match the value configured in the Ruby API.
LTI_SHARED_API_SECRET: your-secret-shared-api-secret

# Production defaults are true and none. Override both for plain-HTTP local development.
LTI_COOKIES_SECURE: true
LTI_COOKIES_SAMESITE: strict
```

`API_HOST` must be reachable from the LTI server and must include the URL scheme, but no
Expand Down
3 changes: 3 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ DB_USER=user
DB_PASS=password

LTI_SHARED_API_SECRET=unset

LTI_COOKIES_SECURE=true
LTI_COOKIES_SAMESITE=strict
14 changes: 11 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
dotenv.config();

const PORT = process.env.PORT || 3001;
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
if (!process.env.API_HOST) throw 'API_HOST is not defined';
if (!process.env.APP_HOST) throw 'APP_HOST is not defined';
const API_HOST = process.env.API_HOST;
Expand All @@ -23,8 +24,15 @@
const LTI_SHARED_API_SECRET = process.env.LTI_SHARED_API_SECRET;
const INTERNAL_SYNC_KEY: string | undefined = process.env.INTERNAL_SYNC_KEY;

const LTI_COOKIES_SECURE = Boolean(process.env.LTI_COOKIES_SECURE ?? false);
const LTI_COOKIES_SAMESITE = process.env.LTI_COOKIES_SAMESITE ?? '';
const LTI_COOKIES_SECURE =
(process.env.LTI_COOKIES_SECURE ?? String(IS_PRODUCTION)).toLowerCase() === 'true';
const sameSite = (
process.env.LTI_COOKIES_SAMESITE ?? (IS_PRODUCTION ? 'none' : 'lax')
).toLowerCase();
if (!['lax', 'strict', 'none'].includes(sameSite)) {
throw 'LTI_COOKIES_SAMESITE must be lax, strict or none';

Check warning on line 33 in src/config.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-lti&issues=AZ_6ki9aBxTRJ8uWvhUb&open=AZ_6ki9aBxTRJ8uWvhUb&pullRequest=25
}
const LTI_COOKIES_SAMESITE = sameSite as 'lax' | 'strict' | 'none';

// Platform Configuration
if (!process.env.PLATFORM_URL) throw 'PLATFORM_URL is not defined';
Expand Down Expand Up @@ -69,5 +77,5 @@
PLATFORM_AUTHCONFIG_METHOD,
PLATFORM_AUTHCONFIG_KEY,

IS_PRODUCTION: process.env.NODE_ENV === 'production',
IS_PRODUCTION,
};
18 changes: 15 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { IdToken, Provider as lti } from 'ltijs';
import mongoose from 'mongoose';
import { Config } from './config';
import { sendError } from './errors';
import {
LTI_SESSION_COOKIE,
installLtiSessionMiddleware,
ltiSessionCookieOptions,
} from './lti-session';
import { EnrolmentRouter } from './routes/enrolment.route';
import { GradeRouter } from './routes/grade.route';
import { INTERNAL_SYNC_ROUTE_PATH, InternalSyncRoute } from './routes/internal-sync.route';
Expand Down Expand Up @@ -65,6 +70,9 @@ lti.setup(
appUrl: '/lti/api/',
loginUrl: '/lti/api/login',
keysetUrl: '/lti/api/keys',
// Disable Ltijs' permissive CORS; our middleware restricts credentials to APP_HOST.
cors: false,
serverAddon: installLtiSessionMiddleware,
cookies: {
// Set secure to true if the testing platform is in a different domain and https is being used
secure: Config.LTI_COOKIES_SECURE,
Expand Down Expand Up @@ -151,9 +159,13 @@ lti.onConnect((_token: IdToken, req: Request, res: Response) => {
return auth as AuthResponse;
})
.then((auth) => {
res.redirect(
`${Config.APP_HOST}/sign_in?ltik=${res.locals.ltik}&authToken=${auth.auth_token}&username=${auth.username}&isLtiLogin=true`,
);
res.cookie(LTI_SESSION_COOKIE, res.locals.ltik, ltiSessionCookieOptions);

const signInUrl = new URL('/sign_in', Config.APP_HOST);
signInUrl.searchParams.set('authToken', auth.auth_token);
signInUrl.searchParams.set('username', auth.username);
signInUrl.searchParams.set('isLtiLogin', 'true');
res.redirect(signInUrl.toString());
})
.catch((error) => {
const authenticationError =
Expand Down
73 changes: 73 additions & 0 deletions src/lti-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { CookieOptions, Express, NextFunction, Request, Response } from 'express';
import { Config } from './config';

export const LTI_SESSION_COOKIE = 'ontrack_lti_launch';

interface LtijsRequest extends Request {
token?: string;
}

const appOrigin = new URL(Config.APP_HOST).origin;
const unsafeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const publicLtiPaths = new Set(['/lti/api', '/lti/api/', '/lti/api/login', '/lti/api/keys']);

function isProtectedBrowserRoute(req: Request): boolean {
return (
req.path.startsWith('/lti/api/') &&
!publicLtiPaths.has(req.path) &&
req.path !== '/lti/api/internal/test-members'
);
}

export const ltiSessionCookieOptions: CookieOptions = {
httpOnly: true,
secure: Config.LTI_COOKIES_SECURE,
sameSite: Config.LTI_COOKIES_SAMESITE,
signed: true,
path: '/lti/api',
};

/**
* Makes the browser-facing LTI API cookie-authenticated without exposing the
* ltik to Angular. This runs after ltijs' cookie parser and before its session
* validator.
*/
export function installLtiSessionMiddleware(app: Express): void {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/lti/api')) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Referrer-Policy', 'no-referrer');
}

const origin = req.get('origin');
if (origin === appOrigin) {
res.setHeader('Access-Control-Allow-Origin', appOrigin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}

if (req.method === 'OPTIONS' && req.path.startsWith('/lti/api/')) {
if (origin !== appOrigin) return res.status(403).json({ error: 'Invalid origin' });
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Auth-Token, Content-Type, Username');
return res.sendStatus(204);
}

if (isProtectedBrowserRoute(req)) {
if (unsafeMethods.has(req.method) && origin !== appOrigin) {
return res.status(403).json({ error: 'Invalid origin' });
}

const cookieToken = req.signedCookies?.[LTI_SESSION_COOKIE];
if (typeof cookieToken !== 'string' || !cookieToken) {
return res.status(401).json({ error: 'LTI session not found' });
}

// Ignore query/body/header ltik values on browser API routes. The signed,
// HttpOnly cookie is the only accepted source after launch.
(req as LtijsRequest).token = cookieToken;
}

return next();
});
}
Loading