Skip to content
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

feat: Add support for forward auth #1341

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion Dockerfile.local
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ FROM node:22-alpine
COPY . /app
WORKDIR /app

Run npm install --global pnpm
RUN npm install --global pnpm

RUN pnpm install

Expand Down
74 changes: 74 additions & 0 deletions docs/extending-jellyseerr/forward-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Forward auth

You can use [forward-auth](https://doc.traefik.io/traefik/middlewares/http/forwardauth/) mechanism to log into Jellyseer.

This works by passing the authenticated user e-mail in `X-Forwarded-User` header by the auth server, therefore enabling single-sign-on (SSO) login.

:::warning
The user has to exist, it will not be created automatically.
:::

:::info
If the user has no email set, the username will also work
:::

## Example with Goauthentik and Traefik

This example assumes that you have already configured an `application` and `provider` for Jellyseer in Authentik, and added the `provider` to the `outpost`.

We now have to create a scope mapping that will pass the `X-Forwarded-User` header containing user e-mail to Jellyseerr application.

### Create scope mapping

In Authentik go to `Customization > Propperty Mappings` and create `Scope Mapping`:

* Name: `jellyseerr-forwardauth`
* Scope: `ak_proxy`
* Expression:

```py
return {
"ak_proxy": {
"user_attributes": {
"additionalHeaders": {
"X-Forwarded-User": request.user.email
}
}
}
}
```

### Add the scope mapping to provider scopes

In authentik go to `Applications > Providers`, edit your `jellyseer` provider:

* Under `Advanced protocol settings` - `Available scopes` select the `jellyseerr-forwardauth` scope that was created in the previous step and add it to the `Selected scopes` list
* Save the changes by clicking the `Update` button

### Create the forward-auth middleware in Traefik

Now you have to define the forward-auth middleware in Traefik and attach it to the `jellyseerr` router. Authentik also requires to set up login page routing so it could redirect properly to Authentik.

```yml
labels:
- traefik.enable=true

# Forward auth middleware
- traefik.http.middlewares.auth-authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/jellyseerr
- traefik.http.middlewares.auth-authentik.forwardauth.trustForwardHeader=true
- traefik.http.middlewares.auth-authentik.forwardauth.authResponseHeaders=X-Forwarded-User

# Router for jellyseerr
- traefik.http.routers.jellyseerr.rule=Host(`jellyseerr.domain.com`)
- traefik.http.routers.jellyseerr.entrypoints=websecure
- traefik.http.routers.jellyseerr.middlewares=auth-authentik@docker
# Service for jellyseerr
- traefik.http.services.jellyseerr.loadbalancer.server.port=5055
- traefik.http.routers.jellyseerr.service=jellyseerr

# Router for login pages
- traefik.http.routers.jellyseerr-auth.rule=Host(`jellyseerr.domain.co`) && PathPrefix(`/outpost.goauthentik.io/`)
- traefik.http.routers.jellyseerr-auth.entrypoints=websecure
# Service - reference the authentik outpost service name
- traefik.http.routers.jellyseerr-auth.service=authentik@docker
```
10 changes: 10 additions & 0 deletions overseerr-api.yml
Copy link
Author

Choose a reason for hiding this comment

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

All the changes in this file are just copied straight from #580. I'm not sure how this file is used or if there is a better way to generate this spec

Copy link
Collaborator

Choose a reason for hiding this comment

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

You should add this inside the securitySchemes section of components (line 1956), with something like this inside:

components:
  ...
  securitySchemes:
    ...
    forwardAuth:
      type: apiKey
      in: header
      name: X-Forwarded-User

I'm not an expert in this kind of spec either, so if someone could confirm it would be nice.

Copy link
Author

Choose a reason for hiding this comment

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

Gotcha. So since that's already in place, I think this file is probably good?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it is too. But it doesn't hurt to have another check.

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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 Overseerr.

Additionally, if forward authentication is enabled, Jellyseerr will authenticate as the email set in the `X-Forwarded-User` header.
tags:
- name: public
description: Public API endpoints requiring no authentication.
Expand Down Expand Up @@ -197,6 +199,9 @@ components:
dnsServers:
type: string
example: '1.1.1.1'
enableForwardAuth:
type: boolean
example: true
PlexLibrary:
type: object
properties:
Expand Down Expand Up @@ -1962,6 +1967,10 @@ components:
type: apiKey
in: header
name: X-Api-Key
forwardAuth:
type: apiKey
in: header
name: X-Forwarded-User

paths:
/status:
Expand Down Expand Up @@ -7060,3 +7069,4 @@ paths:
security:
- cookieAuth: []
- apiKey: []
- forwardAuth: []
2 changes: 2 additions & 0 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export interface MainSettings {
streamingRegion: string;
originalLanguage: string;
trustProxy: boolean;
enableForwardAuth: boolean;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
Expand Down Expand Up @@ -345,6 +346,7 @@ class Settings {
streamingRegion: '',
originalLanguage: '',
trustProxy: false,
enableForwardAuth: false,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
Expand Down
15 changes: 10 additions & 5 deletions server/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ 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);

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 @@ -21,9 +21,14 @@ export const checkUser: Middleware = async (req, _res, next) => {
}

user = await userRepository.findOne({ where: { id: userId } });
} else if (
settings.main.enableForwardAuth === true &&
req.header('X-Forwarded-User')
) {
user = await userRepository.findOne({
where: { email: req.header('X-Forwarded-User') },
});
} else if (req.session?.userId) {
const userRepository = getRepository(User);

user = await userRepository.findOne({
where: { id: req.session.userId },
});
Expand All @@ -44,7 +49,7 @@ export const isAuthenticated = (
permissions?: Permission | Permission[],
options?: PermissionCheckOptions
): Middleware => {
const authMiddleware: Middleware = (req, res, next) => {
const authMiddleware: Middleware = async (req, res, next) => {
gauthier-th marked this conversation as resolved.
Show resolved Hide resolved
if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) {
res.status(403).json({
status: 403,
Expand Down
29 changes: 29 additions & 0 deletions src/components/Settings/SettingsMain/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ const messages = defineMessages('components.Settings.SettingsMain', {
trustProxy: 'Enable Proxy Support',
trustProxyTip:
'Allow Jellyseerr to correctly register client IP addresses behind a proxy',
enableForwardAuth: 'Enable Proxy Forward Authentication',
enableForwardAuthTip:
'Authenticate as the user specified by the X-Forwarded-User header. Only enable when secured behind a trusted proxy.',
validationApplicationTitle: 'You must provide an application title',
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
Expand Down Expand Up @@ -169,6 +172,7 @@ const SettingsMain = () => {
forceIpv4First: data?.forceIpv4First,
dnsServers: data?.dnsServers,
trustProxy: data?.trustProxy,
enableForwardAuth: data?.enableForwardAuth,
cacheImages: data?.cacheImages,
proxyEnabled: data?.proxy?.enabled,
proxyHostname: data?.proxy?.hostname,
Expand Down Expand Up @@ -202,6 +206,7 @@ const SettingsMain = () => {
forceIpv4First: values.forceIpv4First,
dnsServers: values.dnsServers,
trustProxy: values.trustProxy,
enableForwardAuth: values.enableForwardAuth,
cacheImages: values.cacheImages,
proxy: {
enabled: values.proxyEnabled,
Expand Down Expand Up @@ -342,6 +347,30 @@ const SettingsMain = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="enableForwardAuth" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.enableForwardAuth)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<span className="label-tip">
{intl.formatMessage(messages.enableForwardAuthTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableForwardAuth"
name="enableForwardAuth"
onChange={() => {
setFieldValue(
'enableForwardAuth',
!values.enableForwardAuth
);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="csrfProtection" className="checkbox-label">
<span className="mr-2">
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,8 @@
"components.Settings.SettingsMain.discoverRegionTip": "Filter content by regional availability",
"components.Settings.SettingsMain.dnsServers": "Custom DNS Servers",
"components.Settings.SettingsMain.dnsServersTip": "Comma-separated list of custom DNS servers, e.g. \"1.1.1.1,[2606:4700:4700::1111]\"",
"components.Settings.SettingsMain.enableForwardAuth": "Enable Proxy Forward Authentication",
"components.Settings.SettingsMain.enableForwardAuthTip": "Authenticate as the user specified by the X-Forwarded-User header. Only enable when secured behind a trusted proxy.",
"components.Settings.SettingsMain.enableSpecialEpisodes": "Allow Special Episodes Requests",
"components.Settings.SettingsMain.forceIpv4First": "IPv4 Resolution First",
"components.Settings.SettingsMain.forceIpv4FirstTip": "Force Jellyseerr to resolve IPv4 addresses first instead of IPv6",
Expand Down
6 changes: 2 additions & 4 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { UserContext } from '@app/context/UserContext';
import type { User } from '@app/hooks/useUser';
import '@app/styles/globals.css';
import '@app/utils/fetchOverride';
import { getAuthHeaders } from '@app/utils/localRequestHelper';
import { polyfillIntl } from '@app/utils/polyfillIntl';
import { MediaServerType } from '@server/constants/server';
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
Expand Down Expand Up @@ -231,10 +232,7 @@ CoreApp.getInitialProps = async (initialProps) => {
const res = await fetch(
`http://localhost:${process.env.PORT || 5055}/api/v1/auth/me`,
{
headers:
ctx.req && ctx.req.headers.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
headers: getAuthHeaders(ctx),
}
);
if (!res.ok) throw new Error();
Expand Down
5 changes: 2 additions & 3 deletions src/pages/collection/[collectionId]/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CollectionDetails from '@app/components/CollectionDetails';
import { getAuthHeaders } from '@app/utils/localRequestHelper';
import type { Collection } from '@server/models/Collection';
import type { GetServerSideProps, NextPage } from 'next';

Expand All @@ -18,9 +19,7 @@ export const getServerSideProps: GetServerSideProps<
ctx.query.collectionId
}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
headers: getAuthHeaders(ctx),
}
);
if (!res.ok) throw new Error();
Expand Down
5 changes: 2 additions & 3 deletions src/pages/movie/[movieId]/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import MovieDetails from '@app/components/MovieDetails';
import { getAuthHeaders } from '@app/utils/localRequestHelper';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import type { GetServerSideProps, NextPage } from 'next';

Expand All @@ -18,9 +19,7 @@ export const getServerSideProps: GetServerSideProps<MoviePageProps> = async (
ctx.query.movieId
}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
headers: getAuthHeaders(ctx),
}
);
if (!res.ok) throw new Error();
Expand Down
5 changes: 2 additions & 3 deletions src/pages/tv/[tvId]/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import TvDetails from '@app/components/TvDetails';
import { getAuthHeaders } from '@app/utils/localRequestHelper';
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
import type { GetServerSideProps, NextPage } from 'next';

Expand All @@ -16,9 +17,7 @@ export const getServerSideProps: GetServerSideProps<TvPageProps> = async (
const res = await fetch(
`http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ctx.query.tvId}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
headers: getAuthHeaders(ctx),
}
);
if (!res.ok) throw new Error();
Expand Down
18 changes: 18 additions & 0 deletions src/utils/localRequestHelper.ts
gauthier-th marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { NextPageContext } from 'next/dist/shared/lib/utils';
import type { GetServerSidePropsContext, PreviewData } from 'next/types';
import type { ParsedUrlQuery } from 'querystring';

export const getAuthHeaders = (
ctx: NextPageContext | GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
) => {
return ctx.req && ctx.req.headers
? {
...(ctx.req.headers.cookie && {
cookie: ctx.req.headers.cookie,
}),
...(ctx.req.headers['x-forwarded-user'] && {
'x-forwarded-user': ctx.req.headers['x-forwarded-user'] as string,
}),
}
: undefined;
};
Loading