diff --git a/authentik/sources/plex/api.py b/authentik/sources/plex/api.py
index 01b3c1961..a9501132c 100644
--- a/authentik/sources/plex/api.py
+++ b/authentik/sources/plex/api.py
@@ -1,9 +1,27 @@
"""Plex Source Serializer"""
-from rest_framework.viewsets import ModelViewSet
+from urllib.parse import urlencode
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+from drf_yasg import openapi
+from drf_yasg.utils import swagger_auto_schema
+from requests import RequestException, get
+from rest_framework.decorators import action
+from rest_framework.fields import CharField
+from rest_framework.permissions import AllowAny
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.viewsets import ModelViewSet
+from structlog.stdlib import get_logger
+
+from authentik.api.decorators import permission_required
from authentik.core.api.sources import SourceSerializer
+from authentik.core.api.utils import PassiveSerializer
+from authentik.flows.challenge import ChallengeTypes, RedirectChallenge
from authentik.sources.plex.models import PlexSource
+LOGGER = get_logger()
+
class PlexSourceSerializer(SourceSerializer):
"""Plex Source Serializer"""
@@ -13,9 +31,70 @@ class PlexSourceSerializer(SourceSerializer):
fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"]
+class PlexTokenRedeemSerializer(PassiveSerializer):
+ """Serializer to redeem a plex token"""
+
+ plex_token = CharField()
+
+
class PlexSourceViewSet(ModelViewSet):
"""Plex source Viewset"""
queryset = PlexSource.objects.all()
serializer_class = PlexSourceSerializer
lookup_field = "slug"
+
+ @permission_required(None)
+ @swagger_auto_schema(
+ request_body=PlexTokenRedeemSerializer(),
+ responses={200: RedirectChallenge(), 404: "Token not found"},
+ manual_parameters=[
+ openapi.Parameter(
+ name="slug",
+ in_=openapi.IN_QUERY,
+ type=openapi.TYPE_STRING,
+ )
+ ],
+ )
+ @action(
+ methods=["POST"],
+ detail=False,
+ pagination_class=None,
+ filter_backends=[],
+ permission_classes=[AllowAny],
+ )
+ def redeem_token(self, request: Request) -> Response:
+ """Redeem a plex token, check it's access to resources against what's allowed
+ for the source, and redirect to an authentication/enrollment flow."""
+ source: PlexSource = get_object_or_404(
+ PlexSource, slug=request.query_params.get("slug", "")
+ )
+ plex_token = request.data.get("plex_token", None)
+ if not plex_token:
+ raise Http404
+ qs = {"X-Plex-Token": plex_token, "X-Plex-Client-Identifier": source.client_id}
+ try:
+ response = get(
+ f"https://plex.tv/api/v2/resources?{urlencode(qs)}",
+ headers={"Accept": "application/json"},
+ )
+ response.raise_for_status()
+ except RequestException as exc:
+ LOGGER.warning("Unable to fetch user resources", exc=exc)
+ raise Http404
+ else:
+ resources: list[dict] = response.json()
+ for resource in resources:
+ if resource["provides"] != "server":
+ continue
+ if resource["clientIdentifier"] in source.allowed_servers:
+ LOGGER.info(
+ "Plex allowed access from server", name=resource["name"]
+ )
+ request.session["foo"] = "bar"
+ break
+ return Response(
+ RedirectChallenge(
+ {"type": ChallengeTypes.REDIRECT.value, "to": ""}
+ ).data
+ )
diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py
index 7a61c801a..da66c1902 100644
--- a/authentik/sources/plex/models.py
+++ b/authentik/sources/plex/models.py
@@ -3,10 +3,19 @@ from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
+from rest_framework.fields import CharField
from rest_framework.serializers import BaseSerializer
from authentik.core.models import Source
from authentik.core.types import UILoginButton
+from authentik.flows.challenge import Challenge, ChallengeTypes
+
+
+class PlexAuthenticationChallenge(Challenge):
+ """Challenge shown to the user in identification stage"""
+
+ client_id = CharField()
+ slug = CharField()
class PlexSource(Source):
@@ -28,12 +37,16 @@ class PlexSource(Source):
@property
def ui_login_button(self) -> UILoginButton:
return UILoginButton(
- url="",
+ challenge=PlexAuthenticationChallenge(
+ {
+ "type": ChallengeTypes.NATIVE.value,
+ "component": "ak-flow-sources-plex",
+ "client_id": self.client_id,
+ "slug": self.slug,
+ }
+ ),
icon_url=static("authentik/sources/plex.svg"),
name=self.name,
- additional_data={
- "client_id": self.client_id,
- },
)
class Meta:
diff --git a/swagger.yaml b/swagger.yaml
index 4819cb3b3..21486dcf4 100755
--- a/swagger.yaml
+++ b/swagger.yaml
@@ -10307,6 +10307,39 @@ paths:
tags:
- sources
parameters: []
+ /sources/plex/redeem_token/:
+ post:
+ operationId: sources_plex_redeem_token
+ description: |-
+ Redeem a plex token, check it's access to resources against what's allowed
+ for the source, and redirect to an authentication/enrollment flow.
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/PlexTokenRedeem'
+ - name: slug
+ in: query
+ type: string
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/RedirectChallenge'
+ '404':
+ description: Token not found
+ '400':
+ description: Invalid input.
+ schema:
+ $ref: '#/definitions/ValidationError'
+ '403':
+ description: Authentication credentials were invalid, absent or insufficient.
+ schema:
+ $ref: '#/definitions/GenericError'
+ tags:
+ - sources
+ parameters: []
/sources/plex/{slug}/:
get:
operationId: sources_plex_read
@@ -17655,6 +17688,51 @@ definitions:
title: Allowed servers
type: string
minLength: 1
+ PlexTokenRedeem:
+ required:
+ - plex_token
+ type: object
+ properties:
+ plex_token:
+ title: Plex token
+ type: string
+ minLength: 1
+ RedirectChallenge:
+ required:
+ - type
+ - to
+ type: object
+ properties:
+ type:
+ title: Type
+ type: string
+ enum:
+ - native
+ - shell
+ - redirect
+ component:
+ title: Component
+ type: string
+ minLength: 1
+ title:
+ title: Title
+ type: string
+ minLength: 1
+ background:
+ title: Background
+ type: string
+ minLength: 1
+ response_errors:
+ title: Response errors
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/definitions/ErrorDetail'
+ to:
+ title: To
+ type: string
+ minLength: 1
SAMLSource:
required:
- name
diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts
index 2e44b01e7..6338450ba 100644
--- a/web/src/flows/FlowExecutor.ts
+++ b/web/src/flows/FlowExecutor.ts
@@ -23,6 +23,7 @@ import "./stages/email/EmailStage";
import "./stages/identification/IdentificationStage";
import "./stages/password/PasswordStage";
import "./stages/prompt/PromptStage";
+import "./sources/plex/PlexLoginInit";
import { ShellChallenge, RedirectChallenge } from "../api/Flows";
import { IdentificationChallenge } from "./stages/identification/IdentificationStage";
import { PasswordChallenge } from "./stages/password/PasswordStage";
@@ -44,6 +45,7 @@ import { AccessDeniedChallenge } from "./access_denied/FlowAccessDenied";
import { PFSize } from "../elements/Spinner";
import { TITLE_DEFAULT } from "../constants";
import { configureSentry } from "../api/Sentry";
+import { PlexAuthenticationChallenge } from "./sources/plex/PlexLoginInit";
@customElement("ak-flow-executor")
export class FlowExecutor extends LitElement implements StageHost {
@@ -223,6 +225,8 @@ export class FlowExecutor extends LitElement implements StageHost {
return html``;
case "ak-stage-authenticator-validate":
return html``;
+ case "ak-flow-sources-plex":
+ return html``;
default:
break;
}
diff --git a/web/src/flows/sources/plex/API.ts b/web/src/flows/sources/plex/API.ts
index 26e0ce362..a3dc2306f 100644
--- a/web/src/flows/sources/plex/API.ts
+++ b/web/src/flows/sources/plex/API.ts
@@ -21,6 +21,12 @@ export const DEFAULT_HEADERS = {
"X-Plex-Device-Vendor": "BeryJu.org",
};
+export function popupCenterScreen(url: string, title: string, w: number, h: number): Window | null {
+ const top = (screen.height - h) / 4, left = (screen.width - w) / 2;
+ const popup = window.open(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
+ return popup;
+}
+
export class PlexAPIClient {
token: string;
@@ -44,14 +50,38 @@ export class PlexAPIClient {
};
}
- static async pinStatus(id: number): Promise {
+ static async pinStatus(clientIdentifier: string, id: number): Promise {
+ const headers = { ...DEFAULT_HEADERS, ...{
+ "X-Plex-Client-Identifier": clientIdentifier
+ }};
const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, {
- headers: DEFAULT_HEADERS
+ headers: headers
});
const pin: PlexPinResponse = await pinResponse.json();
return pin.authToken || "";
}
+ static async pinPoll(clientIdentifier: string, id: number): Promise {
+ const executePoll = async (
+ resolve: (authToken: string) => void,
+ reject: (e: Error) => void
+ ) => {
+ try {
+ const response = await PlexAPIClient.pinStatus(clientIdentifier, id)
+
+ if (response) {
+ resolve(response);
+ } else {
+ setTimeout(executePoll, 500, resolve, reject);
+ }
+ } catch (e) {
+ reject(e);
+ }
+ };
+
+ return new Promise(executePoll);
+ }
+
async getServers(): Promise {
const resourcesResponse = await fetch(`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`, {
headers: DEFAULT_HEADERS
diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts
index e357b3355..5a134933a 100644
--- a/web/src/flows/sources/plex/PlexLoginInit.ts
+++ b/web/src/flows/sources/plex/PlexLoginInit.ts
@@ -1,11 +1,45 @@
-import {customElement, LitElement} from "lit-element";
+import { Challenge } from "authentik-api";
+import {customElement, property} from "lit-element";
import {html, TemplateResult} from "lit-html";
+import { PFSize } from "../../../elements/Spinner";
+import { BaseStage } from "../../stages/base";
+import {PlexAPIClient, popupCenterScreen} from "./API";
+import {DEFAULT_CONFIG} from "../../../api/Config";
+import { SourcesApi } from "authentik-api";
+
+export interface PlexAuthenticationChallenge extends Challenge {
+
+ client_id: string;
+ slug: string;
+
+}
@customElement("ak-flow-sources-plex")
-export class PlexLoginInit extends LitElement {
+export class PlexLoginInit extends BaseStage {
- render(): TemplateResult {
- return html``;
+ @property({ attribute: false })
+ challenge?: PlexAuthenticationChallenge;
+
+ async firstUpdated(): Promise {
+ const authInfo = await PlexAPIClient.getPin(this.challenge?.client_id || "");
+ const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700);
+ PlexAPIClient.pinPoll(this.challenge?.client_id || "", authInfo.pin.id).then(token => {
+ authWindow?.close();
+ new SourcesApi(DEFAULT_CONFIG).sourcesPlexRedeemToken({
+ data: {
+ plexToken: token,
+ },
+ slug: this.challenge?.slug || "",
+ }).then(r => {
+ window.location.assign(r.to);
+ });
+ });
+ }
+
+ renderLoading(): TemplateResult {
+ return html``;
}
}
diff --git a/web/src/pages/sources/plex/PlexSourceForm.ts b/web/src/pages/sources/plex/PlexSourceForm.ts
index bc963aee5..10c7da501 100644
--- a/web/src/pages/sources/plex/PlexSourceForm.ts
+++ b/web/src/pages/sources/plex/PlexSourceForm.ts
@@ -9,15 +9,9 @@ import "../../../elements/forms/HorizontalFormElement";
import { ifDefined } from "lit-html/directives/if-defined";
import { until } from "lit-html/directives/until";
import { first, randomString } from "../../../utils";
-import { PlexAPIClient, PlexResource} from "../../../flows/sources/plex/API";
+import { PlexAPIClient, PlexResource, popupCenterScreen} from "../../../flows/sources/plex/API";
-function popupCenterScreen(url: string, title: string, w: number, h: number): Window | null {
- const top = (screen.height - h) / 4, left = (screen.width - w) / 2;
- const popup = window.open(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
- return popup;
-}
-
@customElement("ak-source-plex-form")
export class PlexSourceForm extends Form {
@@ -64,15 +58,11 @@ export class PlexSourceForm extends Form {
async doAuth(): Promise {
const authInfo = await PlexAPIClient.getPin(this.source?.clientId);
const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700);
- const timer = setInterval(() => {
- if (authWindow?.closed) {
- clearInterval(timer);
- PlexAPIClient.pinStatus(authInfo.pin.id).then((token: string) => {
- this.plexToken = token;
- this.loadServers();
- });
- }
- }, 500);
+ PlexAPIClient.pinPoll(this.source?.clientId || "", authInfo.pin.id).then(token => {
+ authWindow?.close();
+ this.plexToken = token;
+ this.loadServers();
+ });
}
async loadServers(): Promise {