Skip to content

OIDC / OAuth 2.0 Providers

Plugwerk supports browser-based "Sign in with …" flows against external OpenID Connect and OAuth 2.0 providers. This page is the operator-facing setup guide: how to register a client application at each provider, how to enter the credentials in Plugwerk, and how the Plugwerk-specific identity model behaves on first login.

Plugwerk uses Spring Security's built-in oauth2Login to perform a token-exchange:

  1. The user clicks Sign in with <Provider> on the Plugwerk login page.
  2. The browser is redirected to the provider's authorize endpoint with a PKCE-protected authorization request.
  3. The provider authenticates the user, then redirects back to Plugwerk's callback URL with an authorization code.
  4. Plugwerk's backend exchanges the code for tokens server-side, validates the ID token, and either creates or looks up a plugwerk_user row.
  5. From this point the session is byte-for-byte identical to a local-login session — same Plugwerk-issued JWT, same refresh cookie. The OIDC token is discarded.

The Plugwerk callback URL the provider must redirect to is:

https://<your-plugwerk-host>/login/oauth2/code/<oidc_provider.id>

<oidc_provider.id> is the UUID Plugwerk assigns when you register the provider. Because that UUID does not exist until you save the provider in Plugwerk, the practical workflow is:

  1. Stub the provider in Plugwerk first (any callback URL works for the save).
  2. Copy the assigned UUID and build the full callback URL.
  3. Enter that callback URL at the IdP.
  4. Test the login. If the IdP requires the URL up front, use a wildcard pattern (https://<plugwerk-host>/login/oauth2/code/*) where the provider supports it, or repeat the round-trip.

Three policies are baked into the schema and worth understanding before you onboard users:

  • One identity per (provider, sub). Each OIDC subject maps to exactly one Plugwerk user. A UNIQUE(user_id) constraint on oidc_identity enforces this at the database level.
  • No cross-provider linking. A user who first signs in via Keycloak and later via GitHub gets two unrelated Plugwerk accounts. This is by design — the no-linking policy makes auto-provisioning safe and avoids merge foot-guns. Communicate one provider per user where possible.
  • JIT user has no namespace memberships. A first successful OIDC callback creates a plugwerk_user row with enabled=true, is_superadmin=false, and zero namespace roles. The user lands on a welcome page asking them to contact an administrator. A namespace admin (or a superadmin) then grants access via POST /api/v1/namespaces/{ns}/members.

The email claim is mandatory — see Troubleshooting for the provider-specific failure modes.

Plugwerk's OidcProviderType enum has five values. Pick the type that matches your IdP — the required fields differ per type.

| Type | Use for | Required fields besides name / clientId / clientSecret | | ---------- | ---------------------------------------------------- | -------------------------------------------------------------------- | | OIDC | Any standards-compliant OIDC IdP — Keycloak, Authentik, Auth0, Dex, Microsoft Entra ID, Google Workspace via OIDC, … | issuerUri (Plugwerk auto-discovers all endpoints from ${issuerUri}/.well-known/openid-configuration) | | GITHUB | GitHub OAuth Apps | None — endpoints are hard-wired | | GOOGLE | Google OAuth (consumer + Workspace) | None — endpoints are hard-wired | | FACEBOOK | Facebook Login | None — endpoints are hard-wired | | OAUTH2 | OAuth 2.0 sources without OIDC discovery (GitLab, Bitbucket, custom enterprise IdPs) | authorizationUri, tokenUri, userInfoUri; optionally jwkSetUri and the three attribute names |

For OIDC-compliant providers (which includes most modern IdPs), prefer type OIDC over OAUTH2 — discovery removes four configuration knobs and gives Plugwerk JWKS rotation for free.

  • HTTPS in production. Browsers and most IdPs refuse non-HTTPS callback URLs. Configure your reverse proxy first (see Deployment) and set PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS so the rate-limit buckets stay correct.
  • PLUGWERK_AUTH_ENCRYPTION_KEY is set (see Configuration). Plugwerk encrypts every clientSecret at rest with AES-256-CBC; without the key, provider creation fails. Rotating the key invalidates every stored secret in the database, so plan a re-rotation across all configured providers if you ever change it.
  • Reachable discovery URL. For type OIDC, the server fetches ${issuerUri}/.well-known/openid-configuration at provider creation and at every login — the issuer must be reachable from the Plugwerk container.
  • Publicly resolvable issuer host. Plugwerk's SSRF guard rejects private (RFC 1918), loopback, link-local, and metadata-service hosts at write time and at every registry refresh. For local development against an IdP on localhost, see the Local Keycloak for development Aside below; for the full configuration surface (the two override blocklists and the escape hatch), see OIDC SSRF guard.

You can register providers via the Admin UI or via the REST API. Both write to the same oidc_provider table; the choice is operational preference.

  1. Sign in as a superadmin and open Settings → OIDC Providers.
  2. Click Add provider and pick the provider type.
  3. Fill in name (the label shown on the login page), clientId, and clientSecret. For type OIDC also enter the issuerUri.
  4. Save. Plugwerk assigns the provider a UUID.
  5. Copy the UUID and build the callback URL: https://<your-plugwerk-host>/login/oauth2/code/<uuid>.
  6. Go to the IdP and enter that callback URL on the client-app configuration.
  7. Back in Plugwerk, enable the provider. It now appears as a button on the login page.

All /admin/oidc-providers endpoints require a superadmin Bearer token.

Probe an issuer URI before saving — useful to catch typos and unreachable IdPs without polluting the provider table:

Terminal window
curl -X POST "https://<plugwerk-host>/api/v1/admin/oidc-providers/discover" \
-H "Authorization: Bearer <superadmin-jwt>" \
-H "Content-Type: application/json" \
-d '{ "issuerUri": "https://keycloak.example.com/realms/plugwerk" }'

A successful response carries the four discovered endpoints (authorizationUri, tokenUri, userInfoUri, jwkSetUri); a failure response carries an error string with an actionable reason (DNS failure, 404, malformed JSON, …).

Create a provider:

Terminal window
curl -X POST "https://<plugwerk-host>/api/v1/admin/oidc-providers" \
-H "Authorization: Bearer <superadmin-jwt>" \
-H "Content-Type: application/json" \
-d '{
"name": "Keycloak",
"providerType": "OIDC",
"clientId": "plugwerk",
"clientSecret": "<copied from Keycloak>",
"issuerUri": "https://keycloak.example.com/realms/plugwerk",
"scope": "openid email profile"
}'

