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;