Skip to content
Open
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
89 changes: 76 additions & 13 deletions packages/send/backend/src/auth/oidc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { JWT_EXPIRY_IN_MILLISECONDS } from '@send-backend/config';
import axios from 'axios';
import 'dotenv/config';

Expand All @@ -23,6 +22,12 @@ interface TokenCacheEntry {
// Simple in-memory cache for token introspection results
const tokenCache = new Map<string, TokenCacheEntry>();

// How long an introspection result is trusted before we ask Keycloak again.
// Short enough that a revoked session loses access promptly (#960), long
// enough that a burst of requests (e.g. a multipart upload) isn't one
// introspection call per request.
export const INTROSPECTION_CACHE_MS = 60 * 1000;

/**
* Introspects an OIDC token with the configured authorization server
* @param token The access token to introspect
Expand Down Expand Up @@ -68,13 +73,15 @@ export async function introspectToken(

const introspectionResult: TokenIntrospectionResponse = response.data;

// Cache the result for the same duration as the jwt or until token expires (whichever is shorter)

// Cache the result briefly so per-request introspection doesn't hammer
// Keycloak (e.g. during multipart uploads), while still catching a revoked
// session — logout / password change / admin force-logout — within
// INTROSPECTION_CACHE_MS. Never cache past the token's own expiry.
const tokenExpiry = introspectionResult.exp
? introspectionResult.exp * 1000
: Date.now() + JWT_EXPIRY_IN_MILLISECONDS;
: Date.now() + INTROSPECTION_CACHE_MS;
const cacheExpiry = Math.min(
Date.now() + JWT_EXPIRY_IN_MILLISECONDS,
Date.now() + INTROSPECTION_CACHE_MS,
tokenExpiry
);

Expand All @@ -101,6 +108,68 @@ export async function introspectToken(
}
}

/**
* Per-request liveness check for an access token (#960).
* @returns `true` if Keycloak reports the token active, `false` if it reports
* it inactive (session revoked/expired), and `null` if introspection could not
* be performed (misconfig or Keycloak unreachable). Callers should treat `null`
* as inconclusive and fail OPEN — a Keycloak blip must not sign everyone out.
*/
export async function isTokenActive(token: string): Promise<boolean | null> {
try {
const result = await introspectToken(token);
return result.active === true;
} catch (error) {
console.error('Token introspection unavailable (failing open):', error);
return null;
}
}

/**
* Read a JWT's `exp` (seconds since epoch) without verifying the signature.
* Returns `null` for a non-JWT/opaque token. NOTE: the refresh-vs-revoke
* distinction in isAccessTokenRevoked depends on this being readable — Keycloak
* issues JWT access tokens, so it is today. If access tokens ever become opaque,
* decodeTokenExp returns null, the expiry gate is skipped, and a routinely
* expired token would be treated as revoked (forced logout on expiry). Revisit
* this gate before switching token formats.
*/
function decodeTokenExp(token: string): number | null {
try {
const payload = token.split('.')[1];
if (!payload) {
return null;
}
const claims = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8')
);
return typeof claims?.exp === 'number' ? claims.exp : null;
} catch {
return null;
}
}

/**
* True only when the access token is still within its lifetime but Keycloak
* reports it inactive — i.e. the session was revoked (logout elsewhere,
* password change, admin force-logout), NOT merely expired (#960).
*
* An expired token returns `false` on purpose: routine expiry must go through
* the normal refresh flow (a refresh succeeds if the session is still alive and
* fails — logging the user out — if it isn't). Introspection errors also return
* `false` (fail open) so a Keycloak outage can't sign everyone out. This keeps
* forced logout limited to genuine revocations, per the refresh requirement in
* the issue discussion.
*/
export async function isAccessTokenRevoked(token: string): Promise<boolean> {
const exp = decodeTokenExp(token);
if (exp !== null && exp * 1000 <= Date.now()) {
return false; // expired — let the refresh flow decide, don't force logout
}
const active = await isTokenActive(token);
return active === false;
}

