Native OIDC login (SSO without an external auth proxy)¶
ShinyHub can terminate OpenID Connect single sign-on itself. Enable it and the login page shows a "Sign in with SSO" button that runs the standard authorization-code flow against your identity provider, provisions the user, and issues a ShinyHub session. No external identity proxy is required.
This is the first-party alternative to the two proxy-based paths:
| You want | Use |
|---|---|
| ShinyHub to be the login surface, talking OIDC directly to your IdP | Native OIDC (this page) |
| An existing Caddy/nginx/Cloudflare/Authelia edge to authenticate and forward a user header | Forward-auth |
| LDAP or SAML fronted by an OIDC bridge, then native OIDC to ShinyHub | OIDC bridge |
Any OIDC-compliant provider works: Okta, Microsoft Entra ID (Azure AD), Google
Workspace, Keycloak, Auth0, Authelia, Authentik, and others exposing a discovery
document at {issuer_url}/.well-known/openid-configuration.
Whichever mode you run, apps receive the authenticated user's identity the same way - see Identity forwarding. Native OIDC and forward-auth feed the same downstream contract.
Configuration¶
# shinyhub.yaml
oauth:
oidc:
issuer_url: https://login.example.com # OIDC discovery root
client_id: shinyhub
client_secret: "..." # prefer SHINYHUB_OIDC_CLIENT_SECRET
callback_url: https://shiny.example.com/api/auth/oidc/callback
display_name: "Company SSO" # label on the login button
groups_claim: groups # ID-token claim holding group names
groups_scope: groups # optional extra scope to request
require_valid_groups: false # true => fail login on a malformed groups claim
auth:
oauth_default_role: viewer # role for a first-time SSO user
local_login: true # false => SSO-only (hide + reject password login)
group_role_mappings: # IdP group -> ShinyHub PLATFORM role
platform-admins: admin
data-team: operator
Enabling OIDC requires only issuer_url, client_id, client_secret, and
callback_url; the rest have defaults. display_name defaults to
"Sign in with SSO", groups_claim to groups.
Every option has an environment-variable equivalent (env wins over YAML):
| Variable | Description |
|---|---|
SHINYHUB_OIDC_ISSUER_URL |
Discovery root; enabling OIDC is gated on this being set |
SHINYHUB_OIDC_CLIENT_ID |
Client ID registered with the IdP |
SHINYHUB_OIDC_CLIENT_SECRET |
Client secret |
SHINYHUB_OIDC_CALLBACK_URL |
Redirect URI; must match one registered at the IdP. Path is always /api/auth/oidc/callback |
SHINYHUB_OIDC_DISPLAY_NAME |
Login-button text (default "Sign in with SSO") |
SHINYHUB_OIDC_GROUPS_CLAIM |
ID-token claim carrying group names (default groups) |
SHINYHUB_OIDC_GROUPS_SCOPE |
Extra OAuth2 scope to request (e.g. groups) |
SHINYHUB_OIDC_REQUIRE_VALID_GROUPS |
true to fail login on a malformed groups claim (default false) |
SHINYHUB_AUTH_OAUTH_DEFAULT_ROLE |
Role for a first-time SSO user (default viewer) |
SHINYHUB_AUTH_GROUP_ROLE_MAPPINGS |
group:role,group2:role2 |
SHINYHUB_AUTH_LOCAL_LOGIN |
false for SSO-only: hide and reject username/password login (default true) |
ShinyHub always requests the openid, email, and profile scopes (plus
groups_scope when set), so the email and name claims are available without
extra configuration on most IdPs.
Register the redirect URI at your IdP as <your-public-url>/api/auth/oidc/callback.
The path is fixed; only the scheme/host vary.
The login flow¶
- The login page calls
GET /api/auth/providers; when OIDC is enabled it renders a "Sign in with SSO" link to/api/auth/oidc/login. GET /api/auth/oidc/logingenerates a randomstate, stores it, sets a short-lived state-binding cookie, and redirects to the IdP authorization endpoint.- The IdP authenticates the user and redirects back to
GET /api/auth/oidc/callback?state=...&code=.... - The callback verifies the
state(server-side nonce and the binding cookie, constant-time), exchanges the code for tokens, verifies the ID token, provisions or finds the user, reconciles the role from groups, persists the display name and email, issues the session cookie, and redirects to/.
The full flow is exercised end to end against a mock IdP in
internal/api/oidc_e2e_test.go (TestOIDC_EndToEnd_*).
Claim-to-identity mapping¶
| Claim | Becomes | Notes |
|---|---|---|
sub |
Stable provider identity | The account key (oauth_accounts.provider_id); a sub is required |
email |
User email + username seed | Persisted; forwarded to apps as X-Shinyhub-Email. The local-part seeds the ShinyHub username |
name |
Display name | Persisted; forwarded to apps as X-Shinyhub-Name |
groups_claim (default groups) |
Group memberships | Drive the platform role (below) and reach apps verbatim (below) |
The ShinyHub username is derived once, at first login, from the available claims:
email local-part, else a slugified name, else oidc-<sub-prefix>. It is only a
handle; the durable identity is sub.
Groups and platform roles¶
IdP groups drive the user's global ShinyHub role through
auth.group_role_mappings. On each login ShinyHub reconciles the role to the
highest-ranked mapped group the user is in; a user in no mapped group gets
oauth_default_role (default viewer). Map a group to admin to grant platform
admin from group membership:
Safety and edge behavior:
- An absent groups claim never demotes. If the IdP omits the groups claim
entirely, ShinyHub leaves the user's existing role untouched (a missing claim is
not read as "no groups"). Verified by
TestOIDC_EndToEnd_AbsentGroupsClaimDoesNotDemote. - A malformed groups claim (present but not a string or array of strings) is
skipped with a warning by default, leaving the role unchanged. Set
require_valid_groups: trueto instead fail the login (HTTP 502) so a misconfigured IdP blocks sign-in rather than silently keeping a stale role. forward_auth.admin_groupsis a deprecated alias that merges intogroup_role_mappingsasrole: admin. Prefergroup_role_mappingsdirectly; it applies to both native OIDC and forward-auth.
Platform roles gate what a user can do in ShinyHub (deploy, manage users, etc.). They are deliberately separate from what an app does with the raw groups: apps receive the unmodified IdP group names via identity forwarding and run their own in-app RBAC.
SSO-only (disable password login)¶
To require SSO and remove the built-in username/password path entirely, set:
With this set:
- The login screen hides the password form and leads with the SSO button(s).
- The password endpoints (
/api/auth/login,/api/auth/session) reject with 403. This is enforced server-side, not just in the UI, so a client cannot bypass the IdP by POSTing credentials directly. (The UI hiding is driven off thelocalflag in/api/auth/providersand fails open - the server is the authoritative gate.)
Lockout guard. Startup fails fast if local_login is false and no SSO
login path (GitHub, Google, OIDC, or forward-auth) is configured, so you cannot
accidentally lock every user out. GitHub/Google count only when both a
client_id and a client_secret are set; OIDC's discovery is verified at
startup. The boot log then prints the SSO paths that were counted.
The guard checks that a provider is configured, not that it is working -
test SSO end to end before turning local login off. This matters most for
forward-auth: it authenticates a request only when the direct peer is in
server.trusted_proxies and the proxy sends the user header, neither of which
ShinyHub can verify at startup. An SSO-only server whose trusted_proxies does
not include the real edge proxy will 403 every login. (OAuth callbacks must also
be reachable at the registered redirect URI.)
Break-glass caveat. Disabling local login also disables the built-in admin's
password login (including any SHINYHUB_LOCAL_ADMIN_* break-glass account) and
the SHINYHUB_ADMIN_USER/_PASSWORD bootstrap login. Make sure at least one
admin can reach the platform through SSO (e.g. an IdP group mapped to admin via
group_role_mappings) before enabling SSO-only. To recover, set
SHINYHUB_AUTH_LOCAL_LOGIN=true and restart.
Sessions, cookies, and logout¶
A successful login sets the shiny_session cookie holding a signed HS256 JWT.
Cookie attributes, secure by default:
HttpOnly- not readable from JavaScript.SameSite=Lax- sent on top-level navigations (so the IdP redirect back carries it) but not on cross-site subrequests.Secure- set whenever the request is HTTPS. Behind a TLS-terminating proxy this is decided fromX-Forwarded-Proto, honored only from a configuredtrusted_proxiespeer (see below).- Lifetime - the JWT expires after 1 hour. It is renewed (slid forward) when
the dashboard polls
/api/auth/mewith a still-valid session, capped by a 12-hour absolute session age, after which the user must re-authenticate.
Logout (POST /api/auth/logout) ends the ShinyHub session: it revokes the
session token by its JTI (so the cookie cannot be replayed) and clears the cookie.
IdP-logout boundary (by design): ShinyHub does not perform RP-initiated (single) logout against the IdP. After a ShinyHub logout the browser may still hold an IdP session, so clicking "Sign in with SSO" again can re-authenticate without a password prompt. If you need the IdP session to end too, configure single-logout / a short session lifetime at the IdP; ShinyHub logging out never extends an IdP session, it only ends its own.
Behind a TLS-terminating reverse proxy¶
When ShinyHub runs behind a proxy that terminates TLS (the common deployment):
- Set
callback_url(and the IdP's registered redirect URI) to the external HTTPS URL, e.g.https://shiny.example.com/api/auth/oidc/callback. ShinyHub redirects to exactly this value; a mismatch with the IdP registration is the usual "redirect_uri mismatch" error. - Add the proxy's address to
server.trusted_proxiesso ShinyHub honorsX-Forwarded-Proto: httpsfrom it and marks the session cookieSecure. Without this, a proxied HTTPS login would set a non-Secure cookie. See Deploying behind a proxy.
server.base_url is unrelated to OIDC redirect construction; the redirect URI is
taken verbatim from callback_url.
Multiple replicas / high availability¶
Native OIDC login works across replicas with no sticky sessions:
- The session is a stateless signed JWT. Any control-plane replica that shares
the same
auth.secretcan verify a session another replica issued, so a load balancer may route requests freely. - The login
statenonce is stored in theoauth_statestable (works on both the SQLite and Postgres backends), so the replica that handles the callback validates the state the login replica created.
Keep auth.secret identical on every instance (it also signs sessions and derives
per-app identity keys). See HA data plane.
Troubleshooting¶
oidc: claims not set/ "failed to verify OIDC ID token": the IdP returned an ID token ShinyHub could not verify (wrongclient_id/audience, clock skew, or an unreadable claims payload). Confirmclient_idmatches the IdP client and clocks are in sync.- "redirect_uri mismatch": the IdP's registered redirect URI does not equal
callback_url. They must match exactly, including scheme and host. - Role not promoted from a group: confirm the IdP actually sends the
groups_claim(some IdPs require agroupsscope - setgroups_scope), and thatgroup_role_mappingsnames the group exactly. - Cookie not
Securebehind a proxy: add the proxy IP totrusted_proxies.