sources/plex: add API to redeem token
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
55250e88e5
commit
01d29134b9
|
@ -1,9 +1,27 @@
|
||||||
"""Plex Source Serializer"""
|
"""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.sources import SourceSerializer
|
||||||
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
from authentik.flows.challenge import ChallengeTypes, RedirectChallenge
|
||||||
from authentik.sources.plex.models import PlexSource
|
from authentik.sources.plex.models import PlexSource
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class PlexSourceSerializer(SourceSerializer):
|
class PlexSourceSerializer(SourceSerializer):
|
||||||
"""Plex Source Serializer"""
|
"""Plex Source Serializer"""
|
||||||
|
@ -13,9 +31,70 @@ class PlexSourceSerializer(SourceSerializer):
|
||||||
fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"]
|
fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"]
|
||||||
|
|
||||||
|
|
||||||
|
class PlexTokenRedeemSerializer(PassiveSerializer):
|
||||||
|
"""Serializer to redeem a plex token"""
|
||||||
|
|
||||||
|
plex_token = CharField()
|
||||||
|
|
||||||
|
|
||||||
class PlexSourceViewSet(ModelViewSet):
|
class PlexSourceViewSet(ModelViewSet):
|
||||||
"""Plex source Viewset"""
|
"""Plex source Viewset"""
|
||||||
|
|
||||||
queryset = PlexSource.objects.all()
|
queryset = PlexSource.objects.all()
|
||||||
serializer_class = PlexSourceSerializer
|
serializer_class = PlexSourceSerializer
|
||||||
lookup_field = "slug"
|
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
|
||||||
|
)
|
||||||
|
|
|
@ -3,10 +3,19 @@ from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from rest_framework.fields import CharField
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
from authentik.core.models import Source
|
from authentik.core.models import Source
|
||||||
from authentik.core.types import UILoginButton
|
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):
|
class PlexSource(Source):
|
||||||
|
@ -28,12 +37,16 @@ class PlexSource(Source):
|
||||||
@property
|
@property
|
||||||
def ui_login_button(self) -> UILoginButton:
|
def ui_login_button(self) -> UILoginButton:
|
||||||
return 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"),
|
icon_url=static("authentik/sources/plex.svg"),
|
||||||
name=self.name,
|
name=self.name,
|
||||||
additional_data={
|
|
||||||
"client_id": self.client_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
78
swagger.yaml
78
swagger.yaml
|
@ -10307,6 +10307,39 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- sources
|
- sources
|
||||||
parameters: []
|
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}/:
|
/sources/plex/{slug}/:
|
||||||
get:
|
get:
|
||||||
operationId: sources_plex_read
|
operationId: sources_plex_read
|
||||||
|
@ -17655,6 +17688,51 @@ definitions:
|
||||||
title: Allowed servers
|
title: Allowed servers
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
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:
|
SAMLSource:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
|
|
|
@ -23,6 +23,7 @@ import "./stages/email/EmailStage";
|
||||||
import "./stages/identification/IdentificationStage";
|
import "./stages/identification/IdentificationStage";
|
||||||
import "./stages/password/PasswordStage";
|
import "./stages/password/PasswordStage";
|
||||||
import "./stages/prompt/PromptStage";
|
import "./stages/prompt/PromptStage";
|
||||||
|
import "./sources/plex/PlexLoginInit";
|
||||||
import { ShellChallenge, RedirectChallenge } from "../api/Flows";
|
import { ShellChallenge, RedirectChallenge } from "../api/Flows";
|
||||||
import { IdentificationChallenge } from "./stages/identification/IdentificationStage";
|
import { IdentificationChallenge } from "./stages/identification/IdentificationStage";
|
||||||
import { PasswordChallenge } from "./stages/password/PasswordStage";
|
import { PasswordChallenge } from "./stages/password/PasswordStage";
|
||||||
|
@ -44,6 +45,7 @@ import { AccessDeniedChallenge } from "./access_denied/FlowAccessDenied";
|
||||||
import { PFSize } from "../elements/Spinner";
|
import { PFSize } from "../elements/Spinner";
|
||||||
import { TITLE_DEFAULT } from "../constants";
|
import { TITLE_DEFAULT } from "../constants";
|
||||||
import { configureSentry } from "../api/Sentry";
|
import { configureSentry } from "../api/Sentry";
|
||||||
|
import { PlexAuthenticationChallenge } from "./sources/plex/PlexLoginInit";
|
||||||
|
|
||||||
@customElement("ak-flow-executor")
|
@customElement("ak-flow-executor")
|
||||||
export class FlowExecutor extends LitElement implements StageHost {
|
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>`;
|
return html`<ak-stage-authenticator-webauthn .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn>`;
|
||||||
case "ak-stage-authenticator-validate":
|
case "ak-stage-authenticator-validate":
|
||||||
return html`<ak-stage-authenticator-validate .host=${this} .challenge=${this.challenge as AuthenticatorValidateStageChallenge}></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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,12 @@ export const DEFAULT_HEADERS = {
|
||||||
"X-Plex-Device-Vendor": "BeryJu.org",
|
"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 {
|
export class PlexAPIClient {
|
||||||
|
|
||||||
token: string;
|
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}`, {
|
const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, {
|
||||||
headers: DEFAULT_HEADERS
|
headers: headers
|
||||||
});
|
});
|
||||||
const pin: PlexPinResponse = await pinResponse.json();
|
const pin: PlexPinResponse = await pinResponse.json();
|
||||||
return pin.authToken || "";
|
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[]> {
|
async getServers(): Promise<PlexResource[]> {
|
||||||
const resourcesResponse = await fetch(`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`, {
|
const resourcesResponse = await fetch(`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`, {
|
||||||
headers: DEFAULT_HEADERS
|
headers: DEFAULT_HEADERS
|
||||||
|
|
|
@ -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 {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")
|
@customElement("ak-flow-sources-plex")
|
||||||
export class PlexLoginInit extends LitElement {
|
export class PlexLoginInit extends BaseStage {
|
||||||
|
|
||||||
render(): TemplateResult {
|
@property({ attribute: false })
|
||||||
return html``;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,15 +9,9 @@ import "../../../elements/forms/HorizontalFormElement";
|
||||||
import { ifDefined } from "lit-html/directives/if-defined";
|
import { ifDefined } from "lit-html/directives/if-defined";
|
||||||
import { until } from "lit-html/directives/until";
|
import { until } from "lit-html/directives/until";
|
||||||
import { first, randomString } from "../../../utils";
|
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")
|
@customElement("ak-source-plex-form")
|
||||||
export class PlexSourceForm extends Form<PlexSource> {
|
export class PlexSourceForm extends Form<PlexSource> {
|
||||||
|
|
||||||
|
@ -64,15 +58,11 @@ export class PlexSourceForm extends Form<PlexSource> {
|
||||||
async doAuth(): Promise<void> {
|
async doAuth(): Promise<void> {
|
||||||
const authInfo = await PlexAPIClient.getPin(this.source?.clientId);
|
const authInfo = await PlexAPIClient.getPin(this.source?.clientId);
|
||||||
const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700);
|
const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700);
|
||||||
const timer = setInterval(() => {
|
PlexAPIClient.pinPoll(this.source?.clientId || "", authInfo.pin.id).then(token => {
|
||||||
if (authWindow?.closed) {
|
authWindow?.close();
|
||||||
clearInterval(timer);
|
this.plexToken = token;
|
||||||
PlexAPIClient.pinStatus(authInfo.pin.id).then((token: string) => {
|
this.loadServers();
|
||||||
this.plexToken = token;
|
});
|
||||||
this.loadServers();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadServers(): Promise<void> {
|
async loadServers(): Promise<void> {
|
||||||
|
|
Reference in New Issue