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 {