Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
Comment thread
gauthier-th marked this conversation as resolved.
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
Comment thread
gauthier-th marked this conversation as resolved.
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
```
9 changes: 8 additions & 1 deletion jellyseerr-api.yml
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 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 @@ -194,7 +196,7 @@ components:
forceIpv4First:
type: boolean
example: false
trustProxy:
enableForwardAuth:
type: boolean
example: true
PlexLibrary:
Expand Down Expand Up @@ -1962,6 +1964,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 +7200,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 @@ -138,6 +138,7 @@ export interface NetworkSettings {
csrfProtection: boolean;
forceIpv4First: boolean;
trustProxy: boolean;
enableForwardAuth: boolean;
proxy: ProxySettings;
}

Expand Down Expand Up @@ -509,6 +510,7 @@ class Settings {
csrfProtection: false,
trustProxy: false,
forceIpv4First: false,
enableForwardAuth: false,
proxy: {
enabled: false,
hostname: '',
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.network.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
29 changes: 29 additions & 0 deletions src/components/Settings/SettingsNetwork/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
forceIpv4First: 'Force IPv4 Resolution First',
forceIpv4FirstTip:
'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6',
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.',
});

const SettingsNetwork = () => {
Expand Down Expand Up @@ -91,6 +94,7 @@ const SettingsNetwork = () => {
csrfProtection: data?.csrfProtection,
forceIpv4First: data?.forceIpv4First,
trustProxy: data?.trustProxy,
enableForwardAuth: data?.enableForwardAuth,
proxyEnabled: data?.proxy?.enabled,
proxyHostname: data?.proxy?.hostname,
proxyPort: data?.proxy?.port,
Expand All @@ -113,6 +117,7 @@ const SettingsNetwork = () => {
csrfProtection: values.csrfProtection,
forceIpv4First: values.forceIpv4First,
trustProxy: values.trustProxy,
enableForwardAuth: values.enableForwardAuth,
proxy: {
enabled: values.proxyEnabled,
hostname: values.proxyHostname,
Expand Down Expand Up @@ -174,6 +179,30 @@ const SettingsNetwork = () => {
/>
</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 @@ -941,6 +941,8 @@
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
"components.Settings.SettingsNetwork.docs": "documentation",
"components.Settings.SettingsNetwork.enableForwardAuth": "Enable Proxy Forward Authentication",
"components.Settings.SettingsNetwork.enableForwardAuthTip": "Authenticate as the user specified by the X-Forwarded-User header. Only enable when secured behind a trusted proxy.",
"components.Settings.SettingsNetwork.forceIpv4First": "Force IPv4 Resolution First",
"components.Settings.SettingsNetwork.forceIpv4FirstTip": "Force Jellyseerr to resolve IPv4 addresses first instead of IPv6",
"components.Settings.SettingsNetwork.network": "Network",
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;
};