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/.env.test b/.env.test
index 1f045a3b143f..8ac8fb0becc8 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
+
+DEFAULT_EMAIL_DOMAIN=askii.ai
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/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..3b186511e862 100644
--- a/app/stores/AuthStore.ts
+++ b/app/stores/AuthStore.ts
@@ -352,7 +352,8 @@ export default class AuthStore extends Store {
}
if (userInitiated) {
- this.logoutRedirectUri = env.OIDC_LOGOUT_URI;
+ const portalHost = window.location.hostname.replace(/^[^.]+\.(?=[^.]*\.[^.]*\.)/, "");
+ this.logoutRedirectUri = `${window.location.protocol}//${portalHost}`;
}
if (clearCache) {
diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts
index 58ac9cf59e97..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";
}
@@ -180,6 +190,33 @@ 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.
+ //
+ // 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,
clearCache: false,
diff --git a/server/env.ts b/server/env.ts
index 7d97fbe0ed81..bba2e35bdab0 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,
@@ -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.
+ */
+ @Public
+ @IsOptional()
+ @IsIn(["SSO"])
+ public AUTH_TYPE = this.toOptionalString(environment.AUTH_TYPE);
+
+ /**
+ * Default email domain for ForwardAuth users.
+ */
+ @IsOptional()
+ public DEFAULT_EMAIL_DOMAIN = environment.DEFAULT_EMAIL_DOMAIN ?? "askii.ai";
+
/**
* A boolean switch to toggle the rate limiter at application web server.
*/
@@ -702,19 +720,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.
@@ -778,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/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts
index 5505aed16624..6411fb1186fd 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,41 @@ import {
buildApiKey,
buildOAuthAuthentication,
} from "@server/test/factories";
+import { User } from "@server/models";
import { AuthenticationType } from "@server/types";
-import auth from "./authentication";
+import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication";
+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: overrides.originalUrl || "/",
+
+ get,
+
+ cookies: {
+ get: jest.fn(() => undefined), // 👈 THIS was missing
+ },
+
+ request: {
+ url: "/",
+ headers,
+ header: headers,
+ body: {},
+ get,
+
+ ...(overrides.request || {}),
+ },
+ };
+}
describe("Authentication middleware", () => {
describe("with session JWT", () => {
@@ -174,35 +208,38 @@ 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();
+ expect(error.status).toBe(401);
+ expect(error.message).toBe(
+ "OAuth access token must be passed in the Authorization header"
+ );
});
});
@@ -303,6 +340,256 @@ 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 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;
+ 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 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;
+ 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 {
+ env.DEFAULT_EMAIL_DOMAIN = savedDomain;
+ }
+ });
+
+ 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;
+ 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();
@@ -330,3 +617,83 @@ 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 91e8d6f342cc..a098e610b099 100644
--- a/server/middlewares/authentication.ts
+++ b/server/middlewares/authentication.ts
@@ -1,14 +1,23 @@
+import { addDays } from "date-fns";
import type { Next } from "koa";
import capitalize from "lodash/capitalize";
-import type { UserRole } from "@shared/types";
+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 { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication";
import { getUserForJWT } from "@server/utils/jwt";
import {
AuthenticationError,
@@ -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 = addDays(new Date(), JWT_COOKIE_TTL_DAYS);
+ 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(),
@@ -64,6 +92,31 @@ 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.
+ const authInput = parseAuthentication(ctx);
+ if (
+ err.status === 401 &&
+ authInput.transport === "cookie" &&
+ !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 {
@@ -126,6 +179,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 +308,71 @@ 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
+ : `${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);
+
+ // 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 },
+ });
+
+ 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);
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
diff --git a/shared/types.ts b/shared/types.ts
index de6b1ee535ae..b303e08240f6 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -103,6 +103,8 @@ export enum MentionType {
export type PublicEnv = {
ROOT_SHARE_ID?: string;
+ AUTH_TYPE?: string;
+ SMB_NAME?: string;
analytics: {
service: IntegrationService;
settings: IntegrationSettings;