The response includes the assigned id (UUID) — that is the value to plug into the IdP callback URL.

Update fields later (partial — only the keys you include are changed):

Terminal window
curl -X PATCH "https://<plugwerk-host>/api/v1/admin/oidc-providers/<provider-uuid>" \
-H "Authorization: Bearer <superadmin-jwt>" \
-H "Content-Type: application/json" \
-d '{ "clientSecret": "<rotated value>" }'

providerType is intentionally not editable — to switch from OAUTH2 to OIDC (or vice versa) you must delete and recreate the provider.

Each section has two parts: A — Set up the client app at the provider, and B — Enter the credentials in Plugwerk. Provider UIs change frequently; the field labels below are stable but the navigation paths may shift slightly.

  1. Sign in to the Keycloak admin console and switch to the realm you want to use (or create one).
  2. Clients → Create client.
  3. Client type: OpenID Connect. Client ID: plugwerk (any string is fine; you will paste this back in Plugwerk).
  4. Client authentication: ON (Plugwerk needs a confidential client — public clients have no client secret).
  5. Authentication flow: Standard flow ON; everything else off unless you know you need it.
  6. Valid redirect URIs: https://<your-plugwerk-host>/login/oauth2/code/<plugwerk-provider-uuid> — or, during initial setup, the wildcard https://<your-plugwerk-host>/login/oauth2/code/*.
  7. Save. Open the Credentials tab and copy the Client secret.
  • Type: OIDC
  • Issuer URI: https://<keycloak-host>/realms/<realm-name>
  • Client ID: plugwerk (or whatever you set in step 3)
  • Client secret: the value from the Credentials tab
  • Scope: keep the default openid email profile
  1. Auth0 Dashboard → Applications → Create Application.
  2. Name the application; choose Regular Web Application.
  3. Open the application's Settings.
  4. Allowed Callback URLs: https://<your-plugwerk-host>/login/oauth2/code/<plugwerk-provider-uuid>.
  5. (Optional) Allowed Logout URLs: https://<your-plugwerk-host>/login so RP-Initiated Logout returns to your login page.
  6. Save changes. Note the Domain, Client ID, and Client Secret at the top of the Settings page.
  • Type: OIDC
  • Issuer URI: https://<your-tenant>.auth0.com/ (note the trailing slash — Auth0 includes it in the iss claim)
  • Client ID and Client Secret: copied from Auth0
  • Scope: openid email profile
  1. Azure Portal → Microsoft Entra ID → App registrations → New registration.
  2. Name the application. Pick the Supported account types that match your tenant policy.
  3. Redirect URI: platform Web, value https://<your-plugwerk-host>/login/oauth2/code/<plugwerk-provider-uuid>.
  4. Register. From the Overview page, copy the Application (client) ID and the Directory (tenant) ID.
  5. Certificates & secrets → Client secrets → New client secret. Copy the Value immediately — it is shown only once.
  6. API permissions: the default Microsoft Graph → User.Read is enough; add email and profile only if your tenant overrides the defaults away from them.
  • Type: OIDC
  • Issuer URI: https://login.microsoftonline.com/<tenant-id>/v2.0
  • Client ID: the Application (client) ID
  • Client Secret: the secret Value
  • Scope: openid email profile

