-
-
Notifications
You must be signed in to change notification settings - Fork 938
feat(networking): implement forward auth #1564
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
09f3b30
c3ea9d1
49f6ebd
4f5a702
53b3316
e520d97
b4e1e5e
09f228c
da433c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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/) | ||
|
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
|
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 | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's no validation here to prevent
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @fallenbagel |
||
| 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 warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading Log entry depends on a user-provided value Error loading related location Loading |
||
|
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 warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading Log entry depends on a user-provided value Error loading related location Loading |
||
|
gauthier-th marked this conversation as resolved.
Dismissed
|
||
| { label: 'Auth', errorMessage: (e as Error).message } | ||
| ); | ||
| user = null; | ||
| } | ||
| } | ||
| } | ||
| if (user) { | ||
| req.user = user; | ||
| } | ||
|
|
||
| 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(); | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.