Compare commits

...

1 Commits

Author SHA1 Message Date
connor peshek
3d831766f9 backup oauth2 scim work 2025-11-26 19:36:43 -06:00
11 changed files with 269 additions and 29 deletions

View File

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

View File

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

View File

@@ -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),

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",
]

View File

@@ -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),
),
]

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

View 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"),
}

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

View File

@@ -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"
],

View File

@@ -49524,6 +49524,7 @@ components:
- okta
- patreon
- reddit
- slack
- twitch
- twitter
type: string

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