A — Create the OAuth client in Google Cloud

Section titled “A — Create the OAuth client in Google Cloud”
  1. Google Cloud Console → APIs & Services → OAuth consent screen. Configure user type, app info, and add email to the requested scopes if it isn't already implicit. Without this step the credential creation form is locked.
  2. APIs & Services → Credentials → Create Credentials → OAuth client ID.
  3. Application type: Web application.
  4. Authorized redirect URIs: https://<your-plugwerk-host>/login/oauth2/code/<plugwerk-provider-uuid>.
  5. Create. Copy the Client ID and Client secret from the dialog.
  • Type: GOOGLE (endpoints are hard-wired, no issuerUri needed)
  • Client ID and Client Secret: copied from Google
  • Scope: keep the default openid email profile
  1. GitHub → your profile menu → Settings → Developer settings → OAuth Apps → New OAuth App.
  2. Application name and Homepage URL (https://<your-plugwerk-host>).
  3. Authorization callback URL: https://<your-plugwerk-host>/login/oauth2/code/<plugwerk-provider-uuid>.
  4. Register. From the app page, Generate a new client secret and copy the value immediately.
  5. Copy the Client ID as well.
  • Type: GITHUB
  • Client ID and Client Secret: copied from GitHub

Facebook is supported as type FACEBOOK and the setup is mechanically identical to Google: create an app in the Facebook for Developers console, add a redirect URI, copy the App ID and App Secret. The first-login email failure mode is also similar (Facebook App Review must approve the email permission for your app before it returns the claim). Use only when your audience genuinely sits on Facebook accounts; for B2B Plugwerk instances Keycloak / Entra ID / Google Workspace are the more common picks.

Generic OAuth 2.0 (GitLab, Bitbucket, custom IdPs)

Section titled “Generic OAuth 2.0 (GitLab, Bitbucket, custom IdPs)”

Use type OAUTH2 for any OAuth 2.0 source that is not OIDC-compliant or that the four hard-wired vendors above don't cover. You configure all four endpoints manually plus the JSON attribute names that user-info uses.

Terminal window
curl -X POST "https://<plugwerk-host>/api/v1/admin/oidc-providers" \
-H "Authorization: Bearer <superadmin-jwt>" \
-H "Content-Type: application/json" \
-d '{
"name": "GitLab",
"providerType": "OAUTH2",
"clientId": "<gitlab-application-id>",
"clientSecret": "<gitlab-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",
"subjectAttribute": "id",
"emailAttribute": "email",
"displayNameAttribute": "name"
}'

