Running OpenCloud in a secure infrastructure argues why an external identity provider belongs in front of an OpenCloud instance, and summarises what each of the three common ones needs. This page is the long version of that summary: three complete tutorials, from nothing to a working sign-in, for Keycloak, Authentik and Authelia.
Pick one. They do the same job, and running two is a way of having neither configured properly.
This page changes who may sign in, not how OpenCloud is exposed. An identity provider is one control among several. The firewall, the audit log, the reverse proxy and the release lifecycle are the rest of the job, and Running OpenCloud in a secure infrastructure covers them together.
- Putting an identity provider in front of OpenCloud, step by step
- Before you start
- What every provider has to produce
- What OpenCloud needs, whichever provider you pick
- Tutorial A: Keycloak
- Tutorial B: Authentik
- Tutorial C: Authelia
- Verifying it actually worked
- Moving an instance that already has accounts
- Troubleshooting
- Where to go next
- Trademarks and affiliation
Before you start
Three names, decided now and not changed later. Every URL below is built from them, and an issuer that changes after people have signed in invalidates every session and every desktop client's stored token at once:
| Name | Example | What it is |
|---|---|---|
| The instance | opencloud.example.com |
Where OpenCloud answers |
| The provider | id.example.com |
Where the sign-in page lives |
| The realm or application slug | opencloud |
The provider's own name for this application |
You also need:
- A working OpenCloud instance over HTTPS. Not an instance you are building at the same time. If sign-in breaks you want to know it was the provider, and a half-built instance takes that certainty away.
- A real certificate on both names. OpenID Connect discovery is an HTTPS request from OpenCloud to the provider; a self-signed certificate there fails in a way whose error message rarely says so.
- Both names resolving from inside the container network as well as outside. This is the single most common cause of "it works in the browser and the desktop client hangs" - see Troubleshooting.
- A way back in. Keep one local OpenCloud administrator until the new
sign-in is proven, and do not remove
idpfrom the running services until then.
What every provider has to produce
The clients, the redirect URIs and the scopes are properties of OpenCloud's own applications, not of the provider. They are identical for Keycloak, Authentik and Authelia, and getting one of them wrong produces the same failure whichever provider you chose. Configure these four, every time.
The four clients
| Client | Default client ID | Redirect URIs | Scopes |
|---|---|---|---|
| Web | web |
https://opencloud.example.com/, https://opencloud.example.com/oidc-callback.html, https://opencloud.example.com/oidc-silent-redirect.html |
openid profile email groups |
| Desktop | OpenCloudDesktop |
http://127.0.0.1, http://localhost |
openid profile email groups offline_access |
| Android | OpenCloudAndroid |
oc://android.opencloud.eu |
openid profile email groups offline_access |
| iOS | OpenCloudIOS |
oc://ios.opencloud.eu, oc.ios://ios.opencloud.eu |
openid profile email groups offline_access |
Three things in that table are load-bearing:
The web client needs all three redirect URIs. oidc-callback.html ends
the sign-in; oidc-silent-redirect.html is how the tab renews a token without
sending somebody back to a login screen mid-upload. Register only the first
and sign-in works, then sessions start dying at an interval nobody can
reproduce on purpose.
Only the non-browser clients get offline_access. That scope is what
issues the refresh token a desktop or mobile client needs to survive a
restart. The browser deliberately does not have one - a refresh token in a
tab is a credential sitting in a place that cannot protect it.
The client IDs are configurable, and the two halves must agree. The
provider knows them because you typed them there; OpenCloud publishes them to
its own clients through WebFinger, from
WEBFINGER_WEB_OIDC_CLIENT_ID and its ANDROID, IOS and DESKTOP
counterparts. Change one without the other and the desktop client asks the
provider for a client that does not exist.
Why they are all public clients
Every OpenCloud client - the web app in a tab, the desktop app on a laptop, the two mobile apps - runs entirely on somebody else's machine. None of them can keep a secret, because anything shipped to all of them is a secret every one of their users has a copy of.
So all four are public clients using the authorization code flow with
PKCE, and PKCE is not optional decoration: it is the thing that replaces
the client secret those clients cannot hold. Set the challenge method to
S256, never plain. Do not issue a client secret to any of them - a
provider that requires one for a public client is configured wrong, and
pasting a secret into a desktop app to satisfy it publishes that secret.
What OpenCloud needs, whichever provider you pick
Set these on the OpenCloud side once the provider is up. The external IdP guide is the upstream reference; the notes here are the parts worth a second thought.
# The provider, and turning the built-in one off.
OC_OIDC_ISSUER="https://id.example.com/realms/opencloud"
OC_EXCLUDE_RUN_SERVICES="idp"
# Verify tokens against the provider's published keys rather than asking it
# on every single request.
PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD="jwt"
PROXY_OIDC_REWRITE_WELLKNOWN="true"
# Who a token belongs to, and the OpenCloud attribute it is matched against.
PROXY_USER_OIDC_CLAIM="preferred_username"
PROXY_USER_CS3_CLAIM="username"
# Create the account on first sign-in, and where its fields come from.
PROXY_AUTOPROVISION_ACCOUNTS="true"
PROXY_AUTOPROVISION_CLAIM_USERNAME="preferred_username"
PROXY_AUTOPROVISION_CLAIM_EMAIL="email"
PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME="name"
PROXY_AUTOPROVISION_CLAIM_GROUPS="groups"
# Roles from a claim - and the default role switched off, or everybody gets
# that one as well.
PROXY_ROLE_ASSIGNMENT_DRIVER="oidc"
PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM="roles"
GRAPH_ASSIGN_DEFAULT_USER_ROLE="false"
# The client IDs, published to OpenCloud's own clients through WebFinger.
WEBFINGER_WEB_OIDC_CLIENT_ID="web"
WEBFINGER_DESKTOP_OIDC_CLIENT_ID="OpenCloudDesktop"
WEBFINGER_ANDROID_OIDC_CLIENT_ID="OpenCloudAndroid"
WEBFINGER_IOS_OIDC_CLIENT_ID="OpenCloudIOS"
Two of those are access-control decisions wearing the clothes of configuration, and both are covered at greater length in secure-deployment.md:
PROXY_AUTOPROVISION_ACCOUNTS=truemeans anybody your provider will authenticate gets an OpenCloud account on first visit. That is correct when the provider restricts this application to a group, and wrong when the provider authenticates your whole organisation. Restrict it on the provider side. Leaving autoprovisioning off and creating accounts by hand is not the fix; it is the same decision, made worse by being manual.PROXY_ROLE_ASSIGNMENT_DRIVER=oidcwithGRAPH_ASSIGN_DEFAULT_USER_ROLE=trueis the misconfiguration that gives everybody a role you did not intend. Setting the first means switching off the second.
Restart OpenCloud after changing any of these. OC_EXCLUDE_RUN_SERVICES in
particular is read once, at startup.
Tutorial A: Keycloak
The most common choice where an organisation already runs one, and the heaviest of the three. Pick it if you need a full realm - federation, identity brokering, fine-grained role mapping - or if Keycloak is already there.
1. Run it. A minimal production-shaped compose service, behind whatever reverse proxy already terminates TLS for you:
services:
keycloak:
image: quay.io/keycloak/keycloak:latest
command: ["start", "--optimized"]
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD_FILE: /run/secrets/kc_db_password
KC_HOSTNAME: https://id.example.com
KC_PROXY_HEADERS: xforwarded
KC_HTTP_ENABLED: "true"
# Bootstrap only. Create a real administrator, then remove these two
# and restart - they are a password in an environment variable.
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD_FILE: /run/secrets/kc_bootstrap
secrets: [kc_db_password, kc_bootstrap]
depends_on: [keycloak-db]
KC_PROXY_HEADERS: xforwarded matters: without it Keycloak builds its issuer
and redirect URLs from the internal address and every one of them is wrong in
a way that only shows up at the redirect.
2. Create the realm. Realms → Create realm, named opencloud. Do not
use the master realm for applications - it is the realm that administers
Keycloak itself, and an application client there is an application client on
your administration plane.
Your issuer is now:
https://id.example.com/realms/opencloud
3. Create the four clients. Clients → Create client, four times, using the IDs and redirect URIs from the table above. For each one:
- Client type: OpenID Connect.
- Client authentication: off. This is what makes it a public client.
- Authentication flow: Standard flow only. Turn off Direct access grants - it is the password grant, and it is a way around every second factor you are about to configure.
- Valid redirect URIs: from the table. The desktop client needs
http://127.0.0.1/*andhttp://localhost/*- the port is chosen at runtime, so the wildcard is doing real work here rather than being laziness. - Web origins:
https://opencloud.example.comfor the web client only. - Under Advanced → Advanced settings, set Proof Key for Code Exchange
Code Challenge Method to
S256.
4. Make the claims OpenCloud reads. Client scopes → <client>-dedicated
→ Add mapper → By configuration:
- Group Membership mapper, token claim name
groups, Full group path off. Without the last one your groups arrive as/financeand every comparison againstfinancefails. - User Client Role mapper, token claim name
roles, if you are assigning OpenCloud roles from Keycloak.
Add both to the access token and the userinfo response. OpenCloud reads the token; a claim that exists only in the ID token is a claim it never sees.
5. Require a second factor. Authentication → Required actions → enable Configure OTP, then Authentication → Flows → bind a browser flow that requires it. Verify that users are prompted for the second factor at sign-in.
6. Point OpenCloud at it with the variables above, and restart.
Tutorial B: Authentik
Authentik supports configuration through files and policies for individual applications. It can provide sign-in for several applications from one installation.
This repository already ships an Authentik stack, but for a different purpose: it protects the scan service's own MCP endpoint and operator's area, not OpenCloud.
authentik/blueprints/is a worked example of provisioning a provider from a file, which is worth copying whatever you are configuring.
1. Run it. Authentik publishes a compose file and a generator for it;
follow their installation
guide
for the current setup instructions. Afterwards, check that
https://id.example.com reaches it over HTTPS with a valid certificate.
2. Create the scope mapping for groups. Customisation → Property mappings → Create → Scope mapping:
- Name:
OpenCloud groups - Scope name:
groups - Expression:
python return {"groups": [group.name for group in request.user.ak_groups.all()]}
Authentik ships mappings for openid, profile and email; groups is the
one you usually have to add, and it is the one OpenCloud needs for roles.
3. Create four providers. Applications → Providers → Create → OAuth2/OpenID Provider, once per client in the table above:
- Client type: Public.
- Client ID: from the table.
- Redirect URIs: from the table. Authentik matches these as regular
expressions, so escape the dots -
http://127\.0\.0\.1(:[0-9]+)?for the desktop client's loopback range. - Scopes: the three built-in mappings plus
OpenCloud groups. - Signing key: your certificate, so tokens are signed rather than unsigned.
- Authorization flow:
implicit consentfor an internal application - people should not be asked to consent to your own file server on every sign-in.
4. Create the application and bind it to a group. Applications →
Applications → Create, slug opencloud, provider the web one from step 3.
Then bind it: Policies / Group / User bindings → bind the group that should
have OpenCloud.
This binding is the access control that makes autoprovisioning safe. With
it, PROXY_AUTOPROVISION_ACCOUNTS=true creates accounts only for people you
have already decided should have one.
5. Read the issuer off the provider. It is:
https://id.example.com/application/o/opencloud/
The trailing slash is part of it. OpenID Connect compares the issuer string exactly, so an issuer configured without it fails validation against tokens that carry it, and the error names neither the slash nor the issuer.
6. Point OpenCloud at it with the variables above, and restart.
Tutorial C: Authelia
The lightest of the three, configured entirely in a file, and a good fit where the reverse proxy is already doing forward authentication for other services. Pick it if you want one small binary rather than a realm server.
1. Run it, alongside its session store:
services:
authelia:
image: ghcr.io/authelia/authelia:latest
volumes:
- ./authelia:/config
environment:
AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE: /run/secrets/oidc_hmac
AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE: /run/secrets/oidc_key
secrets: [oidc_hmac, oidc_key]
Generate the two secrets before first start - Authelia will not invent them for you:
docker run --rm ghcr.io/authelia/authelia:latest \
authelia crypto rand --length 64 --charset alphanumeric
docker run --rm -v "$PWD/authelia:/keys" ghcr.io/authelia/authelia:latest \
authelia crypto pair rsa generate --bits 4096 --directory /keys
2. Register the four clients under
identity_providers.oidc.clients in configuration.yml. This is the whole
web client; the other three differ only in client_id, redirect_uris and
their lack of a browser:
identity_providers:
oidc:
clients:
- client_id: 'web'
client_name: 'OpenCloud'
public: true
authorization_policy: 'two_factor'
require_pkce: true
pkce_challenge_method: 'S256'
token_endpoint_auth_method: 'none'
scopes: ['openid', 'profile', 'email', 'groups']
redirect_uris:
- 'https://opencloud.example.com/'
- 'https://opencloud.example.com/oidc-callback.html'
- 'https://opencloud.example.com/oidc-silent-redirect.html'
response_types: ['code']
grant_types: ['authorization_code']
- client_id: 'OpenCloudDesktop'
client_name: 'OpenCloud Desktop'
public: true
authorization_policy: 'two_factor'
require_pkce: true
pkce_challenge_method: 'S256'
token_endpoint_auth_method: 'none'
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access']
redirect_uris: ['http://127.0.0.1', 'http://localhost']
response_types: ['code']
grant_types: ['authorization_code', 'refresh_token']
- client_id: 'OpenCloudAndroid'
client_name: 'OpenCloud Android'
public: true
authorization_policy: 'two_factor'
require_pkce: true
pkce_challenge_method: 'S256'
token_endpoint_auth_method: 'none'
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access']
redirect_uris: ['oc://android.opencloud.eu']
response_types: ['code']
grant_types: ['authorization_code', 'refresh_token']
- client_id: 'OpenCloudIOS'
client_name: 'OpenCloud iOS'
public: true
authorization_policy: 'two_factor'
require_pkce: true
pkce_challenge_method: 'S256'
token_endpoint_auth_method: 'none'
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access']
redirect_uris: ['oc://ios.opencloud.eu', 'oc.ios://ios.opencloud.eu']
response_types: ['code']
grant_types: ['authorization_code', 'refresh_token']
authorization_policy: 'two_factor' is where the second factor is required,
per client, and it is the reason to prefer this over a global rule: the
desktop client and the browser can be held to the same standard without
depending on anybody remembering an access-control rule.
3. Add the access-control rule for the instance itself, so that anything not covered by the OpenID Connect flow is also protected:
access_control:
default_policy: 'deny'
rules:
- domain: 'opencloud.example.com'
policy: 'two_factor'
4. The issuer is the bare host, with no path and no trailing slash:
https://id.example.com
5. Point OpenCloud at it with the variables above, and restart.
Verifying it actually worked
Four checks, in this order. Each one fails differently, so running them out of order costs time.
1. The provider publishes a discovery document.
curl -fsS https://id.example.com/.well-known/openid-configuration | \
python3 -m json.tool | head -20
The issuer field in the answer must be byte-for-byte what you put in
OC_OIDC_ISSUER. A trailing slash counts.
2. OpenCloud points at it. With PROXY_OIDC_REWRITE_WELLKNOWN=true,
asking OpenCloud gives the provider's document:
curl -fsS https://opencloud.example.com/.well-known/openid-configuration | \
python3 -c 'import json,sys; print(json.load(sys.stdin)["issuer"])'
If that returns OpenCloud's own address, the built-in idp is still running -
OC_EXCLUDE_RUN_SERVICES did not take effect, or the container was not
restarted.
3. A person can sign in. In a private window, so you are not testing a session you already had. Then check the account appeared, if you turned autoprovisioning on.
4. Scan it. This is what the rest of this repository is for. The scanner reports which provider it found and reads four properties of the discovery document that provider publishes:
check-opencloud-security --host opencloud.example.com --check-hardening --debug
Look for identityProviderDetected passing, and for oidcPkceSupported,
oidcImplicitFlowDisabled, oidcSigningAlgorithmStrong and
oidcEndpointsUseHttps. Authentication explains what
each of them means and why it is checked. A provider that fails
oidcImplicitFlowDisabled is still offering a flow that puts tokens in a URL,
which is worth fixing before anybody uses it.
Note what this does not prove: the scanner reads what the provider publishes without signing in, so it cannot tell you that your group mapping is right or that your second factor is enforced. Those are the two things to test by hand.
Moving an instance that already has accounts
Switching an instance that people already use is a different job from configuring a new one, and the difference is entirely about identity matching.
The accounts have to line up. PROXY_USER_OIDC_CLAIM and
PROXY_USER_CS3_CLAIM are what connects a person at the provider to their
existing OpenCloud account and everything in it. If preferred_username at
the provider does not equal username in OpenCloud, autoprovisioning creates
a second, empty account for somebody who already had one, and their files
are still in the first.
So, in order:
- Export the existing usernames and compare them against the provider's, before changing anything. Reconcile the differences at the provider.
- Leave
idprunning and configure the external provider alongside it. - Test with one account that exists in both, and confirm it lands in the existing space rather than a new one.
- Then add
idptoOC_EXCLUDE_RUN_SERVICESand restart. - Keep
PROXY_ENABLE_BASIC_AUTH=false. WebDAV mounts, CalDAV clients and backup jobs authenticate with HTTP Basic and bypass the provider and every second factor on it. Where something genuinely needs it, the answer is app tokens rather than account passwords - see secure-deployment.md.
Troubleshooting
| What you see | What it usually is |
|---|---|
invalid issuer or token validation failures |
OC_OIDC_ISSUER does not match the provider's own issuer string exactly. Authentik needs the trailing slash; Authelia has none |
| Sign-in works in the browser, desktop client hangs | The provider's name does not resolve from inside the container network, or the desktop redirect URI has no port wildcard |
| Sign-in works, then sessions die at odd intervals | The web client is missing oidc-silent-redirect.html from its redirect URIs |
| The desktop client never stays signed in | offline_access is missing from that client's scopes, so no refresh token is issued |
| Everybody has more permission than intended | GRAPH_ASSIGN_DEFAULT_USER_ROLE is still true while roles come from a claim |
| A second, empty account for somebody who had one | The claim in PROXY_USER_OIDC_CLAIM does not equal the attribute in PROXY_USER_CS3_CLAIM |
| Groups arrive but never match | Keycloak's Full group path is on, so finance is arriving as /finance |
| Users authenticate who should not have accounts | Autoprovisioning is on and the application is not restricted to a group at the provider |
| Scanner still reports the built-in provider | OC_EXCLUDE_RUN_SERVICES does not include idp, or OpenCloud was not restarted |
Where to go next
| Page | Why |
|---|---|
| Running OpenCloud in a secure infrastructure | The audit log, the firewall and the rest of the job this page is one part of |
| Authentication | Every authentication and OpenID Connect check the scanner runs, in detail |
| Reverse proxies | Terminating TLS in front of both names |
| TLS and certificates | What a good certificate looks like, and every transport check |
| Authentik in front of the MCP endpoint | The same provider, protecting this scan service rather than OpenCloud |
| Hardening measures | What basicAuthDisabled and the rest actually mean |