/**
* Validates an OIDC token and returns user information if valid
* @param token The access token to validate
Expand All @@ -119,18 +188,12 @@ export async function validateOIDCToken(token: string): Promise<{
try {
const introspectionResult = await introspectToken(token);

// `active` already reflects expiry and revocation, so it is the single
// source of truth — an expired or revoked token comes back active:false.
if (!introspectionResult.active) {
return { isValid: false };
}

// Check if token is expired
if (
introspectionResult.exp &&
introspectionResult.exp < Date.now() / JWT_EXPIRY_IN_MILLISECONDS
) {
return { isValid: false };
}

return {
isValid: true,
userInfo: {
Expand Down
6 changes: 6 additions & 0 deletions packages/send/backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export const JWT_REFRESH_TOKEN_EXPIRY_IN_DAYS = ONE_WEEK;
// Determines how many times a file can be attempted to be downloaded with the wrong password before it gets locked
export const MAX_ACCESS_LINK_RETRIES = 5;

// Response header that tells the client its session is gone and it should clear
// local auth and return to login (#960). Lives here (a dependency-light module)
// so both the Express middleware and the tRPC middleware can import it without
// pulling in the heavier models/prisma graph.
export const X_LOGOUT_HEADER = 'x-logout';

export function getEnvironmentName(): EnvironmentName {
if (BASE_URL.includes('send-backend.tb.pro')) {
return 'prod';
Expand Down
50 changes: 48 additions & 2 deletions packages/send/backend/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { PrismaClient } from '@prisma/client';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { getDataFromAuthenticatedRequest } from './auth/client';
import { validateJWT } from './auth/jwt';
import { extractBearerToken, validateOIDCToken } from './auth/oidc';
import { VERSION } from './config';
import {
extractBearerToken,
isAccessTokenRevoked,
validateOIDCToken,
} from './auth/oidc';
import { VERSION, X_LOGOUT_HEADER } from './config';
import { getUsedStorage } from './models';
import { fromPrismaV2 } from './models/prisma-helper';
import { getAdminStatus, getUserByOIDCSubject } from './models/users';
Expand Down Expand Up @@ -69,6 +73,36 @@ export function reject(
return;
}

/**
* Per-request session liveness gate (#960).
*
* If the request carries an OIDC access token that Keycloak reports inactive
* (the user logged out, changed their password, or was force-logged-out by an
* admin), set the `x-logout` header, respond 401, and return `true` so the
* caller stops — a revoked session must not fall back to a still-unexpired JWT
* cookie. Returns `false` (caller continues) when there is no bearer token, the
* token is merely expired (handled by the normal refresh flow), or introspection
* is inconclusive (Keycloak down) — so we never force logout on routine expiry
* or an outage.
*/
export async function rejectIfSessionRevoked(
req: Request,
res: Response
): Promise<boolean> {
const token = extractBearerToken(req.headers?.authorization);
if (!token) {
return false;
}
if (await isAccessTokenRevoked(token)) {
res.setHeader(X_LOGOUT_HEADER, '1');
res
.status(401)
.json({ message: 'Not authorized: session is no longer active' });
return true;
}
return false;
}

