"""Automate end-to-end OAuth2/OIDC SSO integration between Authentik and Portainer EE. Performs: 1. Authentik OAuth2 Provider creation for Portainer with redirect URIs: - https://shipyard.snarfnet.net - https://shipyard.snarfnet.net:9443 2. Authentik Application creation ('portainer') linked to the provider with brand icon. 3. Updating Portainer EE AuthenticationMethod and OAuthSettings via Portainer REST API. 4. Live verification of Portainer settings and SSO readiness. """ import json import secrets import ssl import sys import urllib.error import urllib.request ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE AK_BASE = "https://authentik.snarfnet.net" AK_TOKEN = "SnarfNetPermanentAutomationToken2026" PORTAINER_BASE = "https://shipyard.snarfnet.net" PORTAINER_USER = "admin" PORTAINER_PASS = "Cfxg0GqDfocWcteE" AUTH_FLOW = "38a86ef3-f43a-4756-8f54-ca008a8eef6e" INVAL_FLOW = "7a8145b8-2476-4636-9901-825f826df805" CERT_PK = "6d48ba88-4b51-428f-903c-93334988a941" SCOPES = [ "f521356c-080d-4bd6-b19a-68ab159a5aef", # openid "8395b909-8e8d-4fc1-a620-5592e10df8fe", # email "85b89b43-92d8-425c-b9cc-8e00038cad37", # profile "d2248142-6be2-4833-94f8-e02f5d293b02", # groups ] def ak_request(endpoint, method="GET", data=None): url = f"{AK_BASE.rstrip('/')}/api/v3/{endpoint.lstrip('/')}" body = json.dumps(data).encode("utf-8") if data else None headers = { "Authorization": f"Bearer {AK_TOKEN}", "Content-Type": "application/json", "Accept": "application/json", } req = urllib.request.Request(url, data=body, headers=headers, method=method) with urllib.request.urlopen(req, timeout=10, context=ctx) as r: raw = r.read().decode("utf-8") return r.getcode(), json.loads(raw) if raw else {} def portainer_login(): url = f"{PORTAINER_BASE.rstrip('/')}/api/auth" body = json.dumps({"Username": PORTAINER_USER, "Password": PORTAINER_PASS}).encode("utf-8") req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=10, context=ctx) as r: return json.loads(r.read().decode("utf-8"))["jwt"] def main(): print("=" * 60) print("Portainer <-> Authentik SSO Integration") print("=" * 60) # 1. Check or Create Authentik Provider print("\n[1/3] Configuring Authentik OAuth2 Provider...") code, res = ak_request("providers/oauth2/") existing_provider = next((p for p in res.get("results", []) if p.get("name") == "Portainer"), None) if existing_provider: provider_id = existing_provider["pk"] client_id = existing_provider["client_id"] client_secret = existing_provider["client_secret"] print(f" Existing provider found (ID {provider_id}, client_id: {client_id})") else: client_id = f"portainer-{secrets.token_hex(4)}" client_secret = secrets.token_urlsafe(40) provider_payload = { "name": "Portainer", "authorization_flow": AUTH_FLOW, "invalidation_flow": INVAL_FLOW, "property_mappings": SCOPES, "client_type": "confidential", "client_id": client_id, "client_secret": client_secret, "include_claims_in_id_token": True, "signing_key": CERT_PK, "redirect_uris": [ {"matching_mode": "strict", "url": "https://shipyard.snarfnet.net"}, {"matching_mode": "strict", "url": "https://shipyard.snarfnet.net/"}, {"matching_mode": "regex", "url": r"https://shipyard\.snarfnet\.net.*"}, {"matching_mode": "regex", "url": r"https://shipyard\.snarfnet\.net:9443.*"}, ], "sub_mode": "user_username", "issuer_mode": "per_provider", } code, new_prov = ak_request("providers/oauth2/", method="POST", data=provider_payload) provider_id = new_prov["pk"] print(f" Created new provider (ID {provider_id}, client_id: {client_id})") # 2. Check or Create Authentik Application print("\n[2/3] Configuring Authentik Application...") code, res = ak_request("core/applications/") existing_app = next((a for a in res.get("results", []) if a.get("slug") == "portainer"), None) app_payload = { "name": "Portainer", "slug": "portainer", "provider": provider_id, "group": "Infrastructure & Hypervisors", "meta_icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/portainer.png", "meta_description": "Docker Swarm & Container Management", "meta_launch_url": "https://shipyard.snarfnet.net", "open_in_new_tab": True, } if existing_app: ak_request("core/applications/portainer/", method="PATCH", data=app_payload) print(" Updated existing 'portainer' application.") else: ak_request("core/applications/", method="POST", data=app_payload) print(" Created 'portainer' application.") # 3. Update Portainer Settings print("\n[3/3] Authenticating with Portainer and applying OAuthSettings...") jwt = portainer_login() oauth_settings = { "ClientID": client_id, "ClientSecret": client_secret, "AccessTokenURI": f"{AK_BASE}/application/o/token/", "AuthorizationURI": f"{AK_BASE}/application/o/authorize/", "ResourceURI": f"{AK_BASE}/application/o/userinfo/", "RedirectURI": "https://shipyard.snarfnet.net", "UserIdentifier": "preferred_username", "Scopes": "openid email profile", "OAuthAutoCreateUsers": True, "DefaultTeamID": 0, "SSO": True, "LogoutURI": f"{AK_BASE}/application/o/portainer/end-session/", "AuthStyle": 0, "HideInternalAuth": False, # Keep local login as emergency fallback } # In Portainer, PUT /api/settings updates the settings put_payload = { "AuthenticationMethod": 3, # 3 = OAuth "OAuthSettings": oauth_settings, } req = urllib.request.Request( f"{PORTAINER_BASE}/api/settings", data=json.dumps(put_payload).encode("utf-8"), headers={"Authorization": f"Bearer {jwt}", "Content-Type": "application/json"}, method="PUT", ) with urllib.request.urlopen(req, timeout=10, context=ctx) as r: updated_settings = json.loads(r.read().decode("utf-8")) print(" Portainer settings successfully updated!") print(f" AuthenticationMethod: {updated_settings.get('AuthenticationMethod')} (OAuth)") print(f" SSO Enabled: {updated_settings.get('OAuthSettings', {}).get('SSO')}") print(f" ClientID: {updated_settings.get('OAuthSettings', {}).get('ClientID')}") print("\n" + "=" * 60) print("Portainer SSO integration complete!") print("=" * 60) if __name__ == "__main__": main()