mirror of
https://github.com/goauthentik/authentik
synced 2026-05-15 03:16:22 +02:00
Compare commits
1 Commits
fix-make-a
...
oauth-scim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d831766f9 |
@@ -133,7 +133,7 @@ class SourceFlowManager:
|
||||
if existing := self.user_connection_type.objects.filter(
|
||||
source=self.source, identifier=self.identifier
|
||||
).first():
|
||||
existing = self.update_user_connection(existing)
|
||||
existing = self.update_user_connection(existing, **kwargs)
|
||||
return Action.AUTH, existing
|
||||
return Action.LINK, new_connection
|
||||
|
||||
|
||||
@@ -25,17 +25,31 @@ class SCIMOAuthAuth:
|
||||
self.logger = get_logger().bind()
|
||||
self.connection = self.get_connection()
|
||||
|
||||
def retrieve_token(self):
|
||||
if not self.provider.auth_oauth:
|
||||
return None
|
||||
def refresh_token(self, connection: UserOAuthSourceConnection) -> dict:
|
||||
"""Refresh an expired token using refresh_token grant.
|
||||
|
||||
This is the proper OAuth 2.0 way to get a new access token when the
|
||||
current one expires. Requires a refresh_token to be stored.
|
||||
"""
|
||||
if not connection.refresh_token:
|
||||
raise SCIMOAuthException(
|
||||
None,
|
||||
"No refresh token available. User must re-authenticate via OAuth source.",
|
||||
)
|
||||
|
||||
source: OAuthSource = self.provider.auth_oauth
|
||||
client = OAuth2Client(source, None)
|
||||
access_token_url = source.source_type.access_token_url or ""
|
||||
if source.source_type.urls_customizable and source.access_token_url:
|
||||
access_token_url = source.access_token_url
|
||||
data = client.get_access_token_args(None, None)
|
||||
data["grant_type"] = "password"
|
||||
data.update(self.provider.auth_oauth_params)
|
||||
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": connection.refresh_token,
|
||||
"client_id": source.consumer_key,
|
||||
"client_secret": source.consumer_secret,
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.do_request(
|
||||
"POST",
|
||||
@@ -47,30 +61,61 @@ class SCIMOAuthAuth:
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if "error" in body:
|
||||
self.logger.info("Failed to get new OAuth token", error=body["error"])
|
||||
self.logger.info("Failed to refresh OAuth token", error=body["error"])
|
||||
raise SCIMOAuthException(response, body["error"])
|
||||
return body
|
||||
except RequestException as exc:
|
||||
raise SCIMOAuthException(exc.response, message="Failed to get OAuth token") from exc
|
||||
raise SCIMOAuthException(exc.response, message="Failed to refresh OAuth token") from exc
|
||||
|
||||
def get_connection(self):
|
||||
token = UserOAuthSourceConnection.objects.filter(
|
||||
def get_connection(self) -> UserOAuthSourceConnection:
|
||||
"""Get a valid OAuth connection, refreshing if necessary."""
|
||||
# First, try to get an existing valid (non-expired) token
|
||||
connection = UserOAuthSourceConnection.objects.filter(
|
||||
source=self.provider.auth_oauth, user=self.user, expires__gt=now()
|
||||
).first()
|
||||
if token and token.access_token:
|
||||
return token
|
||||
token = self.retrieve_token()
|
||||
access_token = token["access_token"]
|
||||
expires_in = int(token.get("expires_in", 0))
|
||||
token, _ = UserOAuthSourceConnection.objects.update_or_create(
|
||||
source=self.provider.auth_oauth,
|
||||
user=self.user,
|
||||
defaults={
|
||||
"access_token": access_token,
|
||||
"expires": now() + timedelta(seconds=expires_in),
|
||||
},
|
||||
)
|
||||
return token
|
||||
if connection and connection.access_token:
|
||||
return connection
|
||||
|
||||
# Token expired or doesn't exist - try to find one with a refresh_token
|
||||
connection = UserOAuthSourceConnection.objects.filter(
|
||||
source=self.provider.auth_oauth, user=self.user
|
||||
).first()
|
||||
|
||||
if not connection:
|
||||
raise SCIMOAuthException(
|
||||
None,
|
||||
"No OAuth connection found. User must authenticate via OAuth source first.",
|
||||
)
|
||||
|
||||
if not connection.refresh_token:
|
||||
# No refresh token - for providers like Slack with long-lived tokens,
|
||||
# the token might still be valid even if our expires field says otherwise
|
||||
if connection.access_token:
|
||||
self.logger.warning(
|
||||
"Token expired but no refresh_token available. "
|
||||
"Attempting to use existing token (may fail)."
|
||||
)
|
||||
return connection
|
||||
raise SCIMOAuthException(
|
||||
None,
|
||||
"OAuth token expired and no refresh token available. "
|
||||
"User must re-authenticate via OAuth source.",
|
||||
)
|
||||
|
||||
# Refresh the token
|
||||
self.logger.info("Refreshing expired OAuth token")
|
||||
token_response = self.refresh_token(connection)
|
||||
|
||||
# Update the connection with new tokens
|
||||
connection.access_token = token_response["access_token"]
|
||||
# Some providers return a new refresh_token with each refresh
|
||||
if "refresh_token" in token_response:
|
||||
connection.refresh_token = token_response["refresh_token"]
|
||||
expires_in = int(token_response.get("expires_in", 0))
|
||||
connection.expires = now() + timedelta(seconds=expires_in) if expires_in else now()
|
||||
connection.save()
|
||||
|
||||
return connection
|
||||
|
||||
def __call__(self, request: Request) -> Request:
|
||||
if not self.connection.is_valid:
|
||||
|
||||
@@ -138,7 +138,13 @@ class SyncTasks:
|
||||
provider.dry_run = False
|
||||
try:
|
||||
client = provider.client_for_model(_object_type)
|
||||
except TransientSyncException:
|
||||
except TransientSyncException as exc:
|
||||
self.logger.warning(
|
||||
"Failed to create sync client",
|
||||
exc=exc,
|
||||
message=str(exc),
|
||||
)
|
||||
task.warning(f"Failed to create sync client: {exc}. ")
|
||||
return
|
||||
paginator = Paginator(
|
||||
provider.get_object_qs(_object_type).filter(**filter),
|
||||
|
||||
@@ -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
|
||||
@@ -338,6 +348,7 @@ class UserOAuthSourceConnection(UserSourceConnection):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.access_token = self.access_token or None
|
||||
self.refresh_token = self.refresh_token or None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
|
||||
150
authentik/sources/oauth/types/slack.py
Normal file
150
authentik/sources/oauth/types/slack.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Slack OAuth Views"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||
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)
|
||||
# This is needed for user scopes like 'admin' for SCIM
|
||||
if "authed_user" in token and "access_token" in token.get("authed_user", {}):
|
||||
authed_user = token["authed_user"]
|
||||
# Flatten the authed_user token to top level for compatibility
|
||||
token["access_token"] = authed_user["access_token"]
|
||||
# Always use "Bearer" for Authorization header - Slack returns "user"
|
||||
# as token_type but their API still expects "Bearer" in the header
|
||||
token["token_type"] = "Bearer"
|
||||
# Preserve refresh_token and expires_in for token rotation
|
||||
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"]
|
||||
# Preserve the user ID for profile lookup
|
||||
if "id" not in token:
|
||||
token["id"] = authed_user.get("id")
|
||||
elif "token_type" not in token:
|
||||
# Bot token - ensure token_type is set
|
||||
token["token_type"] = "Bearer"
|
||||
|
||||
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 (which needs 'admin' scope),
|
||||
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": [], # Bot scopes - empty for user auth
|
||||
"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.
|
||||
"""
|
||||
from django.http import Http404
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
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."""
|
||||
# Slack uses 'sub' for OIDC or 'id'/'user_id' for standard OAuth
|
||||
return info.get("sub") or info.get("id") or info.get("user_id")
|
||||
|
||||
|
||||
@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"
|
||||
oidc_well_known_url = "https://slack.com/.well-known/openid-configuration"
|
||||
oidc_jwks_url = "https://slack.com/openid/connect/keys"
|
||||
|
||||
def get_base_user_properties(self, source, info: dict[str, Any], **kwargs) -> dict[str, Any]:
|
||||
return {
|
||||
"username": info.get("email", "").split("@")[0] or 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
|
||||
|
||||
@@ -6267,9 +6267,7 @@
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"maxLength": 50,
|
||||
"minLength": 1,
|
||||
"pattern": "^[-a-zA-Z0-9_]+$",
|
||||
"title": "Slug",
|
||||
"description": "Visible in the URL."
|
||||
},
|
||||
@@ -12056,6 +12054,7 @@
|
||||
"okta",
|
||||
"patreon",
|
||||
"reddit",
|
||||
"slack",
|
||||
"twitch",
|
||||
"twitter"
|
||||
],
|
||||
|
||||
@@ -49524,6 +49524,7 @@ components:
|
||||
- okta
|
||||
- patreon
|
||||
- reddit
|
||||
- slack
|
||||
- twitch
|
||||
- twitter
|
||||
type: string
|
||||
|
||||
6
web/authentik/sources/slack.svg
Normal file
6
web/authentik/sources/slack.svg
Normal 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 |
Reference in New Issue
Block a user