sources/oauth: add initial group sync
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
2ac7eb6f65
commit
d9428dc104
|
@ -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"),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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.")
|
||||||
)
|
)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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"""
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
29
schema.yml
29
schema.yml
|
@ -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.
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Reference in New Issue