diff --git a/authentik/sources/oauth/apps.py b/authentik/sources/oauth/apps.py index 919bf9ba10..ced0e718cd 100644 --- a/authentik/sources/oauth/apps.py +++ b/authentik/sources/oauth/apps.py @@ -22,6 +22,7 @@ AUTHENTIK_SOURCES_OAUTH_TYPES = [ "authentik.sources.oauth.types.okta", "authentik.sources.oauth.types.patreon", "authentik.sources.oauth.types.reddit", + "authentik.sources.oauth.types.slack", "authentik.sources.oauth.types.twitch", "authentik.sources.oauth.types.twitter", ] diff --git a/authentik/sources/oauth/migrations/0013_useroauthsourceconnection_refresh_token.py b/authentik/sources/oauth/migrations/0013_useroauthsourceconnection_refresh_token.py new file mode 100644 index 0000000000..8757269a25 --- /dev/null +++ b/authentik/sources/oauth/migrations/0013_useroauthsourceconnection_refresh_token.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-11-26 21:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_oauth", "0012_oauthsource_pkce"), + ] + + operations = [ + migrations.AddField( + model_name="useroauthsourceconnection", + name="refresh_token", + field=models.TextField(blank=True, default=None, null=True), + ), + ] diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index 1263dfbf37..2232d5b128 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -224,6 +224,15 @@ class DiscordOAuthSource(CreatableType, OAuthSource): verbose_name_plural = _("Discord OAuth Sources") +class SlackOAuthSource(CreatableType, OAuthSource): + """Social Login using Slack.""" + + class Meta: + abstract = True + verbose_name = _("Slack OAuth Source") + verbose_name_plural = _("Slack OAuth Sources") + + class PatreonOAuthSource(CreatableType, OAuthSource): """Social Login using Patreon.""" @@ -322,6 +331,7 @@ class UserOAuthSourceConnection(UserSourceConnection): """Authorized remote OAuth provider.""" access_token = models.TextField(blank=True, null=True, default=None) + refresh_token = models.TextField(blank=True, null=True, default=None) expires = models.DateTimeField(default=now) @property @@ -336,10 +346,6 @@ class UserOAuthSourceConnection(UserSourceConnection): return UserOAuthSourceConnectionSerializer - def save(self, *args, **kwargs): - self.access_token = self.access_token or None - super().save(*args, **kwargs) - class Meta: verbose_name = _("User OAuth Source Connection") verbose_name_plural = _("User OAuth Source Connections") diff --git a/authentik/sources/oauth/tests/test_type_slack.py b/authentik/sources/oauth/tests/test_type_slack.py new file mode 100644 index 0000000000..94b92be936 --- /dev/null +++ b/authentik/sources/oauth/tests/test_type_slack.py @@ -0,0 +1,182 @@ +"""Slack Type tests""" + +from unittest.mock import patch + +from django.test import TestCase + +from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.types.slack import ( + SlackOAuth2Callback, + SlackOAuthClient, + SlackType, +) + +# https://api.slack.com/methods/openid.connect.userInfo +SLACK_USER = { + "ok": True, + "sub": "U09VAHA70UU", + "https://slack.com/user_id": "U09VAHA70UU", + "https://slack.com/team_id": "T08G285D7DX", + "email": "user@example.com", + "email_verified": True, + "name": "Test User", + "picture": "https://secure.gravatar.com/avatar/test.jpg", + "given_name": "Test", + "family_name": "User", + "locale": "en-US", + "https://slack.com/team_name": "Test Workspace", + "https://slack.com/team_domain": "test-workspace", +} + +# https://api.slack.com/methods/oauth.v2.access +# Slack oauth.v2.access response with user token (Sign in with Slack, no token rotation) +SLACK_TOKEN_RESPONSE = { + "ok": True, + "app_id": "A0118NQPZZC", + "authed_user": { + "id": "U065VRX1T0", + "scope": "openid,email,profile", + "access_token": "xoxp-user-token-12345", + "token_type": "user", + }, + "team": {"id": "T024BE7LD"}, + "enterprise": None, + "is_enterprise_install": False, +} + +# https://api.slack.com/methods/oauth.v2.access +# https://api.slack.com/authentication/rotation +# Slack oauth.v2.access response with token rotation enabled +SLACK_TOKEN_RESPONSE_WITH_REFRESH = { + "ok": True, + "app_id": "A0KRD7HC3", + "authed_user": { + "id": "U1234", + "scope": "openid,email,profile", + "access_token": "xoxe.xoxp-1234", + "refresh_token": "xoxe-1-refresh-token", + "token_type": "user", + "expires_in": 43200, + }, + "team": {"id": "T9TK3CUKW", "name": "Slack Softball Team"}, + "enterprise": None, + "is_enterprise_install": False, +} + + +class TestTypeSlack(TestCase): + """Slack OAuth Source tests""" + + def setUp(self): + self.source = OAuthSource.objects.create( + name="test", + slug="test", + provider_type="slack", + ) + + def test_enroll_context(self): + """Test Slack enrollment context""" + ak_context = SlackType().get_base_user_properties( + source=self.source, info=SLACK_USER, token={} + ) + self.assertEqual(ak_context["username"], SLACK_USER["name"]) + self.assertEqual(ak_context["email"], SLACK_USER["email"]) + self.assertEqual(ak_context["name"], SLACK_USER["name"]) + + def test_get_user_id(self): + """Test Slack user ID extraction from profile info""" + callback = SlackOAuth2Callback() + # Test with 'sub' field (OIDC userinfo response) + self.assertEqual(callback.get_user_id({"sub": "U12345"}), "U12345") + # Test with no sub + self.assertIsNone(callback.get_user_id({})) + + def test_token_extraction_user_token(self): + """Test that user token is extracted from nested authed_user""" + client = SlackOAuthClient(self.source, None) + + # Mock the parent class method to return Slack's nested response + with patch( + "authentik.sources.oauth.clients.oauth2.OAuth2Client.get_access_token" + ) as mock_parent: + mock_parent.return_value = SLACK_TOKEN_RESPONSE.copy() + + token = client.get_access_token() + + # Verify user token was extracted to top level + self.assertEqual(token["access_token"], "xoxp-user-token-12345") + # Verify token_type was normalized to Bearer + self.assertEqual(token["token_type"], "Bearer") + # Verify user ID was extracted + self.assertEqual(token["id"], "U065VRX1T0") + + def test_token_extraction_with_refresh(self): + """Test that refresh_token and expires_in are extracted when present""" + client = SlackOAuthClient(self.source, None) + + with patch( + "authentik.sources.oauth.clients.oauth2.OAuth2Client.get_access_token" + ) as mock_parent: + mock_parent.return_value = SLACK_TOKEN_RESPONSE_WITH_REFRESH.copy() + + token = client.get_access_token() + + # Verify tokens were extracted from authed_user + self.assertEqual(token["access_token"], "xoxe.xoxp-1234") + self.assertEqual(token["refresh_token"], "xoxe-1-refresh-token") + self.assertEqual(token["expires_in"], 43200) + self.assertEqual(token["token_type"], "Bearer") + self.assertEqual(token["id"], "U1234") + + def test_token_type_always_bearer(self): + """Test that token_type is always set to Bearer""" + client = SlackOAuthClient(self.source, None) + + # Test with authed_user response + with patch( + "authentik.sources.oauth.clients.oauth2.OAuth2Client.get_access_token" + ) as mock_parent: + mock_parent.return_value = { + "ok": True, + "authed_user": { + "id": "U12345", + "access_token": "xoxp-test", + "token_type": "user", # Slack returns "user" + }, + } + token = client.get_access_token() + self.assertEqual(token["token_type"], "Bearer") + + # Test with bot token response (no authed_user) + with patch( + "authentik.sources.oauth.clients.oauth2.OAuth2Client.get_access_token" + ) as mock_parent: + mock_parent.return_value = { + "ok": True, + "access_token": "xoxb-bot-token", + # No token_type in response + } + token = client.get_access_token() + self.assertEqual(token["token_type"], "Bearer") + + def test_token_error_passthrough(self): + """Test that error responses are passed through unchanged""" + client = SlackOAuthClient(self.source, None) + + with patch( + "authentik.sources.oauth.clients.oauth2.OAuth2Client.get_access_token" + ) as mock_parent: + mock_parent.return_value = {"error": "invalid_grant"} + token = client.get_access_token() + self.assertEqual(token, {"error": "invalid_grant"}) + + def test_token_none_passthrough(self): + """Test that None is passed through""" + client = SlackOAuthClient(self.source, None) + + with patch( + "authentik.sources.oauth.clients.oauth2.OAuth2Client.get_access_token" + ) as mock_parent: + mock_parent.return_value = None + token = client.get_access_token() + self.assertIsNone(token) diff --git a/authentik/sources/oauth/types/slack.py b/authentik/sources/oauth/types/slack.py new file mode 100644 index 0000000000..e15eb11637 --- /dev/null +++ b/authentik/sources/oauth/types/slack.py @@ -0,0 +1,141 @@ +"""Slack OAuth Views""" + +from typing import Any + +from django.http import Http404 + +from authentik.sources.oauth.clients.oauth2 import OAuth2Client +from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.types.registry import SourceType, registry +from authentik.sources.oauth.views.callback import OAuthCallback +from authentik.sources.oauth.views.redirect import OAuthRedirect + + +class SlackOAuthClient(OAuth2Client): + """Slack OAuth2 Client that handles Slack's nested token response. + + Slack's oauth.v2.access returns tokens in a nested structure: + { + "ok": true, + "access_token": "xoxb-...", # bot token + "refresh_token": "xoxe-1-...", # bot refresh token (if rotation enabled) + "authed_user": { + "id": "U1234", + "scope": "...", + "access_token": "xoxp-...", # user token + "refresh_token": "xoxe-1-...", # user refresh token (if rotation enabled) + "token_type": "user", + "expires_in": 43200 + } + } + + For user scopes (like admin for SCIM), we need the authed_user token. + """ + + def get_access_token(self, **request_kwargs) -> dict[str, Any] | None: + """Fetch access token and normalize Slack's nested response.""" + token = super().get_access_token(**request_kwargs) + if token is None or "error" in token: + return token + + # If we have authed_user with access_token, use that (user token) + # If authed_user isn't there, then we were given a bot token + if "authed_user" in token and "access_token" in token.get("authed_user", {}): + authed_user = token["authed_user"] + token["access_token"] = authed_user["access_token"] + + if "refresh_token" in authed_user: + token["refresh_token"] = authed_user["refresh_token"] + if "expires_in" in authed_user: + token["expires_in"] = authed_user["expires_in"] + token["id"] = authed_user.get("id") + + # Slack returns "user", but API expects Bearer + token["token_type"] = "Bearer" # nosec B105 - not a password, OAuth token type + + return token + + +class SlackOAuthRedirect(OAuthRedirect): + """Slack OAuth2 Redirect + + Slack uses two separate scope parameters: + - scope: Bot token scopes (xoxb- tokens) + - user_scope: User token scopes (xoxp- tokens) + + For user authentication and SCIM, + we need scopes in user_scope, not scope. + """ + + def get_additional_parameters(self, source): + # Start with base user scopes for authentication + user_scopes = ["openid", "email", "profile"] + + # Add any additional scopes from the source config to user_scope + # (not to scope, which is for bot tokens) + if source.additional_scopes: + additional = source.additional_scopes + if additional.startswith("*"): + additional = additional[1:] + user_scopes.extend(additional.split()) + + return { + "scope": [], + "user_scope": user_scopes, + } + + def get_redirect_url(self, **kwargs) -> str: + """Build redirect URL with Slack-specific scope handling. + + Slack uses two separate scope parameters: + - scope: Bot token scopes (xoxb- tokens) + - user_scope: User token scopes (xoxp- tokens) + + The base class adds additional_scopes to 'scope', but Slack needs them + in 'user_scope'. We override completely to handle this properly. + """ + + slug = kwargs.get("source_slug", "") + try: + source: OAuthSource = OAuthSource.objects.get(slug=slug) + except OAuthSource.DoesNotExist: + raise Http404(f"Unknown OAuth source '{slug}'.") from None + if not source.enabled: + raise Http404(f"source {slug} is not enabled.") + + client = self.get_client(source, callback=self.get_callback_url(source)) + # get_additional_parameters handles all scopes for Slack (both scope and user_scope) + params = self.get_additional_parameters(source) + params.update(self._try_login_hint_extract()) + return client.get_redirect_url(params) + + +class SlackOAuth2Callback(OAuthCallback): + """Slack OAuth2 Callback""" + + client_class = SlackOAuthClient + + def get_user_id(self, info: dict[str, Any]) -> str | None: + """Return unique identifier from Slack profile info.""" + return info.get("sub") + + +@registry.register() +class SlackType(SourceType): + """Slack Type definition""" + + callback_view = SlackOAuth2Callback + redirect_view = SlackOAuthRedirect + verbose_name = "Slack" + name = "slack" + + authorization_url = "https://slack.com/oauth/v2/authorize" + access_token_url = "https://slack.com/api/oauth.v2.access" # nosec + profile_url = "https://slack.com/api/openid.connect.userInfo" + + def get_base_user_properties(self, source, info: dict[str, Any], **kwargs) -> dict[str, Any]: + return { + "username": info.get("name"), + "email": info.get("email"), + "name": info.get("name"), + } diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py index a2e75877dd..bfc098a171 100644 --- a/authentik/sources/oauth/views/callback.py +++ b/authentik/sources/oauth/views/callback.py @@ -79,6 +79,7 @@ class OAuthCallback(OAuthClientMixin, View): return sfm.get_flow( raw_info=raw_info, access_token=self.token.get("access_token"), + refresh_token=self.token.get("refresh_token"), expires=self.token.get("expires_in"), ) @@ -122,10 +123,12 @@ class OAuthSourceFlowManager(SourceFlowManager): self, connection: UserOAuthSourceConnection, access_token: str | None = None, + refresh_token: str | None = None, expires_in: int | None = None, **_, ) -> UserOAuthSourceConnection: - """Set the access_token on the connection""" + """Set the access_token and refresh_token on the connection""" connection.access_token = access_token + connection.refresh_token = refresh_token connection.expires = now() + timedelta(seconds=expires_in) if expires_in else now() return connection diff --git a/blueprints/schema.json b/blueprints/schema.json index d6dc9f95af..a3c3d21ada 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -12063,6 +12063,7 @@ "okta", "patreon", "reddit", + "slack", "twitch", "twitter" ], diff --git a/schema.yml b/schema.yml index 25519e63af..bd61a1a38b 100644 --- a/schema.yml +++ b/schema.yml @@ -49778,6 +49778,7 @@ components: - okta - patreon - reddit + - slack - twitch - twitter type: string diff --git a/web/authentik/sources/slack.svg b/web/authentik/sources/slack.svg new file mode 100644 index 0000000000..e8fbe7806b --- /dev/null +++ b/web/authentik/sources/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/admin/sources/oauth/OAuthSourceViewPage.ts b/web/src/admin/sources/oauth/OAuthSourceViewPage.ts index 928489b0d2..1effa6cde9 100644 --- a/web/src/admin/sources/oauth/OAuthSourceViewPage.ts +++ b/web/src/admin/sources/oauth/OAuthSourceViewPage.ts @@ -65,6 +65,8 @@ export function ProviderToLabel(provider?: ProviderTypeEnum): string { return "Patreon"; case ProviderTypeEnum.Reddit: return "Reddit"; + case ProviderTypeEnum.Slack: + return "Slack"; case ProviderTypeEnum.Twitter: return "Twitter"; case ProviderTypeEnum.Twitch: