From f8f00ff0339bc667e5264b2610cab1371fb8c4ce Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 10:24:58 -0400 Subject: [PATCH 01/23] feat: add duo auth to the default setup --- .../50-opensource-authentication-flow.yaml | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/authentik/blueprints/50-opensource-authentication-flow.yaml b/authentik/blueprints/50-opensource-authentication-flow.yaml index 082b6b90..a552b273 100644 --- a/authentik/blueprints/50-opensource-authentication-flow.yaml +++ b/authentik/blueprints/50-opensource-authentication-flow.yaml @@ -60,9 +60,21 @@ entries: model: authentik_stages_password.passwordstage permissions: [] state: present + - attrs: + friendly_name: Push Notification + client_id: !Env OPENSOURCE_PUSH_MFA_CLIENT_ID + client_secret: !Env OPENSOURCE_PUSH_MFA_CLIENT_SECRET + api_hostname: !Env OPENSOURCE_PUSH_MFA_HOSTNAME + admin_integration_key: !Env OPENSOURCE_PUSH_MFA_ADMIN_ID + admin_secret_key: !Env OPENSOURCE_PUSH_MFA_ADMIN_SECRET + id: opensource-authenticator-duo-setup + identifiers: + name: opensource-authenticator-duo-setup + model: authentik_stages_authenticator_duo.authenticatorduostage + state: present - attrs: configuration_stages: - - !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]] + - !KeyOf opensource-authenticator-duo-setup device_classes: - static - totp @@ -207,8 +219,8 @@ entries: model: authentik_policies.policybinding permissions: [] state: present - metadata: - labels: - blueprints.goauthentik.io/generated: 'true' - name: authentik Export - 2026-06-04 15:13:39.590416+00:00 - version: 1 +metadata: + labels: + blueprints.goauthentik.io/generated: 'true' + name: authentik Export - 2026-06-04 15:13:39.590416+00:00 +version: 1 From 5867742e9ffe83184e0f3a91d6da346ac917cde0 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 11:06:19 -0400 Subject: [PATCH 02/23] feat: add duo auth stage for manual enrollment --- ...40-opensource-authenticator-duo-setup.yaml | 37 +++++++++++++++++++ .../50-opensource-authentication-flow.yaml | 14 +------ 2 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 authentik/blueprints/40-opensource-authenticator-duo-setup.yaml diff --git a/authentik/blueprints/40-opensource-authenticator-duo-setup.yaml b/authentik/blueprints/40-opensource-authenticator-duo-setup.yaml new file mode 100644 index 00000000..770369bc --- /dev/null +++ b/authentik/blueprints/40-opensource-authenticator-duo-setup.yaml @@ -0,0 +1,37 @@ +version: 1 +metadata: + name: Opensource - DUO Authenticator Setup Flow +entries: + - attrs: + authentication: require_authenticated + designation: stage_configuration + layout: stacked + name: opensource-authenticator-duo-setup + policy_engine_mode: any + title: Set up Push Notifications + conditions: [] + id: flow + identifiers: + slug: opensource-authenticator-duo-setup + model: authentik_flows.flow + permissions: [] + state: present + - attrs: + friendly_name: Push Notification + client_id: !Env OPENSOURCE_PUSH_MFA_CLIENT_ID + client_secret: !Env OPENSOURCE_PUSH_MFA_CLIENT_SECRET + api_hostname: !Env OPENSOURCE_PUSH_MFA_HOSTNAME + admin_integration_key: !Env OPENSOURCE_PUSH_MFA_ADMIN_ID + admin_secret_key: !Env OPENSOURCE_PUSH_MFA_ADMIN_SECRET + configure_flow: !KeyOf flow + id: opensource-authenticator-duo-setup + identifiers: + name: opensource-authenticator-duo-setup + model: authentik_stages_authenticator_duo.authenticatorduostage + state: present + - identifiers: + order: 0 + stage: !KeyOf opensource-authenticator-duo-setup + target: !KeyOf flow + model: authentik_flows.flowstagebinding + state: present diff --git a/authentik/blueprints/50-opensource-authentication-flow.yaml b/authentik/blueprints/50-opensource-authentication-flow.yaml index a552b273..fd1fc944 100644 --- a/authentik/blueprints/50-opensource-authentication-flow.yaml +++ b/authentik/blueprints/50-opensource-authentication-flow.yaml @@ -60,21 +60,9 @@ entries: model: authentik_stages_password.passwordstage permissions: [] state: present - - attrs: - friendly_name: Push Notification - client_id: !Env OPENSOURCE_PUSH_MFA_CLIENT_ID - client_secret: !Env OPENSOURCE_PUSH_MFA_CLIENT_SECRET - api_hostname: !Env OPENSOURCE_PUSH_MFA_HOSTNAME - admin_integration_key: !Env OPENSOURCE_PUSH_MFA_ADMIN_ID - admin_secret_key: !Env OPENSOURCE_PUSH_MFA_ADMIN_SECRET - id: opensource-authenticator-duo-setup - identifiers: - name: opensource-authenticator-duo-setup - model: authentik_stages_authenticator_duo.authenticatorduostage - state: present - attrs: configuration_stages: - - !KeyOf opensource-authenticator-duo-setup + - !Find [authentik_stages_authenticator_duo.authenticatorduostage, [name, opensource-authenticator-duo-setup]] device_classes: - static - totp From 2c173352f714f668c281e97bee79d6e9fda437f4 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 13:43:19 -0400 Subject: [PATCH 03/23] refactor: use prettier formatting for consistency --- .../50-opensource-authentication-flow.yaml | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/authentik/blueprints/50-opensource-authentication-flow.yaml b/authentik/blueprints/50-opensource-authentication-flow.yaml index fd1fc944..7d1b4c6b 100644 --- a/authentik/blueprints/50-opensource-authentication-flow.yaml +++ b/authentik/blueprints/50-opensource-authentication-flow.yaml @@ -2,7 +2,7 @@ context: {} entries: - attrs: authentication: none - background: '' + background: "" compatibility_mode: false denied_action: message_continue designation: authentication @@ -19,7 +19,8 @@ entries: state: present - attrs: execution_logging: false - expression: "flow_plan = request.context.get(\"flow_plan\")\nif not flow_plan:\n\ + expression: + "flow_plan = request.context.get(\"flow_plan\")\nif not flow_plan:\n\ \ return True\n# If the user does not have a backend attached to it, they\ \ haven't\n# been authenticated yet and we need the password stage\nreturn not\ \ hasattr(flow_plan.context.get(\"pending_user\"), \"backend\")" @@ -32,7 +33,8 @@ entries: state: present - attrs: execution_logging: false - expression: "flow_plan = request.context.get(\"flow_plan\")\nif not flow_plan:\n\ + expression: + "flow_plan = request.context.get(\"flow_plan\")\nif not flow_plan:\n\ \ return True\n# if the authentication method is webauthn (passwordless),\ \ then we skip the authenticator\n# validation stage by returning false (true\ \ will execute the stage)\nreturn not (flow_plan.context.get(\"auth_method\"\ @@ -51,7 +53,8 @@ entries: - authentik.sources.kerberos.auth.KerberosBackend - authentik.sources.ldap.auth.LDAPBackend - authentik.core.auth.TokenBackend - configure_flow: !Find [authentik_flows.flow, [slug, default-password-change]] + configure_flow: + !Find [authentik_flows.flow, [slug, default-password-change]] failed_attempts_before_cancel: 5 conditions: [] id: opensource-authentication-password @@ -62,7 +65,10 @@ entries: state: present - attrs: configuration_stages: - - !Find [authentik_stages_authenticator_duo.authenticatorduostage, [name, opensource-authenticator-duo-setup]] + - !Find [ + authentik_stages_authenticator_duo.authenticatorduostage, + [name, opensource-authenticator-duo-setup], + ] device_classes: - static - totp @@ -90,7 +96,8 @@ entries: captcha_stage: null case_insensitive_matching: true enable_remember_me: true - enrollment_flow: !Find [authentik_flows.flow, [slug, default-enrollment-flow]] + enrollment_flow: + !Find [authentik_flows.flow, [slug, default-enrollment-flow]] password_stage: !KeyOf opensource-authentication-password passwordless_flow: null pretend_user_exists: true @@ -209,6 +216,6 @@ entries: state: present metadata: labels: - blueprints.goauthentik.io/generated: 'true' + blueprints.goauthentik.io/generated: "true" name: authentik Export - 2026-06-04 15:13:39.590416+00:00 version: 1 From 19f7b9fd9daf1e7aca8e3ef7c694a28be17c18bb Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 13:52:13 -0400 Subject: [PATCH 04/23] checkpoint: basic authentik invitation flow from example --- .../10-flows-invitation-enrollment.yaml | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 authentik/blueprints/10-flows-invitation-enrollment.yaml diff --git a/authentik/blueprints/10-flows-invitation-enrollment.yaml b/authentik/blueprints/10-flows-invitation-enrollment.yaml new file mode 100644 index 00000000..88f42604 --- /dev/null +++ b/authentik/blueprints/10-flows-invitation-enrollment.yaml @@ -0,0 +1,396 @@ +# Example - Invitation-based Enrollment Blueprint +# +# This blueprint demonstrates invitation-based user enrollment with support for +# internal and external user types, automatic group assignment, and user path organization. +# +# What this blueprint creates: +# - 3 enrollment flows: +# * External users flow (invitation-enrollment-flow-external) +# * Internal users flow (invitation-enrollment-flow-internal) +# * Internal users flow with automatic group assignment (invitation-enrollment-flow-internal-engineering) +# - 3 invitation stages (one for each flow) +# - Prompt fields for collecting user credentials and details (username, password, name, email) +# - 2 prompt stages (credentials and user details) +# - 3 user write stages configured for different user types and paths: +# * External users: user_type=external, path=users/external +# * Internal users: user_type=internal, path=users/internal +# * Engineering team: user_type=internal, path=users/internal/engineering, auto-assigned to engineering-team group +# - 1 user login stage +# - 1 example group (engineering-team) +# - 5 example invitations demonstrating different use cases +# +# For detailed documentation, see: +# https://docs.goauthentik.io/users-sources/user/invitations/ +# +version: 1 +metadata: + labels: + blueprints.goauthentik.io/instantiate: "false" + name: Example - Invitation-based Enrollment +entries: + # Flow definition for external users + - identifiers: + slug: invitation-enrollment-flow-external + model: authentik_flows.flow + id: flow-external + attrs: + name: Invitation Enrollment Flow (External Users) + title: Welcome! Complete your enrollment + designation: enrollment + authentication: require_unauthenticated + + # Flow definition for internal users + - identifiers: + slug: invitation-enrollment-flow-internal + model: authentik_flows.flow + id: flow-internal + attrs: + name: Invitation Enrollment Flow (Internal Users) + title: Welcome! Complete your enrollment + designation: enrollment + authentication: require_unauthenticated + + # Flow definition for internal users with group assignment + - identifiers: + slug: invitation-enrollment-flow-internal-engineering + model: authentik_flows.flow + id: flow-internal-engineering + attrs: + name: Invitation Enrollment Flow (Internal - Engineering Team) + title: Welcome to the Engineering Team! + designation: enrollment + authentication: require_unauthenticated + + # Invitation Stage for external users + - identifiers: + name: invitation-enrollment-invitation-external + id: invitation-stage-external + model: authentik_stages_invitation.invitationstage + attrs: + continue_flow_without_invitation: false + + # Invitation Stage for internal users + - identifiers: + name: invitation-enrollment-invitation-internal + id: invitation-stage-internal + model: authentik_stages_invitation.invitationstage + attrs: + continue_flow_without_invitation: false + + # Invitation Stage for internal engineering users + - identifiers: + name: invitation-enrollment-invitation-internal-engineering + id: invitation-stage-internal-engineering + model: authentik_stages_invitation.invitationstage + attrs: + continue_flow_without_invitation: false + + # Prompt fields for user information + - id: prompt-field-username + model: authentik_stages_prompt.prompt + identifiers: + name: invitation-enrollment-field-username + attrs: + field_key: username + label: Username + type: username + required: true + placeholder: Username + placeholder_expression: false + order: 0 + + - identifiers: + name: invitation-enrollment-field-password + id: prompt-field-password + model: authentik_stages_prompt.prompt + attrs: + field_key: password + label: Password + type: password + required: true + placeholder: Password + placeholder_expression: false + order: 1 + + - identifiers: + name: invitation-enrollment-field-password-repeat + id: prompt-field-password-repeat + model: authentik_stages_prompt.prompt + attrs: + field_key: password_repeat + label: Password (repeat) + type: password + required: true + placeholder: Password (repeat) + placeholder_expression: false + order: 2 + + - identifiers: + name: invitation-enrollment-field-name + id: prompt-field-name + model: authentik_stages_prompt.prompt + attrs: + field_key: name + label: Name + type: text + required: true + placeholder: Name + placeholder_expression: false + order: 0 + + - identifiers: + name: invitation-enrollment-field-email + id: prompt-field-email + model: authentik_stages_prompt.prompt + attrs: + field_key: email + label: Email + type: email + required: true + placeholder: Email + placeholder_expression: false + order: 1 + + # Prompt stage for credentials + - identifiers: + name: invitation-enrollment-prompt-credentials + id: prompt-stage-credentials + model: authentik_stages_prompt.promptstage + attrs: + fields: + - !KeyOf prompt-field-username + - !KeyOf prompt-field-password + - !KeyOf prompt-field-password-repeat + + # Prompt stage for user details + - identifiers: + name: invitation-enrollment-prompt-details + id: prompt-stage-details + model: authentik_stages_prompt.promptstage + attrs: + fields: + - !KeyOf prompt-field-name + - !KeyOf prompt-field-email + + # User write stage for external users + - identifiers: + name: invitation-enrollment-user-write-external + id: user-write-stage-external + model: authentik_stages_user_write.userwritestage + attrs: + user_creation_mode: always_create + user_type: external + user_path_template: users/external + + # User write stage for internal users + - identifiers: + name: invitation-enrollment-user-write-internal + id: user-write-stage-internal + model: authentik_stages_user_write.userwritestage + attrs: + user_creation_mode: always_create + user_type: internal + user_path_template: users/internal + + # Example group for demonstrating group assignment + - identifiers: + name: engineering-team + id: group-engineering + model: authentik_core.group + attrs: + is_superuser: false + + # User write stage for internal users with group assignment + - identifiers: + name: invitation-enrollment-user-write-internal-engineering + id: user-write-stage-internal-engineering + model: authentik_stages_user_write.userwritestage + attrs: + user_creation_mode: always_create + user_type: internal + user_path_template: users/internal/engineering + create_users_group: !KeyOf group-engineering + + # User login stage + - identifiers: + name: invitation-enrollment-user-login + id: user-login-stage + model: authentik_stages_user_login.userloginstage + + # Flow stage bindings for EXTERNAL users flow + - identifiers: + target: !KeyOf flow-external + stage: !KeyOf invitation-stage-external + order: 5 + model: authentik_flows.flowstagebinding + attrs: + evaluate_on_plan: true + re_evaluate_policies: true + + - identifiers: + target: !KeyOf flow-external + stage: !KeyOf prompt-stage-credentials + order: 10 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-external + stage: !KeyOf prompt-stage-details + order: 15 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-external + stage: !KeyOf user-write-stage-external + order: 20 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-external + stage: !KeyOf user-login-stage + order: 100 + model: authentik_flows.flowstagebinding + + # Flow stage bindings for INTERNAL users flow + - identifiers: + target: !KeyOf flow-internal + stage: !KeyOf invitation-stage-internal + order: 5 + model: authentik_flows.flowstagebinding + attrs: + evaluate_on_plan: true + re_evaluate_policies: true + + - identifiers: + target: !KeyOf flow-internal + stage: !KeyOf prompt-stage-credentials + order: 10 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-internal + stage: !KeyOf prompt-stage-details + order: 15 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-internal + stage: !KeyOf user-write-stage-internal + order: 20 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-internal + stage: !KeyOf user-login-stage + order: 100 + model: authentik_flows.flowstagebinding + + # Flow stage bindings for INTERNAL ENGINEERING users flow (with group assignment) + - identifiers: + target: !KeyOf flow-internal-engineering + stage: !KeyOf invitation-stage-internal-engineering + order: 5 + model: authentik_flows.flowstagebinding + attrs: + evaluate_on_plan: true + re_evaluate_policies: true + + - identifiers: + target: !KeyOf flow-internal-engineering + stage: !KeyOf prompt-stage-credentials + order: 10 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-internal-engineering + stage: !KeyOf prompt-stage-details + order: 15 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-internal-engineering + stage: !KeyOf user-write-stage-internal-engineering + order: 20 + model: authentik_flows.flowstagebinding + + - identifiers: + target: !KeyOf flow-internal-engineering + stage: !KeyOf user-login-stage + order: 100 + model: authentik_flows.flowstagebinding + + # Example invitations + + # EXTERNAL USER INVITATIONS + + # Example 1: Basic single-use invitation for external user + - identifiers: + name: example-external-basic-invitation + model: authentik_stages_invitation.invitation + id: invitation-external-basic + attrs: + flow: !KeyOf flow-external + single_use: false + fixed_data: {} + + # Example 2: Multi-use invitation for external users with pre-filled email + - identifiers: + name: example-external-prefilled-email-invitation + model: authentik_stages_invitation.invitation + id: invitation-external-prefilled-email + attrs: + flow: !KeyOf flow-external + single_use: true + expires: "2028-12-31T23:59:59Z" + fixed_data: + email: "external@example.com" + + # INTERNAL USER INVITATIONS + + # Example 3: Single-use invitation for internal user with pre-filled fields + - identifiers: + name: example-internal-prefilled-invitation + model: authentik_stages_invitation.invitation + id: invitation-internal-prefilled + attrs: + flow: !KeyOf flow-internal + single_use: true + expires: "2028-12-31T23:59:59Z" + fixed_data: + name: "Jane Smith" + email: "jane.smith@company.com" + + # Example 4: Long-term multi-use invitation for internal department + - identifiers: + name: example-internal-department-invitation + model: authentik_stages_invitation.invitation + id: invitation-internal-department + attrs: + flow: !KeyOf flow-internal + single_use: false + expires: "2028-12-31T23:59:59Z" + fixed_data: + attributes: + department: "Engineering" + team: "Backend" + + # Example 5: Invitation with automatic group assignment + - identifiers: + name: example-engineering-team-invitation + model: authentik_stages_invitation.invitation + id: invitation-engineering-team + attrs: + flow: !KeyOf flow-internal-engineering + single_use: false + expires: "2028-12-31T23:59:59Z" + fixed_data: + attributes: + department: "Engineering" + + # Note: Group assignment works by using a flow with a UserWriteStage that has + # 'create_users_group' configured. See example 5 above - users enrolling via + # the 'invitation-enrollment-flow-internal-engineering' flow will automatically + # be added to the 'engineering-team' group. + # + # Groups cannot be set directly in invitation fixed_data because they require + # database relationships that must be established after user creation. From 37ec91e34aa89860f8438d59f9ac05b1d9654ebc Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 13:55:48 -0400 Subject: [PATCH 05/23] checkpoint: remove external invite flows --- .../10-flows-invitation-enrollment.yaml | 111 ------------------ 1 file changed, 111 deletions(-) diff --git a/authentik/blueprints/10-flows-invitation-enrollment.yaml b/authentik/blueprints/10-flows-invitation-enrollment.yaml index 88f42604..a330af89 100644 --- a/authentik/blueprints/10-flows-invitation-enrollment.yaml +++ b/authentik/blueprints/10-flows-invitation-enrollment.yaml @@ -1,44 +1,9 @@ -# Example - Invitation-based Enrollment Blueprint -# -# This blueprint demonstrates invitation-based user enrollment with support for -# internal and external user types, automatic group assignment, and user path organization. -# -# What this blueprint creates: -# - 3 enrollment flows: -# * External users flow (invitation-enrollment-flow-external) -# * Internal users flow (invitation-enrollment-flow-internal) -# * Internal users flow with automatic group assignment (invitation-enrollment-flow-internal-engineering) -# - 3 invitation stages (one for each flow) -# - Prompt fields for collecting user credentials and details (username, password, name, email) -# - 2 prompt stages (credentials and user details) -# - 3 user write stages configured for different user types and paths: -# * External users: user_type=external, path=users/external -# * Internal users: user_type=internal, path=users/internal -# * Engineering team: user_type=internal, path=users/internal/engineering, auto-assigned to engineering-team group -# - 1 user login stage -# - 1 example group (engineering-team) -# - 5 example invitations demonstrating different use cases -# -# For detailed documentation, see: -# https://docs.goauthentik.io/users-sources/user/invitations/ -# version: 1 metadata: labels: blueprints.goauthentik.io/instantiate: "false" name: Example - Invitation-based Enrollment entries: - # Flow definition for external users - - identifiers: - slug: invitation-enrollment-flow-external - model: authentik_flows.flow - id: flow-external - attrs: - name: Invitation Enrollment Flow (External Users) - title: Welcome! Complete your enrollment - designation: enrollment - authentication: require_unauthenticated - # Flow definition for internal users - identifiers: slug: invitation-enrollment-flow-internal @@ -61,14 +26,6 @@ entries: designation: enrollment authentication: require_unauthenticated - # Invitation Stage for external users - - identifiers: - name: invitation-enrollment-invitation-external - id: invitation-stage-external - model: authentik_stages_invitation.invitationstage - attrs: - continue_flow_without_invitation: false - # Invitation Stage for internal users - identifiers: name: invitation-enrollment-invitation-internal @@ -172,16 +129,6 @@ entries: - !KeyOf prompt-field-name - !KeyOf prompt-field-email - # User write stage for external users - - identifiers: - name: invitation-enrollment-user-write-external - id: user-write-stage-external - model: authentik_stages_user_write.userwritestage - attrs: - user_creation_mode: always_create - user_type: external - user_path_template: users/external - # User write stage for internal users - identifiers: name: invitation-enrollment-user-write-internal @@ -217,40 +164,6 @@ entries: id: user-login-stage model: authentik_stages_user_login.userloginstage - # Flow stage bindings for EXTERNAL users flow - - identifiers: - target: !KeyOf flow-external - stage: !KeyOf invitation-stage-external - order: 5 - model: authentik_flows.flowstagebinding - attrs: - evaluate_on_plan: true - re_evaluate_policies: true - - - identifiers: - target: !KeyOf flow-external - stage: !KeyOf prompt-stage-credentials - order: 10 - model: authentik_flows.flowstagebinding - - - identifiers: - target: !KeyOf flow-external - stage: !KeyOf prompt-stage-details - order: 15 - model: authentik_flows.flowstagebinding - - - identifiers: - target: !KeyOf flow-external - stage: !KeyOf user-write-stage-external - order: 20 - model: authentik_flows.flowstagebinding - - - identifiers: - target: !KeyOf flow-external - stage: !KeyOf user-login-stage - order: 100 - model: authentik_flows.flowstagebinding - # Flow stage bindings for INTERNAL users flow - identifiers: target: !KeyOf flow-internal @@ -321,30 +234,6 @@ entries: # Example invitations - # EXTERNAL USER INVITATIONS - - # Example 1: Basic single-use invitation for external user - - identifiers: - name: example-external-basic-invitation - model: authentik_stages_invitation.invitation - id: invitation-external-basic - attrs: - flow: !KeyOf flow-external - single_use: false - fixed_data: {} - - # Example 2: Multi-use invitation for external users with pre-filled email - - identifiers: - name: example-external-prefilled-email-invitation - model: authentik_stages_invitation.invitation - id: invitation-external-prefilled-email - attrs: - flow: !KeyOf flow-external - single_use: true - expires: "2028-12-31T23:59:59Z" - fixed_data: - email: "external@example.com" - # INTERNAL USER INVITATIONS # Example 3: Single-use invitation for internal user with pre-filled fields From 4afe5899546789d14cc2bc4322e09287917ed885 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 13:58:51 -0400 Subject: [PATCH 06/23] checkpoint: trim to just internal invite w/ group assignment --- .../10-flows-invitation-enrollment.yaml | 115 ------------------ 1 file changed, 115 deletions(-) diff --git a/authentik/blueprints/10-flows-invitation-enrollment.yaml b/authentik/blueprints/10-flows-invitation-enrollment.yaml index a330af89..4f221a21 100644 --- a/authentik/blueprints/10-flows-invitation-enrollment.yaml +++ b/authentik/blueprints/10-flows-invitation-enrollment.yaml @@ -4,17 +4,6 @@ metadata: blueprints.goauthentik.io/instantiate: "false" name: Example - Invitation-based Enrollment entries: - # Flow definition for internal users - - identifiers: - slug: invitation-enrollment-flow-internal - model: authentik_flows.flow - id: flow-internal - attrs: - name: Invitation Enrollment Flow (Internal Users) - title: Welcome! Complete your enrollment - designation: enrollment - authentication: require_unauthenticated - # Flow definition for internal users with group assignment - identifiers: slug: invitation-enrollment-flow-internal-engineering @@ -26,14 +15,6 @@ entries: designation: enrollment authentication: require_unauthenticated - # Invitation Stage for internal users - - identifiers: - name: invitation-enrollment-invitation-internal - id: invitation-stage-internal - model: authentik_stages_invitation.invitationstage - attrs: - continue_flow_without_invitation: false - # Invitation Stage for internal engineering users - identifiers: name: invitation-enrollment-invitation-internal-engineering @@ -129,16 +110,6 @@ entries: - !KeyOf prompt-field-name - !KeyOf prompt-field-email - # User write stage for internal users - - identifiers: - name: invitation-enrollment-user-write-internal - id: user-write-stage-internal - model: authentik_stages_user_write.userwritestage - attrs: - user_creation_mode: always_create - user_type: internal - user_path_template: users/internal - # Example group for demonstrating group assignment - identifiers: name: engineering-team @@ -164,40 +135,6 @@ entries: id: user-login-stage model: authentik_stages_user_login.userloginstage - # Flow stage bindings for INTERNAL users flow - - identifiers: - target: !KeyOf flow-internal - stage: !KeyOf invitation-stage-internal - order: 5 - model: authentik_flows.flowstagebinding - attrs: - evaluate_on_plan: true - re_evaluate_policies: true - - - identifiers: - target: !KeyOf flow-internal - stage: !KeyOf prompt-stage-credentials - order: 10 - model: authentik_flows.flowstagebinding - - - identifiers: - target: !KeyOf flow-internal - stage: !KeyOf prompt-stage-details - order: 15 - model: authentik_flows.flowstagebinding - - - identifiers: - target: !KeyOf flow-internal - stage: !KeyOf user-write-stage-internal - order: 20 - model: authentik_flows.flowstagebinding - - - identifiers: - target: !KeyOf flow-internal - stage: !KeyOf user-login-stage - order: 100 - model: authentik_flows.flowstagebinding - # Flow stage bindings for INTERNAL ENGINEERING users flow (with group assignment) - identifiers: target: !KeyOf flow-internal-engineering @@ -231,55 +168,3 @@ entries: stage: !KeyOf user-login-stage order: 100 model: authentik_flows.flowstagebinding - - # Example invitations - - # INTERNAL USER INVITATIONS - - # Example 3: Single-use invitation for internal user with pre-filled fields - - identifiers: - name: example-internal-prefilled-invitation - model: authentik_stages_invitation.invitation - id: invitation-internal-prefilled - attrs: - flow: !KeyOf flow-internal - single_use: true - expires: "2028-12-31T23:59:59Z" - fixed_data: - name: "Jane Smith" - email: "jane.smith@company.com" - - # Example 4: Long-term multi-use invitation for internal department - - identifiers: - name: example-internal-department-invitation - model: authentik_stages_invitation.invitation - id: invitation-internal-department - attrs: - flow: !KeyOf flow-internal - single_use: false - expires: "2028-12-31T23:59:59Z" - fixed_data: - attributes: - department: "Engineering" - team: "Backend" - - # Example 5: Invitation with automatic group assignment - - identifiers: - name: example-engineering-team-invitation - model: authentik_stages_invitation.invitation - id: invitation-engineering-team - attrs: - flow: !KeyOf flow-internal-engineering - single_use: false - expires: "2028-12-31T23:59:59Z" - fixed_data: - attributes: - department: "Engineering" - - # Note: Group assignment works by using a flow with a UserWriteStage that has - # 'create_users_group' configured. See example 5 above - users enrolling via - # the 'invitation-enrollment-flow-internal-engineering' flow will automatically - # be added to the 'engineering-team' group. - # - # Groups cannot be set directly in invitation fixed_data because they require - # database relationships that must be established after user creation. From d5dd67ca9d1f55875c9d48514e59dcdb6c5b6d6e Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 14:07:24 -0400 Subject: [PATCH 07/23] feat: invite users and add to ldapusers group --- .../10-flows-invitation-enrollment.yaml | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/authentik/blueprints/10-flows-invitation-enrollment.yaml b/authentik/blueprints/10-flows-invitation-enrollment.yaml index 4f221a21..0829da13 100644 --- a/authentik/blueprints/10-flows-invitation-enrollment.yaml +++ b/authentik/blueprints/10-flows-invitation-enrollment.yaml @@ -2,23 +2,23 @@ version: 1 metadata: labels: blueprints.goauthentik.io/instantiate: "false" - name: Example - Invitation-based Enrollment + name: Opensource - Invitation-based Enrollment entries: # Flow definition for internal users with group assignment - identifiers: - slug: invitation-enrollment-flow-internal-engineering + slug: invitation-enrollment-flow model: authentik_flows.flow - id: flow-internal-engineering + id: flow attrs: - name: Invitation Enrollment Flow (Internal - Engineering Team) - title: Welcome to the Engineering Team! + name: Opensource Invitation Enrollment Flow + title: Welcome to MIE Opensource! designation: enrollment authentication: require_unauthenticated # Invitation Stage for internal engineering users - identifiers: name: invitation-enrollment-invitation-internal-engineering - id: invitation-stage-internal-engineering + id: invitation-stage model: authentik_stages_invitation.invitationstage attrs: continue_flow_without_invitation: false @@ -112,22 +112,24 @@ entries: # Example group for demonstrating group assignment - identifiers: - name: engineering-team - id: group-engineering + name: ldapusers + id: ldapusers-group model: authentik_core.group attrs: is_superuser: false + attributes: + gidNumber: 2001 # User write stage for internal users with group assignment - identifiers: - name: invitation-enrollment-user-write-internal-engineering - id: user-write-stage-internal-engineering + name: invitation-enrollment-user-write + id: user-write-stage model: authentik_stages_user_write.userwritestage attrs: user_creation_mode: always_create user_type: internal - user_path_template: users/internal/engineering - create_users_group: !KeyOf group-engineering + user_path_template: users/internal + create_users_group: !KeyOf ldapusers-group # User login stage - identifiers: @@ -137,8 +139,8 @@ entries: # Flow stage bindings for INTERNAL ENGINEERING users flow (with group assignment) - identifiers: - target: !KeyOf flow-internal-engineering - stage: !KeyOf invitation-stage-internal-engineering + target: !KeyOf flow + stage: !KeyOf invitation-stage order: 5 model: authentik_flows.flowstagebinding attrs: @@ -146,25 +148,25 @@ entries: re_evaluate_policies: true - identifiers: - target: !KeyOf flow-internal-engineering + target: !KeyOf flow stage: !KeyOf prompt-stage-credentials order: 10 model: authentik_flows.flowstagebinding - identifiers: - target: !KeyOf flow-internal-engineering + target: !KeyOf flow stage: !KeyOf prompt-stage-details order: 15 model: authentik_flows.flowstagebinding - identifiers: - target: !KeyOf flow-internal-engineering - stage: !KeyOf user-write-stage-internal-engineering + target: !KeyOf flow + stage: !KeyOf user-write-stage order: 20 model: authentik_flows.flowstagebinding - identifiers: - target: !KeyOf flow-internal-engineering + target: !KeyOf flow stage: !KeyOf user-login-stage order: 100 model: authentik_flows.flowstagebinding From 43673a2224f1e5d83309875ccdd216fd5246575d Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 14:39:44 -0400 Subject: [PATCH 08/23] refactor: fix ordering to be parellel --- ...-flow.yaml => 10-opensource-authentication-flow.yaml} | 0 ...p.yaml => 10-opensource-authenticator-duo-setup.yaml} | 0 authentik/data/icon.svg | 9 +++++++++ authentik/data/logo-lm.svg | 9 +++++++++ 4 files changed, 18 insertions(+) rename authentik/blueprints/{50-opensource-authentication-flow.yaml => 10-opensource-authentication-flow.yaml} (100%) rename authentik/blueprints/{40-opensource-authenticator-duo-setup.yaml => 10-opensource-authenticator-duo-setup.yaml} (100%) create mode 100644 authentik/data/icon.svg create mode 100644 authentik/data/logo-lm.svg diff --git a/authentik/blueprints/50-opensource-authentication-flow.yaml b/authentik/blueprints/10-opensource-authentication-flow.yaml similarity index 100% rename from authentik/blueprints/50-opensource-authentication-flow.yaml rename to authentik/blueprints/10-opensource-authentication-flow.yaml diff --git a/authentik/blueprints/40-opensource-authenticator-duo-setup.yaml b/authentik/blueprints/10-opensource-authenticator-duo-setup.yaml similarity index 100% rename from authentik/blueprints/40-opensource-authenticator-duo-setup.yaml rename to authentik/blueprints/10-opensource-authenticator-duo-setup.yaml diff --git a/authentik/data/icon.svg b/authentik/data/icon.svg new file mode 100644 index 00000000..172f984f --- /dev/null +++ b/authentik/data/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/authentik/data/logo-lm.svg b/authentik/data/logo-lm.svg new file mode 100644 index 00000000..6a5bd593 --- /dev/null +++ b/authentik/data/logo-lm.svg @@ -0,0 +1,9 @@ + + + + + + + + + From 83dc15fa1f64230a98ff7bca5992ff330b2c5317 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 14:39:54 -0400 Subject: [PATCH 09/23] feat: add opensource branding --- authentik/blueprints/20-opensource-brand.yaml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 authentik/blueprints/20-opensource-brand.yaml diff --git a/authentik/blueprints/20-opensource-brand.yaml b/authentik/blueprints/20-opensource-brand.yaml new file mode 100644 index 00000000..2f357b3e --- /dev/null +++ b/authentik/blueprints/20-opensource-brand.yaml @@ -0,0 +1,26 @@ +version: 1 +metadata: + name: Opensource - Brand +entries: + # Disable the current default brand + - attrs: + default: false + identifiers: + pk: !Find [authentik_brands.brand, [default, True]] + state: present + conditions: + - !Condition [OR, !Find [authentik_brands.brand, [default, True]]] + model: authentik_brands.brand + + - attrs: + default: true + branding_title: MIE Opensource SSO + branding_logo: logo-lm.svg + branding_favicon: icon.svg + flow_authentication: !Find [authentik_flows.flow, [slug, opensource-authentication-flow]] + flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] + flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]] + identifiers: + domain: opensource-default + state: present + model: authentik_brands.brand From 12b4364453bf6dd34553f37e47e97aee92976028 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 14:50:30 -0400 Subject: [PATCH 10/23] feat: ensure user enrolls an authenticator before logging in --- ...-enrollment.yaml => 20-flow-invitation-enrollment.yaml} | 7 +++++++ 1 file changed, 7 insertions(+) rename authentik/blueprints/{10-flows-invitation-enrollment.yaml => 20-flow-invitation-enrollment.yaml} (93%) diff --git a/authentik/blueprints/10-flows-invitation-enrollment.yaml b/authentik/blueprints/20-flow-invitation-enrollment.yaml similarity index 93% rename from authentik/blueprints/10-flows-invitation-enrollment.yaml rename to authentik/blueprints/20-flow-invitation-enrollment.yaml index 0829da13..eb471e4f 100644 --- a/authentik/blueprints/10-flows-invitation-enrollment.yaml +++ b/authentik/blueprints/20-flow-invitation-enrollment.yaml @@ -165,6 +165,13 @@ entries: order: 20 model: authentik_flows.flowstagebinding + # Force the user to setup or validate an authenticator before logging in + - identifiers: + target: !KeyOf flow + stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, opensource-authentication-mfa-validation]] + order: 30 + model: authentik_flows.flowstagebinding + - identifiers: target: !KeyOf flow stage: !KeyOf user-login-stage From 2a6f297bba8bafdbd9f27f89e031d2947eb798e0 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 14:58:34 -0400 Subject: [PATCH 11/23] fix: remove internal-engineering specifier from stage name --- authentik/blueprints/20-flow-invitation-enrollment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/blueprints/20-flow-invitation-enrollment.yaml b/authentik/blueprints/20-flow-invitation-enrollment.yaml index eb471e4f..a7be2fb8 100644 --- a/authentik/blueprints/20-flow-invitation-enrollment.yaml +++ b/authentik/blueprints/20-flow-invitation-enrollment.yaml @@ -17,7 +17,7 @@ entries: # Invitation Stage for internal engineering users - identifiers: - name: invitation-enrollment-invitation-internal-engineering + name: invitation-enrollment-invitation id: invitation-stage model: authentik_stages_invitation.invitationstage attrs: From ce4839550025e00443d2d7363c34b6b0ac164293 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 15:07:32 -0400 Subject: [PATCH 12/23] fix: correct application order for blueprints --- ...ntication-flow.yaml => 20-opensource-authentication-flow.yaml} | 0 ...itation-enrollment.yaml => 30-flow-invitation-enrollment.yaml} | 0 .../{20-opensource-brand.yaml => 30-opensource-brand.yaml} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename authentik/blueprints/{10-opensource-authentication-flow.yaml => 20-opensource-authentication-flow.yaml} (100%) rename authentik/blueprints/{20-flow-invitation-enrollment.yaml => 30-flow-invitation-enrollment.yaml} (100%) rename authentik/blueprints/{20-opensource-brand.yaml => 30-opensource-brand.yaml} (100%) diff --git a/authentik/blueprints/10-opensource-authentication-flow.yaml b/authentik/blueprints/20-opensource-authentication-flow.yaml similarity index 100% rename from authentik/blueprints/10-opensource-authentication-flow.yaml rename to authentik/blueprints/20-opensource-authentication-flow.yaml diff --git a/authentik/blueprints/20-flow-invitation-enrollment.yaml b/authentik/blueprints/30-flow-invitation-enrollment.yaml similarity index 100% rename from authentik/blueprints/20-flow-invitation-enrollment.yaml rename to authentik/blueprints/30-flow-invitation-enrollment.yaml diff --git a/authentik/blueprints/20-opensource-brand.yaml b/authentik/blueprints/30-opensource-brand.yaml similarity index 100% rename from authentik/blueprints/20-opensource-brand.yaml rename to authentik/blueprints/30-opensource-brand.yaml From a450bc47d61d7edeb98e42bac29deb6be7d868b7 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 16:29:28 -0400 Subject: [PATCH 13/23] feat: add proxyuser which bypasses 2fa for directory searching --- .../10-opensource-ldap-identity.yaml | 49 +++++++++++ .../20-opensource-authentication-flow.yaml | 30 ++++++- .../30-flow-invitation-enrollment.yaml | 13 +-- authentik/blueprints/40-opensource-ldap.yaml | 84 +++++++++++++++++++ 4 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 authentik/blueprints/10-opensource-ldap-identity.yaml create mode 100644 authentik/blueprints/40-opensource-ldap.yaml diff --git a/authentik/blueprints/10-opensource-ldap-identity.yaml b/authentik/blueprints/10-opensource-ldap-identity.yaml new file mode 100644 index 00000000..8f4b89d1 --- /dev/null +++ b/authentik/blueprints/10-opensource-ldap-identity.yaml @@ -0,0 +1,49 @@ +version: 1 +metadata: + name: Opensource - LDAP Identity (group & proxyuser) +context: + # The proxyuser password is injected from the environment so that no secret is + # committed to the blueprint. + ldap_proxyuser_password: !Env [AUTHENTIK_LDAP_PROXYUSER_PASSWORD, null] +entries: + # --------------------------------------------------------------------------- + # Group that is allowed to bind against LDAP. + # + # This group is defined here (and referenced via !Find by the enrollment flow + # in 30-flow-invitation-enrollment.yaml and the LDAP provider/application in + # 40-opensource-ldap.yaml). It has no dependencies, so it is created early. + # --------------------------------------------------------------------------- + - id: ldapusers-group + model: authentik_core.group + state: present + identifiers: + name: ldapusers + attrs: + is_superuser: false + attributes: + gidNumber: 2001 + + # --------------------------------------------------------------------------- + # Dedicated proxyuser service account. + # + # This account is used by downstream services to bind to LDAP. It is a member + # of the ldapusers group (so it is permitted to bind) and is granted an + # exception from 2FA in the bind flow (the policy binding lives alongside the + # authentication flow in 20-opensource-authentication-flow.yaml, which is why + # this identity must be imported before that flow). + # + # NOTE: This account is intentionally NOT granted the "Search full LDAP + # directory" permission, so even the proxyuser can only read its own object. + # --------------------------------------------------------------------------- + - id: ldap-proxyuser + model: authentik_core.user + state: present + identifiers: + username: ldap-proxyuser + attrs: + name: LDAP Proxy User + type: service_account + path: users/service-accounts + password: !Context ldap_proxyuser_password + groups: + - !KeyOf ldapusers-group diff --git a/authentik/blueprints/20-opensource-authentication-flow.yaml b/authentik/blueprints/20-opensource-authentication-flow.yaml index 7d1b4c6b..bbe9f726 100644 --- a/authentik/blueprints/20-opensource-authentication-flow.yaml +++ b/authentik/blueprints/20-opensource-authentication-flow.yaml @@ -160,7 +160,11 @@ entries: - attrs: evaluate_on_plan: false invalid_response_action: retry - policy_engine_mode: any + # Use `all` so that every bound policy/binding must pass for the MFA stage + # to run. This allows the negated user binding for the LDAP proxyuser (see + # 40-opensource-ldap.yaml) to skip 2FA for that single service account while + # still enforcing MFA for everyone else. + policy_engine_mode: all re_evaluate_policies: true conditions: [] id: opensource-authentication-mfa-validation-stagebinding @@ -214,6 +218,30 @@ entries: model: authentik_policies.policybinding permissions: [] state: present + # 2FA exception for the LDAP proxyuser. + # + # This binding references the proxyuser directly (via the `user` field) and is + # negated, so it passes for every user EXCEPT the proxyuser. Because the MFA + # stage binding uses policy_engine_mode `all`, every bound policy must pass for + # the MFA stage to run; for the proxyuser this negated binding fails, so MFA is + # skipped and it can bind without 2FA. All other users still go through MFA. + # + # The proxyuser is defined in 10-opensource-ldap-identity.yaml (imported first), + # so it is resolved here via !Find. + - attrs: + enabled: true + failure_result: true + group: null + negate: true + timeout: 30 + conditions: [] + identifiers: + order: 20 + user: !Find [authentik_core.user, [username, ldap-proxyuser]] + target: !KeyOf opensource-authentication-mfa-validation-stagebinding + model: authentik_policies.policybinding + permissions: [] + state: present metadata: labels: blueprints.goauthentik.io/generated: "true" diff --git a/authentik/blueprints/30-flow-invitation-enrollment.yaml b/authentik/blueprints/30-flow-invitation-enrollment.yaml index a7be2fb8..0a46cd69 100644 --- a/authentik/blueprints/30-flow-invitation-enrollment.yaml +++ b/authentik/blueprints/30-flow-invitation-enrollment.yaml @@ -110,16 +110,6 @@ entries: - !KeyOf prompt-field-name - !KeyOf prompt-field-email - # Example group for demonstrating group assignment - - identifiers: - name: ldapusers - id: ldapusers-group - model: authentik_core.group - attrs: - is_superuser: false - attributes: - gidNumber: 2001 - # User write stage for internal users with group assignment - identifiers: name: invitation-enrollment-user-write @@ -129,7 +119,8 @@ entries: user_creation_mode: always_create user_type: internal user_path_template: users/internal - create_users_group: !KeyOf ldapusers-group + # The ldapusers group is defined in 10-opensource-ldap-identity.yaml. + create_users_group: !Find [authentik_core.group, [name, ldapusers]] # User login stage - identifiers: diff --git a/authentik/blueprints/40-opensource-ldap.yaml b/authentik/blueprints/40-opensource-ldap.yaml new file mode 100644 index 00000000..d75dc2b3 --- /dev/null +++ b/authentik/blueprints/40-opensource-ldap.yaml @@ -0,0 +1,84 @@ +version: 1 +metadata: + name: Opensource - LDAP Application & Provider +context: + # The Base DN under which the LDAP directory is served is injected from the + # environment so that no environment-specific value is committed to the + # blueprint. + ldap_base_dn: !Env [AUTHENTIK_LDAP_BASE_DN, "DC=ldap,DC=goauthentik,DC=io"] +entries: + # --------------------------------------------------------------------------- + # LDAP provider. + # + # The bind flow is the opensource authentication flow (defined in + # 20-opensource-authentication-flow.yaml and resolved here via !Find), which + # enforces password + code-based MFA for everyone except the proxyuser. + # mfa_support is enabled so that ldapusers can append their TOTP code to the + # bind password (i.e. "password;123456"). + # + # Only the proxyuser is granted the `search_full_directory` permission (so it + # can search the whole directory on behalf of downstream services). This is + # read-only search access - the LDAP provider does not expose any write/edit + # capability - and no other user is granted it, so regular users can still only + # read their own object. + # --------------------------------------------------------------------------- + - id: ldap-provider + model: authentik_providers_ldap.ldapprovider + state: present + identifiers: + name: opensource-ldap + attrs: + name: opensource-ldap + # LDAP outposts use authorization_flow as the bind flow. + authorization_flow: + !Find [authentik_flows.flow, [slug, opensource-authentication-flow]] + invalidation_flow: + !Find [authentik_flows.flow, [slug, default-invalidation-flow]] + base_dn: !Context ldap_base_dn + bind_mode: cached + search_mode: cached + mfa_support: true + uid_start_number: 2000 + gid_start_number: 4000 + permissions: + # Allow the proxyuser to search the full LDAP directory. The proxyuser is + # defined in 10-opensource-ldap-identity.yaml and resolved via !Find. + - permission: authentik_providers_ldap.search_full_directory + user: !Find [authentik_core.user, [username, ldap-proxyuser]] + + # --------------------------------------------------------------------------- + # LDAP application. + # + # The provider is attached directly (the LDAP outpost reads the application via + # the provider's assigned application). It is hidden from the user dashboard + # since LDAP has no launch URL and is not user-facing. + # --------------------------------------------------------------------------- + - id: ldap-application + model: authentik_core.application + state: present + identifiers: + slug: opensource-ldap + attrs: + name: Opensource LDAP + policy_engine_mode: any + provider: !KeyOf ldap-provider + meta_hide: true + + # --------------------------------------------------------------------------- + # Access restriction: only members of the ldapusers group may bind. + # + # Binding against the LDAP provider requires access to this application. By + # binding the ldapusers group to the application, only users in that group + # (including the proxyuser) are able to bind. The ldapusers group is defined in + # 10-opensource-ldap-identity.yaml and resolved here via !Find. + # --------------------------------------------------------------------------- + - model: authentik_policies.policybinding + state: present + identifiers: + target: !KeyOf ldap-application + group: !Find [authentik_core.group, [name, ldapusers]] + order: 0 + attrs: + enabled: true + negate: false + timeout: 30 From 500921604d265f2d5865a6c6630014512d9a9c72 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 16:49:58 -0400 Subject: [PATCH 14/23] fix: enable direct bind so 2fa is validated --- authentik/blueprints/40-opensource-ldap.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/authentik/blueprints/40-opensource-ldap.yaml b/authentik/blueprints/40-opensource-ldap.yaml index d75dc2b3..31eb1121 100644 --- a/authentik/blueprints/40-opensource-ldap.yaml +++ b/authentik/blueprints/40-opensource-ldap.yaml @@ -35,7 +35,10 @@ entries: invalidation_flow: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] base_dn: !Context ldap_base_dn - bind_mode: cached + # bind_mode must be `direct` so the bind flow (and its MFA check) runs on + # every bind. `cached` would cache the bind result and effectively bypass + # 2FA for the cache duration after the first successful bind. + bind_mode: direct search_mode: cached mfa_support: true uid_start_number: 2000 From 40d661f592d9ddac37ff352c5ec4ebb868114619 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 9 Jun 2026 17:00:18 -0400 Subject: [PATCH 15/23] docs: add README --- authentik/README.md | 296 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 authentik/README.md diff --git a/authentik/README.md b/authentik/README.md new file mode 100644 index 00000000..fdae932c --- /dev/null +++ b/authentik/README.md @@ -0,0 +1,296 @@ +# MIE Opensource — authentik + +This directory contains the [authentik](https://goauthentik.io/) deployment that +provides Single Sign-On (SSO), enrollment, and LDAP for the MIE Opensource +platform. Configuration is delivered declaratively through +[blueprints](https://docs.goauthentik.io/docs/customize/blueprints/) so the +identity provider can be reproduced from source. + +Two-factor authentication (2FA) is enforced for all interactive users via a +**push notification service** (an external authenticator that approves logins +with a phone push). Throughout this document and the diagrams, "push +notification service" refers to that provider. + +## Contents + +| Path | Purpose | +|---|---| +| `compose.yml` | Server + worker + PostgreSQL services | +| `blueprints/` | Declarative flows, stages, providers, and applications | +| `certs/` | LDAPS / outpost certificates (git-ignored) | +| `custom-templates/` | Email and flow template overrides (git-ignored) | +| `data/` | Media (logos, icons) and runtime data (git-ignored) | + +## Blueprints + +Blueprints are applied **in filename order**. The numeric prefix encodes the +required import order — later files reference objects created by earlier ones +(via `!Find`/`!KeyOf`), so importing out of order will fail. + +| Order | File | Creates | +|---|---|---| +| 10 | `10-flows-enrollment-email-verification.yaml` | `default-enrollment-flow` (self-service registration) | +| 10 | `10-flows-recovery-email-mfa-verification.yaml` | `default-recovery-flow` (password reset) | +| 10 | `10-opensource-authenticator-duo-setup.yaml` | Push-notification authenticator + its setup flow | +| 10 | `10-opensource-ldap-identity.yaml` | `ldapusers` group and the `ldap-proxyuser` service account | +| 20 | `20-opensource-authentication-flow.yaml` | `opensource-authentication-flow` (login/bind) + proxyuser 2FA exception | +| 30 | `30-flow-invitation-enrollment.yaml` | `invitation-enrollment-flow` (invite-only onboarding) | +| 30 | `30-opensource-brand.yaml` | Brand: title, logos, and default flows | +| 40 | `40-opensource-ldap.yaml` | LDAP provider + application + access restriction | + +> The `10-flows-*` files are upstream authentik examples +> (`blueprints.goauthentik.io/instantiate: "false"`) used as the enrollment and +> recovery flows referenced by the identification stage. + +### Required environment variables + +These are read by `!Env` tags at import time and must be present in the +authentik **server** and **worker** environment: + +| Variable | Used by | Purpose | +|---|---|---| +| `OPENSOURCE_PUSH_MFA_CLIENT_ID` | `10-...duo-setup` | Push service application key | +| `OPENSOURCE_PUSH_MFA_CLIENT_SECRET` | `10-...duo-setup` | Push service application secret | +| `OPENSOURCE_PUSH_MFA_HOSTNAME` | `10-...duo-setup` | Push service API hostname | +| `OPENSOURCE_PUSH_MFA_ADMIN_ID` | `10-...duo-setup` | Push service admin API key | +| `OPENSOURCE_PUSH_MFA_ADMIN_SECRET` | `10-...duo-setup` | Push service admin API secret | +| `AUTHENTIK_LDAP_PROXYUSER_PASSWORD` | `10-...ldap-identity` | Password for the `ldap-proxyuser` account | +| `AUTHENTIK_LDAP_BASE_DN` | `40-...ldap` | Base DN of the LDAP directory | + +## Flow reference + +The sections below describe each flow as configured by the blueprints. Stage +order numbers match the `flowstagebinding` `order` values. + +### Authentication flow + +`opensource-authentication-flow` is the brand's default login flow. It is also +used as the LDAP **bind** flow. Stages: + +1. **Identification** (order 10) — username/email; links to enrollment and + recovery. +2. **Password** (order 20) — skipped if the user already has an authentication + backend attached (e.g. arrived passwordless). +3. **Authenticator validation** (order 30) — the push-notification 2FA check. + The binding uses `policy_engine_mode: all`, and the stage is skipped only + when every bound policy passes — used to exempt the LDAP proxyuser. +4. **User login** (order 100) — creates the session. + +```mermaid +sequenceDiagram + actor User + participant Browser + participant authentik + participant Push as Push Notification Service + + User->>Browser: Open protected app / login page + Browser->>authentik: Start opensource-authentication-flow + + Note over authentik: Stage 10 — Identification + authentik-->>Browser: Prompt for username/email + User->>Browser: Enter identifier + Browser->>authentik: Submit identifier + + Note over authentik: Stage 20 — Password
(skipped if backend already attached) + authentik-->>Browser: Prompt for password + User->>Browser: Enter password + Browser->>authentik: Submit password + authentik->>authentik: Validate credentials + + Note over authentik: Stage 30 — Authenticator validation (2FA) + authentik->>Push: Request login approval + Push-->>User: Push notification to device + User->>Push: Approve + Push-->>authentik: Approved + + Note over authentik: Stage 100 — User login + authentik->>authentik: Create session + authentik-->>Browser: Redirect back to app (authenticated) +``` + +### Invitation enrollment flow + +`invitation-enrollment-flow` onboards users who hold a valid invitation. New +users are created as **internal** accounts, added to the `ldapusers` group, and +are required to set up the push-notification authenticator before their first +login (the flow reuses the authentication flow's validation stage with +`not_configured_action: configure`). + +```mermaid +sequenceDiagram + actor User + participant Browser + participant authentik + participant Push as Push Notification Service + + User->>Browser: Open invitation link (with token) + Browser->>authentik: Start invitation-enrollment-flow + + Note over authentik: Stage 5 — Invitation + authentik->>authentik: Validate invitation token + alt Invalid or missing token + authentik-->>Browser: Deny (continue_flow_without_invitation = false) + end + + Note over authentik: Stage 10 — Prompt: credentials + authentik-->>Browser: Prompt username, password, repeat + User->>Browser: Enter credentials + Browser->>authentik: Submit + + Note over authentik: Stage 15 — Prompt: details + authentik-->>Browser: Prompt name, email + User->>Browser: Enter details + Browser->>authentik: Submit + + Note over authentik: Stage 20 — User write + authentik->>authentik: Create internal user + authentik->>authentik: Add user to "ldapusers" group + + Note over authentik: Stage 30 — Authenticator setup/validation + authentik-->>Browser: Require push-notification enrollment + User->>Push: Enroll device + Push-->>authentik: Device registered + approved + + Note over authentik: Stage 100 — User login + authentik->>authentik: Create session + authentik-->>Browser: Logged in +``` + +### Registration flow (self-service) + +`default-enrollment-flow` is the self-service registration flow linked from the +identification stage. Users are created **inactive** and must confirm via email +before the account is activated and logged in. + +```mermaid +sequenceDiagram + actor User + participant Browser + participant authentik + participant Email as Email Server + + User->>Browser: Click "Sign up" on login page + Browser->>authentik: Start default-enrollment-flow + + Note over authentik: Stage 10 — Prompt: first + authentik-->>Browser: Prompt username, password, repeat + User->>Browser: Enter credentials + Browser->>authentik: Submit + + Note over authentik: Stage 11 — Prompt: second + authentik-->>Browser: Prompt name, email + User->>Browser: Enter details + Browser->>authentik: Submit + + Note over authentik: Stage 20 — User write + authentik->>authentik: Create user (inactive) + + Note over authentik: Stage 30 — Email verification + authentik->>Email: Send confirmation email + Email-->>User: Confirmation link + User->>Browser: Open confirmation link + Browser->>authentik: Verify token + authentik->>authentik: Activate user + + Note over authentik: Stage 100 — User login + authentik->>authentik: Create session + authentik-->>Browser: Logged in +``` + +> Self-registered users are not added to the `ldapusers` group and therefore +> cannot bind to LDAP. They also still complete 2FA on their next login via the +> authentication flow. + +### Push-notification authenticator setup + +`opensource-authenticator-duo-setup` is a `stage_configuration` flow that +registers a user's device with the push notification service. It runs whenever a +flow's authenticator-validation stage encounters a user without a configured +authenticator (`not_configured_action: configure`), such as during invitation +enrollment or a first login. + +```mermaid +sequenceDiagram + actor User + participant Browser + participant authentik + participant Push as Push Notification Service + + Note over authentik: Triggered when 2FA is required
but no authenticator is configured + authentik->>Browser: Start opensource-authenticator-duo-setup + Browser->>authentik: Begin device enrollment + authentik->>Push: Create enrollment for user + Push-->>User: Pair device (push notification) + User->>Push: Confirm pairing + Push-->>authentik: Device enrolled + authentik->>authentik: Attach authenticator to user + authentik-->>Browser: Return to originating flow +``` + +### LDAP bind flow + +The LDAP application/provider (`opensource-ldap`) lets directory-aware services +authenticate against authentik. The provider uses the **authentication flow** as +its bind flow with `bind_mode: direct`, so 2FA is evaluated on **every** bind. + +Key access rules: + +- Only members of the `ldapusers` group may bind (enforced by a group policy + binding on the application). +- `mfa_support` is enabled, so human users append their one-time code to the + bind password as `password;code`. (The push notification service prompts the + user's device during the bind.) +- The `ldap-proxyuser` service account is exempt from 2FA (a negated user policy + binding skips the validation stage) and is the only principal granted + `search_full_directory` (read-only search of the whole directory). No user has + write access over LDAP. + +```mermaid +sequenceDiagram + participant Client as LDAP Client / Service + participant Outpost as LDAP Outpost + participant authentik + participant Push as Push Notification Service + + Client->>Outpost: bind request (DN + password, optional 2FA code) + Outpost->>authentik: Run bind flow (direct mode) + + Note over authentik: Identification + password + authentik->>authentik: Resolve user, validate password + + alt User is in "ldapusers" + alt User is ldap-proxyuser + Note over authentik: 2FA stage skipped (negated binding) + else Regular ldapusers member + authentik->>Push: Request approval + Push-->>authentik: Approved + end + authentik-->>Outpost: Bind success + Outpost-->>Client: Bind OK + + opt Directory search + Client->>Outpost: search request + alt Caller is ldap-proxyuser + Note over Outpost: search_full_directory →
full directory results + else Regular user + Note over Outpost: Returns only the caller's own object + end + Outpost-->>Client: Search results + end + else Not in "ldapusers" + authentik-->>Outpost: Access denied + Outpost-->>Client: Bind failed + end +``` + +## Deployment + +1. Provide the [environment variables](#required-environment-variables) to the + server and worker (e.g. via the authentik `.env`). +2. Make the blueprints available to authentik (mounted into the container's + blueprints path). +3. Apply blueprints **in numeric order** (`10` → `20` → `30` → `40`). authentik + discovers and applies file-based blueprints automatically; when importing + manually, follow the order in the [Blueprints](#blueprints) table. +4. Deploy an **LDAP outpost** and assign it the `opensource-ldap` application to + serve the directory. (The outpost is not created by these blueprints.) From 1c3c67198a8c550f1df5dfff08ec98b1e1fe8eac Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 10 Jun 2026 11:22:08 -0400 Subject: [PATCH 16/23] feat: add user_name/gecos overrides and ldap filter options for authentik integration --- ...-seed-sssd-access-filter-and-user-attrs.js | 98 +++++++++++++++++++ images/base/sssd.conf.template | 9 +- .../docs/admins/ldap-servers.md | 19 +++- 3 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js diff --git a/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js b/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js new file mode 100644 index 00000000..e2ee43e8 --- /dev/null +++ b/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js @@ -0,0 +1,98 @@ +'use strict'; + +// Adds SSSD env vars introduced after the initial sssd.conf.template: +// - SSSD_LDAP_USER_NAME (login-name attribute; blank => sssd default) +// - SSSD_LDAP_USER_GECOS (full-name/gecos attribute; defaults to cn) +// - SSSD_LDAP_ACCESS_FILTER (login access filter) +// +// This is a separate seeder (rather than an edit to 20260604000000) because +// that seeder is already released and recorded as executed in existing +// databases, so editing it in place would not back-fill the new keys. +// +// SSSD_LDAP_ACCESS_FILTER defaults to a permissive filter that every directory +// entry matches, so out of the box all directory-authenticated users may log +// in. This is deliberate: with access_provider=ldap and +// ldap_access_order=filter, an EMPTY ldap_access_filter denies ALL users, so +// the value must not be left blank. Admins restrict access by setting a more +// specific filter, e.g. memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com. +const NEW_SSSD_DEFAULTS = [ + { + key: 'SSSD_LDAP_USER_NAME', + value: '', + description: "LDAP attribute mapped to the user's login name. Leave blank to use the sssd builtin default (uid)" + }, + { + key: 'SSSD_LDAP_USER_GECOS', + value: 'cn', + description: "LDAP attribute mapped to the NSS gecos (full name) field, read by getent/finger and the git-identity script" + }, + { + key: 'SSSD_LDAP_ACCESS_FILTER', + value: '(objectClass=*)', + description: 'LDAP access filter; users matching it may log in. Defaults to (objectClass=*) so all directory users are allowed. Set a stricter filter (e.g. memberOf=cn=allowedusers,...) to restrict access. Must not be blank, which would deny everyone.' + } +]; + +function parseEnvVars(rawValue) { + try { + const parsed = JSON.parse(rawValue); + if (Array.isArray(parsed)) return parsed; + // Migrate from the legacy flat-object format {KEY: value} to array format. + if (typeof parsed === 'object' && parsed !== null) { + return Object.entries(parsed).map(([key, value]) => ({ key, value, description: '' })); + } + } catch (_) { + /* fall through */ + } + return []; +} + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const [rows] = await queryInterface.sequelize.query( + `SELECT value FROM "Settings" WHERE key = 'default_container_env_vars'` + ); + + const existing = rows.length > 0 ? parseEnvVars(rows[0].value) : []; + const existingKeys = new Set(existing.map((e) => e.key)); + + // Only add keys that are not already present so an admin's customized + // values are never overwritten. + const toAdd = NEW_SSSD_DEFAULTS.filter((e) => !existingKeys.has(e.key)); + if (toAdd.length === 0) return; + + const merged = [...existing, ...toAdd]; + const now = new Date(); + + if (rows.length > 0) { + await queryInterface.sequelize.query( + `UPDATE "Settings" SET value = :value, "updatedAt" = :now WHERE key = 'default_container_env_vars'`, + { replacements: { value: JSON.stringify(merged), now } } + ); + } else { + await queryInterface.bulkInsert('Settings', [{ + key: 'default_container_env_vars', + value: JSON.stringify(merged), + createdAt: now, + updatedAt: now + }]); + } + }, + + async down(queryInterface) { + const [rows] = await queryInterface.sequelize.query( + `SELECT value FROM "Settings" WHERE key = 'default_container_env_vars'` + ); + if (rows.length === 0) return; + + const existing = parseEnvVars(rows[0].value); + const keysToRemove = new Set(NEW_SSSD_DEFAULTS.map((e) => e.key)); + const reverted = existing.filter((e) => !keysToRemove.has(e.key)); + + await queryInterface.sequelize.query( + `UPDATE "Settings" SET value = :value, "updatedAt" = :now WHERE key = 'default_container_env_vars'`, + { replacements: { value: JSON.stringify(reverted), now: new Date() } } + ); + } +}; diff --git a/images/base/sssd.conf.template b/images/base/sssd.conf.template index f889f46e..417e3f8e 100644 --- a/images/base/sssd.conf.template +++ b/images/base/sssd.conf.template @@ -4,6 +4,7 @@ domains = default [domain/default] id_provider = ldap auth_provider = ldap +access_provider = ldap ldap_uri = ${SSSD_LDAP_URI} ldap_tls_reqcert = ${SSSD_LDAP_TLS_REQCERT} @@ -12,9 +13,11 @@ ldap_search_base = ${SSSD_LDAP_SEARCH_BASE} ldap_user_search_base = ${SSSD_LDAP_USER_SEARCH_BASE} ldap_group_search_base = ${SSSD_LDAP_GROUP_SEARCH_BASE} -# Map LDAP cn attribute to the NSS gecos field so that tools like getent, -# finger, and the git-identity profile script can read the user's full name. -ldap_user_gecos = cn +ldap_user_name = ${SSSD_LDAP_USER_NAME} +ldap_user_gecos = ${SSSD_LDAP_USER_GECOS} + +ldap_access_order = filter +ldap_access_filter = ${SSSD_LDAP_ACCESS_FILTER} ldap_default_bind_dn = ${SSSD_LDAP_DEFAULT_BIND_DN} ldap_default_authtok_type = ${SSSD_DEFAULT_AUTHTOK_TYPE} diff --git a/mie-opensource-landing/docs/admins/ldap-servers.md b/mie-opensource-landing/docs/admins/ldap-servers.md index 830fb9b7..932c9390 100644 --- a/mie-opensource-landing/docs/admins/ldap-servers.md +++ b/mie-opensource-landing/docs/admins/ldap-servers.md @@ -22,6 +22,7 @@ domains = default [domain/default] id_provider = ldap auth_provider = ldap +access_provider = ldap ldap_uri = ${SSSD_LDAP_URI} ldap_tls_reqcert = ${SSSD_LDAP_TLS_REQCERT} @@ -30,9 +31,13 @@ ldap_search_base = ${SSSD_LDAP_SEARCH_BASE} ldap_user_search_base = ${SSSD_LDAP_USER_SEARCH_BASE} ldap_group_search_base = ${SSSD_LDAP_GROUP_SEARCH_BASE} -# Map the LDAP cn attribute to the NSS gecos field so tools like getent, -# finger, and the git-identity profile script can read the user's full name. -ldap_user_gecos = cn +ldap_user_name = ${SSSD_LDAP_USER_NAME} +ldap_user_gecos = ${SSSD_LDAP_USER_GECOS} + +# Which users may log in. The default filter matches every entry, so all +# directory users are allowed; set a stricter filter to restrict access. +ldap_access_order = filter +ldap_access_filter = ${SSSD_LDAP_ACCESS_FILTER} ldap_default_bind_dn = ${SSSD_LDAP_DEFAULT_BIND_DN} ldap_default_authtok_type = ${SSSD_DEFAULT_AUTHTOK_TYPE} @@ -43,7 +48,10 @@ ldap_opt_timeout = 60 ``` !!! note - Any `SSSD_*` variable left blank is substituted as an empty value, and SSSD falls back to its built-in default (for example, auto-detecting the search base from the directory's RootDSE). Only set the variables your directory actually requires. + Most `SSSD_*` variables left blank are substituted as an empty value, and SSSD falls back to its built-in default (for example, auto-detecting the search base from the directory's RootDSE). Only set the variables your directory actually requires. + +!!! warning "Do not blank out `SSSD_LDAP_ACCESS_FILTER`" + `SSSD_LDAP_ACCESS_FILTER` is the exception to the rule above. Because the container uses `access_provider = ldap` with `ldap_access_order = filter`, an **empty** access filter causes SSSD to **deny every user**. It is seeded with the permissive default `(objectClass=*)` so that all directory users can log in out of the box. Replace it with a narrower filter to restrict access — but never leave it blank. ## Configuring the Connection @@ -59,6 +67,9 @@ In the admin UI: **Settings** → **Default Container Environment Variables**. T | `SSSD_LDAP_SEARCH_BASE` | *(blank)* | No | Base DN for all searches (e.g. `dc=example,dc=com`). Leave blank to let SSSD auto-detect it from the RootDSE. | | `SSSD_LDAP_USER_SEARCH_BASE` | *(blank)* | No | Base DN for user searches. Overrides `SSSD_LDAP_SEARCH_BASE` for users (e.g. `ou=people,dc=example,dc=com`). | | `SSSD_LDAP_GROUP_SEARCH_BASE` | *(blank)* | No | Base DN for group searches (e.g. `ou=groups,dc=example,dc=com`). | +| `SSSD_LDAP_USER_NAME` | *(blank)* | No | LDAP attribute mapped to the user's login name. Leave blank for the SSSD default (`uid`); set to `sAMAccountName` for Active Directory. | +| `SSSD_LDAP_USER_GECOS` | `cn` | No | LDAP attribute mapped to the NSS gecos (full name) field, read by `getent`, `finger`, and the git-identity script. | +| `SSSD_LDAP_ACCESS_FILTER` | `(objectClass=*)` | Yes | LDAP filter deciding **who may log in**. The default matches every entry, so all directory users are allowed. Restrict access with a stricter filter, e.g. `memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com`. Must not be blank — an empty filter denies everyone. | | `SSSD_LDAP_DEFAULT_BIND_DN` | *(blank)* | No | DN used to bind for lookups. Leave blank for anonymous bind; set it if your directory disallows anonymous searches (e.g. `cn=svc-sssd,ou=services,dc=example,dc=com`). | | `SSSD_DEFAULT_AUTHTOK_TYPE` | *(blank)* | No | Type of the bind credential, typically `password`. Required when `SSSD_LDAP_DEFAULT_BIND_DN` is set. | | `SSSD_DEFAULT_AUTHTOK` | *(blank)* | No | The bind credential (password) for the bind DN. Required when `SSSD_LDAP_DEFAULT_BIND_DN` is set. | From 86d17858e8738993de25bb30facb72b4d98d3816 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 10 Jun 2026 11:47:36 -0400 Subject: [PATCH 17/23] feat: force 2FA for email registered users --- authentik/README.md | 24 ++++++--- .../11-opensource-mfa-validation-stage.yaml | 50 +++++++++++++++++++ ...-flows-enrollment-email-verification.yaml} | 8 +++ .../20-opensource-authentication-flow.yaml | 43 +++++----------- 4 files changed, 88 insertions(+), 37 deletions(-) create mode 100644 authentik/blueprints/11-opensource-mfa-validation-stage.yaml rename authentik/blueprints/{10-flows-enrollment-email-verification.yaml => 12-flows-enrollment-email-verification.yaml} (89%) diff --git a/authentik/README.md b/authentik/README.md index fdae932c..d286c3fd 100644 --- a/authentik/README.md +++ b/authentik/README.md @@ -29,17 +29,23 @@ required import order — later files reference objects created by earlier ones | Order | File | Creates | |---|---|---| -| 10 | `10-flows-enrollment-email-verification.yaml` | `default-enrollment-flow` (self-service registration) | | 10 | `10-flows-recovery-email-mfa-verification.yaml` | `default-recovery-flow` (password reset) | | 10 | `10-opensource-authenticator-duo-setup.yaml` | Push-notification authenticator + its setup flow | | 10 | `10-opensource-ldap-identity.yaml` | `ldapusers` group and the `ldap-proxyuser` service account | +| 11 | `11-opensource-mfa-validation-stage.yaml` | Shared 2FA validation stage (`opensource-authentication-mfa-validation`) | +| 12 | `12-flows-enrollment-email-verification.yaml` | `default-enrollment-flow` (self-service registration) | | 20 | `20-opensource-authentication-flow.yaml` | `opensource-authentication-flow` (login/bind) + proxyuser 2FA exception | | 30 | `30-flow-invitation-enrollment.yaml` | `invitation-enrollment-flow` (invite-only onboarding) | | 30 | `30-opensource-brand.yaml` | Brand: title, logos, and default flows | | 40 | `40-opensource-ldap.yaml` | LDAP provider + application + access restriction | -> The `10-flows-*` files are upstream authentik examples -> (`blueprints.goauthentik.io/instantiate: "false"`) used as the enrollment and +> The shared 2FA stage is defined once in `11-...` and referenced (via `!Find`) +> by the registration, authentication, and invitation flows so every interactive +> flow enforces identical 2FA behaviour. It must be imported before those flows, +> and after the push-notification authenticator it depends on. +> +> The `1x-flows-*` files are based on upstream authentik examples +> (`blueprints.goauthentik.io/instantiate: "false"`), used as the enrollment and > recovery flows referenced by the identification stage. ### Required environment variables @@ -160,7 +166,8 @@ sequenceDiagram `default-enrollment-flow` is the self-service registration flow linked from the identification stage. Users are created **inactive** and must confirm via email -before the account is activated and logged in. +before the account is activated. After verification they are required to set up +the push-notification authenticator (2FA) before the login stage completes. ```mermaid sequenceDiagram @@ -168,6 +175,7 @@ sequenceDiagram participant Browser participant authentik participant Email as Email Server + participant Push as Push Notification Service User->>Browser: Click "Sign up" on login page Browser->>authentik: Start default-enrollment-flow @@ -192,14 +200,18 @@ sequenceDiagram Browser->>authentik: Verify token authentik->>authentik: Activate user + Note over authentik: Stage 40 — Authenticator setup (2FA)
required before login + authentik-->>Browser: Require push-notification enrollment + User->>Push: Enroll device + Push-->>authentik: Device registered + approved + Note over authentik: Stage 100 — User login authentik->>authentik: Create session authentik-->>Browser: Logged in ``` > Self-registered users are not added to the `ldapusers` group and therefore -> cannot bind to LDAP. They also still complete 2FA on their next login via the -> authentication flow. +> cannot bind to LDAP. ### Push-notification authenticator setup diff --git a/authentik/blueprints/11-opensource-mfa-validation-stage.yaml b/authentik/blueprints/11-opensource-mfa-validation-stage.yaml new file mode 100644 index 00000000..c4563e9f --- /dev/null +++ b/authentik/blueprints/11-opensource-mfa-validation-stage.yaml @@ -0,0 +1,50 @@ +version: 1 +metadata: + name: Opensource - MFA Validation Stage +entries: + # --------------------------------------------------------------------------- + # Shared authenticator validation (2FA) stage. + # + # This is the single MFA validation stage used by every interactive flow: + # - the authentication / LDAP bind flow (20-opensource-authentication-flow) + # - the invitation enrollment flow (30-flow-invitation-enrollment) + # - the self-service registration flow (12-flows-enrollment-email-verification) + # + # It is defined in its own blueprint so it can be imported before all of those + # flows and referenced from each via !Find, keeping a single definition of the + # 2FA behaviour. + # + # not_configured_action: configure forces users without an authenticator to + # enroll one (via the push-notification setup flow) before they can proceed. + # It depends on the push-notification authenticator defined in + # 10-opensource-authenticator-duo-setup.yaml, which must be imported first. + # --------------------------------------------------------------------------- + - attrs: + configuration_stages: + - !Find [ + authentik_stages_authenticator_duo.authenticatorduostage, + [name, opensource-authenticator-duo-setup], + ] + device_classes: + - static + - totp + - webauthn + - duo + - sms + - email + email_otp_throttling_factor: 1.0 + last_auth_threshold: seconds=0 + not_configured_action: configure + sms_otp_throttling_factor: 1.0 + static_otp_throttling_factor: 1.0 + totp_otp_throttling_factor: 1.0 + webauthn_allowed_device_types: [] + webauthn_hints: [] + webauthn_user_verification: preferred + conditions: [] + id: opensource-authentication-mfa-validation + identifiers: + name: opensource-authentication-mfa-validation + model: authentik_stages_authenticator_validate.authenticatorvalidatestage + permissions: [] + state: present diff --git a/authentik/blueprints/10-flows-enrollment-email-verification.yaml b/authentik/blueprints/12-flows-enrollment-email-verification.yaml similarity index 89% rename from authentik/blueprints/10-flows-enrollment-email-verification.yaml rename to authentik/blueprints/12-flows-enrollment-email-verification.yaml index 275313f9..aa0e5d82 100644 --- a/authentik/blueprints/10-flows-enrollment-email-verification.yaml +++ b/authentik/blueprints/12-flows-enrollment-email-verification.yaml @@ -129,6 +129,14 @@ entries: stage: !KeyOf default-enrollment-email-verification order: 30 model: authentik_flows.flowstagebinding + # Force the user to set up an authenticator (2FA) before logging in. Reuses the + # shared MFA validation stage defined in 11-opensource-mfa-validation-stage.yaml + # (not_configured_action: configure forces push-notification enrollment). + - identifiers: + target: !KeyOf flow + stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, opensource-authentication-mfa-validation]] + order: 40 + model: authentik_flows.flowstagebinding - identifiers: target: !KeyOf flow stage: !KeyOf default-enrollment-user-login diff --git a/authentik/blueprints/20-opensource-authentication-flow.yaml b/authentik/blueprints/20-opensource-authentication-flow.yaml index bbe9f726..d967b620 100644 --- a/authentik/blueprints/20-opensource-authentication-flow.yaml +++ b/authentik/blueprints/20-opensource-authentication-flow.yaml @@ -63,35 +63,6 @@ entries: model: authentik_stages_password.passwordstage permissions: [] state: present - - attrs: - configuration_stages: - - !Find [ - authentik_stages_authenticator_duo.authenticatorduostage, - [name, opensource-authenticator-duo-setup], - ] - device_classes: - - static - - totp - - webauthn - - duo - - sms - - email - email_otp_throttling_factor: 1.0 - last_auth_threshold: seconds=0 - not_configured_action: configure - sms_otp_throttling_factor: 1.0 - static_otp_throttling_factor: 1.0 - totp_otp_throttling_factor: 1.0 - webauthn_allowed_device_types: [] - webauthn_hints: [] - webauthn_user_verification: preferred - conditions: [] - id: opensource-authentication-mfa-validation - identifiers: - name: opensource-authentication-mfa-validation - model: authentik_stages_authenticator_validate.authenticatorvalidatestage - permissions: [] - state: present - attrs: captcha_stage: null case_insensitive_matching: true @@ -108,7 +79,13 @@ entries: user_fields: - email - username - webauthn_stage: !KeyOf opensource-authentication-mfa-validation + # The shared MFA validation stage is defined in + # 11-opensource-mfa-validation-stage.yaml. + webauthn_stage: + !Find [ + authentik_stages_authenticator_validate.authenticatorvalidatestage, + [name, opensource-authentication-mfa-validation], + ] conditions: [] id: opensource-authentication-identification identifiers: @@ -170,7 +147,11 @@ entries: id: opensource-authentication-mfa-validation-stagebinding identifiers: order: 30 - stage: !KeyOf opensource-authentication-mfa-validation + stage: + !Find [ + authentik_stages_authenticator_validate.authenticatorvalidatestage, + [name, opensource-authentication-mfa-validation], + ] target: !KeyOf opensource-authentication-flow model: authentik_flows.flowstagebinding permissions: [] From 66339517428d8f49d33f2a47d25f89dc09e425cd Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 10 Jun 2026 14:05:59 -0400 Subject: [PATCH 18/23] feat: add sshPubKey to ldap --- authentik/README.md | 38 ++++ .../20-opensource-user-settings-flow.yaml | 182 ++++++++++++++++++ authentik/blueprints/30-opensource-brand.yaml | 2 +- images/base/50-sss-ssh-authorizedkeys.conf | 4 + images/base/Dockerfile | 5 +- images/base/sssd.conf.template | 4 + 6 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 authentik/blueprints/20-opensource-user-settings-flow.yaml create mode 100644 images/base/50-sss-ssh-authorizedkeys.conf diff --git a/authentik/README.md b/authentik/README.md index d286c3fd..92685479 100644 --- a/authentik/README.md +++ b/authentik/README.md @@ -35,6 +35,7 @@ required import order — later files reference objects created by earlier ones | 11 | `11-opensource-mfa-validation-stage.yaml` | Shared 2FA validation stage (`opensource-authentication-mfa-validation`) | | 12 | `12-flows-enrollment-email-verification.yaml` | `default-enrollment-flow` (self-service registration) | | 20 | `20-opensource-authentication-flow.yaml` | `opensource-authentication-flow` (login/bind) + proxyuser 2FA exception | +| 20 | `20-opensource-user-settings-flow.yaml` | `opensource-user-settings-flow` (profile + SSH key, read-only username) | | 30 | `30-flow-invitation-enrollment.yaml` | `invitation-enrollment-flow` (invite-only onboarding) | | 30 | `30-opensource-brand.yaml` | Brand: title, logos, and default flows | | 40 | `40-opensource-ldap.yaml` | LDAP provider + application + access restriction | @@ -239,6 +240,43 @@ sequenceDiagram authentik-->>Browser: Return to originating flow ``` +### User settings flow + +`opensource-user-settings-flow` is the brand's user-settings flow (reachable from +the user dropdown). It is based on authentik's `default-user-settings-flow` with +two changes: + +- The **username is read-only** — it is displayed but cannot be edited, and the + prompt stage enforces this server-side (read-only fields are reset to their + original value before the user-write stage). +- A new **SSH public key** field writes to the user's `sshPublicKey` attribute. + The LDAP provider exposes this attribute, SSSD maps it with + `ldap_user_ssh_public_key = sshPublicKey`, and sshd serves it via + `sss_ssh_authorizedkeys`, enabling SSH public-key login on managed hosts. + +```mermaid +sequenceDiagram + actor User + participant Browser + participant authentik + + User->>Browser: Open user settings + Browser->>authentik: Start opensource-user-settings-flow + + Note over authentik: Stage 20 — Prompt (profile) + authentik-->>Browser: Show username (read-only), name, email,
locale, SSH public key + User->>Browser: Edit name / email / locale / SSH key + Browser->>authentik: Submit + + Note over authentik: Validation policy + authentik->>authentik: Enforce name/email change permissions + authentik->>authentik: Force username back to its current value + + Note over authentik: Stage 100 — User write + authentik->>authentik: Persist changes (incl. sshPublicKey attribute) + authentik-->>Browser: Settings saved +``` + ### LDAP bind flow The LDAP application/provider (`opensource-ldap`) lets directory-aware services diff --git a/authentik/blueprints/20-opensource-user-settings-flow.yaml b/authentik/blueprints/20-opensource-user-settings-flow.yaml new file mode 100644 index 00000000..4a47a5cf --- /dev/null +++ b/authentik/blueprints/20-opensource-user-settings-flow.yaml @@ -0,0 +1,182 @@ +version: 1 +metadata: + name: Opensource - User settings flow +entries: + # Based on authentik's default-user-settings-flow, with two changes: + # 1. The username field is read-only (users cannot change their username). + # 2. A new sshPublicKey field writes to the user's `sshPublicKey` attribute, + # which SSSD exposes to SSH via ldap_user_ssh_public_key. + - attrs: + designation: stage_configuration + name: Opensource User settings + title: Update your info + authentication: require_authenticated + identifiers: + slug: opensource-user-settings-flow + model: authentik_flows.flow + id: flow + + # Username — read-only. Shown for reference but cannot be edited, and because a + # read-only field is not submitted, it is never written back. + - attrs: + order: 200 + placeholder: Username + placeholder_expression: false + initial_value: | + try: + return user.username + except: + return '' + initial_value_expression: true + required: false + type: text_read_only + field_key: username + label: Username + identifiers: + name: opensource-user-settings-field-username + id: prompt-field-username + model: authentik_stages_prompt.prompt + + - attrs: + order: 201 + placeholder: Name + placeholder_expression: false + initial_value: | + try: + return user.name + except: + return '' + initial_value_expression: true + required: true + type: text + field_key: name + label: Name + identifiers: + name: opensource-user-settings-field-name + id: prompt-field-name + model: authentik_stages_prompt.prompt + + - attrs: + order: 202 + placeholder: Email + placeholder_expression: false + initial_value: | + try: + return user.email + except: + return '' + initial_value_expression: true + required: true + type: email + field_key: email + label: Email + identifiers: + name: opensource-user-settings-field-email + id: prompt-field-email + model: authentik_stages_prompt.prompt + + - attrs: + order: 203 + placeholder: Locale + placeholder_expression: false + initial_value: | + try: + return user.attributes.get("settings", {}).get("locale", "") + except: + return '' + initial_value_expression: true + required: true + type: ak-locale + field_key: attributes.settings.locale + label: Locale + identifiers: + name: opensource-user-settings-field-locale + id: prompt-field-locale + model: authentik_stages_prompt.prompt + + # New: SSH public key. Stored on the user's `sshPublicKey` attribute, which the + # LDAP provider exposes and SSSD reads (ldap_user_ssh_public_key = sshPublicKey) + # so the key can be used for SSH public-key authentication on managed hosts. + - attrs: + order: 204 + placeholder: "ssh-ed25519 AAAA... user@host" + placeholder_expression: false + initial_value: | + try: + return user.attributes.get("sshPublicKey", "") + except: + return '' + initial_value_expression: true + required: false + type: text_area + field_key: attributes.sshPublicKey + label: SSH Public Key + sub_text: Used for SSH public-key authentication on Opensource hosts. + identifiers: + name: opensource-user-settings-field-ssh-public-key + id: prompt-field-ssh-public-key + model: authentik_stages_prompt.prompt + + # Authorization policy: enforces authentik's name/email change permissions. + # The username check from the upstream flow is intentionally omitted because the + # username field is read-only and is never submitted. + - attrs: + expression: | + from authentik.core.models import ( + USER_ATTRIBUTE_CHANGE_EMAIL, + USER_ATTRIBUTE_CHANGE_NAME, + ) + prompt_data = request.context.get("prompt_data") + + if not request.user.group_attributes(request.http_request).get( + USER_ATTRIBUTE_CHANGE_EMAIL, request.http_request.tenant.default_user_change_email + ): + if prompt_data.get("email") != request.user.email: + ak_message("Not allowed to change email address.") + return False + + if not request.user.group_attributes(request.http_request).get( + USER_ATTRIBUTE_CHANGE_NAME, request.http_request.tenant.default_user_change_name + ): + if prompt_data.get("name") != request.user.name: + ak_message("Not allowed to change name.") + return False + + return True + identifiers: + name: opensource-user-settings-authorization + id: opensource-user-settings-authorization + model: authentik_policies_expression.expressionpolicy + + - identifiers: + name: opensource-user-settings-write + attrs: + user_creation_mode: never_create + id: opensource-user-settings-write + model: authentik_stages_user_write.userwritestage + + - attrs: + fields: + - !KeyOf prompt-field-username + - !KeyOf prompt-field-name + - !KeyOf prompt-field-email + - !KeyOf prompt-field-locale + - !KeyOf prompt-field-ssh-public-key + validation_policies: + - !KeyOf opensource-user-settings-authorization + identifiers: + name: opensource-user-settings + id: opensource-user-settings + model: authentik_stages_prompt.promptstage + + - identifiers: + order: 20 + stage: !KeyOf opensource-user-settings + target: !KeyOf flow + model: authentik_flows.flowstagebinding + + - identifiers: + order: 100 + stage: !KeyOf opensource-user-settings-write + target: !KeyOf flow + model: authentik_flows.flowstagebinding diff --git a/authentik/blueprints/30-opensource-brand.yaml b/authentik/blueprints/30-opensource-brand.yaml index 2f357b3e..fce0f8ad 100644 --- a/authentik/blueprints/30-opensource-brand.yaml +++ b/authentik/blueprints/30-opensource-brand.yaml @@ -19,7 +19,7 @@ entries: branding_favicon: icon.svg flow_authentication: !Find [authentik_flows.flow, [slug, opensource-authentication-flow]] flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] - flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]] + flow_user_settings: !Find [authentik_flows.flow, [slug, opensource-user-settings-flow]] identifiers: domain: opensource-default state: present diff --git a/images/base/50-sss-ssh-authorizedkeys.conf b/images/base/50-sss-ssh-authorizedkeys.conf new file mode 100644 index 00000000..e4c83cfe --- /dev/null +++ b/images/base/50-sss-ssh-authorizedkeys.conf @@ -0,0 +1,4 @@ +# Fetch users' authorized SSH public keys from SSSD (which serves the LDAP +# `sshPublicKey` attribute). %u is replaced by sshd with the login username. +AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u +AuthorizedKeysCommandUser nobody diff --git a/images/base/Dockerfile b/images/base/Dockerfile index 6e89cdfe..d0d7a97c 100644 --- a/images/base/Dockerfile +++ b/images/base/Dockerfile @@ -15,8 +15,10 @@ COPY --from=builder /rootfs / # Install and setup sssd autoconfiguration. # ldap-utils provides ldapsearch, used by the git-identity profile script to # fetch a user's email from the internal LDAP server on first interactive login. +# sssd-tools provides sss_ssh_authorizedkeys, used by sshd to serve users' SSH +# public keys (stored on the LDAP sshPublicKey attribute). RUN apt-get update && \ - apt-get install -y sssd sudo tmux curl gnupg git jq ldap-utils unattended-upgrades locales && \ + apt-get install -y sssd sssd-tools sudo tmux curl gnupg git jq ldap-utils unattended-upgrades locales && \ echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen && \ locale-gen && \ update-locale LANG=en_US.UTF-8 && \ @@ -25,6 +27,7 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* COPY --chmod=0440 sssd.conf.template /etc/sssd/sssd.conf.template COPY --chmod=0440 50-sssd-conf-template.conf /etc/systemd/system/sssd.service.d/50-sssd-conf-template.conf +COPY --chmod=0644 50-sss-ssh-authorizedkeys.conf /etc/ssh/sshd_config.d/50-sss-ssh-authorizedkeys.conf COPY --chmod=0440 ldapusers /etc/sudoers.d/ldapusers COPY --chmod=0644 ldap.conf /etc/ldap/ldap.conf COPY --chmod=0755 git-identity.sh /etc/profile.d/git-identity.sh diff --git a/images/base/sssd.conf.template b/images/base/sssd.conf.template index 417e3f8e..4f1428bf 100644 --- a/images/base/sssd.conf.template +++ b/images/base/sssd.conf.template @@ -16,6 +16,10 @@ ldap_group_search_base = ${SSSD_LDAP_GROUP_SEARCH_BASE} ldap_user_name = ${SSSD_LDAP_USER_NAME} ldap_user_gecos = ${SSSD_LDAP_USER_GECOS} +# Expose the user's SSH public key (stored on the LDAP `sshPublicKey` attribute) +# so it can be served to sshd via `sss_ssh_authorizedkeys`. +ldap_user_ssh_public_key = sshPublicKey + ldap_access_order = filter ldap_access_filter = ${SSSD_LDAP_ACCESS_FILTER} From e4f2be59619f795344a5b0d0d4e44d323f1943f6 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 10 Jun 2026 15:44:13 -0400 Subject: [PATCH 19/23] fix: store sshPublicKey as list in authentik to properly expose via ldap --- authentik/README.md | 17 ++-- .../20-opensource-user-settings-flow.yaml | 81 +++++++++++++++++-- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/authentik/README.md b/authentik/README.md index 92685479..d9b0b312 100644 --- a/authentik/README.md +++ b/authentik/README.md @@ -249,10 +249,12 @@ two changes: - The **username is read-only** — it is displayed but cannot be edited, and the prompt stage enforces this server-side (read-only fields are reset to their original value before the user-write stage). -- A new **SSH public key** field writes to the user's `sshPublicKey` attribute. - The LDAP provider exposes this attribute, SSSD maps it with +- A new **SSH public keys** field (one key per line) writes to the user's + `sshPublicKey` attribute as a **list**. The LDAP provider exposes this as a + multi-valued attribute, SSSD maps it with `ldap_user_ssh_public_key = sshPublicKey`, and sshd serves it via - `sss_ssh_authorizedkeys`, enabling SSH public-key login on managed hosts. + `sss_ssh_authorizedkeys`, enabling SSH public-key login on managed hosts with + any of the user's keys. ```mermaid sequenceDiagram @@ -264,16 +266,17 @@ sequenceDiagram Browser->>authentik: Start opensource-user-settings-flow Note over authentik: Stage 20 — Prompt (profile) - authentik-->>Browser: Show username (read-only), name, email,
locale, SSH public key - User->>Browser: Edit name / email / locale / SSH key + authentik-->>Browser: Show username (read-only), name, email,
locale, SSH public keys + User->>Browser: Edit name / email / locale / SSH keys Browser->>authentik: Submit Note over authentik: Validation policy authentik->>authentik: Enforce name/email change permissions authentik->>authentik: Force username back to its current value - Note over authentik: Stage 100 — User write - authentik->>authentik: Persist changes (incl. sshPublicKey attribute) + Note over authentik: Stage 100 — User write (re-evaluated policy) + authentik->>authentik: Normalize SSH keys into a list (one per line) + authentik->>authentik: Persist changes (incl. sshPublicKey list attribute) authentik-->>Browser: Settings saved ``` diff --git a/authentik/blueprints/20-opensource-user-settings-flow.yaml b/authentik/blueprints/20-opensource-user-settings-flow.yaml index 4a47a5cf..48d6665a 100644 --- a/authentik/blueprints/20-opensource-user-settings-flow.yaml +++ b/authentik/blueprints/20-opensource-user-settings-flow.yaml @@ -4,8 +4,9 @@ metadata: entries: # Based on authentik's default-user-settings-flow, with two changes: # 1. The username field is read-only (users cannot change their username). - # 2. A new sshPublicKey field writes to the user's `sshPublicKey` attribute, - # which SSSD exposes to SSH via ldap_user_ssh_public_key. + # 2. A new SSH public keys field (one key per line) writes a LIST to the + # user's `sshPublicKey` attribute, which SSSD exposes to SSH via + # ldap_user_ssh_public_key as a multi-valued attribute. - attrs: designation: stage_configuration name: Opensource User settings @@ -94,24 +95,34 @@ entries: id: prompt-field-locale model: authentik_stages_prompt.prompt - # New: SSH public key. Stored on the user's `sshPublicKey` attribute, which the - # LDAP provider exposes and SSSD reads (ldap_user_ssh_public_key = sshPublicKey) - # so the key can be used for SSH public-key authentication on managed hosts. + # New: SSH public key(s). Users enter one key per line in a text area. The + # submitted value is a single multiline string; the + # opensource-user-settings-ssh-keys policy (bound to the user-write stage + # below) splits it into a LIST before the value is written to the user's + # `sshPublicKey` attribute. Storing a list makes `sshPublicKey` a multi-valued + # LDAP attribute, so SSSD (ldap_user_ssh_public_key = sshPublicKey) exposes + # every key to sshd and a user can authenticate with any one of them. - attrs: order: 204 placeholder: "ssh-ed25519 AAAA... user@host" placeholder_expression: false + # Pre-fill the editor with the user's existing keys, one per line. The + # stored attribute may be a list (multiple keys) or, for legacy records, a + # single string; handle both. initial_value: | try: - return user.attributes.get("sshPublicKey", "") + keys = user.attributes.get("sshPublicKey", []) + if isinstance(keys, str): + return keys + return "\n".join(keys) except: return '' initial_value_expression: true required: false type: text_area field_key: attributes.sshPublicKey - label: SSH Public Key - sub_text: Used for SSH public-key authentication on Opensource hosts. + label: SSH Public Keys + sub_text: One key per line. Used for SSH public-key authentication on hosts. identifiers: name: opensource-user-settings-field-ssh-public-key id: prompt-field-ssh-public-key @@ -148,6 +159,39 @@ entries: id: opensource-user-settings-authorization model: authentik_policies_expression.expressionpolicy + # Converts the SSH public key text area into a LIST stored on the user's + # `sshPublicKey` attribute. Users enter one key per line; we split, trim, + # de-duplicate (preserving order), and drop blank lines. + # + # This is bound to the USER-WRITE stage (not the prompt stage) so it runs in + # the flow process after the prompt's data has been merged into the plan + # context, where mutating prompt_data in place persists to the User Write + # stage. (Policies on the prompt stage run against a separate validated-data + # dict, and process isolation can drop the mutation.) + # + # The submitted value lives at context["prompt_data"]["attributes"]["sshPublicKey"] + # (see goauthentik/authentik#3134). The conversion is idempotent: if the value + # is already a list (e.g. on a policy re-evaluation) it is left as-is. Always + # returns True; it normalizes the value and never blocks the stage. + - attrs: + expression: | + attributes = context["prompt_data"]["attributes"] + value = attributes["sshPublicKey"] + + if not isinstance(value, list): + keys = [] + for line in value.split("\n"): + key = line.strip() + if key and key not in keys: + keys.append(key) + attributes["sshPublicKey"] = keys + + return True + identifiers: + name: opensource-user-settings-ssh-keys + id: opensource-user-settings-ssh-keys + model: authentik_policies_expression.expressionpolicy + - identifiers: name: opensource-user-settings-write attrs: @@ -179,4 +223,25 @@ entries: order: 100 stage: !KeyOf opensource-user-settings-write target: !KeyOf flow + # re_evaluate_policies makes the flow re-run this binding's policies at + # execution time (when prompt_data is populated), rather than only at plan + # time. This is what lets opensource-user-settings-ssh-keys normalize the + # submitted SSH keys into a list just before they are written to the user. + attrs: + re_evaluate_policies: true + evaluate_on_plan: false + id: opensource-user-settings-write-binding model: authentik_flows.flowstagebinding + + # Run the SSH-key normalization policy immediately before the user-write + # stage. Bound to the stage binding above so it executes in the flow process + # with access to the live prompt_data. + - identifiers: + target: !KeyOf opensource-user-settings-write-binding + policy: !KeyOf opensource-user-settings-ssh-keys + order: 0 + attrs: + enabled: true + negate: false + timeout: 30 + model: authentik_policies.policybinding From 418ff58e6b7c006ad77041d639aa05965341a9f8 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 10 Jun 2026 15:45:23 -0400 Subject: [PATCH 20/23] fix: add default shell and homedir for proper behavior with ldap --- images/base/sssd.conf.template | 3 +++ 1 file changed, 3 insertions(+) diff --git a/images/base/sssd.conf.template b/images/base/sssd.conf.template index 4f1428bf..bebfe10c 100644 --- a/images/base/sssd.conf.template +++ b/images/base/sssd.conf.template @@ -29,3 +29,6 @@ ldap_default_authtok = ${SSSD_DEFAULT_AUTHTOK} # set a timeout long enough for a push notification to be responded to ldap_opt_timeout = 60 + +fallback_homedir = /home/%u +default_shell = /bin/bash From f8ed080e134430bb5c837a1892b093b4a026257c Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 11 Jun 2026 15:27:03 -0400 Subject: [PATCH 21/23] feat: add sync script to pull users from manager and insert into ldap --- .../bin/sync-users-to-authentik.sh | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100755 create-a-container/bin/sync-users-to-authentik.sh diff --git a/create-a-container/bin/sync-users-to-authentik.sh b/create-a-container/bin/sync-users-to-authentik.sh new file mode 100755 index 00000000..906440af --- /dev/null +++ b/create-a-container/bin/sync-users-to-authentik.sh @@ -0,0 +1,252 @@ +#!/bin/bash + +set -euo pipefail + +# Sync users from the create-a-container API into the authentik API. +# +# Reads every user from `GET /api/v1/users` (admin-only) and creates any that +# are missing in authentik via `POST /api/v3/core/users/`. Users that already +# exist in authentik (matched by username) are skipped — this script never +# updates or deletes existing authentik users, so it is safe to re-run. +# +# Passwords are NOT copied: the create-a-container API never exposes password +# hashes, and authentik users authenticate via the enrollment/recovery flows or +# an external source. Created users are therefore left without a usable password. + +usage() { + cat <&2 + echo "" >&2 + usage + ;; + esac +done + +# --- Dependency & config checks ---------------------------------------------- +for cmd in curl jq; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Error: required command '$cmd' is not installed." >&2 + exit 1 + fi +done + +missing=0 +for var in CAC_API_URL CAC_API_KEY AUTHENTIK_URL AUTHENTIK_TOKEN; do + if [[ -z "${!var:-}" ]]; then + echo "Error: $var is required." >&2 + missing=1 + fi +done +[[ "$missing" -eq 1 ]] && { echo "" >&2; usage; } + +AUTHENTIK_USER_PATH="${AUTHENTIK_USER_PATH:-users}" +AUTHENTIK_USER_TYPE="${AUTHENTIK_USER_TYPE:-internal}" + +# Strip any trailing slash so we can build URLs consistently. +CAC_API_URL="${CAC_API_URL%/}" +AUTHENTIK_URL="${AUTHENTIK_URL%/}" + +# --- HTTP helpers ------------------------------------------------------------ +# Each helper prints the response body to stdout and the HTTP status to fd 3 +# via a temp file, so callers can branch on the status code. + +cac_get() { + # $1 = path (e.g. /api/v1/users) + curl -sS \ + -H "Authorization: Bearer $CAC_API_KEY" \ + -H "Accept: application/json" \ + "$CAC_API_URL$1" +} + +authentik_get() { + # $1 = path with query string + curl -sS \ + -H "Authorization: Bearer $AUTHENTIK_TOKEN" \ + -H "Accept: application/json" \ + "$AUTHENTIK_URL$1" +} + +authentik_post() { + # $1 = path, $2 = json body; writes HTTP status as the last line. + curl -sS -w '\n%{http_code}' \ + -X POST \ + -H "Authorization: Bearer $AUTHENTIK_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$2" \ + "$AUTHENTIK_URL$1" +} + +# --- Fetch users from create-a-container ------------------------------------- +echo "Fetching users from create-a-container API..." +echo " $CAC_API_URL/api/v1/users" + +users_response="$(cac_get /api/v1/users)" + +# The API wraps success as { data: [...] } and errors as { error: {...} }. +if echo "$users_response" | jq -e '.error' >/dev/null 2>&1; then + echo "Error from create-a-container API:" >&2 + echo "$users_response" | jq -r '.error | " [\(.code)] \(.message)"' >&2 + exit 1 +fi + +if ! echo "$users_response" | jq -e '.data | type == "array"' >/dev/null 2>&1; then + echo "Error: unexpected response from create-a-container API:" >&2 + echo "$users_response" >&2 + exit 1 +fi + +user_count="$(echo "$users_response" | jq '.data | length')" +echo " Found $user_count user(s)." +echo "" + +# --- Sync loop --------------------------------------------------------------- +created=0 +skipped=0 +failed=0 + +# Iterate users as compact JSON lines so each record survives the pipe intact. +while IFS= read -r user; do + uid="$(echo "$user" | jq -r '.uid')" + cn="$(echo "$user" | jq -r '.cn // .uid')" + mail="$(echo "$user" | jq -r '.mail // ""')" + status="$(echo "$user" | jq -r '.status // ""')" + + if [[ -z "$uid" || "$uid" == "null" ]]; then + echo "! Skipping a user with no uid: $user" >&2 + failed=$((failed + 1)) + continue + fi + + # Check whether the user already exists in authentik (match by username). + # The list endpoint supports an exact ?username= filter. + existing="$(authentik_get "/api/v3/core/users/?username=$(jq -rn --arg u "$uid" '$u|@uri')")" + + if ! echo "$existing" | jq -e 'has("results")' >/dev/null 2>&1; then + echo "! Failed to query authentik for '$uid':" >&2 + echo "$existing" | jq -r '.detail // .' >&2 || echo "$existing" >&2 + failed=$((failed + 1)) + continue + fi + + match_count="$(echo "$existing" | jq '[.results[] | select(.username == $uid)] | length' --arg uid "$uid")" + if [[ "$match_count" -gt 0 ]]; then + echo "= Skip (exists): $uid" + skipped=$((skipped + 1)) + continue + fi + + # Build the authentik create payload. + # username <- uid + # name <- cn (full name) + # email <- mail + # is_active <- status == 'active' + # attributes preserves the POSIX/source fields used by the LDAP provider. + is_active="false" + [[ "$status" == "active" ]] && is_active="true" + + payload="$(jq -n \ + --arg username "$uid" \ + --arg name "$cn" \ + --arg email "$mail" \ + --arg path "$AUTHENTIK_USER_PATH" \ + --arg type "$AUTHENTIK_USER_TYPE" \ + --argjson is_active "$is_active" \ + --argjson source "$user" \ + '{ + username: $username, + name: $name, + email: $email, + is_active: $is_active, + path: $path, + type: $type, + attributes: { + uidNumber: $source.uidNumber, + givenName: $source.givenName, + sn: $source.sn, + status: $source.status, + managedBy: "create-a-container-sync" + } + }')" + + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "~ Would create: $uid ($cn <$mail>, active=$is_active)" + created=$((created + 1)) + continue + fi + + response="$(authentik_post /api/v3/core/users/ "$payload")" + http_code="$(echo "$response" | tail -n1)" + body="$(echo "$response" | sed '$d')" + + if [[ "$http_code" == "201" ]]; then + new_pk="$(echo "$body" | jq -r '.pk // "?"')" + echo "+ Created: $uid (authentik pk=$new_pk)" + created=$((created + 1)) + else + echo "! Failed to create '$uid' (HTTP $http_code):" >&2 + echo "$body" | jq -r 'if type=="object" then (to_entries | map(" \(.key): \(.value)") | join("\n")) else . end' >&2 \ + || echo " $body" >&2 + failed=$((failed + 1)) + fi +done < <(echo "$users_response" | jq -c '.data[]') + +# --- Summary ----------------------------------------------------------------- +echo "" +echo "=================================================" +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "Dry run complete (no changes made)." + echo " Would create: $created" +else + echo "Sync complete." + echo " Created: $created" +fi +echo " Skipped (already exist): $skipped" +echo " Failed: $failed" +echo "=================================================" + +[[ "$failed" -gt 0 ]] && exit 1 +exit 0 From 71447be84191d77e72f13e748af005d3d418b126 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 11 Jun 2026 22:05:43 -0400 Subject: [PATCH 22/23] fix: account for missing sshPublicKey attribute --- authentik/blueprints/20-opensource-user-settings-flow.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authentik/blueprints/20-opensource-user-settings-flow.yaml b/authentik/blueprints/20-opensource-user-settings-flow.yaml index 48d6665a..de47df07 100644 --- a/authentik/blueprints/20-opensource-user-settings-flow.yaml +++ b/authentik/blueprints/20-opensource-user-settings-flow.yaml @@ -175,8 +175,8 @@ entries: # returns True; it normalizes the value and never blocks the stage. - attrs: expression: | - attributes = context["prompt_data"]["attributes"] - value = attributes["sshPublicKey"] + attributes = context["prompt_data"].get("attributes", {}) + value = attributes.get("sshPublicKey", []) if not isinstance(value, list): keys = [] From 1baa1f19deef19251beb9d8cbffaeee3df5fd822 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 11 Jun 2026 22:13:59 -0400 Subject: [PATCH 23/23] docs: address PR review comments on LDAP docs and descriptions - Wrap LDAP access-filter examples in parentheses so they are valid filter syntax when copied into SSSD_LDAP_ACCESS_FILTER (seeder + ldap-servers docs). - Correct the authentik README description of the MFA stage binding: with policy_engine_mode: all the stage runs only when all policies pass; the negated proxyuser binding fails, skipping 2FA for that account. - Fix the proxyuser note in 10-opensource-ldap-identity.yaml, which wrongly claimed the proxyuser is not granted search_full_directory (it is, read-only, per 40-opensource-ldap.yaml). --- authentik/README.md | 6 ++++-- authentik/blueprints/10-opensource-ldap-identity.yaml | 7 +++++-- ...0260604000003-seed-sssd-access-filter-and-user-attrs.js | 4 ++-- mie-opensource-landing/docs/admins/ldap-servers.md | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/authentik/README.md b/authentik/README.md index d9b0b312..b70a6e38 100644 --- a/authentik/README.md +++ b/authentik/README.md @@ -79,8 +79,10 @@ used as the LDAP **bind** flow. Stages: 2. **Password** (order 20) — skipped if the user already has an authentication backend attached (e.g. arrived passwordless). 3. **Authenticator validation** (order 30) — the push-notification 2FA check. - The binding uses `policy_engine_mode: all`, and the stage is skipped only - when every bound policy passes — used to exempt the LDAP proxyuser. + The binding uses `policy_engine_mode: all`, so the stage runs only when + every bound policy passes. A negated user binding for the LDAP proxyuser + fails for that account, so the stage is skipped for it (exempting the + proxyuser from 2FA) while every other user still gets the check. 4. **User login** (order 100) — creates the session. ```mermaid diff --git a/authentik/blueprints/10-opensource-ldap-identity.yaml b/authentik/blueprints/10-opensource-ldap-identity.yaml index 8f4b89d1..d49c75e1 100644 --- a/authentik/blueprints/10-opensource-ldap-identity.yaml +++ b/authentik/blueprints/10-opensource-ldap-identity.yaml @@ -32,8 +32,11 @@ entries: # authentication flow in 20-opensource-authentication-flow.yaml, which is why # this identity must be imported before that flow). # - # NOTE: This account is intentionally NOT granted the "Search full LDAP - # directory" permission, so even the proxyuser can only read its own object. + # NOTE: This account is intentionally granted the "Search full LDAP directory" + # permission so it can resolve any user/group on behalf of downstream services. + # This is read-only search access only; the LDAP provider exposes no write/edit + # capability, and no other user is granted it. The permission itself is defined + # alongside the LDAP provider in 40-opensource-ldap.yaml. # --------------------------------------------------------------------------- - id: ldap-proxyuser model: authentik_core.user diff --git a/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js b/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js index e2ee43e8..af443093 100644 --- a/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js +++ b/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js @@ -14,7 +14,7 @@ // in. This is deliberate: with access_provider=ldap and // ldap_access_order=filter, an EMPTY ldap_access_filter denies ALL users, so // the value must not be left blank. Admins restrict access by setting a more -// specific filter, e.g. memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com. +// specific filter, e.g. (memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com). const NEW_SSSD_DEFAULTS = [ { key: 'SSSD_LDAP_USER_NAME', @@ -29,7 +29,7 @@ const NEW_SSSD_DEFAULTS = [ { key: 'SSSD_LDAP_ACCESS_FILTER', value: '(objectClass=*)', - description: 'LDAP access filter; users matching it may log in. Defaults to (objectClass=*) so all directory users are allowed. Set a stricter filter (e.g. memberOf=cn=allowedusers,...) to restrict access. Must not be blank, which would deny everyone.' + description: 'LDAP access filter; users matching it may log in. Defaults to (objectClass=*) so all directory users are allowed. Set a stricter filter (e.g. (memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com)) to restrict access. Must not be blank, which would deny everyone.' } ]; diff --git a/mie-opensource-landing/docs/admins/ldap-servers.md b/mie-opensource-landing/docs/admins/ldap-servers.md index 932c9390..38338c7c 100644 --- a/mie-opensource-landing/docs/admins/ldap-servers.md +++ b/mie-opensource-landing/docs/admins/ldap-servers.md @@ -69,7 +69,7 @@ In the admin UI: **Settings** → **Default Container Environment Variables**. T | `SSSD_LDAP_GROUP_SEARCH_BASE` | *(blank)* | No | Base DN for group searches (e.g. `ou=groups,dc=example,dc=com`). | | `SSSD_LDAP_USER_NAME` | *(blank)* | No | LDAP attribute mapped to the user's login name. Leave blank for the SSSD default (`uid`); set to `sAMAccountName` for Active Directory. | | `SSSD_LDAP_USER_GECOS` | `cn` | No | LDAP attribute mapped to the NSS gecos (full name) field, read by `getent`, `finger`, and the git-identity script. | -| `SSSD_LDAP_ACCESS_FILTER` | `(objectClass=*)` | Yes | LDAP filter deciding **who may log in**. The default matches every entry, so all directory users are allowed. Restrict access with a stricter filter, e.g. `memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com`. Must not be blank — an empty filter denies everyone. | +| `SSSD_LDAP_ACCESS_FILTER` | `(objectClass=*)` | Yes | LDAP filter deciding **who may log in**. The default matches every entry, so all directory users are allowed. Restrict access with a stricter filter, e.g. `(memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com)`. Must not be blank — an empty filter denies everyone. | | `SSSD_LDAP_DEFAULT_BIND_DN` | *(blank)* | No | DN used to bind for lookups. Leave blank for anonymous bind; set it if your directory disallows anonymous searches (e.g. `cn=svc-sssd,ou=services,dc=example,dc=com`). | | `SSSD_DEFAULT_AUTHTOK_TYPE` | *(blank)* | No | Type of the bind credential, typically `password`. Required when `SSSD_LDAP_DEFAULT_BIND_DN` is set. | | `SSSD_DEFAULT_AUTHTOK` | *(blank)* | No | The bind credential (password) for the bind DN. Required when `SSSD_LDAP_DEFAULT_BIND_DN` is set. |