Compare commits

...
This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.

1 commit

Author SHA1 Message Date
Jens Langhammer d9428dc104
sources/oauth: add initial group sync
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-07-30 17:46:45 +02:00
12 changed files with 160 additions and 12 deletions

View file

@ -0,0 +1,17 @@
# Generated by Django 4.1.10 on 2023-07-30 14:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0031_alter_user_type"),
]
operations = [
migrations.AlterField(
model_name="group",
name="name",
field=models.TextField(verbose_name="name"),
),
]

View file

@ -83,7 +83,7 @@ class Group(SerializerModel):
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.CharField(_("name"), max_length=80) name = models.TextField(_("name"))
is_superuser = models.BooleanField( is_superuser = models.BooleanField(
default=False, help_text=_("Users added to this group will be superusers.") default=False, help_text=_("Users added to this group will be superusers.")
) )

View file

@ -220,7 +220,7 @@ class SourceFlowManager:
flow: Flow, flow: Flow,
connection: UserSourceConnection, connection: UserSourceConnection,
stages: Optional[list[StageView]] = None, stages: Optional[list[StageView]] = None,
**kwargs, **flow_context,
) -> HttpResponse: ) -> HttpResponse:
"""Prepare Authentication Plan, redirect user FlowExecutor""" """Prepare Authentication Plan, redirect user FlowExecutor"""
# Ensure redirect is carried through when user was trying to # Ensure redirect is carried through when user was trying to
@ -228,7 +228,7 @@ class SourceFlowManager:
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user" NEXT_ARG_NAME, "authentik_core:if-user"
) )
kwargs.update( flow_context.update(
{ {
# Since we authenticate the user by their token, they have no backend set # Since we authenticate the user by their token, they have no backend set
PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT,
@ -238,7 +238,7 @@ class SourceFlowManager:
PLAN_CONTEXT_SOURCES_CONNECTION: connection, PLAN_CONTEXT_SOURCES_CONNECTION: connection,
} }
) )
kwargs.update(self.policy_context) flow_context.update(self.policy_context)
if not flow: if not flow:
return bad_request_message( return bad_request_message(
self.request, self.request,
@ -246,7 +246,7 @@ class SourceFlowManager:
) )
# We run the Flow planner here so we can pass the Pending user in the context # We run the Flow planner here so we can pass the Pending user in the context
planner = FlowPlanner(flow) planner = FlowPlanner(flow)
plan = planner.plan(self.request, kwargs) plan = planner.plan(self.request, flow_context)
for stage in self.get_stages_to_append(flow): for stage in self.get_stages_to_append(flow):
plan.append_stage(stage) plan.append_stage(stage)
if stages: if stages:

View file

@ -105,6 +105,7 @@ class OAuthSourceSerializer(SourceSerializer):
"consumer_secret", "consumer_secret",
"callback_url", "callback_url",
"additional_scopes", "additional_scopes",
"groups_claim",
"type", "type",
"oidc_well_known_url", "oidc_well_known_url",
"oidc_jwks_url", "oidc_jwks_url",
@ -137,6 +138,7 @@ class OAuthSourceFilter(FilterSet):
"authorization_url", "authorization_url",
"access_token_url", "access_token_url",
"profile_url", "profile_url",
"groups_claim",
"consumer_key", "consumer_key",
"additional_scopes", "additional_scopes",
] ]

View file

@ -0,0 +1,24 @@
# Generated by Django 4.1.10 on 2023-07-30 14:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_sources_oauth",
"0007_oauthsource_oidc_jwks_oauthsource_oidc_jwks_url_and_more",
),
]
operations = [
migrations.AddField(
model_name="oauthsource",
name="groups_claim",
field=models.TextField(
default=None,
help_text="Sync groups and group membership from the source. Only use this option with sources that you control, as otherwise unwanted users might get added to groups with superuser permissions.",
null=True,
),
),
]

View file

@ -54,6 +54,16 @@ class OAuthSource(Source):
oidc_jwks_url = models.TextField(default="", blank=True) oidc_jwks_url = models.TextField(default="", blank=True)
oidc_jwks = models.JSONField(default=dict, blank=True) oidc_jwks = models.JSONField(default=dict, blank=True)
groups_claim = models.TextField(
default=None,
null=True,
help_text=_(
"Sync groups and group membership from the source. Only use this option with "
"sources that you control, as otherwise unwanted users might get added to "
"groups with superuser permissions."
),
)
@property @property
def type(self) -> type["SourceType"]: def type(self) -> type["SourceType"]:
"""Return the provider instance for this source""" """Return the provider instance for this source"""

View file