/**
* Unified authentication middleware that supports both OIDC and legacy JWT authentication
* This middleware prioritizes OIDC authentication but falls back to JWT for backward compatibility
Expand All @@ -79,6 +113,12 @@ export async function requireAuth(
res: Response,
next: NextFunction
) {
// A revoked OIDC session must lose access immediately — do not fall back to a
// still-unexpired JWT cookie.
if (await rejectIfSessionRevoked(req, res)) {
return;
}

// First, try OIDC authentication
const authHeader = req.headers.authorization;
const oidcToken = extractBearerToken(authHeader);
Expand Down Expand Up @@ -165,6 +205,12 @@ export async function requireJWT(
res: Response,
next: NextFunction
) {
// If the OIDC session behind this request has been revoked, deny and tell the
// client to log out — regardless of the (still-unexpired) JWT cookie (#960).
if (await rejectIfSessionRevoked(req, res)) {
return;
}

const jwtToken = getCookie(req?.headers?.cookie, 'authorization');
const jwtRefreshToken = getCookie(req?.headers?.cookie, 'refresh_token');

Expand Down
3 changes: 3 additions & 0 deletions packages/send/backend/src/origins.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextFunction, Request, Response } from 'express';
import { getAllowedOrigins } from './auth/client';
import { X_LOGOUT_HEADER } from './config';
import cors from 'cors';

const allowedOrigins = getAllowedOrigins();
Expand All @@ -26,5 +27,7 @@ export const originsHandler = (
}
},
credentials: true,
// Expose the forced-logout header so the browser can read it cross-origin (#960)
exposedHeaders: [X_LOGOUT_HEADER],
})(req, res, next);
};
62 changes: 62 additions & 0 deletions packages/send/backend/src/test/auth/oidc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import axios from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isAccessTokenRevoked } from '../../auth/oidc';

vi.mock('axios', () => ({
default: { post: vi.fn() },
}));

const mockedPost = vi.mocked(axios.post);

// Build a JWT-shaped token with a given `exp` (seconds) so decodeTokenExp can
// read it. Signature is irrelevant — the exp is decoded, not verified.
function tokenWithExp(expSeconds: number, salt = 'a'): string {
const payload = Buffer.from(
JSON.stringify({ exp: expSeconds, salt })
).toString('base64url');
return `header.${payload}.sig`;
}

const nowSec = () => Math.floor(Date.now() / 1000);

describe('isAccessTokenRevoked (#960 exp-gated introspection)', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.OIDC_TOKEN_INTROSPECTION_URL = 'https://kc.test/introspect';
process.env.OIDC_CLIENT_ID = 'client';
process.env.OIDC_CLIENT_SECRET = 'secret';
});

it('returns false for an EXPIRED token without introspecting (refresh flow owns it)', async () => {
const token = tokenWithExp(nowSec() - 60, 'expired');

const revoked = await isAccessTokenRevoked(token);

expect(revoked).toBe(false);
expect(mockedPost).not.toHaveBeenCalled();
});

it('returns true when a still-valid token is reported inactive (revoked session)', async () => {
mockedPost.mockResolvedValue({ data: { active: false } });
const token = tokenWithExp(nowSec() + 300, 'revoked');

const revoked = await isAccessTokenRevoked(token);

expect(revoked).toBe(true);
expect(mockedPost).toHaveBeenCalledTimes(1);
});

it('returns false when a still-valid token is active', async () => {
mockedPost.mockResolvedValue({ data: { active: true } });
const token = tokenWithExp(nowSec() + 300, 'active');

expect(await isAccessTokenRevoked(token)).toBe(false);
});

it('fails open (false) when introspection errors', async () => {
mockedPost.mockRejectedValue(new Error('keycloak down'));
const token = tokenWithExp(nowSec() + 300, 'error');

expect(await isAccessTokenRevoked(token)).toBe(false);
});
});
66 changes: 65 additions & 1 deletion packages/send/backend/src/test/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getDataFromAuthenticatedRequest } from '../auth/client';
import { extractBearerToken, validateOIDCToken } from '../auth/oidc';
import {
extractBearerToken,
isAccessTokenRevoked,
validateOIDCToken,
} from '../auth/oidc';
import { validateJWT } from '../auth/jwt';
import {
addVersionHeader,
Expand Down Expand Up @@ -67,6 +71,7 @@ vi.mock('../types/custom', () => ({
vi.mock('../auth/oidc', () => ({
extractBearerToken: vi.fn(),
validateOIDCToken: vi.fn(),
isAccessTokenRevoked: vi.fn(),
}));

vi.mock('../models/users', () => ({
Expand All @@ -93,8 +98,47 @@ describe('requireJWT', () => {
mockResponse = {
json: vi.fn(),
status: vi.fn(() => mockResponse),
setHeader: vi.fn(),
};
process.env.ACCESS_TOKEN_SECRET = 'your_secret_key';
// Default: no bearer / not revoked. Individual tests override.
vi.mocked(extractBearerToken).mockReturnValue(null);
vi.mocked(isAccessTokenRevoked).mockResolvedValue(false);
});

it('should deny with 401 and set x-logout when the OIDC session is revoked (#960)', async () => {
mockRequest.headers.authorization = 'Bearer revoked.token';
vi.mocked(extractBearerToken).mockReturnValue('revoked.token');
vi.mocked(isAccessTokenRevoked).mockResolvedValue(true);

await requireJWT(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

expect(mockResponse.setHeader).toHaveBeenCalledWith('x-logout', '1');
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(nextFunction).not.toHaveBeenCalled();
});

it('should NOT force logout for a not-revoked token (expired/active/inconclusive) — refresh flow handles it (#960)', async () => {
mockRequest.headers.authorization = 'Bearer some.token';
mockRequest.headers.cookie = `authorization=Bearer%20valid.token`;
vi.mocked(extractBearerToken).mockReturnValue('some.token');
// isAccessTokenRevoked returns false for expired tokens and when
// introspection is inconclusive, so we must not log the user out.
vi.mocked(isAccessTokenRevoked).mockResolvedValue(false);
vi.mocked(validateJWT).mockReturnValue('valid');

await requireJWT(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

expect(mockResponse.setHeader).not.toHaveBeenCalledWith('x-logout', '1');
expect(nextFunction).toHaveBeenCalled();
});

it('should call next() for a valid token', async () => {
Expand Down Expand Up @@ -390,7 +434,27 @@ describe('requireAuth', () => {
mockResponse = {
json: vi.fn(),
status: vi.fn(() => mockResponse),
setHeader: vi.fn(),
};
// Default: no bearer / not revoked. Individual tests override.
vi.mocked(extractBearerToken).mockReturnValue(null);
vi.mocked(isAccessTokenRevoked).mockResolvedValue(false);
});

it('denies with 401 + x-logout when the OIDC session is revoked (#960)', async () => {
mockRequest.headers.authorization = 'Bearer revoked.token';
vi.mocked(extractBearerToken).mockReturnValue('revoked.token');
vi.mocked(isAccessTokenRevoked).mockResolvedValue(true);

await requireAuth(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

expect(mockResponse.setHeader).toHaveBeenCalledWith('x-logout', '1');
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(nextFunction).not.toHaveBeenCalled();
});

it('should call next() and set oidcUser for a valid OIDC token', async () => {
Expand Down
Loading
Loading