sources/plex: add API to redeem token

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-05-02 16:47:20 +02:00
parent 55250e88e5
commit 01d29134b9
7 changed files with 255 additions and 27 deletions

View File

@ -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
)

View File

@ -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:

View File

@ -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

View File

@ -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`<ak-stage-authenticator-webauthn .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn>`;
case "ak-stage-authenticator-validate":
return html`<ak-stage-authenticator-validate .host=${this} .challenge=${this.challenge as AuthenticatorValidateStageChallenge}></ak-stage-authenticator-validate>`;
case "ak-flow-sources-plex":
return html`<ak-flow-sources-plex .host=${this} .challenge=${this.challenge as PlexAuthenticationChallenge}></ak-flow-sources-plex>`;
default:
break;
}

View File

@ -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<string> {
static async pinStatus(clientIdentifier: string, id: number): Promise<string | undefined> {
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<string> {
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<PlexResource[]> {
const resourcesResponse = await fetch(`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`, {
headers: DEFAULT_HEADERS

View File

@ -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<void> {
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`<div class="ak-loading">
<ak-spinner size=${PFSize.XLarge}></ak-spinner>
</div>`;
}
}

View File

@ -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<PlexSource> {
@ -64,16 +58,12 @@ export class PlexSourceForm extends Form<PlexSource> {
async doAuth(): Promise<void> {
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) => {
PlexAPIClient.pinPoll(this.source?.clientId || "", authInfo.pin.id).then(token => {
authWindow?.close();
this.plexToken = token;
this.loadServers();
});
}
}, 500);
}
async loadServers(): Promise<void> {
if (!this.plexToken) {