subjectAttribute, emailAttribute, displayNameAttribute default to sub, email, name respectively if omitted. They are only meaningful for type OAUTH2; for type OIDC the standard claim names are used and these fields are ignored.

For GitLab and other providers that do expose an OIDC discovery document (https://gitlab.com/.well-known/openid-configuration), prefer type OIDC — fewer knobs, JWKS rotation handled automatically.

The public GET /api/v1/config endpoint returns enabled providers in the order they were created. For each provider the SPA renders a Sign in with <name> button using the provider's iconKind (github, google, facebook, oidc, oauth2) for the glyph.

Each button hits GET /oauth2/authorization/{provider-uuid}, which is Spring Security's authorize-start endpoint. PKCE verifier and OAuth2 state are stashed in the HTTP session (httpOnly + SameSite + Secure in production) until the callback consumes them.

Users who want to sign in as a different upstream account (e.g. switching between two Google accounts) need the IdP to re-prompt instead of silently re-authenticating the existing session. Plugwerk surfaces an account-picker URL per provider that appends the right OIDC prompt parameter:

| Provider type | prompt value used | Notes | | ------------- | ---------------------- | ----------------------------------------------------- | | OIDC, GOOGLE, FACEBOOK | select_account | Standard OIDC prompt — most providers honour it | | OAUTH2 | login | Best-effort; some OAuth2 providers ignore prompt | | GITHUB | (none) | GitHub does not support prompt. Plugwerk surfaces a side-link to https://github.com/logout instead so the user can terminate the GitHub session before retrying |

First successful callback (JIT provisioning)

Section titled “First successful callback (JIT provisioning)”

On the first successful callback for a (provider, sub) pair Plugwerk creates:

  • a plugwerk_user row with display_name from the name claim (falling back to preferred_username, then subject)
  • an oidc_identity row that ties this Plugwerk user to the provider UUID + IdP subject
  • enabled=true, is_superadmin=false, no namespace memberships

The user lands on a welcome page that hides the "Create Namespace" button (non-superadmins cannot create namespaces) and displays a hint to ask an administrator. To grant access, a namespace admin runs:

Terminal window
curl -X POST "https://<plugwerk-host>/api/v1/namespaces/<ns-slug>/members" \
-H "Authorization: Bearer <admin-jwt>" \
-H "Content-Type: application/json" \
-d '{ "userId": "<plugwerk-user-uuid>", "role": "MEMBER" }'

(Roles: ADMIN, MEMBER, READ_ONLY — see Concepts.)

POST /api/v1/auth/logout is provider-aware:

  • Local sessions204 No Content. The SPA clears its store and routes to /login.
  • OIDC sessions200 OK with LogoutResponse{ endSessionUrl }. The SPA navigates window.location.assign(endSessionUrl). The IdP destroys its own session, then bounces the browser back to ${plugwerk-host}/login via the post_logout_redirect_uri parameter Plugwerk fills in. Without this navigation the next "Sign in with <provider>" click would silently re-authenticate the same user.

Providers without an end_session_endpoint (vanilla OAuth2 surfaces) get a graceful fallback: the backend returns 204 and there is no IdP-side cleanup. This is documented as a known limitation for type OAUTH2.

Only the fields you include change. clientSecret is writeOnly — omit it (or send blank) to leave the stored value intact, send a value to rotate.

Terminal window
# Rotate the client secret
curl -X PATCH "https://<plugwerk-host>/api/v1/admin/oidc-providers/<uuid>" \
-H "Authorization: Bearer <superadmin-jwt>" \
-H "Content-Type: application/json" \
-d '{ "clientSecret": "<new value>" }'
# Disable temporarily (keeps the row, hides the login button)
curl -X PATCH "https://<plugwerk-host>/api/v1/admin/oidc-providers/<uuid>" \
-H "Authorization: Bearer <superadmin-jwt>" \
-H "Content-Type: application/json" \
-d '{ "enabled": false }'

Constraints to be aware of:

  • providerType is not editable. To switch types: delete and recreate.
  • Changing clientId on an enabled provider invalidates every existing access token issued by that provider (the aud claim no longer matches). Users will be forced to sign in again.
  • For type OIDC, scope must include openid. Patches that violate this are rejected.
  • issuerUri and jwkSetUri cannot be cleared via PATCH. To remove either, delete and recreate the provider.

DELETE /api/v1/admin/oidc-providers/<uuid> removes the provider row. Plugwerk handles the dependent data conservatively:

  • The OIDC-identity records for this provider are removed.
  • The orphaned Plugwerk user accounts survive with enabled=false. Audit history (namespace memberships, refresh-token records, download events) is preserved.
  • Disabled users cannot log in. An administrator can review them and hard-delete them via the user-admin UI later.

This is the right behaviour for "we are rotating IdPs" or "this provider was a mistake" — it preserves the audit trail without keeping accounts that can no longer authenticate active.

  • HTTPS-only callbacks in production. Plain-HTTP redirect URIs are rejected by most modern IdPs and will leak authorization codes.
  • Rotate client secrets periodically via PATCH { "clientSecret": "..." }. Spring Security picks up the new value on the next authorize-start request — no server restart needed.
  • Minimum scope is openid email profile for type OIDC. Plugwerk needs email to provision the user; profile is what populates display_name. Don't grant scopes you don't use.
  • Treat PLUGWERK_AUTH_ENCRYPTION_KEY as a long-lived secret. Rotating it requires re-saving every provider's clientSecret — plan a maintenance window.
  • Communicate the one-provider-per-user policy to your users. Two providers = two accounts; account-linking is intentionally not supported.
  • Use the lab /discover endpoint before saving a Generic OIDC provider — it catches typos in issuerUri and unreachable IdPs without polluting your provider list.
  • Keep the OIDC SSRF guard on in production. PLUGWERK_AUTH_OIDC_ALLOW_PRIVATE_DISCOVERY_URIS defaults to false for a reason — the guard rejects metadata-service hosts (169.254.169.254, metadata.google.internal) that an attacker could otherwise use as the issuer URI of a malicious provider to coerce the server into making outbound requests against your cloud's instance metadata. The server logs a startup WARN line whenever the escape hatch is enabled. See OIDC SSRF guard for the configuration surface.
  • Consider enabling PLUGWERK_AUTH_OIDC_FAIL_FAST_ON_UNDECRYPTABLE_PROVIDERS=true in production. When the server cannot decrypt a stored client secret (typically after a PLUGWERK_AUTH_ENCRYPTION_KEY rotation without a re-encrypt step, a DB restore, or a deployment-environment switch), the default behaviour is to log the affected provider at ERROR and silently skip it — Sign-in with that provider then 4xx's at runtime while the server says UP. Fail-fast mode refuses to start until the affected secrets are re-entered or the previous key is restored, surfacing the misconfiguration in your deployment pipeline rather than at the user's login button. See OIDC encryption-key safety.

This is the most common first-login failure. Plugwerk requires an email for every user; a callback without one is rejected with HTTP 400 and a provider-aware message:

| Provider | Likely cause | Fix | | -------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Keycloak / Authentik / generic OIDC | email scope missing from the requested scope list, or the IdP is not configured to release the email mapper | Add email to scope; on Keycloak verify the email user attribute is mapped to the client | | Auth0 | Same — scope missing or the user has no email on file | Add email to scope; ensure the connection populates email | | Microsoft Entra ID | email scope missing, or the user is a guest account without a mail attribute set | Add email to scope; for guests the Object ID's mail field must be populated | | Google | OAuth consent screen does not request email | OAuth consent screen → add email and profile | | GitHub | The user's primary email is private | The user goes to Settings → Emails and unchecks "Keep my email addresses private", then retries the login | | Facebook | The Facebook App has not been approved for the email permission | Run the Facebook App Review for email permission | | Generic OAUTH2 | The configured emailAttribute is absent from the user-info response | PATCH the provider with the correct emailAttribute for your IdP |

The callback URL the IdP sees does not match what is registered on the client app. Check:

  • The Plugwerk provider UUID in the URL — it is the live UUID from oidc_provider.id, not a placeholder.
  • HTTPS vs HTTP — most IdPs reject mismatched schemes even on localhost.
  • Trailing slash — Plugwerk emits the URL without a trailing slash; some IdPs do strict-equality matching.

If the IdP supports wildcards in callback URLs, a temporary https://<plugwerk-host>/login/oauth2/code/* simplifies the round-trip during initial setup.

"Issuer URI host is blocked" / discovery refused

Section titled “"Issuer URI host is blocked" / discovery refused”

Plugwerk's SSRF guard rejected the host before the discovery request was made. Two paths:

  • Public IdP — the host matched one of the two configurable blocklists. Inspect PLUGWERK_AUTH_OIDC_BLOCKED_HOST_NAMES and PLUGWERK_AUTH_OIDC_BLOCKED_HOST_SUFFIXES. Note the replace-only semantics: if either variable is set, it has fully replaced the defaults — see OIDC SSRF guard.
  • Local IdP on localhost (development) — set PLUGWERK_AUTH_OIDC_ALLOW_PRIVATE_DISCOVERY_URIS=true on the Plugwerk server. The escape hatch must stay false in production.

"Sign in with <provider>" returns 4xx but the server is healthy

Section titled “"Sign in with <provider>" returns 4xx but the server is healthy”

The stored client secret cannot be decrypted with the current PLUGWERK_AUTH_ENCRYPTION_KEY — typically because the key rotated without re-encrypting existing secrets, a DB backup was restored under a different key, or the deployment-environment swapped the env var. Server health endpoints stay UP because other providers are unaffected; only the broken provider's login flow fails.

Check the server logs for the structured ERROR line:

Cannot decrypt OIDC provider '<name>' (registrationId=…) client secret. The data was
encrypted with a different key than the current PLUGWERK_AUTH_ENCRYPTION_KEY.

Two fixes:

  1. Re-enter the client secret in the admin UI under Settings → OIDC Providers (or via PATCH /api/v1/admin/oidc-providers/<uuid> with a fresh clientSecret).
  2. Restore the previous PLUGWERK_AUTH_ENCRYPTION_KEY value if the rotation was unintended.

To make this fail at boot rather than at the user's login button — recommended for production — set PLUGWERK_AUTH_OIDC_FAIL_FAST_ON_UNDECRYPTABLE_PROVIDERS=true. See OIDC encryption-key safety.

"Issuer mismatch" / token validation fails right after creation

Section titled “"Issuer mismatch" / token validation fails right after creation”

Plugwerk's runtime issuer check compares the iss claim in the ID token to the issuerUri field exactly. Common causes:

  • Auth0: trailing slash difference (https://tenant.auth0.com vs https://tenant.auth0.com/). Auth0 always sends the iss with a trailing slash — store it that way in Plugwerk.
  • Keycloak behind a reverse proxy: the issuer returned by .well-known/openid-configuration may be the internal hostname instead of the proxy hostname. Configure KC_HOSTNAME on Keycloak so the discovery document lists the externally reachable URL.

Run POST /admin/oidc-providers/discover with the suspect URI to see the full discovery document the server actually receives.

Login worked yesterday, fails today after a PATCH

Section titled “Login worked yesterday, fails today after a PATCH”

Changing clientId on an enabled provider invalidates every currently-valid OIDC-issued access token (aud claim mismatch). Users must sign in again. This is expected and not a bug.

Plugwerk emits OIDC login events at INFO level (OidcLoginSuccessHandler) and rejected callbacks at WARN. See Monitoring for the log scrape setup. Spring Security's OAuth2LoginAuthenticationFilter also emits its own DEBUG entries — enable with logging.level.org.springframework.security.oauth2=DEBUG if you need wire-level detail.