OIDC / OAuth 2.0 providers
qnop supports browser-based “Sign in with …” against external OpenID Connect and OAuth 2.0 providers. Providers are rows in the database, managed at runtime under Administration → OIDC providers — there is no provider configuration in the environment or in a config file.
This page is the operator’s guide: how the flow works, what qnop does to identities, how to register each provider, and what goes wrong.
How it works
Section titled “How it works”- The user clicks Sign in with
<name>on the login page, which is a link to/oauth2/authorization/<provider-uuid>. - qnop redirects the browser to the provider’s authorization endpoint with a PKCE-protected request.
- The provider authenticates the user and redirects back to qnop’s callback with an authorization code.
- qnop exchanges the code for tokens server-side, validates the ID token where one is issued, and resolves or creates the user.
- qnop issues its own refresh cookie and redirects to the application. The provider’s tokens are not reused — from here the session is identical to one from a local login.
The callback URL the provider must redirect to is:
https://<your-qnop-host>/login/oauth2/code/<provider-uuid>The UUID is used rather than the name so that renaming a provider never invalidates the URL you registered upstream.
Note that the callback path is not under /api/v1 — only REST controllers carry
that prefix.
qnop’s identity model
Section titled “qnop’s identity model”Four policies are built into the schema and are worth understanding before you onboard anyone.
One identity per (provider, subject). The lookup key is the provider UUID plus the subject claim. Nothing is matched on e-mail address.
No linking, in either direction. A user who signs in through Keycloak and later through GitHub ends up with two unrelated qnop accounts. If an account with the same e-mail address already exists — including a local account — SSO creates a second account rather than linking or refusing. This is deliberate: linking by e-mail would let a compromised secondary identity provider take over an account. There is no linking UI or API, and a user can hold at most one external identity.
Every SSO user is provisioned as MEMBER. The account is created on first
successful callback, enabled immediately, and e-mail verification is skipped.
No claim is mapped to a role or a group — promoting someone to ADMIN or
AUDITOR is a manual step afterwards.
Profile data is taken on first login only. Display name and e-mail are not re-synced on later logins; a rename at the identity provider does not propagate.
Provider types
Section titled “Provider types”Five types exist. What you must supply differs sharply between them.
| Type | Use for | Endpoints | You supply |
|---|---|---|---|
OIDC | any standards-compliant provider — Keycloak, Entra ID, Auth0, Authentik, Okta, … | discovered from issuerUri | name, client id, client secret, issuer URI |
GOOGLE | Google (consumer and Workspace) | discovered from a hard-coded Google issuer | name, client id, client secret |
GITHUB | GitHub OAuth apps | hard-coded | name, client id, client secret |
FACEBOOK | Facebook Login | hard-coded | name, client id, client secret |
OAUTH2 | OAuth 2.0 sources without OIDC — GitLab, Bitbucket, in-house | all operator-supplied | name, client id, secret, authorization/token/user-info URIs |
Prefer OIDC over OAUTH2 wherever the provider publishes a discovery
document: fewer fields, and JWKS rotation is handled for you.
Scopes
Section titled “Scopes”| Type | Default scope | Configurable |
|---|---|---|
OIDC, GOOGLE, OAUTH2 | openid email profile | yes |
GITHUB | read:user user:email | no — hard-coded |
FACEBOOK | public_profile email | no — hard-coded |
Attribute mapping
Section titled “Attribute mapping”userNameAttribute, emailAttribute and displayNameAttribute control which
claim or user-info field becomes the subject, the e-mail and the display name.
They default to sub, email and name, falling back sensibly per type — for
GitHub and Facebook the subject defaults to id.
An e-mail address is mandatory. A provider that returns none fails the login and creates no account.
Prerequisites
Section titled “Prerequisites”- HTTPS in production. The refresh cookie is
Secure, and most providers refuse plain-HTTP callbacks. QNOP_HTTP_FORWARD_HEADERS_STRATEGY=frameworkif you terminate TLS at a proxy — see the warning above.- The three secrets are set,
QNOP_AUTH_ENCRYPTION_KEYin particular: every client secret is encrypted at rest with it. See Configuration. - The issuer must be reachable from the qnop container, and its host must pass the SSRF guard.
Registering a provider
Section titled “Registering a provider”- Sign in as
ADMINand open Administration → OIDC providers. - Add provider, choose the type, and fill in the name (this is the button
label on the login page), client id and client secret — plus the issuer URI
for type
OIDC. - For type
OIDC, use Discover to verify the issuer before saving. It fetches the discovery document and reports what it found without creating anything. - Save. qnop assigns the UUID and shows the callback URL to register upstream.
- Register that callback URL at the identity provider.
- Come back and enable the provider. It now appears on the login page.
The same operations are available over the REST API under
/api/v1/admin/oidc-providers, which requires an ADMIN bearer token. The
probe endpoint is useful in scripts:
curl -X POST "https://qnop.example.com/api/v1/admin/oidc-providers/discover" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "issuerUri": "https://keycloak.example.com/realms/qnop" }'It always answers 200; check the success field rather than the status code.
Provider walkthroughs
Section titled “Provider walkthroughs”Each has two halves: A at the identity provider, B in qnop. Vendor UIs move around; the field names are stable even when the navigation is not.
Keycloak
Section titled “Keycloak”A — Create the client
Section titled “A — Create the client”- Admin console → select or create your realm → Clients → Create client.
- Client type: OpenID Connect. Client ID: e.g.
qnop. - Client authentication: ON — qnop needs a confidential client.
- Standard flow on; the rest off unless you know otherwise.
- Valid redirect URIs: your callback URL, or
https://<qnop-host>/login/oauth2/code/*while setting up. - Save, open Credentials, copy the client secret.
B — Register in qnop
Section titled “B — Register in qnop”- Type:
OIDC - Issuer URI:
https://<keycloak-host>/realms/<realm> - Client id / secret: from steps 2 and 6
- Scope: keep
openid email profile
Microsoft Entra ID
Section titled “Microsoft Entra ID”A — Register the app
Section titled “A — Register the app”- Azure portal → Microsoft Entra ID → App registrations → New registration.
- Give it a name; choose the account types your tenant needs.
- Redirect URI: platform Web, value = your qnop callback URL.
- Register, then note the Application (client) ID and Directory (tenant) ID.
- Certificates & secrets → New client secret; copy the value immediately.
B — Register in qnop
Section titled “B — Register in qnop”- Type:
OIDC - Issuer URI:
https://login.microsoftonline.com/<tenant-id>/v2.0 - Client id / secret: from steps 4 and 5
Guest accounts frequently have no mail attribute populated, which surfaces as
a missing e-mail on first login.
A — Create the application
Section titled “A — Create the application”- Auth0 dashboard → Applications → Create Application → Regular Web Application.
- In Settings, set Allowed Callback URLs to your qnop callback URL.
- Save; note the Domain, Client ID and Client Secret.
B — Register in qnop
Section titled “B — Register in qnop”- Type:
OIDC - Issuer URI:
https://<tenant>.auth0.com/— with the trailing slash, because that is what Auth0 puts in theissclaim - Client id / secret: from step 3
A — Create the OAuth client
Section titled “A — Create the OAuth client”- Google Cloud console → APIs & Services → Credentials → Create credentials → OAuth client ID.
- Application type Web application.
- Authorized redirect URIs: your qnop callback URL.
- Create, then copy the client id and client secret.
- On the OAuth consent screen, make sure
emailandprofileare requested.
B — Register in qnop
Section titled “B — Register in qnop”- Type:
GOOGLE - Client id / secret: from step 4
Nothing else is needed — the endpoints are fixed. Any issuer URI you enter is ignored.
GitHub
Section titled “GitHub”A — Create the OAuth app
Section titled “A — Create the OAuth app”- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Homepage URL: your qnop URL.
- Authorization callback URL: your qnop callback URL.
- Register, generate a client secret, and copy it immediately.
B — Register in qnop
Section titled “B — Register in qnop”- Type:
GITHUB - Client id / secret: from step 4
GitHub also does not support the OIDC prompt parameter, so qnop cannot ask it
to re-prompt for a different account. The login page shows a hint to sign out at
github.com first instead.
- Type:
FACEBOOK - Client id / secret: the App ID and App Secret from the Facebook developer console, with your callback URL registered as a valid OAuth redirect URI.
The email permission must have passed Facebook’s app review before the
provider returns an address, otherwise every first login fails on the missing
e-mail.
Generic OAuth 2.0
Section titled “Generic OAuth 2.0”For providers that are not OIDC-compliant. You supply all three endpoints and, usually, the attribute names.
curl -X POST "https://qnop.example.com/api/v1/admin/oidc-providers" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "GitLab", "providerType": "OAUTH2", "clientId": "<application-id>", "clientSecret": "<application-secret>", "scope": "read_user email", "authorizationUri": "https://gitlab.com/oauth/authorize", "tokenUri": "https://gitlab.com/oauth/token", "userInfoUri": "https://gitlab.com/api/v4/user", "userNameAttribute": "id", "emailAttribute": "email", "displayNameAttribute": "name" }'Note the scope: openid has been removed. GitLab does publish a discovery
document, so type OIDC is the better choice there — this is an illustration of
the generic path.
The login page
Section titled “The login page”GET /api/v1/config exposes, to anonymous visitors, one entry per enabled
provider: its id, name, login URL, an icon kind and the account-switch links.
Client ids, issuer URIs, endpoints and the provider type are not exposed.
Each provider renders as a full-width Sign in with <name> button. Icons are
derived from the type — GitHub, Google and Facebook get their glyph, everything
else a generic key. There is no ordering field and no per-provider colour;
buttons are deliberately neutral and appear in database order.
Users who need to switch upstream accounts get a Use a different account link,
which adds prompt=select_account for Google and Facebook and prompt=login
for OIDC and generic OAuth 2.0. Only those two prompt values are accepted;
anything else in the URL is ignored. GitHub gets the sign-out hint described
above.
Managing providers
Section titled “Managing providers”Updates are partial — only the fields you send change.
# Rotate the client secretcurl -X PATCH "https://qnop.example.com/api/v1/admin/oidc-providers/$ID" \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "clientSecret": "<new value>" }'
# Disable without deleting — hides the button, keeps the accountscurl -X PATCH "https://qnop.example.com/api/v1/admin/oidc-providers/$ID" \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "enabled": false }'Things worth knowing:
- The client secret is write-only. It is never returned; the API reports only whether one is set. Leaving the field blank on an update keeps the stored value — send a value only to rotate.
- The provider type cannot be changed. Switching between
OIDCandOAUTH2means deleting and recreating, which orphans the accounts provisioned through it. - Discovery is cached. It runs at startup and after any provider change, not per login, and has no expiry. If your identity provider moves an endpoint, save the provider again (or restart qnop) to pick it up.
- Updates are not validated as strictly as creation. Clearing a required field on an existing provider — the issuer URI, say — is accepted, and the provider then quietly stops working. See the phantom provider.
Deleting a provider removes its identity links but leaves the accounts behind. Those users have no password, so they cannot sign in; recreating the provider provisions new accounts rather than reconnecting the old ones. Disabling is almost always what you want instead.
Provider changes are not written to the audit trail.
The SSRF guard
Section titled “The SSRF guard”Before qnop fetches anything from an operator-supplied URL, it checks the host.
Rejected are localhost, any host ending .localhost, .local or .internal,
and IP literals in loopback, link-local (including the cloud metadata
address 169.254.169.254), private and unique-local ranges.
The check runs when you save a provider, when you use the probe endpoint, and again every time the registration cache is rebuilt — so a row edited directly in the database is caught too.
Hostnames are deliberately not resolved, to avoid a DNS-rebinding window.
A public name that resolves to a private address is therefore not caught, and a
Docker service name such as keycloak passes.
QNOP_AUTH_OIDC_ALLOW_PRIVATE_DISCOVERY_URIS=true disables the host check
entirely and application-wide — there is no per-provider exception. It exists
for local development. Keep it false in production: with it on, anyone who
can add a provider can make the server issue requests to your internal network
and cloud metadata service.
Security and best practices
Section titled “Security and best practices”- Restrict at the identity provider, not in qnop. Enabling a provider lets
anyone who can authenticate there create an account, as
MEMBER. - Review new accounts. Nothing gates provisioning, and nothing announces it beyond a log line.
- Rotate client secrets with a
PATCH; the next authorization request picks the new value up without a restart. - Treat
QNOP_AUTH_ENCRYPTION_KEYand its salt as long-lived. Changing them makes every stored client secret unreadable, and there is no re-encryption tool — recovery is re-entering each secret by hand. - Ask for the least scope that yields an e-mail — for
OIDC,openid email profilealready is that. - Tell users the one-account-per-provider rule before rollout. Signing in through a second provider silently creates a second account.
Troubleshooting
Section titled “Troubleshooting”The identity provider shows “invalid redirect_uri”
Section titled “The identity provider shows “invalid redirect_uri””The callback URL it received does not match what is registered. Check, in order:
- the UUID — it must be the live provider id, and it changes if you delete and recreate;
- scheme and host — behind a proxy without
QNOP_HTTP_FORWARD_HEADERS_STRATEGY=framework, qnop builds an internalhttp://…:8080/…URL; - exact match — some providers compare strictly, including the trailing slash.
qnop logs nothing here: the provider rejects the request before the browser ever comes back.
“The identity provider did not share an email address”
Section titled ““The identity provider did not share an email address””The login reached qnop but carried no usable e-mail, and no account was created.
| Provider | Usual cause |
|---|---|
| Keycloak, generic OIDC | email missing from the scope, or no e-mail mapper on the client |
| Entra ID | guest account with no mail attribute populated |
| Auth0 | the connection does not populate email |
the consent screen does not request email | |
| GitHub | the account has no verified address — the private-address case is handled automatically |
the app has not passed review for the email permission | |
Generic OAUTH2 | emailAttribute does not match the user-info response |
A provider button that fails with a server error
Section titled “A provider button that fails with a server error”The login page lists providers from the database, but the handshake uses a registration cache built at startup. If building the registration failed — discovery unreachable, the issuer URI cleared by an update, a host now blocked by the SSRF guard, a client secret that no longer decrypts — the provider is skipped from the cache while the button keeps rendering. Clicking it produces a server error rather than the friendly login-page message.
The only signal is a warning in the server log:
Skipping OIDC provider <name> (registrationId=<uuid>): <reason>Check the log after any change to a provider, after an encryption-key change, and after a restart.
“Sign-in via the identity provider failed”
Section titled ““Sign-in via the identity provider failed””The generic failure: a wrong client secret, a failed token exchange, ID-token validation problems, or a callback replayed against a provider that has since been disabled. The server log carries the reason:
OIDC login could not be completed: <message>An API call returns 400 with no explanation
Section titled “An API call returns 400 with no explanation”Validation of URIs and the SSRF rejection attach their reason as the response reason phrase, which Spring Boot does not include in the body by default. The UI then shows a generic message. Field-level validation errors — a name that is too long, a missing client id — do come back with per-field detail.
If a save is rejected without explanation, suspect the SSRF guard or a malformed URI first.
A user ended up with two accounts
Section titled “A user ended up with two accounts”Expected: they signed in through a second provider, or through SSO after having a local account with the same address. There is no linking. Decide which account is canonical and disable the other.
Login worked, but the user has no permissions
Section titled “Login worked, but the user has no permissions”Every SSO user arrives as MEMBER. Roles are not mapped from claims — promote
them under Administration → Users.
Local development with Keycloak
Section titled “Local development with Keycloak”The main repository ships a Keycloak profile with a pre-configured realm:
cp .env.example .envdocker compose --profile keycloak up -d| Admin console | http://localhost:8081 — admin / admin |
| Issuer URI | http://localhost:8081/realms/qnop |
| Client id | qnop-local |
| Client secret | local-dev-secret-do-not-use-in-prod |
| Test users | alice / password, bob / password |
Register it in qnop as type OIDC. The realm’s redirect URIs are wildcards, so
the callback works before you know the provider UUID.
This profile is loopback-bound and uses published credentials. It is for a laptop, never for a shared environment.