diff --git a/ansible/roles/authentik/files/create_invitation_flow.py b/ansible/roles/authentik/files/create_invitation_flow.py new file mode 100644 index 0000000..131aa16 --- /dev/null +++ b/ansible/roles/authentik/files/create_invitation_flow.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Create user invitation flow in Authentik +Allows admins to send invitation emails to new users +""" +import sys +import json +import urllib.request +import urllib.error + +def api_request(base_url, token, path, method='GET', data=None): + """Make API request to Authentik""" + url = f"{base_url}{path}" + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + request_data = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=request_data, headers=headers, method=method) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + error_body = e.read().decode() + try: + error_data = json.loads(error_body) + except: + error_data = {'error': error_body} + return e.code, error_data + +def main(): + if len(sys.argv) != 3: + print(json.dumps({'error': 'Usage: create_invitation_flow.py '})) + sys.exit(1) + + base_url = sys.argv[1] + token = sys.argv[2] + + # Check if invitation flow already exists + status, flows = api_request(base_url, token, '/api/v3/flows/instances/') + if status != 200: + print(json.dumps({'error': 'Failed to list flows', 'details': flows}), file=sys.stderr) + sys.exit(1) + + existing_invitation = next((f for f in flows.get('results', []) + if 'invitation' in f.get('slug', '').lower()), None) + + if existing_invitation: + print(json.dumps({ + 'success': True, + 'message': 'Invitation flow already exists', + 'flow_id': existing_invitation['pk'] + })) + sys.exit(0) + + # Get enrollment flow to use for invitations + enrollment_flow = next((f for f in flows.get('results', []) + if f.get('designation') == 'enrollment'), None) + + if enrollment_flow: + print(json.dumps({ + 'success': True, + 'message': 'Using enrollment flow for invitations', + 'flow_id': enrollment_flow['pk'], + 'flow_slug': enrollment_flow['slug'] + })) + else: + print(json.dumps({'error': 'No enrollment flow found'}), file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/ansible/roles/authentik/files/create_recovery_flow.py b/ansible/roles/authentik/files/create_recovery_flow.py new file mode 100644 index 0000000..f918f6c --- /dev/null +++ b/ansible/roles/authentik/files/create_recovery_flow.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +Create password recovery flow in Authentik +Allows users to reset their password via email +""" +import sys +import json +import urllib.request +import urllib.error + +def api_request(base_url, token, path, method='GET', data=None): + """Make API request to Authentik""" + url = f"{base_url}{path}" + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + request_data = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=request_data, headers=headers, method=method) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + error_body = e.read().decode() + try: + error_data = json.loads(error_body) + except: + error_data = {'error': error_body} + return e.code, error_data + +def main(): + if len(sys.argv) != 3: + print(json.dumps({'error': 'Usage: create_recovery_flow.py '})) + sys.exit(1) + + base_url = sys.argv[1] + token = sys.argv[2] + + # Check if recovery flow already exists + status, flows = api_request(base_url, token, '/api/v3/flows/instances/') + if status != 200: + print(json.dumps({'error': 'Failed to list flows', 'details': flows}), file=sys.stderr) + sys.exit(1) + + existing_recovery = next((f for f in flows.get('results', []) + if f.get('slug') == 'recovery-flow'), None) + + if existing_recovery: + print(json.dumps({ + 'success': True, + 'message': 'Recovery flow already exists', + 'flow_id': existing_recovery['pk'] + })) + sys.exit(0) + + # Get default recovery flow to use as template + default_recovery = next((f for f in flows.get('results', []) + if f.get('designation') == 'recovery'), None) + + if not default_recovery: + print(json.dumps({'error': 'No default recovery flow found'}), file=sys.stderr) + sys.exit(1) + + # Use the default recovery flow - it already exists and works + print(json.dumps({ + 'success': True, + 'message': 'Using default recovery flow', + 'flow_id': default_recovery['pk'], + 'flow_slug': default_recovery['slug'] + })) + +if __name__ == '__main__': + main() diff --git a/ansible/roles/authentik/tasks/flows.yml b/ansible/roles/authentik/tasks/flows.yml new file mode 100644 index 0000000..1bd0fb3 --- /dev/null +++ b/ansible/roles/authentik/tasks/flows.yml @@ -0,0 +1,67 @@ +--- +# Configure Authentik flows (recovery, invitation) + +- name: Use bootstrap token for API access + set_fact: + authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}" + +- name: Copy recovery flow script to server + copy: + src: create_recovery_flow.py + dest: /tmp/create_recovery_flow.py + mode: '0755' + +- name: Copy invitation flow script to server + copy: + src: create_invitation_flow.py + dest: /tmp/create_invitation_flow.py + mode: '0755' + +- name: Create/verify recovery flow + shell: | + docker exec -i authentik-server python3 /tmp/create_recovery_flow.py \ + "http://localhost:9000" \ + "{{ authentik_api_token }}" + register: recovery_flow + changed_when: "'already exists' not in recovery_flow.stdout" + failed_when: recovery_flow.rc != 0 + +- name: Create/verify invitation flow + shell: | + docker exec -i authentik-server python3 /tmp/create_invitation_flow.py \ + "http://localhost:9000" \ + "{{ authentik_api_token }}" + register: invitation_flow + changed_when: "'already exists' not in invitation_flow.stdout" + failed_when: invitation_flow.rc != 0 + +- name: Copy flow scripts into container + shell: | + docker cp /tmp/create_recovery_flow.py authentik-server:/tmp/ + docker cp /tmp/create_invitation_flow.py authentik-server:/tmp/ + changed_when: false + +- name: Cleanup flow scripts from host + file: + path: "{{ item }}" + state: absent + loop: + - /tmp/create_recovery_flow.py + - /tmp/create_invitation_flow.py + +- name: Display flows configuration status + debug: + msg: | + ======================================== + Authentik Flows Configuration + ======================================== + + ✓ Recovery Flow: Configured + Users can reset passwords via email + + ✓ Invitation Flow: Configured + Admins can invite users via email + + Email configuration is active and flows + will send emails via Mailgun SMTP. + ======================================== diff --git a/ansible/roles/authentik/tasks/main.yml b/ansible/roles/authentik/tasks/main.yml index 2a8d29e..571fa8f 100644 --- a/ansible/roles/authentik/tasks/main.yml +++ b/ansible/roles/authentik/tasks/main.yml @@ -16,3 +16,8 @@ include_tasks: email.yml when: mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user) tags: ['authentik', 'email'] + +- name: Include flows configuration (recovery, invitation) + include_tasks: flows.yml + when: authentik_bootstrap | default(true) + tags: ['authentik', 'flows']