Proxy Providers¶
Overview¶
Proxy Providers let JustIAM act as an authentication and authorisation gate in front of any HTTP application — without modifying the upstream app. Two deployment modes are available:
| Mode | How it works | Use when |
|---|---|---|
forward_auth |
Traefik sends a sub-request to JustIAM's /proxy/auth endpoint before forwarding the original request. JustIAM returns 200 (pass) or 401/403 (reject). |
App is on the same domain as JustIAM (shared Traefik) |
reverse_proxy |
A standalone proxy-provider binary sits in front of the upstream. It owns the OIDC session and proxies authenticated requests. |
App is on a different domain / cluster |
In both modes the same Access Policy engine runs: glob path matching, per-method filtering, group/role/attribute conditions, and optional Tengo scripts.
Concepts¶
| Concept | Description |
|---|---|
| Provider | A named proxy configuration scoped to one or more hostnames |
| Binding | A hostname registered to a provider (one hostname → one provider) |
| Access groups | Groups that may reach this binding; empty = unrestricted. Checked before per-path policies |
| Access Policy | An ordered rule (priority ASC) — first matching policy wins |
| Default action | Applied per binding when no policy matches: allow, deny, or authenticate |
| Auto-created OIDC app | Each provider gets a private OIDC application (hidden from dashboard) with groups, roles, and attributes claim mappings pre-configured |
| Cookie secret | AES-256-GCM key; dual-key rotation supported for zero-downtime secret changes |
Binding access groups¶
Access groups let you restrict an entire binding to specific groups of users. The gate is enforced by the proxy binary (both forward_auth and reverse_proxy modes) before any per-path policy is evaluated.
| Behaviour | Result |
|---|---|
| No groups configured | All authenticated users may access the binding (default) |
| One or more groups configured | User must belong to at least one of the listed groups (OR semantics) |
| User not in any configured group | HTTP 403 branded access-denied page is returned immediately |
The same group list also controls My Apps portal visibility — users who cannot access a binding will not see it in the portal.
Managing access groups¶
# List configured groups
GET /api/v1/proxy-providers/{id}/bindings/{binding_id}/access/groups
# Restrict to a group
POST /api/v1/proxy-providers/{id}/bindings/{binding_id}/access/groups
Content-Type: application/json
{ "group_id": "<group-uuid>" }
# Remove a group
DELETE /api/v1/proxy-providers/{id}/bindings/{binding_id}/access/groups/{group_id}
Terraform¶
resource "justiam_proxy_binding_access_group" "engineering" {
provider_id = justiam_proxy_provider.myapp.id
binding_id = "<binding-uuid>"
group_id = justiam_group.engineering.id
}
Multiple groups can be added by creating multiple justiam_proxy_binding_access_group resources.
Access Policies¶
Policies are evaluated in ascending priority order. The first policy whose path and method filters match wins.
| Field | Description |
|---|---|
path_pattern |
Doublestar glob matched against the request path. Examples: /health, /api/v1/*, /admin/**, /** |
methods |
Comma-separated HTTP methods (GET, POST, …). Empty = match all methods |
action |
allow — pass unconditionally; deny — return 403; authenticate — require a valid session + conditions |
require_any_group |
OR — user must be in at least one of the listed groups |
require_all_groups |
AND — user must be in every listed group |
require_roles |
OR — user must hold at least one of the listed app-roles |
require_attributes |
AND — all key=value attribute pairs must match |
script |
Optional Tengo expression (must return bool). Receives user, groups, roles, attributes variables |
Condition types are combined with AND between types: for a policy to pass, all non-empty condition groups must pass.
Headers injected on 200 (both modes):
| Header | Value |
|---|---|
X-Auth-User |
User UUID |
X-Auth-Email |
Email address |
X-Auth-Name |
Display name |
X-Auth-Groups |
Comma-separated group names |
X-Auth-Roles |
Comma-separated app-role values |
forward_auth mode (same domain)¶
In this mode no extra binary is needed. Traefik calls GET /proxy/auth on the JustIAM backend before forwarding each request. The IDP session cookie (idp_token) is used for identity.
Traefik Middleware (Kubernetes)¶
Create one Middleware resource per protected application (or share one across apps on the same ingress class):
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: justiam-forward-auth
namespace: justiam-mt # same namespace as your Traefik instance
spec:
forwardAuth:
address: "https://idp.example.com/proxy/auth"
authResponseHeaders:
- X-Auth-User
- X-Auth-Email
- X-Auth-Name
- X-Auth-Groups
- X-Auth-Roles
trustForwardHeader: true # preserve X-Forwarded-* from the original request
Then annotate the protected Ingress or reference it in an IngressRoute:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
namespace: myapp
annotations:
traefik.ingress.kubernetes.io/router.middlewares: "justiam-mt-justiam-forward-auth@kubernetescrd"
spec:
rules:
- host: myapp.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 8080
The Kubernetes Gateway API does not natively support forward-auth. Use the
reverse_proxy mode with a Gateway HTTPRoute instead,
or annotate the underlying Traefik IngressRoute.
Registering the binding¶
After creating the Middleware, add the hostname as a binding on the proxy provider:
POST /api/v1/proxy-providers/{id}/bindings
Content-Type: application/json
{ "hostname": "myapp.example.com" }
JustIAM uses the X-Forwarded-Host header to look up the provider (30 s TTL cache).
reverse_proxy mode (cross-domain)¶
In this mode the proxy-provider binary handles the full OIDC authorization-code flow and session management independently of the IDP session cookie. Sessions are stored in AES-256-GCM encrypted cookies tied to the provider's auto-created OIDC application.
Architecture¶
Browser → [Traefik] → proxy-provider :4180 ──(authenticated)──▶ Upstream app
│
├─ /__proxy/callback (OIDC code exchange)
├─ /__proxy/sign-out (clear cookie + IDP logout)
└─ /healthz (liveness / readiness)
The binary polls GET /api/v1/proxy/v1/config every 30 seconds (configurable) using a service-account PAT with the proxy_providers.config_read permission.
Service Account Setup¶
- Create a service account user in the admin UI (Users → New User → enable "Service Account")
- Create a PAT for it with scope
proxy_providers.config_read(under Users → select the service account → Tokens) - Copy the token — it will only be shown once
Kubernetes Deployment¶
The k8s-mt/proxy-provider.yaml manifest in this repository provides a ready-to-use deployment:
apiVersion: v1
kind: Secret
metadata:
name: justiam-proxy-provider
namespace: justiam-mt
type: Opaque
stringData:
JUSTIAM_ISSUER: "https://idp.example.com" # tenant issuer URL
JUSTIAM_TOKEN: "justiam_…" # service-account PAT
apiVersion: apps/v1
kind: Deployment
metadata:
name: proxy-provider
namespace: justiam-mt
spec:
replicas: 2 # stateless — scale freely
# ... (see k8s-mt/proxy-provider.yaml)
Environment variables:
| Variable | Default | Description |
|---|---|---|
JUSTIAM_ISSUER |
— | Required. Tenant OIDC issuer URL |
JUSTIAM_TOKEN |
— | Required. Service-account PAT |
LISTEN |
:4180 |
Bind address |
POLICY_REFRESH_SECS |
30 |
Config poll interval (seconds) |
LOG_LEVEL |
info |
Log level (debug, info, warn, error) |
Traefik HTTPRoute + ForwardAuth¶
For cross-domain apps that still go through Traefik, use forward_auth pointing to the proxy-provider binary:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: proxy-provider-auth
namespace: justiam-mt
spec:
forwardAuth:
address: "http://proxy-provider.justiam-mt.svc:4180"
authResponseHeaders:
- X-Auth-User
- X-Auth-Email
- X-Auth-Name
- X-Auth-Groups
- X-Auth-Roles
trustForwardHeader: true
Kubernetes Gateway API (HTTPRoute)¶
When using the Kubernetes Gateway API directly (e.g. with Traefik as a Gateway controller), route traffic through the proxy-provider Service:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: myapp
namespace: myapp
spec:
parentRefs:
- name: main-gateway
namespace: traefik
hostnames:
- "myapp.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
# Route to proxy-provider first; it proxies to the upstream after auth.
- name: proxy-provider
namespace: justiam-mt
port: 4180
The proxy-provider handles the /__proxy/callback and /__proxy/sign-out paths automatically.
Register myapp.example.com as a binding on the provider and set the mode to reverse_proxy.
Quick-start example¶
# 1. Create the provider
curl -X POST https://idp.example.com/api/v1/proxy-providers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "My App",
"mode": "forward_auth",
"session_duration_secs": 3600
}'
# → returns { "provider": {...}, "client_id": "...", "client_secret": "..." }
# Save client_id and client_secret — the secret is shown only once.
# 2. Bind a hostname (default_action is per-binding, default: authenticate)
curl -X POST https://idp.example.com/api/v1/proxy-providers/{id}/bindings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "hostname": "myapp.example.com", "default_action": "authenticate" }'
# → returns { "id": "<binding_id>", "provider_id": "...", "hostname": "...", "default_action": "..." }
# 3. Allow /health unauthenticated (priority 1), require auth for everything else (priority 100)
curl -X POST https://idp.example.com/api/v1/proxy-providers/{id}/bindings/{binding_id}/policies \
-H "Authorization: Bearer $TOKEN" \
-d '{ "name": "health", "priority": 1, "path_pattern": "/health", "action": "allow" }'
curl -X POST https://idp.example.com/api/v1/proxy-providers/{id}/bindings/{binding_id}/policies \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "catch-all",
"priority": 100,
"path_pattern": "/**",
"action": "authenticate",
"require_any_group": ["engineering", "ops"]
}'
Terraform¶
resource "justiam_proxy_provider" "myapp" {
name = "My App"
mode = "forward_auth"
session_duration_secs = 3600
}
# The auto-created OIDC app credentials are exposed as computed attributes:
output "client_id" { value = justiam_proxy_provider.myapp.client_id }
output "client_secret" {
value = justiam_proxy_provider.myapp.client_secret
sensitive = true
}
resource "justiam_access_policy" "health" {
provider_id = justiam_proxy_provider.myapp.id
binding_id = "<binding-uuid>" # UUID from AddBinding response
name = "health check"
priority = 1
path_pattern = "/health"
action = "allow"
}
resource "justiam_access_policy" "catch_all" {
provider_id = justiam_proxy_provider.myapp.id
binding_id = "<binding-uuid>"
name = "require engineering or ops"
priority = 100
path_pattern = "/**"
action = "authenticate"
require_any_group = ["engineering", "ops"]
}
# Domain roles — grant a role to a group for use in require_app_mapping policies
resource "justiam_proxy_binding_role" "admin_role" {
provider_id = justiam_proxy_provider.myapp.id
binding_id = "<binding-uuid>"
role_name = "admin"
description = "Admin users on myapp.example.com"
group_ids = ["<group-uuid>"]
}
# Restrict the binding to a specific group (HTTP-level gate + portal visibility)
resource "justiam_proxy_binding_access_group" "engineering_only" {
provider_id = justiam_proxy_provider.myapp.id
binding_id = "<binding-uuid>"
group_id = "<engineering-group-uuid>"
}
Domain Roles¶
Domain roles allow fine-grained role-based access within a proxy provider. Each role is scoped to a specific binding (hostname) and can be assigned to one or more groups.
How it works¶
- Create a role on a binding via
POST /proxy-providers/{id}/roles(specifying thebinding_id). - Assign groups to the role via
POST /proxy-providers/{id}/roles/{role_id}/groups. - Reference the role in an access policy using
require_app_mapping: ["role_name"].
When the proxy evaluates a request, it checks whether the authenticated user is in any group that has been assigned the required role on that binding. No OIDC claim is needed — the check is done inline by the proxy engine.
Role vs. Group¶
| Groups | Domain Roles | |
|---|---|---|
| Scope | Tenant-wide | Per binding (hostname) |
| Evaluated via | require_any_group / require_all_groups |
require_app_mapping |
| Use case | Coarse-grained access (who can enter the app) | Fine-grained in-app permissions (admin vs. viewer) |
API¶
| Method | Path | Description |
|---|---|---|
GET |
/proxy-providers/{id}/roles |
List all roles (paginated; domain= and q= filters) |
POST |
/proxy-providers/{id}/roles |
Create a role (requires binding_id + role_name) |
DELETE |
/proxy-providers/{id}/roles/{role_id} |
Delete a role |
POST |
/proxy-providers/{id}/roles/{role_id}/groups |
Assign a group to the role |
DELETE |
/proxy-providers/{id}/roles/{role_id}/groups/{group_id} |
Remove a group from the role |
Portal Access Groups¶
Each binding can optionally be restricted in the My Apps portal to specific groups. When at least one access group is configured, only members of those groups see the binding in the portal. Users who pass access policy checks can still reach the upstream application regardless of portal visibility.
| Method | Path | Description |
|---|---|---|
GET |
/proxy-providers/{id}/bindings/{binding_id}/access/groups |
List access groups for a binding |
POST |
/proxy-providers/{id}/bindings/{binding_id}/access/groups |
Add a group (body: {"group_id":"<uuid>"}) |
DELETE |
/proxy-providers/{id}/bindings/{binding_id}/access/groups/{group_id} |
Remove a group |
API Reference¶
All endpoints require a Bearer token and the proxy_providers.manage permission.
Providers¶
| Method | Path | Description |
|---|---|---|
GET |
/proxy-providers |
List all providers |
POST |
/proxy-providers |
Create a provider — returns { provider, client_id, client_secret } |
GET |
/proxy-providers/{id} |
Get a provider |
PATCH |
/proxy-providers/{id} |
Update a provider |
DELETE |
/proxy-providers/{id} |
Delete a provider (cascades bindings + policies) |
Bindings¶
| Method | Path | Description |
|---|---|---|
GET |
/proxy-providers/{id}/bindings |
List hostname bindings (paginated; q= filter) |
POST |
/proxy-providers/{id}/bindings |
Add a hostname binding (accepts optional default_action) |
PATCH |
/proxy-providers/{id}/bindings/{hostname} |
Update binding default action, portal settings, or cleanup config |
DELETE |
/proxy-providers/{id}/bindings/{hostname} |
Remove a hostname binding |
Portal Access Groups (per binding)¶
| Method | Path | Description |
|---|---|---|
GET |
/proxy-providers/{id}/bindings/{binding_id}/access/groups |
List access groups |
POST |
/proxy-providers/{id}/bindings/{binding_id}/access/groups |
Add access group |
DELETE |
/proxy-providers/{id}/bindings/{binding_id}/access/groups/{group_id} |
Remove access group |
Access Policies¶
Policies are scoped to a specific binding. The binding_id is returned when adding a binding.
| Method | Path | Description |
|---|---|---|
GET |
/proxy-providers/{id}/policies |
List all policies across all bindings (paginated; domain= + q= filters) |
GET |
/proxy-providers/{id}/bindings/{binding_id}/policies |
List policies for a specific binding |
POST |
/proxy-providers/{id}/bindings/{binding_id}/policies |
Create a policy for a binding |
GET |
/proxy-providers/{id}/policies/{policy_id} |
Get a policy |
PATCH |
/proxy-providers/{id}/policies/{policy_id} |
Update a policy |
DELETE |
/proxy-providers/{id}/policies/{policy_id} |
Delete a policy |
Domain Roles¶
| Method | Path | Description |
|---|---|---|
GET |
/proxy-providers/{id}/roles |
List all roles (paginated; domain= + q= filters) |
POST |
/proxy-providers/{id}/roles |
Create a role (binding_id + role_name required) |
DELETE |
/proxy-providers/{id}/roles/{role_id} |
Delete a role |
POST |
/proxy-providers/{id}/roles/{role_id}/groups |
Assign a group to a role |
DELETE |
/proxy-providers/{id}/roles/{role_id}/groups/{group_id} |
Remove a group from a role |
Forward-auth endpoint¶
| Method | Path | Permission | Description |
|---|---|---|---|
GET |
/proxy/auth |
none | Forward-auth sub-request handler (called by Traefik) |
The endpoint reads X-Forwarded-Host, X-Forwarded-Uri, and X-Forwarded-Method headers.
Returns 200 with X-Auth-* headers on success, 401 + Location to trigger login, or 403 for denied requests.
Binding Cleanup Config¶
Each binding can declare additional client-side state to clear when the proxy forces a re-authentication (e.g. on session expiry, revocation, or group change). This is configured via PATCH /proxy-providers/{id}/bindings/{binding_id}:
| Field | Type | Description |
|---|---|---|
downstream_cookies |
[]string |
Regex patterns matched against cookie names. Matching cookies are cleared server-side (via Set-Cookie: name=; MaxAge=-1) on forced re-auth. Supports HttpOnly cookies (e.g. JSESSIONID\..+). |
downstream_storage_keys |
[]string |
localStorage keys to clear client-side when the cleanup page is loaded. |
reauth_redirect_url |
string |
Optional. After a successful OIDC re-authentication, redirect the user to this URL instead of the page they originally requested. Must be an absolute URL on the same hostname as the binding. Use the {path} token to pass the original path to the app (e.g. https://myapp.example.com/login?return_url={path}). Leave empty (default) to return the user to their original URL. |
Preserving the original path with {path}
Use {path} anywhere in the URL to have it replaced with the URL-encoded path (and query string) of the original request. This lets downstream apps like ArgoCD or Kargo redirect the user back to the exact page they were trying to reach after their own login handshake completes.
Example — ArgoCD supports ?return_url= on its login page:
/user-info is redirected to:
Regex patterns for cookies
Patterns are anchored (^(?:pattern)$) and matched with Go's regexp package.
Use JSESSIONID\..+ to clear any JSESSIONID.<suffix> cookie, including HttpOnly ones that are invisible to JavaScript.
When to use reauth_redirect_url
Some apps (Spring Boot, ArgoCD, Grafana) keep their own internal session that needs to be initialised via a specific login endpoint before the user can reach the original page. Setting reauth_redirect_url to that endpoint (e.g. https://myapp.example.com/login) ensures the app's session is created cleanly after every proxy-level re-authentication. Combine with {path} to preserve deep-link navigation.
Example — full cleanup config with post-reauth redirect:
curl -X PATCH https://idp.example.com/api/v1/proxy-providers/{id}/bindings/{binding_id} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"downstream_cookies": ["JSESSIONID\\..+", "MY_APP_SESSION"],
"downstream_storage_keys": ["oidc.user", "app.state"],
"reauth_redirect_url": "https://myapp.example.com/login?return_url={path}"
}'
Cleanup Debug Page¶
The proxy-provider binary exposes a debug page at /__proxy/cleanup on the protected hostname.
Read-only view¶
Visiting /__proxy/cleanup shows:
- Configured cookie patterns — the regex patterns registered on the binding
- Current cookies — lists each configured pattern and whether a matching cookie is JS-readable or HttpOnly
- Current localStorage — lists configured keys and their current values
Test server-side clearing (?test=1)¶
Clicking "Run cleanup now" (or navigating to /__proxy/cleanup?test=1) triggers a server-side test run:
- The binary reads all cookies on the request (including HttpOnly ones invisible to JS).
- Cookies matching the configured patterns are expired via
Set-Cookie: name=; Path=/; MaxAge=-1. - The page redirects back to
/__proxy/cleanupwith a result banner: - Green: lists cookies that were cleared.
- Amber warning: lists configured patterns for which no matching cookie was found (they may already be absent or have a different name).
Session Revocation¶
Automatic revocation on app-mapping changes¶
When a group app-mapping is created, updated, or deleted — or when groups are assigned to / removed from an app-mapping — the proxy provider automatically revokes the proxy sessions for all affected group members. This forces those users to re-authenticate on their next request, at which point the new (or removed) mapping values take effect.
Revocation uses a per-group, per-binding strategy so that only users who actually belong to the changed group, and only on the specific binding linked to the affected application, are forced to re-authenticate. Sessions on unrelated bindings (e.g. ArgoCD) are not touched when a Kargo mapping changes.
Linking a binding to an application¶
For scoped revocation to work, each binding must declare which OIDC application owns its app-mappings via linked_application_id. Without a link the binding is not affected by any app-mapping change.
PATCH /api/v1/proxy-providers/{id}/bindings/{hostname}
Content-Type: application/json
{ "linked_application_id": "<application-uuid>" }
To clear the link send an empty string:
You can also set it from the UI: Proxy Providers → Hostnames → Settings (⚙) → Linked application.
How revocation works¶
- An admin creates / updates / deletes an app-mapping entry, or adds / removes a group from one.
- The backend identifies the changed group and the affected mapping.
- It upserts a row in
proxy_binding_group_revocations(binding_id, group_id, revoked_at)for every binding whoselinked_application_idmatches the mapping'sapplication_id. - The
proxy-providerbinary observes the config change event stream (SSE), re-fetches the config, and from that point rejects any session whoseissued_atis older than the storedrevoked_atand whose login-time groups include the revoked group. - The affected user is sent through a full OIDC re-authentication and receives a fresh token with updated mapping values.
Revocation is asynchronous: in-memory sessions are invalidated within the configured poll interval (default 30 s).
Manual revocation¶
An admin can also revoke all sessions for a specific user:
curl -X POST https://idp.example.com/api/v1/proxy-providers/{id}/revoke \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "user_id": "<uuid>" }'
Built-in service account¶
Each tenant has a built-in service account used exclusively by the control-plane to provision and rotate the proxy PAT:
| Field | Value |
|---|---|
proxy-provider@service.internal |
|
| UUID | 00000000-0000-0000-0000-000000000004 |
| Role | Proxy Config Reader (grants proxy_providers.config_read) |
This account is created automatically by database migration 024. It has no password and cannot authenticate interactively.
Deletion protection: The account cannot be deleted while the tenant's proxy_mode is shared or dedicated. The API returns 409 Conflict in that case. Switch proxy_mode to none first if you need to remove it.