From 2cafb736a9da2e00b34d4531d3fabe882cbe94cb Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Wed, 15 Apr 2026 18:24:30 +0500 Subject: [PATCH 01/15] feat(sso): add proxy auth middleware for mPass ForwardAuth (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sso): add proxy auth middleware for mPass ForwardAuth Koa middleware reads X-Auth-Request-Email header injected by Traefik ForwardAuth (oauth2-proxy), finds or creates user, and sets JWT accessToken cookie. Gated on PROXY_AUTH_ENABLED env var. * refactor(sso): remove unused PROXY_AUTH_ENABLED gate The fork image is purpose-built for mPass SSO via Traefik ForwardAuth — there is no deployment where the middleware should be disabled at runtime. Outline's other auth plugins (OIDC, Google, Slack) are gated on credentials being set, not on a separate on/off flag, so this inconsistent kill switch was dead weight. Drop the env var read and the short-circuit branch. The middleware always runs when the fork image is in use. * ci: gate CI + CodeQL on foss-main instead of main Upstream Outline triggers the lint/types/test/test-server matrix and CodeQL analysis on push/PR to main. The Pressingly fork doesn't carry main, so fork PRs ran nothing. Retarget both workflows to foss-main so fork PRs exercise the full server + app test shards, lint, types, and the weekly CodeQL scan. * fix(proxyAuth): pass LogCategory label to Logger.info calls Logger.info signature is (label: LogCategory, message, extra?). Three call sites passed message as the first arg, tripping TS2345 in yarn tsc. Prepend 'authentication' on all three so the label slot is populated correctly and extras land in their own field. No runtime behaviour change — the fix only corrects the structured log shape and lets the build compile. * added forwardauth middleware * refactor: migrate ForwardAuth to AUTH_TYPE=SSO env var with SMB_NAME support Replaces the boolean FORWARD_AUTH_ENABLED flag with AUTH_TYPE="SSO" for better extensibility, and adds SMB_NAME to construct email addresses for users whose identity provider only returns a username (no domain). Co-Authored-By: Claude Sonnet 4.6 * feat: issue JWT cookie on first ForwardAuth request to auth.info Ensures cookie-dependent services (WebSocket, collaboration) can authenticate when the session was established via ForwardAuth headers. Also extracts FORWARDAUTH_SERVICE constant to remove stringly-typed "forwardauth" literals across middleware and routes. Co-Authored-By: Claude Sonnet 4.6 * refactor(forwardauth): merge sso-auth improvements into integrated auth pipeline Take the best parts of the sso-auth `proxyAuth` middleware and apply them to our integrated `authentication.ts` approach. Key changes: - Fix credential priority in `parseAuthentication`: the SSO header check now runs last — after Authorization header, body, query, and cookie. Previously it ran first, so even after a JWT cookie was issued every subsequent API request still hit the DB via the header path. Now, once the cookie exists the fast JWT path resumes and the DB lookup is skipped entirely. - Move JWT cookie issuance into `auth()` middleware: previously the cookie was only set inside the `auth.info` route handler. Any authenticated endpoint reached before `auth.info` (or a WebSocket upgrade) would have no cookie. Moving issuance into `auth()` means the cookie is issued on the very first successfully authenticated request, regardless of which endpoint it is. - Add `lastSignedIn` cookie alongside `accessToken`: the frontend reads this cookie to display the "last signed in with…" indicator. We were silently missing it. - Add `FORWARDAUTH_SERVICE` to `NON_SSO_SERVICES` in `auth.info`: ForwardAuth users have no `UserAuthentication` record so scheduling `ValidateSSOAccessTask` for them is incorrect. The proxy handles session re-validation. - Fix stale test env var: `env.FORWARD_AUTH_ENABLED` was removed when we migrated to `AUTH_TYPE=SSO`; tests still referenced the old name. Updated to `env.AUTH_TYPE = "SSO"` and added `cookies` mock to ForwardAuth test contexts since `auth()` now calls `ctx.cookies.set()` in that path. - Add `SMB_NAME=test` to `.env.test` for test coverage of non-email username handling. What we deliberately kept from our branch (not adopted from sso-auth): - `teamCreator` command for team provisioning (vs raw `Team.create`) - Role assignment: first user → Admin, subsequent → `team.defaultUserRole` - `SMB_NAME` fallback for non-email username claims - `AUTH_TYPE=SSO` env gate (sso-auth has no gate — always active) - Integrated auth pipeline (vs separate Koa middleware that only works for browser flows where HTML is loaded before any API call) Co-Authored-By: Claude Sonnet 4.6 * refactor(forwardauth): remove proxyAuth middleware, superseded by auth pipeline `proxyAuth.ts` and its mount in `web.ts` are replaced by the ForwardAuth handling integrated into `authentication.ts`. Keeping both would cause double user-provisioning on every first request and leave the Koa middleware as dead code. Co-Authored-By: Claude Sonnet 4.6 * test(forwardauth): lowercase email in provision assertion Middleware lowercases the email claim before storing, so the DB lookup and state.auth.user.email assertion must match on the lowercased value rather than the mixed-case input. --------- Co-authored-by: Azan Ali --- .env.test | 4 +- .github/workflows/ci.yml | 4 +- .github/workflows/codeql-analysis.yml | 4 +- server/env.ts | 18 +++ server/middlewares/authentication.test.ts | 143 +++++++++++++++++++++- server/middlewares/authentication.ts | 115 ++++++++++++++++- server/routes/api/auth/auth.ts | 7 +- 7 files changed, 286 insertions(+), 9 deletions(-) diff --git a/.env.test b/.env.test index 1f045a3b143f..7bd19e61eae4 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,5 @@ NODE_ENV=test -DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test +DATABASE_URL=postgres://postgres:065fec3089ecb7f56bd2d3c35b7eea0e3e777959f7fc36e38ea11b1dd920e5b2@127.0.0.1:5432/outline-test SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B SMTP_HOST=smtp.example.com @@ -41,3 +41,5 @@ UTILS_SECRET=test-utils-secret DEBUG= LOG_LEVEL=error + +SMB_NAME=test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0607e61c5b3..12f91e5facff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [foss-main] pull_request: - branches: [main] + branches: [foss-main] env: NODE_ENV: test diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 34611100db4e..4c063ca1fb55 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [main] + branches: [foss-main] pull_request: # The branches below must be a subset of the branches above - branches: [main] + branches: [foss-main] schedule: - cron: "28 15 * * 2" diff --git a/server/env.ts b/server/env.ts index 7d97fbe0ed81..0896f586d3d1 100644 --- a/server/env.ts +++ b/server/env.ts @@ -518,6 +518,24 @@ export class Environment { public WORKER_CONCURRENCY_TASKS = this.toOptionalNumber(environment.WORKER_CONCURRENCY_TASKS) ?? 10; + /** + * The authentication type to use. When set to "SSO", the server will trust + * X-Auth-Request-Email and X-Auth-Request-User headers injected by a reverse + * proxy (e.g. oauth2-proxy, Authelia) for authentication and automatic user + * provisioning. Only enable this when Outline is deployed behind a trusted + * authenticating proxy on a self-hosted instance. + */ + @IsOptional() + @IsIn(["SSO"]) + public AUTH_TYPE = this.toOptionalString(environment.AUTH_TYPE); + + /** + * The SMB organisation name used to construct internal email addresses for + * users authenticated via ForwardAuth (e.g. "acme" → user@acme.com). + */ + @IsNotEmpty() + public SMB_NAME = environment.SMB_NAME ?? ""; + /** * A boolean switch to toggle the rate limiter at application web server. */ diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 5505aed16624..11b388f7604c 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -1,6 +1,7 @@ import type { DefaultState } from "koa"; import { randomString } from "@shared/random"; import { Scope } from "@shared/types"; +import env from "@server/env"; import { buildUser, buildTeam, @@ -8,8 +9,9 @@ import { buildApiKey, buildOAuthAuthentication, } from "@server/test/factories"; +import { User } from "@server/models"; import { AuthenticationType } from "@server/types"; -import auth from "./authentication"; +import auth, { FORWARDAUTH_SERVICE } from "./authentication"; describe("Authentication middleware", () => { describe("with session JWT", () => { @@ -303,6 +305,145 @@ describe("Authentication middleware", () => { expect(error.errorData.adminEmail).toEqual(admin.email); }); + describe("with ForwardAuth headers", () => { + beforeEach(() => { + env.AUTH_TYPE = "SSO"; + }); + + afterEach(() => { + env.AUTH_TYPE = undefined; + }); + + it("should authenticate an existing user via X-Auth-Request-Email", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const state = {} as DefaultState; + const authMiddleware = auth(); + + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn((header: string) => { + if (header === "x-auth-request-email") { + return user.email!; + } + return ""; + }), + }, + // @ts-expect-error mock cookies + cookies: { get: jest.fn(() => undefined), set: jest.fn() }, + state, + ip: "127.0.0.1", + cache: {}, + }, + jest.fn() + ); + + expect(state.auth.user.id).toEqual(user.id); + expect(state.auth.service).toEqual(FORWARDAUTH_SERVICE); + expect(state.auth.type).toEqual(AuthenticationType.APP); + }); + + it("should provision a new user when X-Auth-Request-Email is unknown", async () => { + await buildTeam(); + const state = {} as DefaultState; + const authMiddleware = auth(); + const newEmail = `newuser-${randomString(6)}@example.com`; + + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn((header: string) => { + if (header === "x-auth-request-email") { + return newEmail; + } + if (header === "x-auth-request-user") { + return "New User"; + } + return ""; + }), + }, + // @ts-expect-error mock cookies + cookies: { get: jest.fn(() => undefined), set: jest.fn() }, + state, + ip: "127.0.0.1", + cache: {}, + }, + jest.fn() + ); + + const provisioned = await User.findOne({ + where: { email: newEmail.toLowerCase() }, + }); + expect(provisioned).not.toBeNull(); + expect(state.auth.user.email).toEqual(newEmail.toLowerCase()); + expect(state.auth.user.name).toEqual("New User"); + }); + + it("should use email prefix as name when X-Auth-Request-User is absent", async () => { + await buildTeam(); + const state = {} as DefaultState; + const authMiddleware = auth(); + const newEmail = `prefix-${randomString(6)}@example.com`; + + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn((header: string) => { + if (header === "x-auth-request-email") { + return newEmail; + } + return ""; + }), + }, + // @ts-expect-error mock cookies + cookies: { get: jest.fn(() => undefined), set: jest.fn() }, + state, + ip: "127.0.0.1", + cache: {}, + }, + jest.fn() + ); + + expect(state.auth.user.email).toEqual(newEmail.toLowerCase()); + expect(state.auth.user.name).toEqual(newEmail.toLowerCase().split("@")[0]); + }); + + it("should not honour ForwardAuth headers when AUTH_TYPE is not SSO", async () => { + env.AUTH_TYPE = undefined; + const state = {} as DefaultState; + const authMiddleware = auth(); + + try { + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn((header: string) => { + if (header === "x-auth-request-email") { + return "attacker@example.com"; + } + return ""; + }), + query: {}, + }, + // @ts-expect-error mock cookies + cookies: { get: jest.fn(() => undefined) }, + state, + cache: {}, + }, + jest.fn() + ); + expect(true).toBe(false); // should not reach here + } catch (e) { + expect(e.message).toEqual("Authentication required"); + } + }); + }); + it("should return an error for deleted team", async () => { const state = {} as DefaultState; const team = await buildTeam(); diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 91e8d6f342cc..819610f2b4a6 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -1,12 +1,21 @@ +import { addMonths } from "date-fns"; import type { Next } from "koa"; import capitalize from "lodash/capitalize"; -import type { UserRole } from "@shared/types"; +import { Op } from "sequelize"; +import { UserRole } from "@shared/types"; +import { slugifyDomain } from "@shared/utils/domains"; +import { parseEmail } from "@shared/utils/email"; import { UserRoleHelper } from "@shared/utils/UserRoleHelper"; import tracer, { addTags, getRootSpanFromRequestContext, } from "@server/logging/tracer"; +import teamCreator from "@server/commands/teamCreator"; +import { createContext } from "@server/context"; +import env from "@server/env"; +import Logger from "@server/logging/Logger"; import { User, Team, ApiKey, OAuthAuthentication } from "@server/models"; +import { sequelize } from "@server/storage/database"; import type { AppContext } from "@server/types"; import { AuthenticationType } from "@server/types"; import { getUserForJWT } from "@server/utils/jwt"; @@ -16,6 +25,9 @@ import { UserSuspendedError, } from "../errors"; +/** Service identifier used by the ForwardAuth authentication flow. */ +export const FORWARDAUTH_SERVICE = "forwardauth"; + type AuthenticationOptions = { /** Role required to access the route. */ role?: UserRole; @@ -40,6 +52,22 @@ export default function auth(options: AuthenticationOptions = {}) { const { type, token, user, service, scope } = await validateAuthentication(ctx, options); + // On the first ForwardAuth-authenticated request, issue a JWT cookie so + // that subsequent requests and cookie-dependent services (WebSocket, + // collaboration) use the fast JWT path instead of the header DB path. + if (service === FORWARDAUTH_SERVICE && !ctx.cookies.get("accessToken")) { + const expires = addMonths(new Date(), 3); + ctx.cookies.set("accessToken", user.getJwtToken(expires, service), { + sameSite: "lax", + expires, + }); + ctx.cookies.set("lastSignedIn", FORWARDAUTH_SERVICE, { + httpOnly: false, + sameSite: "lax", + expires: new Date("2100"), + }); + } + await Promise.all([ user.updateActiveAt(ctx), user.team?.updateActiveAt(), @@ -126,6 +154,20 @@ export function parseAuthentication(ctx: AppContext): AuthInput { } } + // Check proxy-injected identity headers last — after all conventional + // credentials — so an existing session cookie (or Bearer token) is always + // preferred. This means once the JWT cookie has been issued the header path + // is bypassed entirely, avoiding a DB round-trip on every request. + if (env.AUTH_TYPE === "SSO") { + const authRequestEmail = ctx.request.get("x-auth-request-email"); + if (authRequestEmail) { + return { + token: `fwd:${authRequestEmail}`, + transport: "header", + }; + } + } + return { token: undefined, transport: undefined, @@ -241,6 +283,77 @@ async function validateAuthentication( scope = apiKey.scope ?? ["*"]; await apiKey.updateActiveAt(); + } else if (token.startsWith("fwd:") && env.AUTH_TYPE === "SSO") { + type = AuthenticationType.APP; + service = FORWARDAUTH_SERVICE; + + const emailClaim = token.slice(4).toLowerCase().trim(); + const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailClaim); + const email = isValidEmail + ? emailClaim + : (() => { + if (!env.SMB_NAME) { + throw AuthenticationError( + "SMB_NAME environment variable is not set, cannot construct email for ForwardAuth user" + ); + } + return `${emailClaim.split("@")[0]}@${env.SMB_NAME}.com`; + })(); + const localPart = emailClaim.split("@")[0]; + const displayName = + ctx.request.get("x-auth-request-user") || localPart; + const { domain } = parseEmail(email); + + // Find an existing user by email across all teams (self-hosted deployments + // have a single team, but we don't restrict by team here so that the lookup + // is reliable even in test environments with multiple teams). + user = await User.scope("withTeam").findOne({ + where: { + email: { [Op.iLike]: email }, + }, + }); + + if (!user) { + // Self-hosted deployments have a single team. When none exists yet the + // first arriving user bootstraps the installation. + let team = await Team.findOne(); + let isNewTeam = false; + + if (!team) { + Logger.info("authentication", "Provisioning new team via ForwardAuth", { + domain, + }); + const subdomain = slugifyDomain(domain ?? "team"); + team = await sequelize.transaction((transaction) => + teamCreator(createContext({ ip: ctx.ip, transaction }), { + name: env.APP_NAME, + subdomain, + authenticationProviders: [ + { name: FORWARDAUTH_SERVICE, providerId: domain ?? FORWARDAUTH_SERVICE }, + ], + }) + ); + isNewTeam = true; + } + + Logger.info("authentication", "Provisioning new user via ForwardAuth", { + email, + }); + const created = await User.create({ + name: displayName, + email, + teamId: team.id, + // First user into a brand-new team becomes admin. + role: isNewTeam ? UserRole.Admin : team.defaultUserRole, + lastActiveAt: new Date(), + lastActiveIp: ctx.ip, + }); + // Reload with associations so downstream middleware sees a full User. + user = await User.scope("withTeam").findByPk(created.id); + if (!user) { + throw AuthenticationError("Failed to provision ForwardAuth user"); + } + } } else { type = AuthenticationType.APP; const result = await getUserForJWT(token); diff --git a/server/routes/api/auth/auth.ts b/server/routes/api/auth/auth.ts index 47bdd45d7d87..78a9fb17a08b 100644 --- a/server/routes/api/auth/auth.ts +++ b/server/routes/api/auth/auth.ts @@ -4,7 +4,9 @@ import uniqBy from "lodash/uniqBy"; import { TeamPreference } from "@shared/types"; import { parseDomain } from "@shared/utils/domains"; import env from "@server/env"; -import auth from "@server/middlewares/authentication"; +import auth, { + FORWARDAUTH_SERVICE, +} from "@server/middlewares/authentication"; import { transaction } from "@server/middlewares/transaction"; import { Event, Team } from "@server/models"; import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper"; @@ -115,10 +117,11 @@ router.post("auth.config", async (ctx: APIContext) => { }); /** Authentication services that don't require SSO validation. */ -const NON_SSO_SERVICES = ["email", "passkeys"]; +const NON_SSO_SERVICES = ["email", "passkeys", FORWARDAUTH_SERVICE]; router.post("auth.info", auth(), async (ctx: APIContext) => { const { user, service } = ctx.state.auth; + const sessions = getSessionsInCookie(ctx); const signedInTeamIds = Object.keys(sessions); From 8b703b1d255d427f2d92b2b6d04b69d9dd461abc Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Fri, 17 Apr 2026 17:47:12 +0500 Subject: [PATCH 02/15] fix(auth): land on portal after logout, drop dependency on OIDC_LOGOUT_URI (#4) AuthStore.logout() was setting logoutRedirectUri from env.OIDC_LOGOUT_URI, which pointed at a Cognito hosted /logout endpoint. In this deployment the app client has no hosted /logout, so the Cognito session always survives logout and the env-wired redirect was dead weight when unset and pointed at an unregistered URL when set. Simplified to derive the portal host from the current URL: - Rewrite "foss-." -> "foss." and assign that as logoutRedirectUri whenever the user initiates a logout. - The portal host is outside ForwardAuth, so the user lands on the landing page instead of being silently re-authed into the dashboard. Re-auth still happens the next time the user clicks into a gated app, which is the expected behavior while Cognito hosted /logout is absent. Logout.tsx no longer branches on env.OIDC_LOGOUT_URI; AuthStore always sets logoutRedirectUri and the unauthenticated branch in Authenticated.tsx performs the navigation. --- app/scenes/Logout.tsx | 10 +++------- app/stores/AuthStore.ts | 5 ++++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/scenes/Logout.tsx b/app/scenes/Logout.tsx index 31a4f73b7b73..e1fb82acf877 100644 --- a/app/scenes/Logout.tsx +++ b/app/scenes/Logout.tsx @@ -1,17 +1,13 @@ -import { Redirect } from "react-router-dom"; -import env from "~/env"; import useStores from "~/hooks/useStores"; -import { logoutPath } from "~/utils/routeHelpers"; const Logout = () => { const { auth } = useStores(); void auth.logout({ userInitiated: true }); - if (env.OIDC_LOGOUT_URI) { - return null; // user will be redirected to logout URI after logout - } - return ; + // AuthStore.logout() always sets logoutRedirectUri to the portal host; the + // unauthenticated branch in Authenticated.tsx performs the actual navigation. + return null; }; export default Logout; diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index 5d6e0f0bc748..1b3c4e383fc9 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -352,7 +352,10 @@ export default class AuthStore extends Store { } if (userInitiated) { - this.logoutRedirectUri = env.OIDC_LOGOUT_URI; + // Rewrite "foss-." → "foss." so we land on the portal + // (outside ForwardAuth) instead of Outline's own root, which would silently re-auth. + const portalHost = window.location.hostname.replace(/^[^.]*\./, "foss."); + this.logoutRedirectUri = `${window.location.protocol}//${portalHost}`; } if (clearCache) { From 6a4792dc74cc29d51a961bc0e34836220bf8e108 Mon Sep 17 00:00:00 2001 From: Azan Ali Date: Mon, 20 Apr 2026 18:14:16 +0500 Subject: [PATCH 03/15] fixed bug where invalid cookie would lead to log in page --- app/utils/ApiClient.ts | 12 ++ server/env.ts | 1 + server/middlewares/authentication.test.ts | 150 ++++++++++++++++++---- server/middlewares/authentication.ts | 23 ++++ shared/types.ts | 1 + 5 files changed, 164 insertions(+), 23 deletions(-) diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts index 58ac9cf59e97..ca494065a47e 100644 --- a/app/utils/ApiClient.ts +++ b/app/utils/ApiClient.ts @@ -180,6 +180,18 @@ class ApiClient { // Handle 401, log out user if (response.status === 401) { if (!this.shareId) { + if (env.AUTH_TYPE === "SSO") { + // In ForwardAuth mode, the stale JWT cookie has been cleared by the + // server. Navigate to the current URL so the browser makes a fresh + // HTTP request — the proxy will inject new identity headers and a new + // session will be issued automatically. + // + // We skip auth.logout() here: clearing MobX state would cause + // to render and land the user on + // the login page instead of their original document. + window.location.replace(window.location.href); + throw new AuthorizationError(); + } await stores.auth.logout({ savePath: true, clearCache: false, diff --git a/server/env.ts b/server/env.ts index 0896f586d3d1..34334a6185d9 100644 --- a/server/env.ts +++ b/server/env.ts @@ -525,6 +525,7 @@ export class Environment { * provisioning. Only enable this when Outline is deployed behind a trusted * authenticating proxy on a self-hosted instance. */ + @Public @IsOptional() @IsIn(["SSO"]) public AUTH_TYPE = this.toOptionalString(environment.AUTH_TYPE); diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 11b388f7604c..fce905a9bb81 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -13,6 +13,30 @@ import { User } from "@server/models"; import { AuthenticationType } from "@server/types"; import auth, { FORWARDAUTH_SERVICE } from "./authentication"; +function createCtx(overrides: any = {}) { + return { + state: {}, + cache: {}, + + originalUrl: "/", + + request: { + url: "/", + headers: {}, + body: {}, + + get: jest.fn((key: string) => { + if (key.toLowerCase() === "authorization") return null; + return null; + }), + + ...(overrides.request || {}), + }, + + ...overrides, + }; +} + describe("Authentication middleware", () => { describe("with session JWT", () => { it("should authenticate with correct session token", async () => { @@ -176,35 +200,37 @@ describe("Authentication middleware", () => { }); it("should return error with OAuth access token in body", async () => { - const state = {} as DefaultState; const user = await buildUser(); - const authMiddleware = auth(); + const authentication = await buildOAuthAuthentication({ user, scope: [Scope.Read], }); - try { - await authMiddleware( - { - originalUrl: "/api/users.info", - request: { - url: "/users.info", - // @ts-expect-error mock request - get: jest.fn(() => null), - body: { - token: authentication.accessToken, - }, - }, - state, - cache: {}, + + const ctx: any = createCtx({ + originalUrl: "/api/users.info", + request: { + body: { + token: authentication.accessToken, }, - jest.fn() - ); - } catch (e) { - expect(e.message).toContain( - "must be passed in the Authorization header" - ); + }, + }); + + const authMiddleware = auth(); + + let error: any; + + try { + await authMiddleware(ctx, jest.fn()); + throw new Error("Expected middleware to throw"); + } catch (e: any) { + error = e; } + + expect(error).toBeDefined(); + + // IMPORTANT: assert real behavior, not internal wording + expect(error.message).toBeTruthy(); }); }); @@ -409,7 +435,9 @@ describe("Authentication middleware", () => { ); expect(state.auth.user.email).toEqual(newEmail.toLowerCase()); - expect(state.auth.user.name).toEqual(newEmail.toLowerCase().split("@")[0]); + expect(state.auth.user.name).toEqual( + newEmail.toLowerCase().split("@")[0] + ); }); it("should not honour ForwardAuth headers when AUTH_TYPE is not SSO", async () => { @@ -471,3 +499,79 @@ describe("Authentication middleware", () => { expect(error.message).toEqual("Invalid token"); }); }); + +describe("Authentication middleware - cookie cleanup regression", () => { + it("clears auth cookies on 401 when using cookie JWT (no Authorization header)", async () => { + const state = {} as DefaultState; + + const ctx: any = { + state, + cache: {}, + request: { + get: jest.fn(() => undefined), + }, + cookies: { + get: jest.fn((key: string) => { + if (key === "accessToken") return "cookie-token"; + return undefined; + }), + }, + }; + + const authMiddleware = auth(); + + let err: any; + + try { + await authMiddleware(ctx, async () => { + throw Object.assign(new Error("fail"), { status: 401 }); + }); + } catch (e) { + err = e; + } + + expect(err).toBeDefined(); + + expect(err.headers?.["set-cookie"]).toEqual( + expect.arrayContaining([ + expect.stringContaining("accessToken="), + expect.stringContaining("lastSignedIn="), + ]) + ); + }); + + it("does NOT clear cookies when Authorization header is present", async () => { + const state = {} as DefaultState; + + const ctx: any = { + state, + cache: {}, + request: { + get: jest.fn((header: string) => { + if (header === "authorization") return "Bearer fake.jwt.token"; + return undefined; + }), + }, + cookies: { + get: jest.fn(() => "cookie-token"), + }, + }; + + const authMiddleware = auth(); + + let err: any; + + try { + await authMiddleware(ctx, async () => { + throw Object.assign(new Error("fail"), { status: 401 }); + }); + } catch (e) { + err = e; + } + + expect(err).toBeDefined(); + + // 🔥 core regression assertion + expect(err.headers?.["set-cookie"]).toBeUndefined(); + }); +}); diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 819610f2b4a6..52d88acfbc73 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -92,6 +92,29 @@ export default function auth(options: AuthenticationOptions = {}) { ); } } catch (err) { + // If a cookie-transported JWT caused the 401, clear it so the browser + // stops sending it. On the next request ForwardAuth headers take over + // and a fresh session is issued. Only clear when the cookie was the + // active transport (no Authorization: Bearer header present). + // + // IMPORTANT: ctx.cookies.set() cannot be used here — Koa's onerror + // handler strips all response headers before sending the error response, + // then re-applies only err.headers (context.js:139-146). Attaching the + // Set-Cookie directives to the error object is the only way they survive. + if ( + err.status === 401 && + !ctx.request.get("authorization") && + ctx.cookies.get("accessToken") + ) { + const epoch = "Thu, 01 Jan 1970 00:00:00 GMT"; + err.headers = { + ...err.headers, + "set-cookie": [ + `accessToken=; expires=${epoch}; path=/`, + `lastSignedIn=; expires=${epoch}; path=/`, + ], + }; + } if (options.optional) { ctx.state.auth = {}; } else { diff --git a/shared/types.ts b/shared/types.ts index de6b1ee535ae..ac9cb69d04ef 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -103,6 +103,7 @@ export enum MentionType { export type PublicEnv = { ROOT_SHARE_ID?: string; + AUTH_TYPE?: string; analytics: { service: IntegrationService; settings: IntegrationSettings; From 9412f364029c14ab93d2ded11943ca975fd4e4a4 Mon Sep 17 00:00:00 2001 From: Azan Ali Date: Mon, 20 Apr 2026 18:20:47 +0500 Subject: [PATCH 04/15] fix lint --- server/middlewares/authentication.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index fce905a9bb81..c5296dbcd9ef 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -26,7 +26,7 @@ function createCtx(overrides: any = {}) { body: {}, get: jest.fn((key: string) => { - if (key.toLowerCase() === "authorization") return null; + if (key.toLowerCase() === "authorization") {return null;} return null; }), @@ -512,7 +512,7 @@ describe("Authentication middleware - cookie cleanup regression", () => { }, cookies: { get: jest.fn((key: string) => { - if (key === "accessToken") return "cookie-token"; + if (key === "accessToken") {return "cookie-token";} return undefined; }), }, @@ -548,7 +548,7 @@ describe("Authentication middleware - cookie cleanup regression", () => { cache: {}, request: { get: jest.fn((header: string) => { - if (header === "authorization") return "Bearer fake.jwt.token"; + if (header === "authorization") {return "Bearer fake.jwt.token";} return undefined; }), }, From a4482c8118199160e9b6ae33be20990dd6f3137f Mon Sep 17 00:00:00 2001 From: Azan Ali Date: Mon, 20 Apr 2026 20:02:58 +0500 Subject: [PATCH 05/15] addressed review --- server/middlewares/authentication.test.ts | 40 +++++++++++++++-------- server/middlewares/authentication.ts | 10 ++++-- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index c5296dbcd9ef..6dbc7002ad6b 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -14,26 +14,33 @@ import { AuthenticationType } from "@server/types"; import auth, { FORWARDAUTH_SERVICE } from "./authentication"; function createCtx(overrides: any = {}) { + const headers = { + ...(overrides.request?.headers || {}), + }; + + const get = jest.fn((key: string) => headers[key.toLowerCase()]); + return { state: {}, cache: {}, - originalUrl: "/", + originalUrl: overrides.originalUrl || "/", + + get, + + cookies: { + get: jest.fn(() => undefined), // 👈 THIS was missing + }, request: { url: "/", - headers: {}, + headers, + header: headers, body: {}, - - get: jest.fn((key: string) => { - if (key.toLowerCase() === "authorization") {return null;} - return null; - }), + get, ...(overrides.request || {}), }, - - ...overrides, }; } @@ -228,9 +235,10 @@ describe("Authentication middleware", () => { } expect(error).toBeDefined(); - - // IMPORTANT: assert real behavior, not internal wording - expect(error.message).toBeTruthy(); + expect(error.status).toBe(401); + expect(error.message).toBe( + "OAuth access token must be passed in the Authorization header" + ); }); }); @@ -512,7 +520,9 @@ describe("Authentication middleware - cookie cleanup regression", () => { }, cookies: { get: jest.fn((key: string) => { - if (key === "accessToken") {return "cookie-token";} + if (key === "accessToken") { + return "cookie-token"; + } return undefined; }), }, @@ -548,7 +558,9 @@ describe("Authentication middleware - cookie cleanup regression", () => { cache: {}, request: { get: jest.fn((header: string) => { - if (header === "authorization") {return "Bearer fake.jwt.token";} + if (header === "authorization") { + return "Bearer fake.jwt.token"; + } return undefined; }), }, diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 52d88acfbc73..18548fe0451c 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -101,8 +101,10 @@ export default function auth(options: AuthenticationOptions = {}) { // handler strips all response headers before sending the error response, // then re-applies only err.headers (context.js:139-146). Attaching the // Set-Cookie directives to the error object is the only way they survive. + const authInput = parseAuthentication(ctx); if ( err.status === 401 && + authInput.transport === "cookie" && !ctx.request.get("authorization") && ctx.cookies.get("accessToken") ) { @@ -323,8 +325,7 @@ async function validateAuthentication( return `${emailClaim.split("@")[0]}@${env.SMB_NAME}.com`; })(); const localPart = emailClaim.split("@")[0]; - const displayName = - ctx.request.get("x-auth-request-user") || localPart; + const displayName = ctx.request.get("x-auth-request-user") || localPart; const { domain } = parseEmail(email); // Find an existing user by email across all teams (self-hosted deployments @@ -352,7 +353,10 @@ async function validateAuthentication( name: env.APP_NAME, subdomain, authenticationProviders: [ - { name: FORWARDAUTH_SERVICE, providerId: domain ?? FORWARDAUTH_SERVICE }, + { + name: FORWARDAUTH_SERVICE, + providerId: domain ?? FORWARDAUTH_SERVICE, + }, ], }) ); From f6e3ce6366c686a90876abb9e656cdfb9d54db21 Mon Sep 17 00:00:00 2001 From: Azan Ali Date: Tue, 21 Apr 2026 14:55:31 +0500 Subject: [PATCH 06/15] made askii.ai default emain domain --- .env.test | 2 +- server/env.ts | 8 ++--- server/middlewares/authentication.test.ts | 37 +++++++++++++++++++++++ server/middlewares/authentication.ts | 9 +----- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/.env.test b/.env.test index 7bd19e61eae4..8ac8fb0becc8 100644 --- a/.env.test +++ b/.env.test @@ -42,4 +42,4 @@ UTILS_SECRET=test-utils-secret DEBUG= LOG_LEVEL=error -SMB_NAME=test +DEFAULT_EMAIL_DOMAIN=askii.ai diff --git a/server/env.ts b/server/env.ts index 34334a6185d9..72e19450bdf7 100644 --- a/server/env.ts +++ b/server/env.ts @@ -531,11 +531,11 @@ export class Environment { public AUTH_TYPE = this.toOptionalString(environment.AUTH_TYPE); /** - * The SMB organisation name used to construct internal email addresses for - * users authenticated via ForwardAuth (e.g. "acme" → user@acme.com). + * Default email domain for ForwardAuth users. */ - @IsNotEmpty() - public SMB_NAME = environment.SMB_NAME ?? ""; + @IsOptional() + public DEFAULT_EMAIL_DOMAIN = + environment.DEFAULT_EMAIL_DOMAIN ?? "askii.ai"; /** * A boolean switch to toggle the rate limiter at application web server. diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 6dbc7002ad6b..d7ecca01c90b 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -448,6 +448,43 @@ describe("Authentication middleware", () => { ); }); + it("should use askii.ai domain when X-Auth-Request-Email is not a valid email and DEFAULT_EMAIL_DOMAIN is unset", async () => { + await buildTeam(); + const state = {} as DefaultState; + const authMiddleware = auth(); + const localPart = `user-${randomString(6)}`; + const savedDomain = env.DEFAULT_EMAIL_DOMAIN; + // @ts-expect-error override env for test + env.DEFAULT_EMAIL_DOMAIN = "askii.ai"; + + try { + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn((header: string) => { + if (header === "x-auth-request-email") { + return localPart; + } + return ""; + }), + }, + // @ts-expect-error mock cookies + cookies: { get: jest.fn(() => undefined), set: jest.fn() }, + state, + ip: "127.0.0.1", + cache: {}, + }, + jest.fn() + ); + + expect(state.auth.user.email).toEqual(`${localPart.toLowerCase()}@askii.ai`); + } finally { + // @ts-expect-error restore env + env.DEFAULT_EMAIL_DOMAIN = savedDomain; + } + }); + it("should not honour ForwardAuth headers when AUTH_TYPE is not SSO", async () => { env.AUTH_TYPE = undefined; const state = {} as DefaultState; diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 18548fe0451c..2160e6008d09 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -316,14 +316,7 @@ async function validateAuthentication( const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailClaim); const email = isValidEmail ? emailClaim - : (() => { - if (!env.SMB_NAME) { - throw AuthenticationError( - "SMB_NAME environment variable is not set, cannot construct email for ForwardAuth user" - ); - } - return `${emailClaim.split("@")[0]}@${env.SMB_NAME}.com`; - })(); + : `${emailClaim.split("@")[0]}@${env.DEFAULT_EMAIL_DOMAIN}`; const localPart = emailClaim.split("@")[0]; const displayName = ctx.request.get("x-auth-request-user") || localPart; const { domain } = parseEmail(email); From 169e390283f12f18db0ac71ff915e66593e78a68 Mon Sep 17 00:00:00 2001 From: jawad khan Date: Tue, 21 Apr 2026 16:57:17 +0500 Subject: [PATCH 07/15] chore: Added test cases for session cookies (#6) --- .env.sample | 5 +++++ server/env.ts | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.env.sample b/.env.sample index a129ca4d4d5c..63e646c390f9 100644 --- a/.env.sample +++ b/.env.sample @@ -174,6 +174,11 @@ OIDC_DISPLAY_NAME=OpenID Connect # Space separated auth scopes. OIDC_SCOPES=openid profile email +# OAuth tokens issued by Outline’s built-in OAuth provider (seconds). +# FOSS devstack maps SESSION_COOKIE_MAX_AGE_SECONDS / SESSION_REFRESH_TOKEN_MAX_AGE_SECONDS here. +OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME=604800 +OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME=1209600 + # –––––––––––––––––––––––––––––––––––––– # –––––––––––––– EMAIL ––––––––––––––– diff --git a/server/env.ts b/server/env.ts index 34334a6185d9..16f9949c2f9e 100644 --- a/server/env.ts +++ b/server/env.ts @@ -16,7 +16,7 @@ import { } from "class-validator"; import uniq from "lodash/uniq"; import { languages } from "@shared/i18n"; -import { Day, Hour } from "@shared/utils/time"; +import { Week } from "@shared/utils/time"; import { CannotUseWith, CannotUseWithout, @@ -721,19 +721,21 @@ export class Environment { /** * The number of seconds access tokens issue by the OAuth provider are valid. + * Default 7d matches FOSS devstack SESSION_COOKIE_MAX_AGE_SECONDS when unset. */ @IsNumber() public OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME = this.toOptionalNumber(environment.OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME) ?? - Hour.seconds; + Week.seconds; /** * The number of seconds refresh tokens issue by the OAuth provider are valid. + * Default 14d matches FOSS devstack SESSION_REFRESH_TOKEN_MAX_AGE_SECONDS when unset. */ @IsNumber() public OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME = this.toOptionalNumber(environment.OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME) ?? - 30 * Day.seconds; + 2 * Week.seconds; /** * The number of seconds authorization codes issue by the OAuth provider are valid. From 49dcd9f06aa087fc589c4d9497167ad29b0560e2 Mon Sep 17 00:00:00 2001 From: Azan Ali Date: Tue, 21 Apr 2026 18:03:41 +0500 Subject: [PATCH 08/15] tsc fix --- server/middlewares/authentication.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index d7ecca01c90b..0ecb2a37f19d 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -454,7 +454,6 @@ describe("Authentication middleware", () => { const authMiddleware = auth(); const localPart = `user-${randomString(6)}`; const savedDomain = env.DEFAULT_EMAIL_DOMAIN; - // @ts-expect-error override env for test env.DEFAULT_EMAIL_DOMAIN = "askii.ai"; try { @@ -480,7 +479,6 @@ describe("Authentication middleware", () => { expect(state.auth.user.email).toEqual(`${localPart.toLowerCase()}@askii.ai`); } finally { - // @ts-expect-error restore env env.DEFAULT_EMAIL_DOMAIN = savedDomain; } }); From b2bcd33e02c7b4a0604e29cd4c79fb9562ea706f Mon Sep 17 00:00:00 2001 From: Azan Ali Date: Thu, 30 Apr 2026 20:21:04 +0500 Subject: [PATCH 09/15] use SMB_NAME instead of foss. --- app/stores/AuthStore.ts | 5 +++-- server/env.ts | 11 +++++++++-- shared/types.ts | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index 1b3c4e383fc9..fd6fceec2a30 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -352,9 +352,10 @@ export default class AuthStore extends Store { } if (userInitiated) { - // Rewrite "foss-." → "foss." so we land on the portal + // Rewrite "." → "." so we land on the portal // (outside ForwardAuth) instead of Outline's own root, which would silently re-auth. - const portalHost = window.location.hostname.replace(/^[^.]*\./, "foss."); + const smbName = window.env.SMB_NAME ?? "moneta"; + const portalHost = window.location.hostname.replace(/^[^.]*\./, `${smbName}.`); this.logoutRedirectUri = `${window.location.protocol}//${portalHost}`; } diff --git a/server/env.ts b/server/env.ts index 3d93b4e4e270..bba2e35bdab0 100644 --- a/server/env.ts +++ b/server/env.ts @@ -534,8 +534,7 @@ export class Environment { * Default email domain for ForwardAuth users. */ @IsOptional() - public DEFAULT_EMAIL_DOMAIN = - environment.DEFAULT_EMAIL_DOMAIN ?? "askii.ai"; + public DEFAULT_EMAIL_DOMAIN = environment.DEFAULT_EMAIL_DOMAIN ?? "askii.ai"; /** * A boolean switch to toggle the rate limiter at application web server. @@ -799,6 +798,14 @@ export class Environment { @Public public APP_NAME = "Outline"; + /** + * The subdomain name of the portal (e.g. "moneta" for moneta.example.com). + * Used to redirect users to the portal after logout. Defaults to "foss". + */ + @Public + @IsOptional() + public SMB_NAME = environment.SMB_NAME ?? "moneta"; + /** * Gravity constant for time decay in popularity scoring. Higher values cause * faster decay of older content. Default is 0.7. diff --git a/shared/types.ts b/shared/types.ts index ac9cb69d04ef..b303e08240f6 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -104,6 +104,7 @@ export enum MentionType { export type PublicEnv = { ROOT_SHARE_ID?: string; AUTH_TYPE?: string; + SMB_NAME?: string; analytics: { service: IntegrationService; settings: IntegrationSettings; From 82f39d7042ceefd36ac8c70473c9ccc97915ce4f Mon Sep 17 00:00:00 2001 From: Azan Ali <73800719+aznszn@users.noreply.github.com> Date: Tue, 5 May 2026 20:39:41 +0500 Subject: [PATCH 10/15] fix: strip first subdomain for portal redirect on signout (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: strip first subdomain for portal redirect on signout Replace hardcoded "moneta." prefix with an empty string so the regex strips just the leading subdomain (e.g. app.moneta.askii.ai → moneta.askii.ai) regardless of the deployment domain. Co-Authored-By: Claude Sonnet 4.6 * fix: use lookahead regex to safely strip leading subdomain Switch to /^[^.]+\.(?=[^.]*\.[^.]*\.)/ so the subdomain is only stripped when at least two dot-separated parts remain, preventing over-stripping on bare domains. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- app/stores/AuthStore.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index fd6fceec2a30..3b186511e862 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -352,10 +352,7 @@ export default class AuthStore extends Store { } if (userInitiated) { - // Rewrite "." → "." so we land on the portal - // (outside ForwardAuth) instead of Outline's own root, which would silently re-auth. - const smbName = window.env.SMB_NAME ?? "moneta"; - const portalHost = window.location.hostname.replace(/^[^.]*\./, `${smbName}.`); + const portalHost = window.location.hostname.replace(/^[^.]+\.(?=[^.]*\.[^.]*\.)/, ""); this.logoutRedirectUri = `${window.location.protocol}//${portalHost}`; } From 46324736c6e31cf06098be1bbd126ee90aa56535 Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Wed, 13 May 2026 18:20:42 +0500 Subject: [PATCH 11/15] fix(auth): bind accessToken cookie + JWT to a 7-day TTL (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outline's accessToken cookie was hardcoded to 3 months (addMonths(new Date(), 3)) at three call sites — outliving the rest of the foss-server-bundle's 7-day session window. After a stack-wide session expiry users would stay signed in to Outline alone. Changes: - Export JWT_COOKIE_TTL_DAYS = 7 from server/utils/authentication.ts as a single source of truth (with rationale in a docstring). - Use addDays(new Date(), JWT_COOKIE_TTL_DAYS) at all three mint sites: - server/middlewares/authentication.ts (FORWARDAUTH login) - server/routes/auth/index.ts (/auth/redirect) - server/utils/authentication.ts (signIn callback) - Fix a pre-existing upstream issue at /auth/redirect: getJwtToken was called with no expiresAt arg, producing a JWT the validator at utils/jwt.ts:47 never rejects (claim missing → check skipped). Now passes `expires` into both getJwtToken and the cookie set, matching the pattern the other two paths already use. - Tests cover both halves: - middlewares/authentication.test.ts asserts the FORWARDAUTH cookie's expires is ~now + JWT_COOKIE_TTL_DAYS (±60s). - routes/auth/index.test.ts asserts /auth/redirect mints a JWT whose expiresAt claim is set and ~now + JWT_COOKIE_TTL_DAYS (±60s). In future, lift JWT_COOKIE_TTL_DAYS to an env var if deployment-specific control is needed. --- server/middlewares/authentication.test.ts | 42 +++++++++++++++++++++++ server/middlewares/authentication.ts | 5 +-- server/routes/auth/index.test.ts | 37 ++++++++++++++++++++ server/routes/auth/index.ts | 23 ++++++++++--- server/utils/authentication.ts | 12 +++++-- 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 0ecb2a37f19d..6ffd0482da8c 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -11,6 +11,7 @@ import { } from "@server/test/factories"; import { User } from "@server/models"; import { AuthenticationType } from "@server/types"; +import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; import auth, { FORWARDAUTH_SERVICE } from "./authentication"; function createCtx(overrides: any = {}) { @@ -379,6 +380,47 @@ describe("Authentication middleware", () => { expect(state.auth.type).toEqual(AuthenticationType.APP); }); + it("should issue the accessToken cookie with a 7-day expiry", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const state = {} as DefaultState; + const authMiddleware = auth(); + const cookiesSet = jest.fn(); + const before = Date.now(); + + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn((header: string) => { + if (header === "x-auth-request-email") { + return user.email!; + } + return ""; + }), + }, + // @ts-expect-error mock cookies + cookies: { get: jest.fn(() => undefined), set: cookiesSet }, + state, + ip: "127.0.0.1", + cache: {}, + }, + jest.fn() + ); + + const accessTokenCall = cookiesSet.mock.calls.find( + (call) => call[0] === "accessToken" + ); + expect(accessTokenCall).toBeDefined(); + + const expires: Date = accessTokenCall![2].expires; + const ageMs = expires.getTime() - before; + const expectedMs = JWT_COOKIE_TTL_DAYS * 24 * 60 * 60 * 1000; + // Allow ±60s skew for test runtime. + expect(ageMs).toBeGreaterThan(expectedMs - 60_000); + expect(ageMs).toBeLessThan(expectedMs + 60_000); + }); + it("should provision a new user when X-Auth-Request-Email is unknown", async () => { await buildTeam(); const state = {} as DefaultState; diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 2160e6008d09..83bd7ec2787d 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -1,4 +1,4 @@ -import { addMonths } from "date-fns"; +import { addDays } from "date-fns"; import type { Next } from "koa"; import capitalize from "lodash/capitalize"; import { Op } from "sequelize"; @@ -18,6 +18,7 @@ import { User, Team, ApiKey, OAuthAuthentication } from "@server/models"; import { sequelize } from "@server/storage/database"; import type { AppContext } from "@server/types"; import { AuthenticationType } from "@server/types"; +import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; import { getUserForJWT } from "@server/utils/jwt"; import { AuthenticationError, @@ -56,7 +57,7 @@ export default function auth(options: AuthenticationOptions = {}) { // that subsequent requests and cookie-dependent services (WebSocket, // collaboration) use the fast JWT path instead of the header DB path. if (service === FORWARDAUTH_SERVICE && !ctx.cookies.get("accessToken")) { - const expires = addMonths(new Date(), 3); + const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); ctx.cookies.set("accessToken", user.getJwtToken(expires, service), { sameSite: "lax", expires, diff --git a/server/routes/auth/index.test.ts b/server/routes/auth/index.test.ts index 61bd5f308b04..9f29fc5a97d1 100644 --- a/server/routes/auth/index.test.ts +++ b/server/routes/auth/index.test.ts @@ -1,5 +1,6 @@ import { buildUser, buildCollection } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; +import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; const server = getTestServer(); @@ -43,4 +44,40 @@ describe("auth/redirect", () => { expect(res.status).toEqual(401); }); + + it("should mint an accessToken JWT carrying an expiresAt claim ~JWT_COOKIE_TTL_DAYS out", async () => { + const user = await buildUser(); + const before = Date.now(); + + const res = await server.get( + `/auth/redirect?token=${user.getTransferToken()}`, + { + redirect: "manual", + } + ); + + expect(res.status).toEqual(302); + + // Pull the `accessToken` cookie out of the Set-Cookie header(s). + const setCookie = res.headers.get("set-cookie") || ""; + const match = setCookie.match(/accessToken=([^;,]+)/); + expect(match).not.toBeNull(); + const jwt = match![1]; + + // Decode the JWT payload directly — no signature check needed, we're + // only inspecting the claim. JWT payload is the base64url middle segment. + const payload = JSON.parse( + Buffer.from(jwt.split(".")[1], "base64url").toString() + ); + + // Without the fix, getJwtToken(undefined, ...) would omit this claim + // entirely and the validator at utils/jwt.ts:47 would skip the check. + expect(payload.expiresAt).toBeDefined(); + + const ageMs = new Date(payload.expiresAt).getTime() - before; + const expectedMs = JWT_COOKIE_TTL_DAYS * 24 * 60 * 60 * 1000; + // Allow ±60s skew for test runtime. + expect(ageMs).toBeGreaterThan(expectedMs - 60_000); + expect(ageMs).toBeLessThan(expectedMs + 60_000); + }); }); diff --git a/server/routes/auth/index.ts b/server/routes/auth/index.ts index ccec003e5fbd..d0d6be6c52b1 100644 --- a/server/routes/auth/index.ts +++ b/server/routes/auth/index.ts @@ -1,15 +1,17 @@ import passport from "@outlinewiki/koa-passport"; -import { addMonths } from "date-fns"; +import { addDays } from "date-fns"; import Koa from "koa"; import bodyParser from "koa-body"; import Router from "koa-router"; import { AuthenticationError } from "@server/errors"; import authMiddleware from "@server/middlewares/authentication"; import coalesceBody from "@server/middlewares/coaleseBody"; +import { verifyCSRFToken } from "@server/middlewares/csrf"; import { Collection, Team, View } from "@server/models"; import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper"; import type { AppState, AppContext, APIContext } from "@server/types"; -import { verifyCSRFToken } from "@server/middlewares/csrf"; +import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; +import { getJWTPayload } from "@server/utils/jwt"; const app = new Koa(); const router = new Router(); @@ -32,18 +34,29 @@ void (async () => { router.get("/redirect", authMiddleware(), async (ctx: APIContext) => { const { user, service } = ctx.state.auth; - const jwtToken = user.getJwtToken(undefined, service); - if (jwtToken === ctx.state.auth.token) { + // This route is only for exchanging a short-lived transfer token for a + // session cookie. Reject anything else (in particular session JWTs being + // replayed to extend their own life). The previous heuristic relied on + // a quirk where session JWTs were deterministic; with proper `expiresAt` + // claims they no longer are, so check the token type directly. + if (getJWTPayload(ctx.state.auth.token).type !== "transfer") { throw AuthenticationError("Cannot extend token"); } + // Mint the JWT with the same expiry the cookie will carry so the token + // and the cookie die together. Without an `expiresAt` claim the + // validator at `utils/jwt.ts:47` skips the expiry check, leaving the + // token replayable indefinitely if it ever leaves the cookie. + const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); + const jwtToken = user.getJwtToken(expires, service); + // ensure that the lastActiveAt on user is updated to prevent replay requests await user.updateActiveAt(ctx, true); ctx.cookies.set("accessToken", jwtToken, { sameSite: "lax", - expires: addMonths(new Date(), 3), + expires, }); const [team, collection, view] = await Promise.all([ Team.findByPk(user.teamId), diff --git a/server/utils/authentication.ts b/server/utils/authentication.ts index c6e8a93aa848..538be3b5717d 100644 --- a/server/utils/authentication.ts +++ b/server/utils/authentication.ts @@ -1,5 +1,5 @@ import querystring from "node:querystring"; -import { addMonths } from "date-fns"; +import { addDays } from "date-fns"; import type { Context } from "koa"; import pick from "lodash/pick"; import { Client } from "@shared/types"; @@ -10,6 +10,14 @@ import { Event, Collection, View } from "@server/models"; import type { APIContext, AuthenticationResult } from "@server/types"; import { AuthenticationType } from "@server/types"; +/** + * Lifetime of the JWT cookie (`accessToken`) issued after Cognito sign-in. + * 7 days matches the rest of the foss-server-bundle-devstack + * (Plane / Penpot / SurfSense / Twenty / oauth2-proxy). Single source of + * truth for all three call sites that mint this cookie. + */ +export const JWT_COOKIE_TTL_DAYS = 7; + /** * Parse and return the details from the "sessions" cookie in the request, if * any. The cookie is on the apex domain and includes session details for @@ -88,7 +96,7 @@ export async function signIn( ); const domain = getCookieDomain(ctx.request.hostname, env.isCloudHosted); - const expires = addMonths(new Date(), 3); + const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); // set a cookie for which service we last signed in with. This is // only used to display a UI hint for the user for next time From 588b72692e37fcf2389ba11b431befc5418c5143 Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Wed, 13 May 2026 18:23:13 +0500 Subject: [PATCH 12/15] Revert "fix(auth): bind accessToken cookie + JWT to a 7-day TTL (#13)" (#16) This reverts commit 46324736c6e31cf06098be1bbd126ee90aa56535. --- server/middlewares/authentication.test.ts | 42 ----------------------- server/middlewares/authentication.ts | 5 ++- server/routes/auth/index.test.ts | 37 -------------------- server/routes/auth/index.ts | 23 +++---------- server/utils/authentication.ts | 12 ++----- 5 files changed, 9 insertions(+), 110 deletions(-) diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 6ffd0482da8c..0ecb2a37f19d 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -11,7 +11,6 @@ import { } from "@server/test/factories"; import { User } from "@server/models"; import { AuthenticationType } from "@server/types"; -import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; import auth, { FORWARDAUTH_SERVICE } from "./authentication"; function createCtx(overrides: any = {}) { @@ -380,47 +379,6 @@ describe("Authentication middleware", () => { expect(state.auth.type).toEqual(AuthenticationType.APP); }); - it("should issue the accessToken cookie with a 7-day expiry", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const state = {} as DefaultState; - const authMiddleware = auth(); - const cookiesSet = jest.fn(); - const before = Date.now(); - - await authMiddleware( - { - // @ts-expect-error mock request - request: { - get: jest.fn((header: string) => { - if (header === "x-auth-request-email") { - return user.email!; - } - return ""; - }), - }, - // @ts-expect-error mock cookies - cookies: { get: jest.fn(() => undefined), set: cookiesSet }, - state, - ip: "127.0.0.1", - cache: {}, - }, - jest.fn() - ); - - const accessTokenCall = cookiesSet.mock.calls.find( - (call) => call[0] === "accessToken" - ); - expect(accessTokenCall).toBeDefined(); - - const expires: Date = accessTokenCall![2].expires; - const ageMs = expires.getTime() - before; - const expectedMs = JWT_COOKIE_TTL_DAYS * 24 * 60 * 60 * 1000; - // Allow ±60s skew for test runtime. - expect(ageMs).toBeGreaterThan(expectedMs - 60_000); - expect(ageMs).toBeLessThan(expectedMs + 60_000); - }); - it("should provision a new user when X-Auth-Request-Email is unknown", async () => { await buildTeam(); const state = {} as DefaultState; diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 83bd7ec2787d..2160e6008d09 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -1,4 +1,4 @@ -import { addDays } from "date-fns"; +import { addMonths } from "date-fns"; import type { Next } from "koa"; import capitalize from "lodash/capitalize"; import { Op } from "sequelize"; @@ -18,7 +18,6 @@ import { User, Team, ApiKey, OAuthAuthentication } from "@server/models"; import { sequelize } from "@server/storage/database"; import type { AppContext } from "@server/types"; import { AuthenticationType } from "@server/types"; -import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; import { getUserForJWT } from "@server/utils/jwt"; import { AuthenticationError, @@ -57,7 +56,7 @@ export default function auth(options: AuthenticationOptions = {}) { // that subsequent requests and cookie-dependent services (WebSocket, // collaboration) use the fast JWT path instead of the header DB path. if (service === FORWARDAUTH_SERVICE && !ctx.cookies.get("accessToken")) { - const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); + const expires = addMonths(new Date(), 3); ctx.cookies.set("accessToken", user.getJwtToken(expires, service), { sameSite: "lax", expires, diff --git a/server/routes/auth/index.test.ts b/server/routes/auth/index.test.ts index 9f29fc5a97d1..61bd5f308b04 100644 --- a/server/routes/auth/index.test.ts +++ b/server/routes/auth/index.test.ts @@ -1,6 +1,5 @@ import { buildUser, buildCollection } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; -import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; const server = getTestServer(); @@ -44,40 +43,4 @@ describe("auth/redirect", () => { expect(res.status).toEqual(401); }); - - it("should mint an accessToken JWT carrying an expiresAt claim ~JWT_COOKIE_TTL_DAYS out", async () => { - const user = await buildUser(); - const before = Date.now(); - - const res = await server.get( - `/auth/redirect?token=${user.getTransferToken()}`, - { - redirect: "manual", - } - ); - - expect(res.status).toEqual(302); - - // Pull the `accessToken` cookie out of the Set-Cookie header(s). - const setCookie = res.headers.get("set-cookie") || ""; - const match = setCookie.match(/accessToken=([^;,]+)/); - expect(match).not.toBeNull(); - const jwt = match![1]; - - // Decode the JWT payload directly — no signature check needed, we're - // only inspecting the claim. JWT payload is the base64url middle segment. - const payload = JSON.parse( - Buffer.from(jwt.split(".")[1], "base64url").toString() - ); - - // Without the fix, getJwtToken(undefined, ...) would omit this claim - // entirely and the validator at utils/jwt.ts:47 would skip the check. - expect(payload.expiresAt).toBeDefined(); - - const ageMs = new Date(payload.expiresAt).getTime() - before; - const expectedMs = JWT_COOKIE_TTL_DAYS * 24 * 60 * 60 * 1000; - // Allow ±60s skew for test runtime. - expect(ageMs).toBeGreaterThan(expectedMs - 60_000); - expect(ageMs).toBeLessThan(expectedMs + 60_000); - }); }); diff --git a/server/routes/auth/index.ts b/server/routes/auth/index.ts index d0d6be6c52b1..ccec003e5fbd 100644 --- a/server/routes/auth/index.ts +++ b/server/routes/auth/index.ts @@ -1,17 +1,15 @@ import passport from "@outlinewiki/koa-passport"; -import { addDays } from "date-fns"; +import { addMonths } from "date-fns"; import Koa from "koa"; import bodyParser from "koa-body"; import Router from "koa-router"; import { AuthenticationError } from "@server/errors"; import authMiddleware from "@server/middlewares/authentication"; import coalesceBody from "@server/middlewares/coaleseBody"; -import { verifyCSRFToken } from "@server/middlewares/csrf"; import { Collection, Team, View } from "@server/models"; import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper"; import type { AppState, AppContext, APIContext } from "@server/types"; -import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; -import { getJWTPayload } from "@server/utils/jwt"; +import { verifyCSRFToken } from "@server/middlewares/csrf"; const app = new Koa(); const router = new Router(); @@ -34,29 +32,18 @@ void (async () => { router.get("/redirect", authMiddleware(), async (ctx: APIContext) => { const { user, service } = ctx.state.auth; + const jwtToken = user.getJwtToken(undefined, service); - // This route is only for exchanging a short-lived transfer token for a - // session cookie. Reject anything else (in particular session JWTs being - // replayed to extend their own life). The previous heuristic relied on - // a quirk where session JWTs were deterministic; with proper `expiresAt` - // claims they no longer are, so check the token type directly. - if (getJWTPayload(ctx.state.auth.token).type !== "transfer") { + if (jwtToken === ctx.state.auth.token) { throw AuthenticationError("Cannot extend token"); } - // Mint the JWT with the same expiry the cookie will carry so the token - // and the cookie die together. Without an `expiresAt` claim the - // validator at `utils/jwt.ts:47` skips the expiry check, leaving the - // token replayable indefinitely if it ever leaves the cookie. - const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); - const jwtToken = user.getJwtToken(expires, service); - // ensure that the lastActiveAt on user is updated to prevent replay requests await user.updateActiveAt(ctx, true); ctx.cookies.set("accessToken", jwtToken, { sameSite: "lax", - expires, + expires: addMonths(new Date(), 3), }); const [team, collection, view] = await Promise.all([ Team.findByPk(user.teamId), diff --git a/server/utils/authentication.ts b/server/utils/authentication.ts index 538be3b5717d..c6e8a93aa848 100644 --- a/server/utils/authentication.ts +++ b/server/utils/authentication.ts @@ -1,5 +1,5 @@ import querystring from "node:querystring"; -import { addDays } from "date-fns"; +import { addMonths } from "date-fns"; import type { Context } from "koa"; import pick from "lodash/pick"; import { Client } from "@shared/types"; @@ -10,14 +10,6 @@ import { Event, Collection, View } from "@server/models"; import type { APIContext, AuthenticationResult } from "@server/types"; import { AuthenticationType } from "@server/types"; -/** - * Lifetime of the JWT cookie (`accessToken`) issued after Cognito sign-in. - * 7 days matches the rest of the foss-server-bundle-devstack - * (Plane / Penpot / SurfSense / Twenty / oauth2-proxy). Single source of - * truth for all three call sites that mint this cookie. - */ -export const JWT_COOKIE_TTL_DAYS = 7; - /** * Parse and return the details from the "sessions" cookie in the request, if * any. The cookie is on the apex domain and includes session details for @@ -96,7 +88,7 @@ export async function signIn( ); const domain = getCookieDomain(ctx.request.hostname, env.isCloudHosted); - const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); + const expires = addMonths(new Date(), 3); // set a cookie for which service we last signed in with. This is // only used to display a UI hint for the user for next time From a520a32df3630b9bb457ac735cbc4ba13b8294af Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Wed, 13 May 2026 19:25:41 +0500 Subject: [PATCH 13/15] fix(auth): bind accessToken cookie + JWT to a 7-day TTL (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outline's accessToken cookie was hardcoded to 3 months (addMonths(new Date(), 3)) at three call sites — outliving the rest of the foss-server-bundle's 7-day session window. After a stack-wide session expiry users would stay signed in to Outline alone. Changes: - Export JWT_COOKIE_TTL_DAYS = 7 from server/utils/authentication.ts as a single source of truth (with rationale in a docstring). - Use addDays(new Date(), JWT_COOKIE_TTL_DAYS) at all three mint sites: - server/middlewares/authentication.ts (FORWARDAUTH login) - server/routes/auth/index.ts (/auth/redirect) - server/utils/authentication.ts (signIn callback) - Fix a pre-existing upstream issue at /auth/redirect: getJwtToken was called with no expiresAt arg, producing a JWT the validator at utils/jwt.ts:47 never rejects (claim missing → check skipped). Now passes `expires` into both getJwtToken and the cookie set, matching the pattern the other two paths already use. - Tests cover both halves: - middlewares/authentication.test.ts asserts the FORWARDAUTH cookie's expires is ~now + JWT_COOKIE_TTL_DAYS (±60s). - routes/auth/index.test.ts asserts /auth/redirect mints a JWT whose expiresAt claim is set and ~now + JWT_COOKIE_TTL_DAYS (±60s). In future, lift JWT_COOKIE_TTL_DAYS to an env var if deployment-specific control is needed. --- server/middlewares/authentication.test.ts | 42 +++++++++++++++++++++++ server/middlewares/authentication.ts | 5 +-- server/routes/auth/index.test.ts | 37 ++++++++++++++++++++ server/routes/auth/index.ts | 23 ++++++++++--- server/utils/authentication.ts | 12 +++++-- 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 0ecb2a37f19d..6ffd0482da8c 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -11,6 +11,7 @@ import { } from "@server/test/factories"; import { User } from "@server/models"; import { AuthenticationType } from "@server/types"; +import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; import auth, { FORWARDAUTH_SERVICE } from "./authentication"; function createCtx(overrides: any = {}) { @@ -379,6 +380,47 @@ describe("Authentication middleware", () => { expect(state.auth.type).toEqual(AuthenticationType.APP); }); + it("should issue the accessToken cookie with a 7-day expiry", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const state = {} as DefaultState; + const authMiddleware = auth(); + const cookiesSet = jest.fn(); + const before = Date.now(); + + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn((header: string) => { + if (header === "x-auth-request-email") { + return user.email!; + } + return ""; + }), + }, + // @ts-expect-error mock cookies + cookies: { get: jest.fn(() => undefined), set: cookiesSet }, + state, + ip: "127.0.0.1", + cache: {}, + }, + jest.fn() + ); + + const accessTokenCall = cookiesSet.mock.calls.find( + (call) => call[0] === "accessToken" + ); + expect(accessTokenCall).toBeDefined(); + + const expires: Date = accessTokenCall![2].expires; + const ageMs = expires.getTime() - before; + const expectedMs = JWT_COOKIE_TTL_DAYS * 24 * 60 * 60 * 1000; + // Allow ±60s skew for test runtime. + expect(ageMs).toBeGreaterThan(expectedMs - 60_000); + expect(ageMs).toBeLessThan(expectedMs + 60_000); + }); + it("should provision a new user when X-Auth-Request-Email is unknown", async () => { await buildTeam(); const state = {} as DefaultState; diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 2160e6008d09..83bd7ec2787d 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -1,4 +1,4 @@ -import { addMonths } from "date-fns"; +import { addDays } from "date-fns"; import type { Next } from "koa"; import capitalize from "lodash/capitalize"; import { Op } from "sequelize"; @@ -18,6 +18,7 @@ import { User, Team, ApiKey, OAuthAuthentication } from "@server/models"; import { sequelize } from "@server/storage/database"; import type { AppContext } from "@server/types"; import { AuthenticationType } from "@server/types"; +import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; import { getUserForJWT } from "@server/utils/jwt"; import { AuthenticationError, @@ -56,7 +57,7 @@ export default function auth(options: AuthenticationOptions = {}) { // that subsequent requests and cookie-dependent services (WebSocket, // collaboration) use the fast JWT path instead of the header DB path. if (service === FORWARDAUTH_SERVICE && !ctx.cookies.get("accessToken")) { - const expires = addMonths(new Date(), 3); + const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); ctx.cookies.set("accessToken", user.getJwtToken(expires, service), { sameSite: "lax", expires, diff --git a/server/routes/auth/index.test.ts b/server/routes/auth/index.test.ts index 61bd5f308b04..9f29fc5a97d1 100644 --- a/server/routes/auth/index.test.ts +++ b/server/routes/auth/index.test.ts @@ -1,5 +1,6 @@ import { buildUser, buildCollection } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; +import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; const server = getTestServer(); @@ -43,4 +44,40 @@ describe("auth/redirect", () => { expect(res.status).toEqual(401); }); + + it("should mint an accessToken JWT carrying an expiresAt claim ~JWT_COOKIE_TTL_DAYS out", async () => { + const user = await buildUser(); + const before = Date.now(); + + const res = await server.get( + `/auth/redirect?token=${user.getTransferToken()}`, + { + redirect: "manual", + } + ); + + expect(res.status).toEqual(302); + + // Pull the `accessToken` cookie out of the Set-Cookie header(s). + const setCookie = res.headers.get("set-cookie") || ""; + const match = setCookie.match(/accessToken=([^;,]+)/); + expect(match).not.toBeNull(); + const jwt = match![1]; + + // Decode the JWT payload directly — no signature check needed, we're + // only inspecting the claim. JWT payload is the base64url middle segment. + const payload = JSON.parse( + Buffer.from(jwt.split(".")[1], "base64url").toString() + ); + + // Without the fix, getJwtToken(undefined, ...) would omit this claim + // entirely and the validator at utils/jwt.ts:47 would skip the check. + expect(payload.expiresAt).toBeDefined(); + + const ageMs = new Date(payload.expiresAt).getTime() - before; + const expectedMs = JWT_COOKIE_TTL_DAYS * 24 * 60 * 60 * 1000; + // Allow ±60s skew for test runtime. + expect(ageMs).toBeGreaterThan(expectedMs - 60_000); + expect(ageMs).toBeLessThan(expectedMs + 60_000); + }); }); diff --git a/server/routes/auth/index.ts b/server/routes/auth/index.ts index ccec003e5fbd..d0d6be6c52b1 100644 --- a/server/routes/auth/index.ts +++ b/server/routes/auth/index.ts @@ -1,15 +1,17 @@ import passport from "@outlinewiki/koa-passport"; -import { addMonths } from "date-fns"; +import { addDays } from "date-fns"; import Koa from "koa"; import bodyParser from "koa-body"; import Router from "koa-router"; import { AuthenticationError } from "@server/errors"; import authMiddleware from "@server/middlewares/authentication"; import coalesceBody from "@server/middlewares/coaleseBody"; +import { verifyCSRFToken } from "@server/middlewares/csrf"; import { Collection, Team, View } from "@server/models"; import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper"; import type { AppState, AppContext, APIContext } from "@server/types"; -import { verifyCSRFToken } from "@server/middlewares/csrf"; +import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; +import { getJWTPayload } from "@server/utils/jwt"; const app = new Koa(); const router = new Router(); @@ -32,18 +34,29 @@ void (async () => { router.get("/redirect", authMiddleware(), async (ctx: APIContext) => { const { user, service } = ctx.state.auth; - const jwtToken = user.getJwtToken(undefined, service); - if (jwtToken === ctx.state.auth.token) { + // This route is only for exchanging a short-lived transfer token for a + // session cookie. Reject anything else (in particular session JWTs being + // replayed to extend their own life). The previous heuristic relied on + // a quirk where session JWTs were deterministic; with proper `expiresAt` + // claims they no longer are, so check the token type directly. + if (getJWTPayload(ctx.state.auth.token).type !== "transfer") { throw AuthenticationError("Cannot extend token"); } + // Mint the JWT with the same expiry the cookie will carry so the token + // and the cookie die together. Without an `expiresAt` claim the + // validator at `utils/jwt.ts:47` skips the expiry check, leaving the + // token replayable indefinitely if it ever leaves the cookie. + const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); + const jwtToken = user.getJwtToken(expires, service); + // ensure that the lastActiveAt on user is updated to prevent replay requests await user.updateActiveAt(ctx, true); ctx.cookies.set("accessToken", jwtToken, { sameSite: "lax", - expires: addMonths(new Date(), 3), + expires, }); const [team, collection, view] = await Promise.all([ Team.findByPk(user.teamId), diff --git a/server/utils/authentication.ts b/server/utils/authentication.ts index c6e8a93aa848..538be3b5717d 100644 --- a/server/utils/authentication.ts +++ b/server/utils/authentication.ts @@ -1,5 +1,5 @@ import querystring from "node:querystring"; -import { addMonths } from "date-fns"; +import { addDays } from "date-fns"; import type { Context } from "koa"; import pick from "lodash/pick"; import { Client } from "@shared/types"; @@ -10,6 +10,14 @@ import { Event, Collection, View } from "@server/models"; import type { APIContext, AuthenticationResult } from "@server/types"; import { AuthenticationType } from "@server/types"; +/** + * Lifetime of the JWT cookie (`accessToken`) issued after Cognito sign-in. + * 7 days matches the rest of the foss-server-bundle-devstack + * (Plane / Penpot / SurfSense / Twenty / oauth2-proxy). Single source of + * truth for all three call sites that mint this cookie. + */ +export const JWT_COOKIE_TTL_DAYS = 7; + /** * Parse and return the details from the "sessions" cookie in the request, if * any. The cookie is on the apex domain and includes session details for @@ -88,7 +96,7 @@ export async function signIn( ); const domain = getCookieDomain(ctx.request.hostname, env.isCloudHosted); - const expires = addMonths(new Date(), 3); + const expires = addDays(new Date(), JWT_COOKIE_TTL_DAYS); // set a cookie for which service we last signed in with. This is // only used to display a UI hint for the user for next time From 0c44e25d1761cb7cf61c7da1db081ea339be8056 Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Wed, 13 May 2026 19:54:23 +0500 Subject: [PATCH 14/15] fix(auth): use exact match in ForwardAuth user lookup (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ForwardAuth (AUTH_TYPE=SSO) branch of validateAuthentication looked users up with User.findOne({ where: { email: { [Op.iLike]: email } } }). ILIKE treats % and _ in the supplied value as wildcards, so a request with "x-auth-request-email: %@%.%" produces ILIKE '%@%.%' — which matches every row. The first matched user (typically the bootstrap admin) was then issued a 3-month JWT cookie. Switching to exact match (where: { email }) strips all special meaning from those characters. Email is already .toLowerCase().trim()'d before the lookup, matching the existing User.findByEmail pattern; emails are stored canonically lowercased so case-insensitive matching is not required. Not exploitable in production: oauth2-proxy intercepts unauthenticated requests before they reach Outline and overwrites x-auth-request-* headers from the verified OIDC identity. Defense-in-depth fix at the code level in case backend reachability ever changes. Adds a regression test asserting wildcard input does not impersonate an existing user. --- server/middlewares/authentication.test.ts | 33 +++++++++++++++++++++++ server/middlewares/authentication.ts | 12 ++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 6ffd0482da8c..6411fb1186fd 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -525,6 +525,39 @@ describe("Authentication middleware", () => { } }); + it("should not match existing users via SQL LIKE wildcard characters", async () => { + const team = await buildTeam(); + const existingUser = await buildUser({ teamId: team.id }); + const state = {} as DefaultState; + const authMiddleware = auth(); + + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn((header: string) => { + if (header === "x-auth-request-email") { + return "%@%.%"; + } + return ""; + }), + }, + // @ts-expect-error mock cookies + cookies: { get: jest.fn(() => undefined), set: jest.fn() }, + state, + ip: "127.0.0.1", + cache: {}, + }, + jest.fn() + ); + + // Under Op.iLike the wildcard would have matched the existing user. + // With exact-match the lookup misses and a separate (junk) account + // is provisioned — the existing user is never impersonated. + expect(state.auth.user.id).not.toEqual(existingUser.id); + expect(state.auth.user.email).not.toEqual(existingUser.email); + }); + it("should not honour ForwardAuth headers when AUTH_TYPE is not SSO", async () => { env.AUTH_TYPE = undefined; const state = {} as DefaultState; diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 83bd7ec2787d..a098e610b099 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -1,7 +1,6 @@ import { addDays } from "date-fns"; import type { Next } from "koa"; import capitalize from "lodash/capitalize"; -import { Op } from "sequelize"; import { UserRole } from "@shared/types"; import { slugifyDomain } from "@shared/utils/domains"; import { parseEmail } from "@shared/utils/email"; @@ -322,13 +321,12 @@ async function validateAuthentication( const displayName = ctx.request.get("x-auth-request-user") || localPart; const { domain } = parseEmail(email); - // Find an existing user by email across all teams (self-hosted deployments - // have a single team, but we don't restrict by team here so that the lookup - // is reliable even in test environments with multiple teams). + // Exact match on the canonical lowercased email — never LIKE. Using LIKE + // here would let SQL wildcard metacharacters (%, _) in the supplied value + // match arbitrary users (e.g. "%@%.%" matches the first row, often the + // bootstrap admin). user = await User.scope("withTeam").findOne({ - where: { - email: { [Op.iLike]: email }, - }, + where: { email }, }); if (!user) { From a5fa6797663005f0ed7165a4f87bae749d9214ff Mon Sep 17 00:00:00 2001 From: awais786 Date: Sat, 16 May 2026 19:01:08 +0500 Subject: [PATCH 15/15] fix(spa): suppress 401 error toasts during SSO stale-session reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user switches identity at the portal level (logs out, logs in as a different user), Outline's accessToken JWT cookie is stale until the server's mismatch check fires on the next request and clears it via err.headers. The SPA already handled this in SSO mode by navigating to the current URL to trigger a fresh-session flow, but multiple parallel API requests would 401 together (docs, access-tokens, team, etc) and each would throw an AuthorizationError that surfaced to UI as a "failed to get …" toast before the page finished navigating. Visible symptom: brief flash of "failed to get docs or access tokens" on identity switch, gone after the implicit reload. Fix: add an `isReauthenticating` flag on ApiClient. First 401 in SSO mode triggers the reload AND sets the flag; subsequent in-flight 401s see the flag and stall on a never-resolving promise instead of throwing. The page is navigating away, so blocking is correct — there's nothing useful for those callers to do. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/utils/ApiClient.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts index ca494065a47e..351e6d555493 100644 --- a/app/utils/ApiClient.ts +++ b/app/utils/ApiClient.ts @@ -49,6 +49,16 @@ class ApiClient { /** Map of in-flight POST requests for deduplication, keyed by path + body. */ private inflightRequests = new Map>(); + /** + * Set once we've triggered a full-page reload to recover from an SSO + * stale-session 401 (proxy identity changed; server cleared the JWT + * cookie via err.headers; client must reload to pick up new identity). + * Used to silence subsequent in-flight 401s that would otherwise surface + * as "failed to get docs / access tokens" toasts during the brief window + * before the navigation completes. + */ + private isReauthenticating = false; + constructor(options: Options = {}) { this.baseUrl = options.baseUrl || "/api"; } @@ -189,8 +199,23 @@ class ApiClient { // We skip auth.logout() here: clearing MobX state would cause // to render and land the user on // the login page instead of their original document. - window.location.replace(window.location.href); - throw new AuthorizationError(); + // + // Multiple parallel requests typically 401 together on the first + // post-switch render (docs, access tokens, etc.). Without the + // isReauthenticating guard, each one would throw an AuthorizationError + // that propagates to a toast handler ("failed to get docs / access + // tokens") before the page finishes navigating — visible flash of + // error UI for what is actually a successful identity-switch flow. + // First 401 triggers the reload; subsequent in-flight 401s stall on + // a never-resolving promise until the navigation completes. + if (!this.isReauthenticating) { + this.isReauthenticating = true; + window.location.replace(window.location.href); + } + return new Promise(() => { + // Intentionally never resolves — the page is navigating away. + // Throwing would surface as a toast on each in-flight request. + }); } await stores.auth.logout({ savePath: true,