sources/oauth: save returned oauth refresh tokens and add slack provider (#18501)

* sources/oauth: save returned oauth refresh tokens

* Update authentik/sources/oauth/models.py

Co-authored-by: Jens L. <jens@goauthentik.io>
Signed-off-by: Connor Peshek <connor@connorpeshek.me>

* lint

* add tests

* fix proper id setting

* update id test

---------

Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: connor peshek <connorpeshek@unknown1641287c8f5d.attlocal.net>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: connor peshek <connorpeshek@connors-MacBook-Pro.local>
This commit is contained in:
Connor Peshek
2025-12-02 11:49:40 -06:00
committed by GitHub
parent c30d1a478d
commit 45ee4af451
10 changed files with 366 additions and 5 deletions
+1
View File
@@ -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",
]
@@ -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),
),
]
+10 -4
View File
@@ -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")
@@ -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)
+141
View File
@@ -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"),
}
+4 -1
View File
@@ -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
+1
View File
@@ -12063,6 +12063,7 @@
"okta",
"patreon",
"reddit",
"slack",
"twitch",
"twitter"
],
+1
View File
@@ -49778,6 +49778,7 @@ components:
- okta
- patreon
- reddit
- slack
- twitch
- twitter
type: string
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 124 124">
<path d="M26.4 78.9c0 7.2-5.8 13-13 13s-13-5.8-13-13 5.8-13 13-13h13v13zm6.5 0c0-7.2 5.8-13 13-13s13 5.8 13 13v32.5c0 7.2-5.8 13-13 13s-13-5.8-13-13V78.9z" fill="#e01e5a"/>
<path d="M45.9 26.4c-7.2 0-13-5.8-13-13s5.8-13 13-13 13 5.8 13 13v13h-13zm0 6.5c7.2 0 13 5.8 13 13s-5.8 13-13 13H13.4c-7.2 0-13-5.8-13-13s5.8-13 13-13h32.5z" fill="#36c5f0"/>
<path d="M98.1 45.9c0-7.2 5.8-13 13-13s13 5.8 13 13-5.8 13-13 13h-13v-13zm-6.5 0c0 7.2-5.8 13-13 13s-13-5.8-13-13V13.4c0-7.2 5.8-13 13-13s13 5.8 13 13v32.5z" fill="#2eb67d"/>
<path d="M78.6 98.1c7.2 0 13 5.8 13 13s-5.8 13-13 13-13-5.8-13-13v-13h13zm0-6.5c-7.2 0-13-5.8-13-13s5.8-13 13-13h32.5c7.2 0 13 5.8 13 13s-5.8 13-13 13H78.6z" fill="#ecb22e"/>
</svg>

After

Width:  |  Height:  |  Size: 776 B

@@ -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: