Files
SnarfCode/scripts/setup_portainer_authentik_sso.py

175 lines
6.8 KiB
Python

"""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()