sources/plex: initial plex source implementation
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		| @ -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
									
								
								authentik/sources/plex/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/plex/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										21
									
								
								authentik/sources/plex/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								authentik/sources/plex/api.py
									
									
									
									
									
										Normal file
									
								
							| @ -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" | ||||||
							
								
								
									
										10
									
								
								authentik/sources/plex/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/sources/plex/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -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" | ||||||
							
								
								
									
										45
									
								
								authentik/sources/plex/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								authentik/sources/plex/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @ -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
									
								
								authentik/sources/plex/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/plex/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										42
									
								
								authentik/sources/plex/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								authentik/sources/plex/models.py
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||||
|  | |||||||
							
								
								
									
										65
									
								
								web/src/flows/sources/plex/API.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								web/src/flows/sources/plex/API.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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"; | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								web/src/flows/sources/plex/PlexLoginInit.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								web/src/flows/sources/plex/PlexLoginInit.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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> { | ||||||
|  | |||||||
							
								
								
									
										193
									
								
								web/src/pages/sources/plex/PlexSourceForm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								web/src/pages/sources/plex/PlexSourceForm.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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
	
	Block a user
	 Jens Langhammer
					Jens Langhammer