Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cypress/e2e/settings/general-settings.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('General Settings', () => {
cy.visit('/settings/network');

cy.get('#trustProxy').click();
cy.get('#trustedProxies').type('127.0.0.1');
Comment thread
ishanjain28 marked this conversation as resolved.
cy.get('[data-testid=settings-network-form]').submit();
cy.get('[data-testid=modal-title]').should(
'contain',
Expand Down
19 changes: 19 additions & 0 deletions docs/extending-seerr/forward-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Forward auth

You can use Forward Auth mechanism to log into Seerr.

This feature enables single sign-on(SSO) by passing the authenticated user(and optionally the authenticated user's email address for extra security) in the headers
defined by `forwardAuth.userHeader` and `forwardAuth.emailHeader` in the configuration file or the settings in Settings->Network in Web UI.

:::warning
By default the user has to exist, it will not be created automatically unless `forwardAuth.autoProvision` has been set to true in the configuration file or the settings in Settings->Network in Web UI.
:::

:::info
If the user has no email set, Forward Auth can be configured to work with just the username.
:::

## Configuring with Authelia

See the documentation [here](https://www.authelia.com/integration/trusted-header-sso/seerr/)

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"mime": "^4.1.0",
"nanoid": "^5.1.7",
"next": "16.2.6",
"ip-address": "^10.1.0",
"node-cache": "5.1.2",
"node-gyp": "12.2.0",
"node-schedule": "2.1.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ info:

- **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie.
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Seerr.

Additionally, if forward authentication is enabled, Seerr will authenticate as the user set in the header defined by `forwardAuth.userHeader`.
Optionally, You can also configure `forwardAuth.emailHeader` to check both the username and email. `forwardAuth.userHeader` is mandatory.
tags:
- name: public
description: Public API endpoints requiring no authentication.
Expand Down Expand Up @@ -262,6 +265,32 @@ components:
trustProxy:
type: boolean
example: false
trustedProxies:
type: object
properties:
v4:
type: array
items:
type: string
v6:
type: array
Comment thread
ishanjain28 marked this conversation as resolved.
items:
type: string
forwardAuth:
type: object
properties:
enabled:
type: boolean
example: false
userHeader:
type: string
example: ''
emailHeader:
type: string
example: ''
autoProvision:
type: boolean
example: false
proxy:
type: object
properties:
Expand Down Expand Up @@ -2142,6 +2171,14 @@ components:
type: apiKey
in: header
name: X-Api-Key
forwardAuth:
type: apiKey
in: header
name: Remote-User
Comment thread
ishanjain28 marked this conversation as resolved.
description: >
Deployment-configurable trusted header (for example: Remote-User,
Remote-Email, X-authentik-username). Use the header configured in
Network Settings > Forward Authentication.

paths:
/status:
Expand Down Expand Up @@ -8022,3 +8059,4 @@ paths:
security:
- cookieAuth: []
- apiKey: []
- forwardAuth: []
9 changes: 9 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import checkOverseerrMerge from '@server/lib/overseerrMerge';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import { addForwardAuthHeaders } from '@server/middleware/forwardauth';
import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy';
Expand Down Expand Up @@ -165,6 +166,7 @@ app
server.use(cookieParser());
server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use(addForwardAuthHeaders);
server.use((req, _res, next) => {
try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
Expand Down Expand Up @@ -231,6 +233,13 @@ app
OpenApiValidator.middleware({
apiSpec: API_SPEC_PATH,
validateRequests: true,
// The OpenAPI spec declares `cookieAuth` (connect.sid) on every endpoint.
// Letting the validator enforce it rejects every non-cookie auth flow
// (forward-auth here, OIDC in #2715, future API-key paths) with a
// 401 before our auth middleware ever runs. The actual auth check is
// already performed by `isAuthenticated()`, so cookie validation at
// this layer is redundant.
validateSecurity: false,
})
);
/**
Expand Down
24 changes: 24 additions & 0 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,25 @@ export interface NetworkSettings {
csrfProtection: boolean;
forceIpv4First: boolean;
trustProxy: boolean;
trustedProxies: TrustedProxies;
forwardAuth: ForwardAuthSettings;
proxy: ProxySettings;
dnsCache: DnsCacheSettings;
apiRequestTimeout: number;
}

export interface TrustedProxies {
v4: string[];
v6: string[];
}

export interface ForwardAuthSettings {
enabled: boolean;
userHeader: string;
emailHeader: string;
autoProvision: boolean;
}

interface PublicSettings {
initialized: boolean;
}
Expand Down Expand Up @@ -611,6 +625,16 @@ class Settings {
csrfProtection: false,
forceIpv4First: false,
trustProxy: false,
trustedProxies: {
v4: [],
v6: [],
},
Comment thread
gauthier-th marked this conversation as resolved.
forwardAuth: {
enabled: false,
userHeader: '',
emailHeader: '',
autoProvision: false,
},
proxy: {
enabled: false,
hostname: '',
Expand Down
137 changes: 132 additions & 5 deletions server/middleware/auth.ts
Comment thread
ishanjain28 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type {
Permission,
PermissionCheckOptions,
} from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import * as net from 'net';

export const checkUser: Middleware = async (req, _res, next) => {
const settings = getSettings();
let user: User | undefined | null;

if (req.header('X-API-Key') === settings.main.apiKey) {
const userRepository = getRepository(User);
const userRepository = getRepository(User);
let trustedProxy = false;

// Check if the remoteSocketAddress we received the request
// from is trusted!
const socketAddress = req.socket.remoteAddress || '';
const ipv4NormalizedSocketAddress = socketAddress.replace(/^::ffff:/, '');

if (net.isIPv4(ipv4NormalizedSocketAddress)) {
trustedProxy =
ipv4NormalizedSocketAddress === '127.0.0.1' ||
settings.network.trustedProxies.v4.includes(ipv4NormalizedSocketAddress);
} else if (net.isIPv6(socketAddress)) {
trustedProxy =
socketAddress === '::1' ||
settings.network.trustedProxies.v6.includes(socketAddress);
}

if (req.header('X-API-Key') === settings.main.apiKey) {
let userId = 1; // Work on original administrator account

// If a User ID is provided, we will act on that user's behalf
Expand All @@ -22,13 +42,120 @@

user = await userRepository.findOne({ where: { id: userId } });
} else if (req.session?.userId) {
const userRepository = getRepository(User);

user = await userRepository.findOne({
where: { id: req.session.userId },
});
}
} else if (
settings.network.trustProxy &&
settings.network.forwardAuth.enabled &&
trustedProxy
) {
Comment on lines +48 to +52

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no validation here to prevent forwardAuth.enabled = true with an empty trustedProxies list. Wouldn't this be confusing UX since in that case trustedProxy will always be false so forward auth will silently do nothing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fallenbagel trustedProxy can still be true if the request came from localhost. localhost is implicitly always trusted.

let { userHeader, emailHeader } = settings.network.forwardAuth;
userHeader = userHeader.toLowerCase();
emailHeader = emailHeader.toLowerCase();

const hasUserHeader = userHeader !== '';
const hasEmailHeader = emailHeader !== '';
const userValue = (hasUserHeader && req.header(userHeader)) ?? '';
const emailValue = (hasEmailHeader && req.header(emailHeader)) ?? '';

// Match case-insensitively. Jellyfin's AuthenticateByName lowercases the
// username before storing (so `jellyfinUsername` is `tina`), while most
// IDPs preserve the original case in property mappings (`Tina`). Without
// this, every fresh deploy needs either per-user DB fix-ups or a manual
// lowercasing expression in the IDP — surprising in both cases.
const qb = userRepository.createQueryBuilder('user');

if (hasUserHeader && hasEmailHeader) {
// Both headers are configured, so BOTH must match. Do not fall through
// to single-field matching.
if (userValue !== '' && emailValue !== '') {
qb.where(
'(LOWER(user.jellyfinUsername) = LOWER(:user) OR LOWER(user.plexUsername) = LOWER(:user)) AND LOWER(user.email) = LOWER(:email)',
{ user: userValue, email: emailValue }
);
user = await qb.getOne();
}
} else if (hasUserHeader && userValue !== '') {
qb.where(
'LOWER(user.jellyfinUsername) = LOWER(:user) OR LOWER(user.plexUsername) = LOWER(:user)',
{ user: userValue }
);
user = await qb.getOne();
} else if (hasEmailHeader && emailValue !== '') {
qb.where('LOWER(user.email) = LOWER(:email)', { email: emailValue });
user = await qb.getOne();
}

// Auto-provision: if forward-auth identifies a new user that isn't in the
// DB, create one on the fly with the default permission set. Opt-in so
// existing deploys are unaffected. The userType matches whichever media
// server is configured (Plex/Jellyfin/Emby) so existing per-userType
// logic (avatars, server-specific UI) keeps working; falls back to
// LOCAL when no media server is configured.
// Derive a username for provisioning. Prefer the user header when present,
// otherwise fall back to the local-part of the email (everything before
// '@'). This lets email-only setups (e.g. Cloudflare Access, which only
// supplies Cf-Access-Authenticated-User-Email) provision a usable account.
const emailLocalPart = emailValue ? emailValue.split('@')[0] : '';
const provisionUsername = userValue || emailLocalPart;
if (
!user &&
settings.network.forwardAuth.autoProvision &&
provisionUsername
) {
const mediaServerType = settings.main.mediaServerType;
const newUserType =
mediaServerType === MediaServerType.PLEX
? UserType.PLEX
: mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: mediaServerType === MediaServerType.EMBY
? UserType.EMBY
: UserType.LOCAL;

try {
user = new User({
// Email is required NOT NULL — synthesise a stable placeholder when
// the IDP doesn't provide one. Admin can edit it afterwards.
email: emailValue || `${userValue}@forward-auth.local`,
// Only populate the media-server username columns when an actual
// user header was supplied — those are matched against the real
// Plex/Jellyfin/Emby account on subsequent requests, so we must not
// fill them with a guessed value derived from the email.
plexUsername:
newUserType === UserType.PLEX && userValue ? userValue : undefined,
jellyfinUsername:
(newUserType === UserType.JELLYFIN ||
newUserType === UserType.EMBY) &&
userValue
? userValue
: undefined,
// When the User Header is blank (email-only auth, e.g. Cloudflare
// Access), use the local-part of the email as the username — this
// drives `displayName` (see the User entity's @AfterLoad). When a
// user header is present, leave this unset so media-server accounts
// keep displaying via plex/jellyfinUsername as before.
username: userValue ? undefined : emailLocalPart,
permissions: settings.main.defaultPermissions,
userType: newUserType,
// Required NOT NULL column; resolved client-side via Gravatar/avatarproxy.
avatar: '',
});
await userRepository.save(user);
logger.info(
`Auto-provisioned user via forward-auth: ${provisionUsername}`,

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
Log entry depends on a
user-provided value
.
Comment thread
gauthier-th marked this conversation as resolved.
Dismissed
{ label: 'Auth', userId: user.id, userType: newUserType }
);
} catch (e) {
logger.error(
`Failed to auto-provision forward-auth user ${provisionUsername}`,

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
Log entry depends on a
user-provided value
.
Comment thread
gauthier-th marked this conversation as resolved.
Dismissed
{ label: 'Auth', errorMessage: (e as Error).message }
);
user = null;
}
}
}
if (user) {
req.user = user;
}
Expand Down
14 changes: 14 additions & 0 deletions server/middleware/forwardauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getSettings } from '@server/lib/settings';

export const addForwardAuthHeaders: Middleware = async (req, res, next) => {
const settings = getSettings();

if (settings.network.forwardAuth.enabled) {
req.forwardAuth = {
emailHeader: settings.network.forwardAuth.emailHeader,
userHeader: settings.network.forwardAuth.userHeader,
};
}

return next();
};
6 changes: 6 additions & 0 deletions server/routes/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ settingsRoutes.get('/network', (req, res) => {
settingsRoutes.post('/network', async (req, res) => {
const settings = getSettings();

// The merge operation performs a union of existing values
// and the new values set by the user.
// It will not delete a value when the user deletes it
// because that value still exists in local config.
// So, it is necessary to clear this.
settings.network.trustedProxies = { v4: [], v6: [] };
Comment thread
ishanjain28 marked this conversation as resolved.
settings.network = merge(settings.network, req.body);
await settings.save();

Expand Down
4 changes: 4 additions & 0 deletions server/types/express.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ declare global {
export interface Request {
user?: User;
locale?: string;
forwardAuth?: {
emailHeader: string;
userHeader: string;
};
}
}

Expand Down
Loading
Loading