175 lines
6.8 KiB
Python
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()
|