mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
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:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -12063,6 +12063,7 @@
|
||||
"okta",
|
||||
"patreon",
|
||||
"reddit",
|
||||
"slack",
|
||||
"twitch",
|
||||
"twitter"
|
||||
],
|
||||
|
||||
@@ -49778,6 +49778,7 @@ components:
|
||||
- okta
|
||||
- patreon
|
||||
- reddit
|
||||
- slack
|
||||
- twitch
|
||||
- twitter
|
||||
type: string
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user