Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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 –––––––––––––––
Expand Down
4 changes: 3 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -41,3 +41,5 @@ UTILS_SECRET=test-utils-secret

DEBUG=
LOG_LEVEL=error

DEFAULT_EMAIL_DOMAIN=askii.ai
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [main]
branches: [foss-main]
pull_request:
branches: [main]
branches: [foss-main]

env:
NODE_ENV: test
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
10 changes: 3 additions & 7 deletions app/scenes/Logout.tsx
Original file line number Diff line number Diff line change
@@ -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 <Redirect to={logoutPath()} />;
// AuthStore.logout() always sets logoutRedirectUri to the portal host; the
// unauthenticated branch in Authenticated.tsx performs the actual navigation.
return null;
};

export default Logout;
3 changes: 2 additions & 1 deletion app/stores/AuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,8 @@ export default class AuthStore extends Store<Team> {
}

if (userInitiated) {
this.logoutRedirectUri = env.OIDC_LOGOUT_URI;
const portalHost = window.location.hostname.replace(/^[^.]+\.(?=[^.]*\.[^.]*\.)/, "");
this.logoutRedirectUri = `${window.location.protocol}//${portalHost}`;
}

if (clearCache) {
Expand Down
37 changes: 37 additions & 0 deletions app/utils/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ class ApiClient {
/** Map of in-flight POST requests for deduplication, keyed by path + body. */
private inflightRequests = new Map<string, Promise<any>>();

/**
* 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";
}
Expand Down Expand Up @@ -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
// <Authenticated> to render <Redirect to="/" /> 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<T>(() => {
// 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,
Expand Down
34 changes: 31 additions & 3 deletions server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading