How to Use JAMF Connect with Authentik: Building an ROPG Proxy for Password Synchronization
synchronization between macOS local accounts and cloud identity providers. Authentik does not natively support ROPG for regular user authentication, despite listing "password" in its discovery document's supported grant types.
This article provides a working solution using Authentik's Flow Executor API.
The problem: JAMF Connect validates that a user's macOS local password matches their cloud identity password by sending ROPG requests to the identity provider's token endpoint. Authentik's OAuth2 provider rejects these requests with invalid_grant because it treats password grant as client_credentials (service accounts only).
The solution: Build a proxy that accepts ROPG requests from JAMF Connect, translates them into Flow Executor API calls against Authentik, and returns OAuth2-compliant token responses.
This limitation is tracked in GitHub Issue #5860, assigned to the 2026.2 milestone. Until native support ships, this proxy approach works.
Architecture Overview
The proxy sits between JAMF Connect and Authentik, translating OAuth2 password grant requests into Flow Executor API calls:
flowchart TB
subgraph mac["macOS Endpoint"]
JC["JAMF Connect"]
end
JC -->|"1. ROPG Request
grant_type=password"| P
subgraph proxy["ROPG Proxy"]
P["Token Endpoint
/oauth/token"]
end
P -->|"2. Initiate Flow"| FE
subgraph auth["Authentik"]
FE["Flow Executor API"]
AF["Authentication Flow"]
FE <--> AF
end
FE -->|"3. Auth Result"| P
P -->|"4. JWT Tokens"| JC
JAMF Connect sends standard OAuth2 ROPG requests. The proxy validates credentials against Authentik using the same Flow Executor API that powers Authentik's LDAP and RADIUS outposts. On success, the proxy generates JWT tokens. JAMF Connect discards these tokens after validation. It only uses ROPG to verify the password is correct.
How JAMF Connect Uses ROPG
JAMF Connect calls ROPG at three points in the user lifecycle:
flowchart TB
subgraph init["Initial Account Creation"]
A1["User authenticates via browser
(authorization code flow)"] --> A2["JAMF Connect captures password"]
A2 --> A3["ROPG validates password"]
A3 -->|"Success"| A4["Local macOS account created"]
end
subgraph sync["Periodic Sync (every 60 min)"]
B1["Menu bar app sends
cached credentials via ROPG"] --> B2{"Validation
result?"}
B2 -->|"Valid"| B3["Passwords synchronized
No action needed"]
B2 -->|"Invalid"| B4["Prompt user for
new network password"]
end
subgraph change["Password Change Detection"]
C1["User changes password
in Authentik"] --> C2["Next ROPG check fails
(old password rejected)"]
C2 --> C3["User enters new password"]
C3 --> C4["ROPG validates new password"]
C4 --> C5["Local password updated"]
end
What JAMF Connect Sends
POST /oauth/token HTTP/1.1
Host: your-idp.com
Content-Type: application/x-www-form-urlencoded
grant_type=password&
username=user@example.com&
password=MySuperSecretPassword&
scope=openid%20profile%20email&
client_id=YOUR-CLIENT-ID&
redirect_uri=https://127.0.0.1/jamfconnect
What JAMF Connect Expects Back
{
"token_type": "Bearer",
"scope": "profile openid email",
"expires_in": 3600,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiI...",
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiI..."
}
Response Handling
JAMF Connect interprets responses as follows:
| Response | JAMF Interpretation | Action |
|---|---|---|
| HTTP 200 + tokens | Password correct | Sync succeeds |
| Error indicating MFA required | Password valid, MFA blocking | Treats as success (abandons MFA) |
invalid_grant error |
Password incorrect | Prompts user |
Why Authentik's Password Grant Does Not Work
Authentik's discovery document at /application/o/{slug}/.well-known/openid-configuration includes:
{
"grant_types_supported": [
"authorization_code",
"refresh_token",
"implicit",
"client_credentials",
"password"
]
}
This is misleading. Authentik's password grant requires service accounts with app-password tokens. Actual user passwords are rejected:
curl --request POST \
--url 'https://authentik.example.com/application/o/token/' \
--header 'content-type: application/x-www-form-urlencoded' \
--data 'grant_type=password' \
--data 'username=regular_user@example.com' \
--data 'password=user_password' \
--data 'client_id=app_client_id'
# Returns: {"error": "invalid_grant"}
The Flow Executor API provides the workaround. It is what Authentik's LDAP and RADIUS outposts use internally for headless authentication.
Authentik Flow Executor API
The Flow Executor exposes a challenge-response system for programmatic authentication without a browser.
Endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/v3/flows/executor/{flow_slug}/ |
Retrieve next pending challenge |
| POST | /api/v3/flows/executor/{flow_slug}/ |
Submit response, advance to next stage |
Critical: Flow state is stored server-side in the HTTP session. You must persist cookies between requests using a cookie jar. The authentik_session cookie maintains flow state.
Authentication Flow Sequence
sequenceDiagram
participant P as Proxy
participant A as Authentik
P->>A: GET /api/v3/flows/executor/{slug}/
A-->>P: Identification Challenge
P->>A: POST username
A-->>P: Password Challenge
P->>A: POST password
alt Success
A-->>P: Redirect (xak-flow-redirect)
else Invalid Password
A-->>P: Access Denied
else MFA Required
A-->>P: Authenticator Challenge
end
Identification Stage
GET response (first challenge):
{
"type": "native",
"flow_info": {
"title": "Welcome to authentik",
"background": "/static/dist/assets/images/flow_background.jpg",
"cancel_url": "/flows/-/cancel/",
"layout": "stacked"
},
"component": "ak-stage-identification",
"user_fields": ["username", "email"],
"password_fields": false,
"primary_action": "Log in",
"sources": []
}
POST request (submit username):
{
"component": "ak-stage-identification",
"uid_field": "user@example.com"
}
Password Stage
Response after identification:
{
"type": "native",
"component": "ak-stage-password",
"pending_user": "user@example.com",
"pending_user_avatar": "https://secure.gravatar.com/avatar/..."
}
POST request (submit password):
{
"component": "ak-stage-password",
"password": "actual_user_password"
}
Result Evaluation
Success returns a redirect:
{
"type": "redirect",
"component": "xak-flow-redirect",
"to": "/application/o/..."
}
Failure returns access denied:
{
"type": "native",
"component": "ak-stage-access-denied",
"error_message": "Invalid password"
}
Proxy Implementation
Complete Python Code
#!/usr/bin/env python3
"""
ROPG Proxy for Authentik + JAMF Connect
Translates OAuth2 password grant into Authentik Flow Executor API calls
"""
import os
import time
import logging
from typing import Optional, Tuple
import requests
from flask import Flask, request, jsonify
import jwt
app = Flask(__name__)
# Configuration - use environment variables in production
AUTHENTIK_URL = os.getenv("AUTHENTIK_URL", "https://authentik.example.com")
FLOW_SLUG = os.getenv("FLOW_SLUG", "default-authentication-flow")
JWT_SECRET = os.getenv("JWT_SECRET") # Generate with: openssl rand -base64 32
CLIENT_ID = os.getenv("CLIENT_ID", "jamf-connect")
PROXY_ISSUER = os.getenv("PROXY_ISSUER", "https://ropg-proxy.example.com")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class AuthentikFlowExecutor:
"""Executes Authentik authentication flows programmatically."""
def __init__(self, base_url: str, flow_slug: str):
self.base_url = base_url.rstrip("/")
self.flow_slug = flow_slug
self.session = requests.Session()
self.executor_url = f"{self.base_url}/api/v3/flows/executor/{flow_slug}/"
def authenticate(self, username: str, password: str) -> Tuple[bool, Optional[str]]:
"""
Execute authentication flow with provided credentials.
Returns (success: bool, error_message: Optional[str])
"""
try:
# Step 1: Initiate flow, get first challenge
resp = self.session.get(
self.executor_url,
headers={"Accept": "application/json"}
)
resp.raise_for_status()
challenge = resp.json()
# Step 2: Handle identification stage
if challenge.get("component") == "ak-stage-identification":
resp = self.session.post(
self.executor_url,
headers={
"Accept": "application/json",
"Content-Type": "application/json"
},
json={
"component": "ak-stage-identification",
"uid_field": username
}
)
resp.raise_for_status()
challenge = resp.json()
# Step 3: Handle password stage
if challenge.get("component") == "ak-stage-password":
resp = self.session.post(
self.executor_url,
headers={
"Accept": "application/json",
"Content-Type": "application/json"
},
json={
"component": "ak-stage-password",
"password": password
}
)
resp.raise_for_status()
challenge = resp.json()
# Step 4: Evaluate result
component = challenge.get("component", "")
if component == "xak-flow-redirect":
return True, None
if component == "ak-stage-access-denied":
return False, challenge.get("error_message", "Access denied")
# MFA or other stage encountered
if component.startswith("ak-stage-authenticator"):
return False, "mfa_required"
return False, f"Unexpected stage: {component}"
except requests.RequestException as e:
logger.error(f"Flow execution failed: {e}")
return False, str(e)
class TokenGenerator:
"""Generates OAuth2-compliant tokens for JAMF Connect."""
def __init__(self, secret: str, issuer: str, client_id: str):
self.secret = secret
self.issuer = issuer
self.client_id = client_id
def generate_tokens(self, username: str) -> dict:
"""Generate access_token and id_token for successful authentication."""
now = int(time.time())
expires_in = 3600
# ID Token - contains user identity claims
id_token_payload = {
"iss": self.issuer,
"sub": username,
"aud": self.client_id,
"exp": now + expires_in,
"iat": now,
"auth_time": now,
"email": username,
"preferred_username": username.split("@")[0] if "@" in username else username,
"name": username.split("@")[0] if "@" in username else username
}
# Access Token - contains scope/authorization claims
access_token_payload = {
"iss": self.issuer,
"sub": username,
"aud": self.client_id,
"exp": now + expires_in,
"iat": now,
"scope": "openid profile email"
}
id_token = jwt.encode(id_token_payload, self.secret, algorithm="HS256")
access_token = jwt.encode(access_token_payload, self.secret, algorithm="HS256")
return {
"token_type": "Bearer",
"expires_in": expires_in,
"access_token": access_token,
"id_token": id_token,
"scope": "openid profile email"
}
# Initialize components
executor = AuthentikFlowExecutor(AUTHENTIK_URL, FLOW_SLUG)
token_generator = TokenGenerator(JWT_SECRET, PROXY_ISSUER, CLIENT_ID)
@app.route("/oauth/token", methods=["POST"])
def token_endpoint():
"""OAuth2 token endpoint - handles ROPG requests from JAMF Connect."""
grant_type = request.form.get("grant_type")
username = request.form.get("username")
password = request.form.get("password")
# Validate request
if grant_type != "password":
return jsonify({
"error": "unsupported_grant_type",
"error_description": "Only password grant is supported"
}), 400
if not username or not password:
return jsonify({
"error": "invalid_request",
"error_description": "Username and password are required"
}), 400
logger.info(f"ROPG authentication attempt for: {username}")
# Execute authentication flow
flow_executor = AuthentikFlowExecutor(AUTHENTIK_URL, FLOW_SLUG)
success, error = flow_executor.authenticate(username, password)
if success:
logger.info(f"Authentication successful for: {username}")
tokens = token_generator.generate_tokens(username)
return jsonify(tokens)
if error == "mfa_required":
# JAMF Connect treats MFA requirement as "password valid"
logger.info(f"MFA required for: {username}")
return jsonify({
"error": "interaction_required",
"error_description": "Multi-factor authentication required"
}), 400
logger.warning(f"Authentication failed for {username}: {error}")
return jsonify({
"error": "invalid_grant",
"error_description": error or "Invalid username or password"
}), 400
@app.route("/.well-known/openid-configuration")
def discovery():
"""OpenID Connect discovery document."""
return jsonify({
"issuer": PROXY_ISSUER,
"authorization_endpoint": f"{AUTHENTIK_URL}/application/o/authorize/",
"token_endpoint": f"{PROXY_ISSUER}/oauth/token",
"userinfo_endpoint": f"{AUTHENTIK_URL}/application/o/userinfo/",
"jwks_uri": f"{PROXY_ISSUER}/.well-known/jwks.json",
"response_types_supported": ["code", "token", "id_token"],
"grant_types_supported": ["authorization_code", "password", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["HS256"],
"scopes_supported": ["openid", "profile", "email"]
})
@app.route("/.well-known/jwks.json")
def jwks():
"""
JWKS endpoint - required for token validation.
For HS256, returns empty key set (symmetric keys are not published).
For production with RS256, serve the public key here.
"""
return jsonify({"keys": []})
@app.route("/health")
def health():
"""Health check endpoint."""
return jsonify({"status": "healthy"})
if __name__ == "__main__":
if not JWT_SECRET:
raise ValueError("JWT_SECRET environment variable is required")
app.run(host="127.0.0.1", port=8580, debug=False)
Docker Deployment
docker-compose.yml:
version: '3.8'
services:
ropg-proxy:
build: .
container_name: authentik-ropg-proxy
restart: unless-stopped
ports:
- "127.0.0.1:8580:8580"
environment:
AUTHENTIK_URL: "https://authentik.example.com"
FLOW_SLUG: "default-authentication-flow"
JWT_SECRET: "${JWT_SECRET}"
CLIENT_ID: "jamf-connect"
PROXY_ISSUER: "https://ropg-proxy.example.com"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8580/health"]
interval: 30s
timeout: 10s
retries: 3
Dockerfile:
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir flask requests pyjwt gunicorn
COPY proxy.py .
EXPOSE 8580
CMD ["gunicorn", "--bind", "0.0.0.0:8580", "--workers", "2", "proxy:app"]
.env file:
# Generate with: openssl rand -base64 32
JWT_SECRET=your-generated-secret-here
The proxy binds to 127.0.0.1:8580. Place a reverse proxy (nginx, Caddy, Traefik) in front with TLS termination. The ROPG endpoint should not be directly accessible from the internet. It accepts username/password combinations, which creates a password spraying attack surface if exposed.
Authentik Configuration
1. Create an OAuth2/OIDC Provider
- Admin Interface → Providers → Create
- Select "OAuth2/OpenID Provider"
- Configure:
- Name: JAMF Connect
- Authorization flow: default-authentication-flow
- Client type: Confidential
- Client ID:
jamf-connect(must match proxy config) - Redirect URIs:
https://127.0.0.1/jamfconnect - Scopes: openid, profile, email
2. Create an Application
- Admin Interface → Applications → Create
- Configure:
- Name: JAMF Connect
- Slug:
jamf-connect - Provider: Select the provider created above
- Launch URL: Leave blank
3. Verify Flow Allows Headless Execution
The default authentication flow works. If you have customized it:
- No consent stages (or consent is pre-approved for the application)
- No CAPTCHA stages
- MFA stages will trigger
interaction_requirederror (JAMF treats this as "password valid")
JAMF Connect Configuration
Login Window Profile (com.jamf.connect.login)
Create a configuration profile with these keys. Generate UUIDs with uuidgen on macOS.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.jamf.connect.login</string>
<key>PayloadIdentifier</key>
<string>com.jamf.connect.login.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</string>
<key>PayloadUUID</key>
<string>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</string>
<key>PayloadVersion</key>
<integer>1</integer>
<!-- Identity Provider Configuration -->
<key>OIDCProvider</key>
<string>Custom</string>
<key>OIDCClientID</key>
<string>jamf-connect</string>
<key>OIDCROPGID</key>
<string>jamf-connect</string>
<key>OIDCRedirectURI</key>
<string>https://127.0.0.1/jamfconnect</string>
<key>OIDCDiscoveryURL</key>
<string>https://ropg-proxy.example.com/.well-known/openid-configuration</string>
<!-- Password Sync Settings -->
<key>OIDCNewPassword</key>
<false/>
<!-- Account Creation Settings -->
<key>CreateAdminUser</key>
<false/>
<key>DenyLocal</key>
<true/>
<key>DenyLocalExcluded</key>
<array>
<string>admin</string>
</array>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>JAMF Connect Login</string>
<key>PayloadIdentifier</key>
<string>com.example.jamfconnect.login</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
Key settings:
| Key | Value | Reason |
|---|---|---|
OIDCNewPassword |
false |
Enforces password sync via ROPG instead of allowing new local passwords |
OIDCDiscoveryURL |
Proxy URL | Points to proxy, not directly to Authentik |
OIDCROPGID |
jamf-connect |
Must match the Client ID the proxy expects |
DenyLocal |
true |
Prevents local-only accounts (users must authenticate via IdP) |
Menu Bar Profile (com.jamf.connect)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.jamf.connect</string>
<key>PayloadIdentifier</key>
<string>com.jamf.connect.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</string>
<key>PayloadUUID</key>
<string>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>IdPSettings</key>
<dict>
<key>Provider</key>
<string>Custom</string>
<key>DiscoveryURL</key>
<string>https://ropg-proxy.example.com/.well-known/openid-configuration</string>
<key>ClientID</key>
<string>jamf-connect</string>
<key>ROPGID</key>
<string>jamf-connect</string>
<key>RedirectURI</key>
<string>https://127.0.0.1/jamfconnect</string>
</dict>
<!-- Password Sync Interval (seconds) -->
<key>ROPGSuccessfulCheckTime</key>
<integer>3600</integer>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>JAMF Connect Menu Bar</string>
<key>PayloadIdentifier</key>
<string>com.example.jamfconnect.menubar</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
Testing
1. Test the Proxy Directly
Valid credentials:
curl -X POST https://ropg-proxy.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "username=testuser@example.com" \
-d "password=correct_password" \
-d "client_id=jamf-connect"
Expected response (HTTP 200):
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJ...",
"id_token": "eyJ...",
"scope": "openid profile email"
}
Invalid credentials:
curl -X POST https://ropg-proxy.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "username=testuser@example.com" \
-d "password=wrong_password" \
-d "client_id=jamf-connect"
Expected response (HTTP 400):
{
"error": "invalid_grant",
"error_description": "Invalid password"
}
2. Test Discovery Endpoint
curl https://ropg-proxy.example.com/.well-known/openid-configuration | jq .
Verify:
token_endpointpoints to the proxygrant_types_supportedincludespassword
3. Test with JAMF Connect
- Deploy configuration profiles to a test Mac
- Log out and attempt login via JAMF Connect Login Window
- Monitor proxy logs for authentication attempts
- Change password in Authentik and verify JAMF Connect detects the change
Security Considerations
Production hardening:
| Item | Recommendation | Reason |
|---|---|---|
| Token signing | Use RS256 instead of HS256 | Asymmetric keys allow token verification without sharing secrets |
| Rate limiting | Implement at reverse proxy | Prevents password spraying |
| Network access | Restrict to VPN or managed networks | Proxy accepts raw passwords |
| Logging | Log auth attempts, redact passwords | Audit trail without credential exposure |
| Key rotation | Rotate JWT signing keys periodically | Limits exposure if key is compromised |
The proxy should not be internet-accessible. It accepts username/password combinations directly. Even with rate limiting, exposing it creates an attack surface. Deploy behind VPN or restrict to network segments where JAMF-enrolled Macs operate.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
invalid_grant on valid password |
Flow slug incorrect | Verify FLOW_SLUG matches your Authentik flow |
invalid_grant on valid password |
Cookie not persisting | Check session handling in proxy code |
| Connection refused | Proxy not running | Check container status, port binding |
| SSL errors | Certificate issues | Verify TLS cert on proxy and Authentik |
interaction_required |
MFA stage hit | Expected if MFA enabled; JAMF treats as success |
| Tokens not accepted | Issuer mismatch | PROXY_ISSUER must match discovery document |
Enable debug logging:
logging.basicConfig(level=logging.DEBUG)
Source Code and References
Working implementation: github.com/MacJediWizard/jamf-authentik-ropg-bridge
References:
- Authentik Flow Executor API Documentation
- Authentik GitHub Issue #5860: ROPG Support
- JAMF Connect Administrator Guide
- OAuth 2.0 Resource Owner Password Grant (RFC 6749 Section 4.3)
Next Steps
- Configure environment variables for your Authentik instance
- Deploy proxy behind reverse proxy with TLS
- Create OAuth2 provider and application in Authentik
- Test with curl before deploying JAMF profiles
- Deploy configuration profiles via JAMF Pro
- Test end-to-end with a managed Mac
Clone the reference implementation:
git clone https://github.com/MacJediWizard/jamf-authentik-ropg-bridge
If you are facing the same limitation, comment on GitHub Issue #5860 with your use case. More visibility helps prioritize native ROPG support.
Published by MacJediWizard Consulting — JAMF Professional-tier partner specializing in Apple device management.