Skip to content

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.

  1. The user clicks Sign in with <name> on the login page, which is a link to /oauth2/authorization/<provider-uuid>.
  2. qnop redirects the browser to the provider’s authorization endpoint with a PKCE-protected request.
  3. The provider authenticates the user and redirects back to qnop’s callback with an authorization code.
  4. qnop exchanges the code for tokens server-side, validates the ID token where one is issued, and resolves or creates the user.
  5. 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.

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.

Five types exist. What you must supply differs sharply between them.

TypeUse forEndpointsYou supply
OIDCany standards-compliant provider — Keycloak, Entra ID, Auth0, Authentik, Okta, …discovered from issuerUriname, client id, client secret, issuer URI
GOOGLEGoogle (consumer and Workspace)discovered from a hard-coded Google issuername, client id, client secret
GITHUBGitHub OAuth appshard-codedname, client id, client secret
FACEBOOKFacebook Loginhard-codedname, client id, client secret
OAUTH2OAuth 2.0 sources without OIDC — GitLab, Bitbucket, in-houseall operator-suppliedname, 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.

TypeDefault scopeConfigurable
OIDC, GOOGLE, OAUTH2openid email profileyes
GITHUBread:user user:emailno — hard-coded
FACEBOOKpublic_profile emailno — hard-coded

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.

  • HTTPS in production. The refresh cookie is Secure, and most providers refuse plain-HTTP callbacks.
  • QNOP_HTTP_FORWARD_HEADERS_STRATEGY=framework if you terminate TLS at a proxy — see the warning above.
  • The three secrets are set, QNOP_AUTH_ENCRYPTION_KEY in 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.
  1. Sign in as ADMIN and open Administration → OIDC providers.
  2. 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.
  3. For type OIDC, use Discover to verify the issuer before saving. It fetches the discovery document and reports what it found without creating anything.
  4. Save. qnop assigns the UUID and shows the callback URL to register upstream.
  5. Register that callback URL at the identity provider.
  6. 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:

Terminal window
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.

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.

  1. Admin console → select or create your realm → Clients → Create client.
  2. Client type: OpenID Connect. Client ID: e.g. qnop.
  3. Client authentication: ON — qnop needs a confidential client.
  4. Standard flow on; the rest off unless you know otherwise.
  5. Valid redirect URIs: your callback URL, or https://<qnop-host>/login/oauth2/code/* while setting up.
  6. Save, open Credentials, copy the client secret.
  • Type: OIDC
  • Issuer URI: https://<keycloak-host>/realms/<realm>
  • Client id / secret: from steps 2 and 6
  • Scope: keep openid email profile
  1. Azure portal → Microsoft Entra ID → App registrations → New registration.
  2. Give it a name; choose the account types your tenant needs.
  3. Redirect URI: platform Web, value = your qnop callback URL.
  4. Register, then note the Application (client) ID and Directory (tenant) ID.
  5. Certificates & secrets → New client secret; copy the value immediately.
  • 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.

  1. Auth0 dashboard → Applications → Create Application → Regular Web Application.
  2. In Settings, set Allowed Callback URLs to your qnop callback URL.
  3. Save; note the Domain, Client ID and Client Secret.
  • Type: OIDC
  • Issuer URI: https://<tenant>.auth0.com/with the trailing slash, because that is what Auth0 puts in the iss claim
  • Client id / secret: from step 3
  1. Google Cloud console → APIs & Services → Credentials → Create credentials → OAuth client ID.
  2. Application type Web application.
  3. Authorized redirect URIs: your qnop callback URL.
  4. Create, then copy the client id and client secret.
  5. On the OAuth consent screen, make sure email and profile are requested.
  • Type: GOOGLE
  • Client id / secret: from step 4

Nothing else is needed — the endpoints are fixed. Any issuer URI you enter is ignored.

  1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
  2. Homepage URL: your qnop URL.
  3. Authorization callback URL: your qnop callback URL.
  4. Register, generate a client secret, and copy it immediately.
  • 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.

For providers that are not OIDC-compliant. You supply all three endpoints and, usually, the attribute names.

Terminal window
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.

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.

Updates are partial — only the fields you send change.

Terminal window
# Rotate the client secret
curl -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 accounts
curl -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 OIDC and OAUTH2 means 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.

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.

  • 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_KEY and 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 profile already is that.
  • Tell users the one-account-per-provider rule before rollout. Signing in through a second provider silently creates a second account.

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 internal http://…: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.

ProviderUsual cause
Keycloak, generic OIDCemail missing from the scope, or no e-mail mapper on the client
Entra IDguest account with no mail attribute populated
Auth0the connection does not populate email
Googlethe consent screen does not request email
GitHubthe account has no verified address — the private-address case is handled automatically
Facebookthe app has not passed review for the email permission
Generic OAUTH2emailAttribute 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.

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.

The main repository ships a Keycloak profile with a pre-configured realm:

Terminal window
cp .env.example .env
docker compose --profile keycloak up -d
Admin consolehttp://localhost:8081admin / admin
Issuer URIhttp://localhost:8081/realms/qnop
Client idqnop-local
Client secretlocal-dev-secret-do-not-use-in-prod
Test usersalice / 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.