@ -14,6 +14,10 @@ OPENID_USER = {
"department": "Engineering", "department": "Engineering",
"birthdate": "1975-12-31", "birthdate": "1975-12-31",
"nickname": "foo", "nickname": "foo",
"groups": [
"foo",
"bar",
]
} }
@ -28,6 +32,7 @@ class TestTypeOpenID(TestCase):
authorization_url="", authorization_url="",
profile_url="http://localhost/userinfo", profile_url="http://localhost/userinfo",
consumer_key="", consumer_key="",
groups_claim="groups",
) )
self.factory = RequestFactory() self.factory = RequestFactory()

View file

@ -35,6 +35,9 @@ class OpenIDConnectOAuth2Callback(OAuthCallback):
"name": info.get("name"), "name": info.get("name"),
} }
def get_user_group_names(self, info: dict[str, Any]) -> list[str]:
return info.get(self.source.groups_claim, [])
@registry.register() @registry.register()
class OpenIDConnectType(SourceType): class OpenIDConnectType(SourceType):

View file

@ -10,12 +10,17 @@ from django.utils.translation import gettext as _
from django.views.generic import View from django.views.generic import View
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Group, User
from authentik.core.sources.flow_manager import SourceFlowManager from authentik.core.sources.flow_manager import SourceFlowManager
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow, Stage, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from authentik.sources.oauth.views.base import OAuthClientMixin from authentik.sources.oauth.views.base import OAuthClientMixin
LOGGER = get_logger() LOGGER = get_logger()
PLAN_CONTEXT_GROUPS = "goauthentik.io/sources/oauth/groups"
class OAuthCallback(OAuthClientMixin, View): class OAuthCallback(OAuthClientMixin, View):
@ -59,13 +64,17 @@ class OAuthCallback(OAuthClientMixin, View):
return self.handle_login_failure("Could not determine id.") return self.handle_login_failure("Could not determine id.")
# Get or create access record # Get or create access record
enroll_info = self.get_user_enroll_context(raw_info) enroll_info = self.get_user_enroll_context(raw_info)
group_info = self.get_user_group_names(raw_info)
sfm = OAuthSourceFlowManager( sfm = OAuthSourceFlowManager(
source=self.source, source=self.source,
request=self.request, request=self.request,
identifier=identifier, identifier=identifier,
enroll_info=enroll_info, enroll_info=enroll_info,
) )
sfm.policy_context = {"oauth_userinfo": raw_info} sfm.policy_context = {
"oauth_userinfo": raw_info,
PLAN_CONTEXT_GROUPS: group_info,
}
return sfm.get_flow( return sfm.get_flow(
access_token=self.token.get("access_token"), access_token=self.token.get("access_token"),
) )
@ -85,6 +94,10 @@ class OAuthCallback(OAuthClientMixin, View):
"""Create a dict of User data""" """Create a dict of User data"""
raise NotImplementedError() raise NotImplementedError()
def get_user_group_names(self, info: dict[str, Any]) -> list[str]:
"""Return a list of all groups the user is member of"""
return []
def get_user_id(self, info: dict[str, Any]) -> Optional[str]: def get_user_id(self, info: dict[str, Any]) -> Optional[str]:
"""Return unique identifier from the profile info.""" """Return unique identifier from the profile info."""
if "id" in info: if "id" in info:
@ -111,6 +124,13 @@ class OAuthSourceFlowManager(SourceFlowManager):
connection_type = UserOAuthSourceConnection connection_type = UserOAuthSourceConnection
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
return super().get_stages_to_append(flow) + [
# Always run this stage after the default `PostUserEnrollmentStage` stage
# as it relies on the user object existing
in_memory_stage(OAuthUserUpdateStage),
]
def update_connection( def update_connection(
self, self,
connection: UserOAuthSourceConnection, connection: UserOAuthSourceConnection,
@ -119,3 +139,24 @@ class OAuthSourceFlowManager(SourceFlowManager):
"""Set the access_token on the connection""" """Set the access_token on the connection"""
connection.access_token = access_token connection.access_token = access_token
return connection return connection
class OAuthUserUpdateStage(StageView):
"""Dynamically injected stage which updates the user after enrollment/authentication."""
def handle_groups(self):
"""Sync users' groups from oauth data"""
user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
group_names: list[str] = self.executor.plan.context[PLAN_CONTEXT_GROUPS]
for group_name in group_names:
Group.objects.update_or_create(name=group_name, defaults={})
user.ak_groups.set(Group.objects.filter(name__in=[group_names]))
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Stage used after the user has been enrolled"""
self.handle_groups()
return self.executor.stage_ok()
def post(self, request: HttpRequest) -> HttpResponse:
"""Wrapper for post requests"""
return self.get(request)

View file

@ -5117,6 +5117,15 @@
"type": "string", "type": "string",
"title": "Additional Scopes" "title": "Additional Scopes"
}, },
"groups_claim": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Groups claim",
"description": "Sync groups and group membership from the source. Only use this option with sources that you control, as otherwise unwanted users might get added to groups with superuser permissions."
},
"oidc_well_known_url": { "oidc_well_known_url": {
"type": "string", "type": "string",
"title": "Oidc well known url" "title": "Oidc well known url"
@ -8305,7 +8314,6 @@
"properties": { "properties": {
"name": { "name": {
"type": "string", "type": "string",
"maxLength": 80,
"minLength": 1, "minLength": 1,
"title": "Name" "title": "Name"
}, },

View file

@ -17943,6 +17943,10 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
- in: query
name: groups_claim
schema:
type: string
- in: query - in: query
name: has_jwks name: has_jwks
schema: schema:
@ -30470,7 +30474,6 @@ components:
readOnly: true readOnly: true
name: name:
type: string type: string
maxLength: 80
is_superuser: is_superuser:
type: boolean type: boolean
description: Users added to this group will be superusers. description: Users added to this group will be superusers.
@ -30583,7 +30586,6 @@ components:
name: name:
type: string type: string
minLength: 1 minLength: 1
maxLength: 80
is_superuser: is_superuser:
type: boolean type: boolean
description: Users added to this group will be superusers. description: Users added to this group will be superusers.
@ -32649,6 +32651,12 @@ components:
readOnly: true readOnly: true
additional_scopes: additional_scopes:
type: string type: string
groups_claim:
type: string
nullable: true
description: Sync groups and group membership from the source. Only use
this option with sources that you control, as otherwise unwanted users
might get added to groups with superuser permissions.
type: type:
allOf: allOf:
- $ref: '#/components/schemas/SourceType' - $ref: '#/components/schemas/SourceType'
@ -32752,6 +32760,13 @@ components:
minLength: 1 minLength: 1
additional_scopes: additional_scopes:
type: string type: string
groups_claim:
type: string
nullable: true
minLength: 1
description: Sync groups and group membership from the source. Only use
this option with sources that you control, as otherwise unwanted users
might get added to groups with superuser permissions.
oidc_well_known_url: oidc_well_known_url:
type: string type: string
oidc_jwks_url: oidc_jwks_url:
@ -36979,7 +36994,6 @@ components:
name: name:
type: string type: string
minLength: 1 minLength: 1
maxLength: 80
is_superuser: is_superuser:
type: boolean type: boolean
description: Users added to this group will be superusers. description: Users added to this group will be superusers.
@ -37560,6 +37574,13 @@ components:
minLength: 1 minLength: 1
additional_scopes: additional_scopes:
type: string type: string
groups_claim:
type: string
nullable: true
minLength: 1
description: Sync groups and group membership from the source. Only use
this option with sources that you control, as otherwise unwanted users
might get added to groups with superuser permissions.
oidc_well_known_url: oidc_well_known_url:
type: string type: string
oidc_jwks_url: oidc_jwks_url:
@ -42029,7 +42050,6 @@ components:
readOnly: true readOnly: true
name: name:
type: string type: string
maxLength: 80
is_superuser: is_superuser:
type: boolean type: boolean
description: Users added to this group will be superusers. description: Users added to this group will be superusers.
@ -42055,7 +42075,6 @@ components:
name: name:
type: string type: string
minLength: 1 minLength: 1
maxLength: 80
is_superuser: is_superuser:
type: boolean type: boolean
description: Users added to this group will be superusers. description: Users added to this group will be superusers.

View file

@ -70,6 +70,9 @@ export class OAuthSourceForm extends ModelForm<OAuthSource, string> {
async send(data: OAuthSource): Promise<OAuthSource> { async send(data: OAuthSource): Promise<OAuthSource> {
data.providerType = (this.providerType?.slug || "") as ProviderTypeEnum; data.providerType = (this.providerType?.slug || "") as ProviderTypeEnum;
if (data.groupsClaim === "") {
data.groupsClaim = null;
}
let source: OAuthSource; let source: OAuthSource;
if (this.instance) { if (this.instance) {
source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthPartialUpdate({ source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthPartialUpdate({
@ -185,6 +188,7 @@ export class OAuthSourceForm extends ModelForm<OAuthSource, string> {
: html``} : html``}
${this.providerType.slug === ProviderTypeEnum.Openidconnect ${this.providerType.slug === ProviderTypeEnum.Openidconnect
? html` ? html`
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("OIDC Well-known URL")} label=${msg("OIDC Well-known URL")}
name="oidcWellKnownUrl" name="oidcWellKnownUrl"
@ -216,6 +220,21 @@ export class OAuthSourceForm extends ModelForm<OAuthSource, string> {
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("OIDC Groups claim")}
name="groupsClaim"
>
<input
type="text"
value="${first(this.instance?.groupsClaim, "")}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
"Sync groups and group membership from the source. Only use this option with sources that you control, as otherwise unwanted users might get added to groups with superuser permissions.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("OIDC JWKS")} name="oidcJwks"> <ak-form-element-horizontal label=${msg("OIDC JWKS")} name="oidcJwks">
<ak-codemirror <ak-codemirror
mode="javascript" mode="javascript"