diff --git a/authentik/sources/plex/api/source.py b/authentik/sources/plex/api/source.py index e838634230..4e14e51d6d 100644 --- a/authentik/sources/plex/api/source.py +++ b/authentik/sources/plex/api/source.py @@ -5,7 +5,7 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.fields import CharField -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ValidationError @@ -18,7 +18,7 @@ from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import PassiveSerializer from authentik.flows.challenge import RedirectChallenge from authentik.flows.views.executor import to_stage_response -from authentik.sources.plex.models import PlexSource +from authentik.sources.plex.models import PlexSource, PlexSourceConnection from authentik.sources.plex.plex import PlexAuth, PlexSourceFlowManager LOGGER = get_logger() @@ -98,21 +98,11 @@ class PlexSourceViewSet(UsedByMixin, ModelViewSet): user_info, identifier = auth_api.get_user_info() # Check friendship first, then check server overlay friends_allowed = False - owner_id = None if source.allow_friends: owner_api = PlexAuth(source, source.plex_token) - owner_id = owner_api.get_user_info - owner_friends = owner_api.get_friends() - for friend in owner_friends: - if int(friend.get("id", "0")) == int(identifier): - friends_allowed = True - LOGGER.info( - "allowing user for plex because of friend", - user=user_info["username"], - ) + friends_allowed = owner_api.check_friends_overlap(identifier) servers_allowed = auth_api.check_server_overlap() - owner_allowed = owner_id == identifier - if any([friends_allowed, servers_allowed, owner_allowed]): + if any([friends_allowed, servers_allowed]): sfm = PlexSourceFlowManager( source=source, request=request, @@ -125,3 +115,57 @@ class PlexSourceViewSet(UsedByMixin, ModelViewSet): user=user_info["username"], ) raise PermissionDenied("Access denied.") + + @extend_schema( + request=PlexTokenRedeemSerializer(), + responses={ + 204: OpenApiResponse(), + 400: OpenApiResponse(description="Token not found"), + 403: OpenApiResponse(description="Access denied"), + }, + parameters=[ + OpenApiParameter( + name="slug", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + ) + ], + ) + @action( + methods=["POST"], + detail=False, + pagination_class=None, + filter_backends=[], + permission_classes=[IsAuthenticated], + ) + def redeem_token_authenticated(self, request: Request) -> Response: + """Redeem a plex token for an authenticated user, creating a connection""" + 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 ValidationError("No plex token given") + auth_api = PlexAuth(source, plex_token) + user_info, identifier = auth_api.get_user_info() + # Check friendship first, then check server overlay + friends_allowed = False + if source.allow_friends: + owner_api = PlexAuth(source, source.plex_token) + friends_allowed = owner_api.check_friends_overlap(identifier) + servers_allowed = auth_api.check_server_overlap() + if any([friends_allowed, servers_allowed]): + PlexSourceConnection.objects.create( + plex_token=plex_token, + user=request.user, + identifier=identifier, + source=source, + ) + return Response(status=204) + LOGGER.warning( + "Denying plex connection because no server overlay and no friends and not owner", + user=user_info["username"], + friends_allowed=friends_allowed, + servers_allowed=servers_allowed, + ) + raise PermissionDenied("Access denied.") diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index 6a1d91583b..6ccb0293ac 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -83,6 +83,7 @@ class PlexSource(Source): data={ "title": f"Plex {self.name}", "component": "ak-user-settings-source-plex", + "configure_url": self.client_id, } ) diff --git a/authentik/sources/plex/plex.py b/authentik/sources/plex/plex.py index f1f91e3957..29b7c622a8 100644 --- a/authentik/sources/plex/plex.py +++ b/authentik/sources/plex/plex.py @@ -36,7 +36,7 @@ class PlexAuth: return { "X-Plex-Product": "authentik", "X-Plex-Version": __version__, - "X-Plex-Device-Vendor": "BeryJu.org", + "X-Plex-Device-Vendor": "goauthentik.io", } def get_resources(self) -> list[dict]: @@ -96,6 +96,21 @@ class PlexAuth: return True return False + def check_friends_overlap(self, user_ident: int) -> bool: + """Check if the user is a friend of the owner, or the owner themselves""" + friends_allowed = False + _, owner_id = self.get_user_info() + owner_friends = self.get_friends() + for friend in owner_friends: + if int(friend.get("id", "0")) == user_ident: + friends_allowed = True + LOGGER.info( + "allowing user for plex because of friend", + user=user_ident, + ) + owner_allowed = owner_id == user_ident + return any([friends_allowed, owner_allowed]) + class PlexSourceFlowManager(SourceFlowManager): """Flow manager for plex sources""" diff --git a/schema.yml b/schema.yml index 6aa838fd3a..e856e624c5 100644 --- a/schema.yml +++ b/schema.yml @@ -11884,21 +11884,6 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' - /sentry/: - post: - operationId: sentry_create - description: Sentry tunnel, to prevent ad blockers from blocking sentry - tags: - - sentry - security: - - {} - responses: - '200': - description: No response body - '400': - $ref: '#/components/schemas/ValidationError' - '403': - $ref: '#/components/schemas/GenericError' /sources/all/: get: operationId: sources_all_list @@ -12979,6 +12964,32 @@ paths: description: Token not found '403': description: Access denied + /sources/plex/redeem_token_authenticated/: + post: + operationId: sources_plex_redeem_token_authenticated_create + description: Redeem a plex token for an authenticated user, creating a connection + parameters: + - in: query + name: slug + schema: + type: string + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlexTokenRedeemRequest' + required: true + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + description: Token not found + '403': + description: Access denied /sources/saml/: get: operationId: sources_saml_list diff --git a/web/src/flows/sources/plex/API.ts b/web/src/api/Plex.ts similarity index 93% rename from web/src/flows/sources/plex/API.ts rename to web/src/api/Plex.ts index 1fdc201608..d619d24e65 100644 --- a/web/src/flows/sources/plex/API.ts +++ b/web/src/api/Plex.ts @@ -1,4 +1,4 @@ -import { VERSION } from "../../../constants"; +import { VERSION } from "../constants"; export interface PlexPinResponse { // Only has the fields we care about @@ -72,8 +72,12 @@ export class PlexAPIClient { const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, { headers: headers, }); + if (pinResponse.status > 200) { + throw new Error("Invalid response code") + } const pin: PlexPinResponse = await pinResponse.json(); - return pin.authToken || ""; + console.debug(`authentik/plex: polling Pin`); + return pin.authToken; } static async pinPoll(clientIdentifier: string, id: number): Promise { diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts index 2144cc8c9d..c4ad53f5d6 100644 --- a/web/src/flows/sources/plex/PlexLoginInit.ts +++ b/web/src/flows/sources/plex/PlexLoginInit.ts @@ -19,10 +19,10 @@ import { import { SourcesApi } from "@goauthentik/api"; import { DEFAULT_CONFIG } from "../../../api/Config"; +import { PlexAPIClient, popupCenterScreen } from "../../../api/Plex"; import { MessageLevel } from "../../../elements/messages/Message"; import { showMessage } from "../../../elements/messages/MessageContainer"; import { BaseStage } from "../../stages/base"; -import { PlexAPIClient, popupCenterScreen } from "./API"; @customElement("ak-flow-sources-plex") export class PlexLoginInit extends BaseStage< diff --git a/web/src/locales/en.po b/web/src/locales/en.po index ec51559271..916db56d77 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -931,6 +931,7 @@ msgid "Configure what data should be used as unique User Identifier. For most ca msgstr "Configure what data should be used as unique User Identifier. For most cases, the default should be fine." #: src/user/user-settings/sources/SourceSettingsOAuth.ts +#: src/user/user-settings/sources/SourceSettingsPlex.ts msgid "Connect" msgstr "Connect" diff --git a/web/src/locales/fr_FR.po b/web/src/locales/fr_FR.po index 2f25fa47c8..e19a0a461b 100644 --- a/web/src/locales/fr_FR.po +++ b/web/src/locales/fr_FR.po @@ -929,6 +929,7 @@ msgid "Configure what data should be used as unique User Identifier. For most ca msgstr "Configure quelle donnée utiliser pour l'identifiant unique utilisateur. La valeur par défaut devrait être correcte dans la plupart des cas." #: src/user/user-settings/sources/SourceSettingsOAuth.ts +#: src/user/user-settings/sources/SourceSettingsPlex.ts msgid "Connect" msgstr "Connecter" diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index 44eb77cc37..97d8204fc7 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -925,6 +925,7 @@ msgid "Configure what data should be used as unique User Identifier. For most ca msgstr "" #: src/user/user-settings/sources/SourceSettingsOAuth.ts +#: src/user/user-settings/sources/SourceSettingsPlex.ts msgid "Connect" msgstr "" diff --git a/web/src/pages/sources/plex/PlexSourceForm.ts b/web/src/pages/sources/plex/PlexSourceForm.ts index 2f23294df0..08f7eaea0e 100644 --- a/web/src/pages/sources/plex/PlexSourceForm.ts +++ b/web/src/pages/sources/plex/PlexSourceForm.ts @@ -14,10 +14,10 @@ import { } from "@goauthentik/api"; import { DEFAULT_CONFIG } from "../../../api/Config"; +import { PlexAPIClient, PlexResource, popupCenterScreen } from "../../../api/Plex"; import "../../../elements/forms/FormGroup"; import "../../../elements/forms/HorizontalFormElement"; import { ModelForm } from "../../../elements/forms/ModelForm"; -import { PlexAPIClient, PlexResource, popupCenterScreen } from "../../../flows/sources/plex/API"; import { first, randomString } from "../../../utils"; @customElement("ak-source-plex-form") diff --git a/web/src/user/user-settings/sources/SourceSettings.ts b/web/src/user/user-settings/sources/SourceSettings.ts index 4a7a20c87d..9929376a99 100644 --- a/web/src/user/user-settings/sources/SourceSettings.ts +++ b/web/src/user/user-settings/sources/SourceSettings.ts @@ -47,6 +47,7 @@ export class UserSourceSettingsPage extends LitElement { return html` `; default: diff --git a/web/src/user/user-settings/sources/SourceSettingsPlex.ts b/web/src/user/user-settings/sources/SourceSettingsPlex.ts index 292d2ad473..d740d21544 100644 --- a/web/src/user/user-settings/sources/SourceSettingsPlex.ts +++ b/web/src/user/user-settings/sources/SourceSettingsPlex.ts @@ -7,6 +7,8 @@ import { until } from "lit/directives/until"; import { SourcesApi } from "@goauthentik/api"; import { DEFAULT_CONFIG } from "../../../api/Config"; +import { PlexAPIClient, popupCenterScreen } from "../../../api/Plex"; +import { EVENT_REFRESH } from "../../../constants"; import { BaseUserSettings } from "../BaseUserSettings"; @customElement("ak-user-settings-source-plex") @@ -21,6 +23,26 @@ export class SourceSettingsPlex extends BaseUserSettings { `; } + async doPlex(): Promise { + const authInfo = await PlexAPIClient.getPin(this.configureUrl || ""); + const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700); + PlexAPIClient.pinPoll(this.configureUrl || "", authInfo.pin.id).then((token) => { + authWindow?.close(); + new SourcesApi(DEFAULT_CONFIG).sourcesPlexRedeemTokenAuthenticatedCreate({ + plexTokenRedeemRequest: { + plexToken: token, + }, + slug: this.objectId, + }); + }); + this.dispatchEvent( + new CustomEvent(EVENT_REFRESH, { + bubbles: true, + composed: true, + }), + ); + } + renderInner(): TemplateResult { return html`${until( new SourcesApi(DEFAULT_CONFIG) @@ -43,7 +65,10 @@ export class SourceSettingsPlex extends BaseUserSettings { ${t`Disconnect`} `; } - return html`

${t`Not connected.`}

`; + return html`

${t`Not connected.`}

+ `; }), )}`; }