sources/plex: initial plex source implementation
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
19708bc67b
commit
f1b100c8a5
|
@ -63,6 +63,7 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet
|
||||||
from authentik.sources.oauth.api.source_connection import (
|
from authentik.sources.oauth.api.source_connection import (
|
||||||
UserOAuthSourceConnectionViewSet,
|
UserOAuthSourceConnectionViewSet,
|
||||||
)
|
)
|
||||||
|
from authentik.sources.plex.api import PlexSourceViewSet
|
||||||
from authentik.sources.saml.api import SAMLSourceViewSet
|
from authentik.sources.saml.api import SAMLSourceViewSet
|
||||||
from authentik.stages.authenticator_static.api import (
|
from authentik.stages.authenticator_static.api import (
|
||||||
AuthenticatorStaticStageViewSet,
|
AuthenticatorStaticStageViewSet,
|
||||||
|
@ -136,6 +137,7 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS
|
||||||
router.register("sources/ldap", LDAPSourceViewSet)
|
router.register("sources/ldap", LDAPSourceViewSet)
|
||||||
router.register("sources/saml", SAMLSourceViewSet)
|
router.register("sources/saml", SAMLSourceViewSet)
|
||||||
router.register("sources/oauth", OAuthSourceViewSet)
|
router.register("sources/oauth", OAuthSourceViewSet)
|
||||||
|
router.register("sources/plex", PlexSourceViewSet)
|
||||||
|
|
||||||
router.register("policies/all", PolicyViewSet)
|
router.register("policies/all", PolicyViewSet)
|
||||||
router.register("policies/bindings", PolicyBindingViewSet)
|
router.register("policies/bindings", PolicyBindingViewSet)
|
||||||
|
|
|
@ -107,6 +107,7 @@ INSTALLED_APPS = [
|
||||||
"authentik.recovery",
|
"authentik.recovery",
|
||||||
"authentik.sources.ldap",
|
"authentik.sources.ldap",
|
||||||
"authentik.sources.oauth",
|
"authentik.sources.oauth",
|
||||||
|
"authentik.sources.plex",
|
||||||
"authentik.sources.saml",
|
"authentik.sources.saml",
|
||||||
"authentik.stages.authenticator_static",
|
"authentik.stages.authenticator_static",
|
||||||
"authentik.stages.authenticator_totp",
|
"authentik.stages.authenticator_totp",
|
||||||
|
|
|
@ -2,11 +2,21 @@
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.conf import settings
|
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
AUTHENTIK_SOURCES_OAUTH_TYPES = [
|
||||||
|
"authentik.sources.oauth.types.discord",
|
||||||
|
"authentik.sources.oauth.types.facebook",
|
||||||
|
"authentik.sources.oauth.types.github",
|
||||||
|
"authentik.sources.oauth.types.google",
|
||||||
|
"authentik.sources.oauth.types.reddit",
|
||||||
|
"authentik.sources.oauth.types.twitter",
|
||||||
|
"authentik.sources.oauth.types.azure_ad",
|
||||||
|
"authentik.sources.oauth.types.oidc",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class AuthentikSourceOAuthConfig(AppConfig):
|
class AuthentikSourceOAuthConfig(AppConfig):
|
||||||
"""authentik source.oauth config"""
|
"""authentik source.oauth config"""
|
||||||
|
@ -18,7 +28,7 @@ class AuthentikSourceOAuthConfig(AppConfig):
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""Load source_types from config file"""
|
"""Load source_types from config file"""
|
||||||
for source_type in settings.AUTHENTIK_SOURCES_OAUTH_TYPES:
|
for source_type in AUTHENTIK_SOURCES_OAUTH_TYPES:
|
||||||
try:
|
try:
|
||||||
import_module(source_type)
|
import_module(source_type)
|
||||||
LOGGER.debug("Loaded OAuth Source Type", type=source_type)
|
LOGGER.debug("Loaded OAuth Source Type", type=source_type)
|
||||||
|
|
|
@ -163,16 +163,6 @@ class OpenIDOAuthSource(OAuthSource):
|
||||||
verbose_name_plural = _("OpenID OAuth Sources")
|
verbose_name_plural = _("OpenID OAuth Sources")
|
||||||
|
|
||||||
|
|
||||||
class PlexOAuthSource(OAuthSource):
|
|
||||||
"""Login using plex.tv."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
|
|
||||||
abstract = True
|
|
||||||
verbose_name = _("Plex OAuth Source")
|
|
||||||
verbose_name_plural = _("Plex OAuth Sources")
|
|
||||||
|
|
||||||
|
|
||||||
class UserOAuthSourceConnection(UserSourceConnection):
|
class UserOAuthSourceConnection(UserSourceConnection):
|
||||||
"""Authorized remote OAuth provider."""
|
"""Authorized remote OAuth provider."""
|
||||||
|
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
"""Oauth2 Client Settings"""
|
|
||||||
|
|
||||||
AUTHENTIK_SOURCES_OAUTH_TYPES = [
|
|
||||||
"authentik.sources.oauth.types.discord",
|
|
||||||
"authentik.sources.oauth.types.facebook",
|
|
||||||
"authentik.sources.oauth.types.github",
|
|
||||||
"authentik.sources.oauth.types.google",
|
|
||||||
"authentik.sources.oauth.types.reddit",
|
|
||||||
"authentik.sources.oauth.types.twitter",
|
|
||||||
"authentik.sources.oauth.types.azure_ad",
|
|
||||||
"authentik.sources.oauth.types.oidc",
|
|
||||||
"authentik.sources.oauth.types.plex",
|
|
||||||
]
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Plex Source Serializer"""
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
|
from authentik.core.api.sources import SourceSerializer
|
||||||
|
from authentik.sources.plex.models import PlexSource
|
||||||
|
|
||||||
|
|
||||||
|
class PlexSourceSerializer(SourceSerializer):
|
||||||
|
"""Plex Source Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PlexSource
|
||||||
|
fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"]
|
||||||
|
|
||||||
|
|
||||||
|
class PlexSourceViewSet(ModelViewSet):
|
||||||
|
"""Plex source Viewset"""
|
||||||
|
|
||||||
|
queryset = PlexSource.objects.all()
|
||||||
|
serializer_class = PlexSourceSerializer
|
||||||
|
lookup_field = "slug"
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""authentik plex config"""
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthentikSourcePlexConfig(AppConfig):
|
||||||
|
"""authentik source plex config"""
|
||||||
|
|
||||||
|
name = "authentik.sources.plex"
|
||||||
|
label = "authentik_sources_plex"
|
||||||
|
verbose_name = "authentik Sources.Plex"
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Generated by Django 3.2 on 2021-05-02 12:34
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0019_source_managed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PlexSource",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"source_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="authentik_core.source",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("client_id", models.TextField()),
|
||||||
|
(
|
||||||
|
"allowed_servers",
|
||||||
|
django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), size=None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Plex Source",
|
||||||
|
"verbose_name_plural": "Plex Sources",
|
||||||
|
},
|
||||||
|
bases=("authentik_core.source",),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Plex source"""
|
||||||
|
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.serializers import BaseSerializer
|
||||||
|
|
||||||
|
from authentik.core.models import Source
|
||||||
|
from authentik.core.types import UILoginButton
|
||||||
|
|
||||||
|
|
||||||
|
class PlexSource(Source):
|
||||||
|
"""Authenticate against plex.tv"""
|
||||||
|
|
||||||
|
client_id = models.TextField()
|
||||||
|
allowed_servers = ArrayField(models.TextField())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def component(self) -> str:
|
||||||
|
return "ak-source-plex-form"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serializer(self) -> BaseSerializer:
|
||||||
|
from authentik.sources.plex.api import PlexSourceSerializer
|
||||||
|
|
||||||
|
return PlexSourceSerializer
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ui_login_button(self) -> UILoginButton:
|
||||||
|
return UILoginButton(
|
||||||
|
url="",
|
||||||
|
icon_url=static("authentik/sources/plex.svg"),
|
||||||
|
name=self.name,
|
||||||
|
additional_data={
|
||||||
|
"client_id": self.client_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
verbose_name = _("Plex Source")
|
||||||
|
verbose_name_plural = _("Plex Sources")
|
|
@ -85,6 +85,7 @@ class PlexOAuthClient(OAuth2Client):
|
||||||
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
||||||
"Fetch user profile information."
|
"Fetch user profile information."
|
||||||
qs = {"X-Plex-Token": token["plex_token"]}
|
qs = {"X-Plex-Token": token["plex_token"]}
|
||||||
|
print(token)
|
||||||
try:
|
try:
|
||||||
response = self.do_request(
|
response = self.do_request(
|
||||||
"get", f"https://plex.tv/users/account.json?{urlencode(qs)}"
|
"get", f"https://plex.tv/users/account.json?{urlencode(qs)}"
|
||||||
|
@ -94,7 +95,8 @@ class PlexOAuthClient(OAuth2Client):
|
||||||
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return response.json().get("user", {})
|
info = response.json()
|
||||||
|
return info.get("user", {})
|
||||||
|
|
||||||
|
|
||||||
class PlexOAuth2Callback(OAuthCallback):
|
class PlexOAuth2Callback(OAuthCallback):
|
269
swagger.yaml
269
swagger.yaml
|
@ -10213,6 +10213,205 @@ paths:
|
||||||
description: A unique integer value identifying this User OAuth Source Connection.
|
description: A unique integer value identifying this User OAuth Source Connection.
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
/sources/plex/:
|
||||||
|
get:
|
||||||
|
operationId: sources_plex_list
|
||||||
|
description: Plex source Viewset
|
||||||
|
parameters:
|
||||||
|
- name: ordering
|
||||||
|
in: query
|
||||||
|
description: Which field to use when ordering the results.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: A search term.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
description: Page Index
|
||||||
|
required: false
|
||||||
|
type: integer
|
||||||
|
- name: page_size
|
||||||
|
in: query
|
||||||
|
description: Page Size
|
||||||
|
required: false
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
required:
|
||||||
|
- results
|
||||||
|
- pagination
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pagination:
|
||||||
|
required:
|
||||||
|
- next
|
||||||
|
- previous
|
||||||
|
- count
|
||||||
|
- current
|
||||||
|
- total_pages
|
||||||
|
- start_index
|
||||||
|
- end_index
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
next:
|
||||||
|
type: number
|
||||||
|
previous:
|
||||||
|
type: number
|
||||||
|
count:
|
||||||
|
type: number
|
||||||
|
current:
|
||||||
|
type: number
|
||||||
|
total_pages:
|
||||||
|
type: number
|
||||||
|
start_index:
|
||||||
|
type: number
|
||||||
|
end_index:
|
||||||
|
type: number
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/PlexSource'
|
||||||
|
'403':
|
||||||
|
description: Authentication credentials were invalid, absent or insufficient.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/GenericError'
|
||||||
|
tags:
|
||||||
|
- sources
|
||||||
|
post:
|
||||||
|
operationId: sources_plex_create
|
||||||
|
description: Plex source Viewset
|
||||||
|
parameters:
|
||||||
|
- name: data
|
||||||
|
in: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/PlexSource'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/PlexSource'
|
||||||
|
'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
|
||||||
|
description: Plex source Viewset
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/PlexSource'
|
||||||
|
'403':
|
||||||
|
description: Authentication credentials were invalid, absent or insufficient.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/GenericError'
|
||||||
|
'404':
|
||||||
|
description: Object does not exist or caller has insufficient permissions
|
||||||
|
to access it.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/APIException'
|
||||||
|
tags:
|
||||||
|
- sources
|
||||||
|
put:
|
||||||
|
operationId: sources_plex_update
|
||||||
|
description: Plex source Viewset
|
||||||
|
parameters:
|
||||||
|
- name: data
|
||||||
|
in: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/PlexSource'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/PlexSource'
|
||||||
|
'400':
|
||||||
|
description: Invalid input.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ValidationError'
|
||||||
|
'403':
|
||||||
|
description: Authentication credentials were invalid, absent or insufficient.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/GenericError'
|
||||||
|
'404':
|
||||||
|
description: Object does not exist or caller has insufficient permissions
|
||||||
|
to access it.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/APIException'
|
||||||
|
tags:
|
||||||
|
- sources
|
||||||
|
patch:
|
||||||
|
operationId: sources_plex_partial_update
|
||||||
|
description: Plex source Viewset
|
||||||
|
parameters:
|
||||||
|
- name: data
|
||||||
|
in: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/PlexSource'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/PlexSource'
|
||||||
|
'400':
|
||||||
|
description: Invalid input.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ValidationError'
|
||||||
|
'403':
|
||||||
|
description: Authentication credentials were invalid, absent or insufficient.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/GenericError'
|
||||||
|
'404':
|
||||||
|
description: Object does not exist or caller has insufficient permissions
|
||||||
|
to access it.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/APIException'
|
||||||
|
tags:
|
||||||
|
- sources
|
||||||
|
delete:
|
||||||
|
operationId: sources_plex_delete
|
||||||
|
description: Plex source Viewset
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: ''
|
||||||
|
'403':
|
||||||
|
description: Authentication credentials were invalid, absent or insufficient.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/GenericError'
|
||||||
|
'404':
|
||||||
|
description: Object does not exist or caller has insufficient permissions
|
||||||
|
to access it.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/APIException'
|
||||||
|
tags:
|
||||||
|
- sources
|
||||||
|
parameters:
|
||||||
|
- name: slug
|
||||||
|
in: path
|
||||||
|
description: Internal source name, used in URLs.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
format: slug
|
||||||
|
pattern: ^[-a-zA-Z0-9_]+$
|
||||||
/sources/saml/:
|
/sources/saml/:
|
||||||
get:
|
get:
|
||||||
operationId: sources_saml_list
|
operationId: sources_saml_list
|
||||||
|
@ -16210,6 +16409,7 @@ definitions:
|
||||||
- authentik.recovery
|
- authentik.recovery
|
||||||
- authentik.sources.ldap
|
- authentik.sources.ldap
|
||||||
- authentik.sources.oauth
|
- authentik.sources.oauth
|
||||||
|
- authentik.sources.plex
|
||||||
- authentik.sources.saml
|
- authentik.sources.saml
|
||||||
- authentik.stages.authenticator_static
|
- authentik.stages.authenticator_static
|
||||||
- authentik.stages.authenticator_totp
|
- authentik.stages.authenticator_totp
|
||||||
|
@ -17386,6 +17586,75 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 255
|
maxLength: 255
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
PlexSource:
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- slug
|
||||||
|
- client_id
|
||||||
|
- allowed_servers
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pk:
|
||||||
|
title: Pbm uuid
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
readOnly: true
|
||||||
|
name:
|
||||||
|
title: Name
|
||||||
|
description: Source's display Name.
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
slug:
|
||||||
|
title: Slug
|
||||||
|
description: Internal source name, used in URLs.
|
||||||
|
type: string
|
||||||
|
format: slug
|
||||||
|
pattern: ^[-a-zA-Z0-9_]+$
|
||||||
|
maxLength: 50
|
||||||
|
minLength: 1
|
||||||
|
enabled:
|
||||||
|
title: Enabled
|
||||||
|
type: boolean
|
||||||
|
authentication_flow:
|
||||||
|
title: Authentication flow
|
||||||
|
description: Flow to use when authenticating existing users.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
|
enrollment_flow:
|
||||||
|
title: Enrollment flow
|
||||||
|
description: Flow to use when enrolling new users.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
|
component:
|
||||||
|
title: Component
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
verbose_name:
|
||||||
|
title: Verbose name
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
verbose_name_plural:
|
||||||
|
title: Verbose name plural
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
policy_engine_mode:
|
||||||
|
title: Policy engine mode
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- all
|
||||||
|
- any
|
||||||
|
client_id:
|
||||||
|
title: Client id
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
allowed_servers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
title: Allowed servers
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
SAMLSource:
|
SAMLSource:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { VERSION } from "../../../constants";
|
||||||
|
|
||||||
|
export interface PlexPinResponse {
|
||||||
|
// Only has the fields we care about
|
||||||
|
authToken?: string;
|
||||||
|
code: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlexResource {
|
||||||
|
name: string;
|
||||||
|
provides: string;
|
||||||
|
clientIdentifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HEADERS = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Plex-Product": "authentik",
|
||||||
|
"X-Plex-Version": VERSION,
|
||||||
|
"X-Plex-Device-Vendor": "BeryJu.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PlexAPIClient {
|
||||||
|
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
constructor(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPin(clientIdentifier: string): Promise<{ authUrl: string, pin: PlexPinResponse }> {
|
||||||
|
const headers = { ...DEFAULT_HEADERS, ...{
|
||||||
|
"X-Plex-Client-Identifier": clientIdentifier
|
||||||
|
}};
|
||||||
|
const pinResponse = await fetch("https://plex.tv/api/v2/pins.json?strong=true", {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers
|
||||||
|
});
|
||||||
|
const pin: PlexPinResponse = await pinResponse.json();
|
||||||
|
return {
|
||||||
|
authUrl: `https://app.plex.tv/auth#!?clientID=${encodeURIComponent(clientIdentifier)}&code=${pin.code}`,
|
||||||
|
pin: pin
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async pinStatus(id: number): Promise<string> {
|
||||||
|
const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, {
|
||||||
|
headers: DEFAULT_HEADERS
|
||||||
|
});
|
||||||
|
const pin: PlexPinResponse = await pinResponse.json();
|
||||||
|
return pin.authToken || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
const resources: PlexResource[] = await resourcesResponse.json();
|
||||||
|
return resources.filter(r => {
|
||||||
|
return r.provides === "server";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import {customElement, LitElement} from "lit-element";
|
||||||
|
import {html, TemplateResult} from "lit-html";
|
||||||
|
|
||||||
|
@customElement("ak-flow-sources-plex")
|
||||||
|
export class PlexLoginInit extends LitElement {
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ import { ifDefined } from "lit-html/directives/if-defined";
|
||||||
import "./ldap/LDAPSourceForm";
|
import "./ldap/LDAPSourceForm";
|
||||||
import "./saml/SAMLSourceForm";
|
import "./saml/SAMLSourceForm";
|
||||||
import "./oauth/OAuthSourceForm";
|
import "./oauth/OAuthSourceForm";
|
||||||
|
import "./plex/PlexSourceForm";
|
||||||
|
|
||||||
@customElement("ak-source-list")
|
@customElement("ak-source-list")
|
||||||
export class SourceListPage extends TablePage<Source> {
|
export class SourceListPage extends TablePage<Source> {
|
||||||
|
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { PlexSource, SourcesApi, FlowsApi, FlowDesignationEnum } from "authentik-api";
|
||||||
|
import { t } from "@lingui/macro";
|
||||||
|
import { customElement, property } from "lit-element";
|
||||||
|
import { html, TemplateResult } from "lit-html";
|
||||||
|
import { DEFAULT_CONFIG } from "../../../api/Config";
|
||||||
|
import { Form } from "../../../elements/forms/Form";
|
||||||
|
import "../../../elements/forms/FormGroup";
|
||||||
|
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";
|
||||||
|
|
||||||
|
|
||||||
|
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> {
|
||||||
|
|
||||||
|
set sourceSlug(value: string) {
|
||||||
|
new SourcesApi(DEFAULT_CONFIG).sourcesPlexRead({
|
||||||
|
slug: value,
|
||||||
|
}).then(source => {
|
||||||
|
this.source = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({attribute: false})
|
||||||
|
source: PlexSource = {
|
||||||
|
clientId: randomString(40)
|
||||||
|
} as PlexSource;
|
||||||
|
|
||||||
|
@property()
|
||||||
|
plexToken?: string;
|
||||||
|
|
||||||
|
@property({attribute: false})
|
||||||
|
plexResources?: PlexResource[];
|
||||||
|
|
||||||
|
getSuccessMessage(): string {
|
||||||
|
if (this.source) {
|
||||||
|
return t`Successfully updated source.`;
|
||||||
|
} else {
|
||||||
|
return t`Successfully created source.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send = (data: PlexSource): Promise<PlexSource> => {
|
||||||
|
if (this.source.slug) {
|
||||||
|
return new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({
|
||||||
|
slug: this.source.slug,
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return new SourcesApi(DEFAULT_CONFIG).sourcesPlexCreate({
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
this.plexToken = token;
|
||||||
|
this.loadServers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadServers(): Promise<void> {
|
||||||
|
if (!this.plexToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.plexResources = await new PlexAPIClient(this.plexToken).getServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderForm(): TemplateResult {
|
||||||
|
return html`<form class="pf-c-form pf-m-horizontal">
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${t`Name`}
|
||||||
|
?required=${true}
|
||||||
|
name="name">
|
||||||
|
<input type="text" value="${ifDefined(this.source?.name)}" class="pf-c-form-control" required>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${t`Slug`}
|
||||||
|
?required=${true}
|
||||||
|
name="slug">
|
||||||
|
<input type="text" value="${ifDefined(this.source?.slug)}" class="pf-c-form-control" required>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal name="enabled">
|
||||||
|
<div class="pf-c-check">
|
||||||
|
<input type="checkbox" class="pf-c-check__input" ?checked=${first(this.source?.enabled, true)}>
|
||||||
|
<label class="pf-c-check__label">
|
||||||
|
${t`Enabled`}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
|
||||||
|
<ak-form-group .expanded=${true}>
|
||||||
|
<span slot="header">
|
||||||
|
${t`Protocol settings`}
|
||||||
|
</span>
|
||||||
|
<div slot="body" class="pf-c-form">
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${t`Client ID`}
|
||||||
|
?required=${true}
|
||||||
|
name="clientId">
|
||||||
|
<input type="text" value="${first(this.source?.clientId)}" class="pf-c-form-control" required>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${t`Allowed servers`}
|
||||||
|
?required=${true}
|
||||||
|
name="allowedServers">
|
||||||
|
<select class="pf-c-form-control" multiple>
|
||||||
|
${this.plexResources?.map(r => {
|
||||||
|
const selected = Array.from(this.source?.allowedServers || []).some(server => {
|
||||||
|
return server == r.clientIdentifier;
|
||||||
|
});
|
||||||
|
return html`<option value=${r.clientIdentifier} ?selected=${selected}>${r.name}</option>`;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<p class="pf-c-form__helper-text">${t`Select which server a user has to be a member of to be allowed to authenticate.`}</p>
|
||||||
|
<p class="pf-c-form__helper-text">${t`Hold control/command to select multiple items.`}</p>
|
||||||
|
<p class="pf-c-form__helper-text">
|
||||||
|
<button class="pf-c-button pf-m-primary" type="button" @click=${() => {
|
||||||
|
this.doAuth();
|
||||||
|
}}>
|
||||||
|
${t`Load servers`}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
</div>
|
||||||
|
</ak-form-group>
|
||||||
|
<ak-form-group>
|
||||||
|
<span slot="header">
|
||||||
|
${t`Flow settings`}
|
||||||
|
</span>
|
||||||
|
<div slot="body" class="pf-c-form">
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${t`Authentication flow`}
|
||||||
|
?required=${true}
|
||||||
|
name="authenticationFlow">
|
||||||
|
<select class="pf-c-form-control">
|
||||||
|
${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
|
||||||
|
ordering: "pk",
|
||||||
|
designation: FlowDesignationEnum.Authentication,
|
||||||
|
}).then(flows => {
|
||||||
|
return flows.results.map(flow => {
|
||||||
|
let selected = this.source?.authenticationFlow === flow.pk;
|
||||||
|
if (!this.source?.pk && !this.source?.authenticationFlow && flow.slug === "default-source-authentication") {
|
||||||
|
selected = true;
|
||||||
|
}
|
||||||
|
return html`<option value=${ifDefined(flow.pk)} ?selected=${selected}>${flow.name} (${flow.slug})</option>`;
|
||||||
|
});
|
||||||
|
}), html`<option>${t`Loading...`}</option>`)}
|
||||||
|
</select>
|
||||||
|
<p class="pf-c-form__helper-text">${t`Flow to use when authenticating existing users.`}</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${t`Enrollment flow`}
|
||||||
|
?required=${true}
|
||||||
|
name="enrollmentFlow">
|
||||||
|
<select class="pf-c-form-control">
|
||||||
|
${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
|
||||||
|
ordering: "pk",
|
||||||
|
designation: FlowDesignationEnum.Enrollment,
|
||||||
|
}).then(flows => {
|
||||||
|
return flows.results.map(flow => {
|
||||||
|
let selected = this.source?.enrollmentFlow === flow.pk;
|
||||||
|
if (!this.source?.pk && !this.source?.enrollmentFlow && flow.slug === "default-source-enrollment") {
|
||||||
|
selected = true;
|
||||||
|
}
|
||||||
|
return html`<option value=${ifDefined(flow.pk)} ?selected=${selected}>${flow.name} (${flow.slug})</option>`;
|
||||||
|
});
|
||||||
|
}), html`<option>${t`Loading...`}</option>`)}
|
||||||
|
</select>
|
||||||
|
<p class="pf-c-form__helper-text">${t`Flow to use when enrolling new users.`}</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
</div>
|
||||||
|
</ak-form-group>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Reference in New Issue