How to Use JAMF Connect with Authentik: Building an ROPG Proxy for Password Synchronization

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

  1. Admin Interface → Providers → Create
  2. Select "OAuth2/OpenID Provider"
  3. 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

  1. Admin Interface → Applications → Create
  2. 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_required error (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)
<?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_endpoint points to the proxy
  • grant_types_supported includes password

3. Test with JAMF Connect

  1. Deploy configuration profiles to a test Mac
  2. Log out and attempt login via JAMF Connect Login Window
  3. Monitor proxy logs for authentication attempts
  4. 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:


Next Steps

  1. Configure environment variables for your Authentik instance
  2. Deploy proxy behind reverse proxy with TLS
  3. Create OAuth2 provider and application in Authentik
  4. Test with curl before deploying JAMF profiles
  5. Deploy configuration profiles via JAMF Pro
  6. 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.

Share