Skip to content
Closed
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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ RUN \
;; \
esac

RUN npm install --global pnpm@9

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.

Why is this being changed in this pr

RUN npm install --global pnpm

COPY package.json pnpm-lock.yaml postinstall-win.js ./
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
Expand Down Expand Up @@ -45,7 +45,7 @@ WORKDIR /app

RUN apk add --no-cache tzdata tini && rm -rf /tmp/*

RUN npm install -g pnpm@9

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.

Same here.

RUN npm install -g pnpm

# copy from build image
COPY --from=BUILD_IMAGE /app ./
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
```
11 changes: 11 additions & 0 deletions jellyseerr-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 Jellyseerr.

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 +200,9 @@ components:
trustProxy:
type: boolean
example: true
enableForwardAuth:
type: boolean
example: true
PlexLibrary:
type: object
properties:
Expand Down Expand Up @@ -1962,6 +1968,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 @@ -7194,3 +7204,4 @@ paths:
security:
- cookieAuth: []
- apiKey: []
- forwardAuth: []
4 changes: 4 additions & 0 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export interface MainSettings {
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
trustProxy: boolean;
enableForwardAuth: boolean;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
Expand Down Expand Up @@ -349,6 +351,8 @@ class Settings {
discoverRegion: '',
streamingRegion: '',
originalLanguage: '',
trustProxy: false,
enableForwardAuth: false,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
Expand Down
13 changes: 9 additions & 4 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 Down
90 changes: 90 additions & 0 deletions src/components/Settings/SettingsMain/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ const messages = defineMessages('components.Settings.SettingsMain', {
cacheImages: 'Enable Image Caching',
cacheImagesTip:
'Cache externally sourced images (requires a significant amount of disk space)',
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 @@ -134,6 +140,10 @@ const SettingsMain = () => {
streamingRegion: data?.streamingRegion || 'US',
partialRequestsEnabled: data?.partialRequestsEnabled,
enableSpecialEpisodes: data?.enableSpecialEpisodes,
forceIpv4First: data?.forceIpv4First,
dnsServers: data?.dnsServers,
trustProxy: data?.trustProxy,
enableForwardAuth: data?.enableForwardAuth,
cacheImages: data?.cacheImages,
}}
enableReinitialize
Expand All @@ -155,6 +165,10 @@ const SettingsMain = () => {
originalLanguage: values.originalLanguage,
partialRequestsEnabled: values.partialRequestsEnabled,
enableSpecialEpisodes: values.enableSpecialEpisodes,
forceIpv4First: values.forceIpv4First,
dnsServers: values.dnsServers,
trustProxy: values.trustProxy,
enableForwardAuth: values.enableForwardAuth,
cacheImages: values.cacheImages,
}),
});
Expand Down Expand Up @@ -264,6 +278,82 @@ const SettingsMain = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="trustProxy" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.trustProxy)}
</span>
<SettingsBadge badgeType="restartRequired" />
<span className="label-tip">
{intl.formatMessage(messages.trustProxyTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="trustProxy"
name="trustProxy"
onChange={() => {
setFieldValue('trustProxy', !values.trustProxy);
}}
/>
</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">
{intl.formatMessage(messages.csrfProtection)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
<span className="label-tip">
{intl.formatMessage(messages.csrfProtectionTip)}
</span>
</label>
<div className="form-input-area">
<Tooltip
content={intl.formatMessage(
messages.csrfProtectionHoverTip
)}
>
<Field
type="checkbox"
id="csrfProtection"
name="csrfProtection"
onChange={() => {
setFieldValue(
'csrfProtection',
!values.csrfProtection
);
}}
/>
</Tooltip>
</div>
</div>
<div className="form-row">
<label htmlFor="cacheImages" className="checkbox-label">
<span className="mr-2">
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,10 @@
"components.Settings.SettingsMain.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)",
"components.Settings.SettingsMain.discoverRegion": "Discover Region",
"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.general": "General",
"components.Settings.SettingsMain.generalsettings": "General Settings",
Expand Down
6 changes: 2 additions & 4 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import '@app/styles/globals.css';
import '@app/utils/fetchOverride';
import { polyfillIntl } from '@app/utils/polyfillIntl';
import { getAuthHeaders } from '@app/utils/serverSidePropsHelpers';
import { MediaServerType } from '@server/constants/server';
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
import type { AppInitialProps, AppProps } from 'next/app';
Expand Down Expand Up @@ -262,10 +263,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/serverSidePropsHelpers';
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/serverSidePropsHelpers';
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/serverSidePropsHelpers';
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/serverSidePropsHelpers.ts
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;
};