diff --git a/docs/extending-jellyseerr/forward-auth.md b/docs/extending-jellyseerr/forward-auth.md new file mode 100644 index 0000000000..5300e5078a --- /dev/null +++ b/docs/extending-jellyseerr/forward-auth.md @@ -0,0 +1,68 @@ +# 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. + +> The user has to exist, it will not be created automatically. + +## 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 +``` diff --git a/overseerr-api.yml b/overseerr-api.yml index 96a4520a7f..8158f9b83b 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -9,6 +9,7 @@ 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. + - **ForwardAuth Authentication**: Sign-in is also possible by passing an `X-Forwarded-User` header containing user's e-mail. tags: - name: public description: Public API endpoints requiring no authentication. @@ -186,6 +187,9 @@ components: defaultPermissions: type: number example: 32 + enableForwardAuth: + type: boolean + example: true PlexLibrary: type: object properties: @@ -1939,6 +1943,10 @@ components: type: apiKey in: header name: X-Api-Key + forwardAuth: + type: apiKey + in: header + name: X-Forwarded-User paths: /status: @@ -6939,3 +6947,4 @@ paths: security: - cookieAuth: [] - apiKey: [] + - forwardAuth: [] diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 326d460d8b..d205be31eb 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -21,6 +21,19 @@ export const checkUser: Middleware = async (req, _res, next) => { } user = await userRepository.findOne({ where: { id: userId } }); + } else if (req.header('x-forwarded-user')) { + const userRepository = getRepository(User); + user = await userRepository.findOne({ + where: { email: req.header('x-forwarded-user') }, + }); + + if (user) { + req.user = user; + } + + req.locale = user?.settings?.locale + ? user.settings.locale + : settings.main.locale; } else if (req.session?.userId) { const userRepository = getRepository(User); @@ -44,7 +57,18 @@ export const isAuthenticated = ( permissions?: Permission | Permission[], options?: PermissionCheckOptions ): Middleware => { - const authMiddleware: Middleware = (req, res, next) => { + const authMiddleware: Middleware = async (req, res, next) => { + if (req.header('x-forwarded-user')) { + const userRepository = getRepository(User); + const user = await userRepository.findOne({ + where: { email: req.header('x-forwarded-user') }, + }); + + if (user) { + req.user = user; + } + } + if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) { res.status(403).json({ status: 403, diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index e5704052d5..acb6a4f642 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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 { getRequestHeaders } from '@app/utils/localRequestHelper'; import { polyfillIntl } from '@app/utils/polyfillIntl'; import { MediaServerType } from '@server/constants/server'; import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces'; @@ -227,10 +228,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: getRequestHeaders(ctx), } ); if (!res.ok) throw new Error(); diff --git a/src/pages/collection/[collectionId]/index.tsx b/src/pages/collection/[collectionId]/index.tsx index b0c47b17c2..5c502a1153 100644 --- a/src/pages/collection/[collectionId]/index.tsx +++ b/src/pages/collection/[collectionId]/index.tsx @@ -1,4 +1,5 @@ import CollectionDetails from '@app/components/CollectionDetails'; +import { getRequestHeaders } from '@app/utils/localRequestHelper'; import type { Collection } from '@server/models/Collection'; import type { GetServerSideProps, NextPage } from 'next'; @@ -18,9 +19,7 @@ export const getServerSideProps: GetServerSideProps< ctx.query.collectionId }`, { - headers: ctx.req?.headers?.cookie - ? { cookie: ctx.req.headers.cookie } - : undefined, + headers: getRequestHeaders(ctx), } ); if (!res.ok) throw new Error(); diff --git a/src/pages/movie/[movieId]/index.tsx b/src/pages/movie/[movieId]/index.tsx index be0d2aa5a3..186478785c 100644 --- a/src/pages/movie/[movieId]/index.tsx +++ b/src/pages/movie/[movieId]/index.tsx @@ -1,4 +1,5 @@ import MovieDetails from '@app/components/MovieDetails'; +import { getRequestHeaders } from '@app/utils/localRequestHelper'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import type { GetServerSideProps, NextPage } from 'next'; @@ -18,9 +19,7 @@ export const getServerSideProps: GetServerSideProps = async ( ctx.query.movieId }`, { - headers: ctx.req?.headers?.cookie - ? { cookie: ctx.req.headers.cookie } - : undefined, + headers: getRequestHeaders(ctx), } ); if (!res.ok) throw new Error(); diff --git a/src/pages/tv/[tvId]/index.tsx b/src/pages/tv/[tvId]/index.tsx index 3961b157a3..904f0b962a 100644 --- a/src/pages/tv/[tvId]/index.tsx +++ b/src/pages/tv/[tvId]/index.tsx @@ -1,4 +1,5 @@ import TvDetails from '@app/components/TvDetails'; +import { getRequestHeaders } from '@app/utils/localRequestHelper'; import type { TvDetails as TvDetailsType } from '@server/models/Tv'; import type { GetServerSideProps, NextPage } from 'next'; @@ -16,9 +17,7 @@ export const getServerSideProps: GetServerSideProps = 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: getRequestHeaders(ctx), } ); if (!res.ok) throw new Error(); diff --git a/src/utils/localRequestHelper.ts b/src/utils/localRequestHelper.ts new file mode 100644 index 0000000000..0d55859e43 --- /dev/null +++ b/src/utils/localRequestHelper.ts @@ -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 getRequestHeaders = ( + ctx: NextPageContext | GetServerSidePropsContext +) => { + 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; +};