On this page
Configure OIDC Single Sign-On with Self-Hosted Casdoor
Casdoor is an open-source centralized Identity and Access Management (IAM) platform (Apache-2.0) built by the Casbin community. It features a built-in administrative web UI and natively supports OAuth 2.0, OIDC, SAML, LDAP, SCIM, and more. When used as Holon's OIDC identity provider, organizations, users, applications, and session policies are managed entirely within your own Casdoor deployment—no registration with external third-party providers is needed. Compared to Keycloak, Casdoor offers a lighter footprint (a single container plus a database) and starting from v4.26.0 supports organization-level path-based discovery issuers. This guide walks through the complete process from deployment to end-to-end verification, reproducible on a single host.
For general OIDC concepts (issuer, client ID, redirect URI, session TTL, and prompt attribution), see Configure OIDC authentication. This guide focuses specifically on self-hosted Casdoor.
Prerequisites
- A host capable of running Docker; Casdoor officially recommends MySQL 8 as its persistence backend.
- An HTTPS domain pointing to this host (this guide uses
https://casdoor.example.com:9445as an example; a Tailscale domain withtailscale certor a public domain with Let's Encrypt works equally well). - Any recent version of Holon (with support for
auth.oidc.*configuration). - Casdoor v4.26.0 or higher (verified with v4.26.0 in November 2026; organization-level path issuers require v4.26.0+).
Step 1: Deploy Casdoor
Key concept: Casdoor listens on port 8000 (HTTP) inside its container. The origin environment variable must match the exact external base URL accessed by browsers, or the issuer claim in the discovery document and authorization redirect URLs will point to incorrect endpoints. Terminating TLS at a reverse proxy (in this guide, an nginx sidecar with TLS certificates) is recommended, while Casdoor itself stays on plain HTTP.
Start the three containers in order: MySQL → Casdoor → nginx (nginx must start last so it can resolve the upstream hostname):
docker network create casdoor-net
docker run -d --name casdoor-mysql --network casdoor-net \
-e MYSQL_ROOT_PASSWORD="<database-root-password>" \
-v "$PWD/mysql-data:/var/lib/mysql" \
mysql:8.0
# Wait for MySQL readiness: you must use a TCP probe (-h 127.0.0.1).
# During initial bootstrapping, the MySQL entrypoint temporarily runs
# a socket-only instance. Omitting -h 127.0.0.1 returns success prematurely,
# causing Casdoor's immediate database connection to fail.
for i in $(seq 1 60); do
docker exec casdoor-mysql mysqladmin ping -h 127.0.0.1 -uroot -p"<database-root-password>" --silent && break
sleep 2
done
# Once the persistent data directory is initialized, MYSQL_DATABASE is not re-executed;
# therefore, create the database idempotently:
docker exec casdoor-mysql mysql -uroot -p"<database-root-password>" \
-e "CREATE DATABASE IF NOT EXISTS casdoor CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
docker run -d --name casdoor --network casdoor-net \
-e driverName=mysql \
-e "dataSourceName=root:<database-root-password>@tcp(casdoor-mysql:3306)/" \
-e origin="https://casdoor.example.com:9445" \
-v "$PWD/init_data.json:/init_data.json:ro" \
casbin/casdoor:v4.26.0
nginx TLS sidecar configuration (replace certificate paths with your actual domain certs):
server {
listen 9445 ssl;
server_name casdoor.example.com;
ssl_certificate /etc/nginx/certs/tls.crt;
ssl_certificate_key /etc/nginx/certs/tls.key;
location / {
proxy_pass http://casdoor:8000;
proxy_set_header Host $host:9445;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
docker run -d --name casdoor-nginx --network casdoor-net \
-p 9445:9445 \
-v "$PWD/tls.crt:/etc/nginx/certs/tls.crt:ro" \
-v "$PWD/tls.key:/etc/nginx/certs/tls.key:ro" \
-v "$PWD/nginx-casdoor.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx:alpine
Verify the deployment:
curl -s https://casdoor.example.com:9445/.well-known/openid-configuration | jq -r .issuer
# https://casdoor.example.com:9445
The administration console is available at https://casdoor.example.com:9445/, with default username admin (change the password immediately upon first login).
Step 2: Initialize Organization, Application, and Users
Choose either approach:
Option A: Manual creation in the Admin Console. Create an organization, users within that organization, and an application in order. Under the application, configure Redirect URLs, grant types (Authorization code + Refresh token), and token format (JWT). Note down the application's Client ID and Client Secret.
Option B: Declarative initialization via init_data.json (recommended for reproducible setups). On startup, Casdoor reads configuration mounted to /init_data.json, creating the organization, users, and application in a single step. Below is a minimal reproducible example (replace client secrets and user passwords with your own values):
{
"organizations": [
{
"owner": "admin",
"name": "holon-org",
"displayName": "Holon Org",
"passwordType": "bcrypt",
"defaultTokenFormat": "JWT",
"useOrganizationIssuer": false
}
],
"users": [
{
"owner": "holon-org",
"name": "alice",
"type": "normal-user",
"password": "<user-password>",
"displayName": "Alice",
"email": "alice@example.test",
"emailVerified": true
}
],
"applications": [
{
"owner": "admin",
"name": "holon-app",
"displayName": "Holon RP",
"organization": "holon-org",
"clientId": "holon-rp-<your-suffix>",
"clientSecret": "<client-secret>",
"signinMethods": [{ "name": "Password", "displayName": "Password", "rule": "All" }],
"grantTypes": ["authorization_code", "refresh_token"],
"redirectUris": ["http://localhost:7880/api/auth/oidc/callback"],
"tokenFormat": "JWT",
"expireInHours": 168
}
]
}
Organization-level issuer (v4.26.0+ optional): Setting useOrganizationIssuer to true on an organization changes the discovery and issuer URL for that organization's applications to a path-based format: https://casdoor.example.com:9445/orgs/<org-name>. Holon supports this path-based issuer directly with no special configuration (see Step 3).
Step 3: Configure Holon
Global issuer (default configuration from Step 1):
holon config set auth.oidc.issuer_url https://casdoor.example.com:9445
holon config set auth.oidc.client_id holon-rp-<your-suffix>
holon config set auth.oidc.client_secret_env CASDOOR_OIDC_CLIENT_SECRET
holon config set auth.oidc.redirect_uri http://localhost:7880/api/auth/oidc/callback
holon config set auth.mode oidc
Organization-level path issuer (optional): only issuer_url differs, pointing directly to the organization discovery root:
holon config set auth.oidc.issuer_url https://casdoor.example.com:9445/orgs/holon-org
Three common pitfalls:
issuer_urlmust match theissuerreturned by discovery verbatim, character-for-character (including ports, and for organization-level issuers, the/orgs/<org-name>prefix).redirect_urimust appear verbatim in the application's Redirect URLs whitelist in Casdoor: Casdoor expectshttp://localhost:7880/api/auth/oidc/callback, exactly matching Holon's configuration (localhost is permitted with non-80 ports).client_secret_envspecifies the environment variable name. The secret value is injected when launching Holon, rather than stored in the configuration file:
CASDOOR_OIDC_CLIENT_SECRET="<client-secret>" holon serve --port 7880
The configuration write order is identical to Keycloak: set the auth.oidc.* child keys first, and set auth.mode oidc last (currently holon config set validates the complete authentication schema on every write, so reversing this order causes validation to fail).
Step 4: Verification
- Open the Holon login page. You should see "Continue with organization login". Clicking it redirects to Casdoor's login page (at your Casdoor domain).
- Log in with user credentials from the organization. After returning to Holon, inspect the active session:
curl -s http://localhost:7880/api/auth/session/me
# {"ok":true,"user_id":"oidc-…","display_name":"…","auth_method":"oidc"}
- Logging out should cause the same endpoint to return 401.
Casdoor-specific secret semantics (check here first during troubleshooting): Casdoor's token endpoint evaluates Client Secret under a "validate if provided" rule: Holon operates as a confidential client and submits client_id + client_secret via form-encoded post, where an incorrect secret returns 401 invalid_client. However, if the application's Client Secret is left blank in Casdoor, Casdoor allows PKCE requests through as a public client. Normal setups (setting an application secret in Casdoor + configuring client_secret_env in Holon) are unaffected, but mixing an empty secret on Casdoor with an incorrect secret in Holon creates confusing edge cases. Always set explicit secrets.
Differences Across GitLab CE, Keycloak, and Google Workspace
| Dimension | Self-Hosted Casdoor | Self-Hosted Keycloak | Self-Hosted GitLab CE | Google Workspace |
|---|---|---|---|---|
| External Dependencies | MySQL 8 (recommended for persistence) | None (embedded H2 suitable for evaluation) | None (packaged embedded DB) | Requires Google Cloud project and workspace domain |
| IdP Role | Centralized IAM, multi-protocol (OIDC/SAML/LDAP/SCIM) | Dedicated identity platform, realm-level isolation | Built-in OAuth in DevOps platform | Cloud productivity suite identity |
| Access Control | Applications bound to organizations, users managed per organization | Realm user management; identity federation | GitLab user/group management | OAuth consent screen User Type = Internal + hd domain |
| Issuer Format | Global, or path-based /orgs/<org-name> (v4.26.0+) | Realm path /realms/<realm> | Root origin URL | Fixed accounts.google.com |
| Single Logout | Holon logout terminates local session only | Same (discovery advertises end_session_endpoint but Holon does not call it) | Same | Same |
| Refresh Token | Issued when application grantTypes includes refresh_token; validity expireInHours configurable (default 168h) | Issued by default (1800s) | Issued by default (2h, configurable) | Requires explicit offline_access |
| Secret Semantics | "Validate if provided"; blank secret + PKCE treated as public client | Standard confidential client | Standard OAuth application | Standard OAuth client |
Verified Scope of This Guide
The following conclusions reflect testing conducted on 2026-11-08 (official Casdoor v4.26.0 Docker image + MySQL 8.0.46 + nginx TLS sidecar + isolated Holon instance, testing both global and organization-level path issuers):
- Verified: End-to-end programmatic authorization code flow (PKCE S256, state, nonce) between Casdoor and an isolated Holon instance completed genuine login and established sessions (login requests matched Casdoor React frontend API calls;
/api/auth/session/mereturnedauth_method=oidc, 401 after logout); both global issuer and organization-level path issuer (/orgs/<org-name>) passed completely, with Holon accepting path-based discovery without issuer mismatch; negative test cases—unregistered redirect URI rejected without code grant, invalid client_id rejected, invalid client_secret returning 401invalid_client, credential revocation taking effect immediately upon secret rotation, and disabled users prevented from creating sessions—all rejected as expected. - Observed behavior (non-blocking finding): When an invalid OIDC callback occurs (such as state or browser session mismatch), Holon correctly rejects the request and does not establish a session, but returns a 500
runtime_errorrather than a 4xx client error (holon-run/holon#3882), reproducible across GitLab, Keycloak, and Casdoor; this does not affect functional correctness. - Not verified: Manual browser UI click workflows (verification used programmatic HTTP flows matching frontend requests), Casdoor versions prior to v4.26.0, SAML/LDAP/SCIM and external federation, JWKS key difference comparisons between global and organization path issuers, and production-scale deployment or backup/recovery procedures.
