shinyhub-identity
Read the signed identity ShinyHub forwards to your app, in one call. No per-app JWT plumbing.
ShinyHub injects a short-lived, per-app HS256 JWT
(X-Shinyhub-Identity-Token) into every request it proxies, and hands your app
its verification key via SHINYHUB_IDENTITY_KEY and SHINYHUB_APP_SLUG. This
package verifies that token and returns the identity.
pip install shinyhub-identity
# or: uv add shinyhub-identity
Use it
from shinyhub_identity.shiny import session_identity
def server(input, output, session):
user = session_identity(session) # None when anonymous
if user is None:
... # logged-out visitor
elif "platform-admins" in user.groups:
... # gate admin features on the VERIFIED groups
session_identity(session) verifies the session's handshake token once and
returns that answer for the session's life. That is a correctness property, not
a cache: ShinyHub binds identity at the WebSocket handshake, and the token it
forwarded there expires five minutes later, so re-verifying those headers from
a reactive starts failing part-way through a long session even though nothing
about the user changed.
Outside Shiny (Streamlit, Dash, FastAPI, ...) use the framework-free primitive,
which takes any header mapping - a Starlette/Flask request's headers, or a
plain dict - and verifies per request:
from shinyhub_identity import current_user
user = current_user(request.headers) # None when anonymous
Both return an Identity or None. Identity fields:
| Field | Type | Value |
|---|---|---|
user_id |
str |
Decimal user ID |
username |
str |
Username |
role |
str |
Platform role: viewer, developer, operator, or admin |
groups |
tuple[str, ...] |
Verified group names |
groups_truncated |
bool |
True when the group list was capped at 100 |
email |
str |
"" unless the deployment's IdP asserts one |
name |
str |
Display name; "" unless the IdP asserts one |
claims |
Mapping |
The raw verified JWT claims |
Key and slug default to the SHINYHUB_IDENTITY_KEY/SHINYHUB_APP_SLUG
environment variables ShinyHub injects; pass key=/slug= explicitly for
tests.
None means anonymous, and nothing else
A genuine anonymous visitor sends no token at all, so that is the only case
that returns None. A token that is present but fails verification is a broken
deployment - missing or wrong SHINYHUB_IDENTITY_KEY, audience or issuer
mismatch, an expired token, clock skew - and raises IdentityError instead. An
app that renders that as "logged out" hides the outage behind an empty
dashboard, which is exactly what this contract prevents.
from shinyhub_identity import IdentityError
try:
user = session_identity(session)
except IdentityError as e:
log.error("identity broken: %s", e) # e.reason, e.detail
raise
IdentityError.reason is a stable classification (the R helper uses the same
vocabulary, and a cross-language conformance test pins them together):
reason |
Meaning |
|---|---|
no_token |
nothing to verify (verify_token only; current_user returns None) |
no_key |
SHINYHUB_IDENTITY_KEY unset or empty |
bad_key |
SHINYHUB_IDENTITY_KEY is not valid hex |
no_slug |
SHINYHUB_APP_SLUG unset or empty |
bad_signature |
signed with a different key |
expired |
past its exp (tokens live 5 minutes) |
wrong_audience |
minted for a different app slug |
wrong_issuer |
iss is not shinyhub |
malformed |
unparseable, or missing the required exp claim |
IdentityError.detail carries the human-readable specifics for a log line.
Letting the error propagate is a reasonable default: the app is not going to
render anything trustworthy anyway.
Testing your app
Because verification is strict, a signed-in code path cannot be tested with a
made-up token string: it needs a genuinely valid one. shinyhub_identity.testing
mints those.
from shinyhub_identity.shiny import session_identity
from shinyhub_identity.testing import fake_session, identity_env
def test_admins_see_the_panel():
with identity_env():
session = fake_session(username="alice", groups=["platform-admins"])
assert session_identity(session).username == "alice"
identity_env() sets the two environment variables ShinyHub injects (and
restores them afterwards), so the app calls session_identity(session) exactly
as it does in production. fake_headers(...) is the equivalent for
current_user(headers), and an anonymous request is just {}.
Every rejection path is one argument, so a test names the thing that is wrong:
mint_token(expires_in=-60) # -> expired
mint_token(key=b"\xff" * 32) # -> bad_signature
mint_token(slug="other-app") # -> wrong_audience
mint_token(issuer="evil") # -> wrong_issuer
fake_headers(token="not-a-jwt") # -> malformed
The tokens are real, not stubs. They carry the same claim set the proxy
mints, including which claims are omitted when empty, so an app that reads
user.claims["email"] fails in a test exactly where it would fail in
production. ShinyHub's conformance suite checks this field by field against the
production minter and fails if either side drifts.
This module is for tests only. The default key is a fixed, published constant, so tokens minted with it are forgeable by anyone.
Local development
With no ShinyHub proxy in front there is no token, so the identity is always
None. Instead of writing a per-app mock, set:
export SHINYHUB_IDENTITY_DEV_USER=devlin
export SHINYHUB_IDENTITY_DEV_GROUPS="team-a, team-b" # optional
export SHINYHUB_IDENTITY_DEV_EMAIL=devlin@example.com # optional
export SHINYHUB_IDENTITY_DEV_NAME="Devlin Example" # optional
export SHINYHUB_IDENTITY_DEV_ROLE=admin # optional, default viewer
Both helpers then return a synthetic Identity marked with
claims == {"dev": True, ...}. This can never activate under a real
deployment: it only applies when no token arrived and
SHINYHUB_IDENTITY_KEY is absent, and ShinyHub always injects that key into
app processes.
Compatibility
Requires Python 3.10+.
This helper is versioned independently of the ShinyHub server: its version
tracks changes to this package's API, not the server's release train. Any
release verifies tokens from any ShinyHub v0.8.6 or later (the release
that introduced identity forwarding); the token contract is stable across
server releases. Claims a later server added (email, name) are simply
"" when an older server minted the token.
Upgrading from 0.3: additive only. shinyhub_identity.testing is new;
nothing else changed.
Upgrading from 0.2: a rejected token now raises IdentityError instead of
returning None with a warning, so code that treated None as "not logged in"
should either let the error propagate or catch it explicitly. Identity is
keyword-only now, and Shiny apps should call session_identity(session) rather
than current_user(session.http_conn.headers).
Why verify, not just read the plain headers?
ShinyHub also forwards convenience plain headers (X-Shinyhub-User, -Role,
-Groups, ...) and strips any client-supplied ones. But app processes listen on
host-local ports, so a co-located process can bypass the proxy and forge plain
headers. Anything that gates access must verify the token - which is exactly
what this package does. See ShinyHub's docs/identity.md for the full trust
model.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shinyhub_identity-0.4.0.tar.gz.
File metadata
- Download URL: shinyhub_identity-0.4.0.tar.gz
- Upload date:
- Size: 17.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a4d15d3bffbfd21e410bf8d2b768e3a4be610ab6ed1faeff7f6f2ee9ea5a948e
|
|
| MD5 |
b7bc280f1a433f4662e2aa7eab9c5f49
|
|
| BLAKE2b-256 |
df88fe45a4de7589ddf07a97a9289d6993c146c9cc5492ccb9878c81fb7a72f4
|
Provenance
The following attestation bundles were made for shinyhub_identity-0.4.0.tar.gz:
Publisher:
release.yml on rvben/shinyhub
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shinyhub_identity-0.4.0.tar.gz -
Subject digest:
a4d15d3bffbfd21e410bf8d2b768e3a4be610ab6ed1faeff7f6f2ee9ea5a948e - Sigstore transparency entry: 2527999690
- Sigstore integration time:
-
Permalink:
rvben/shinyhub@3da9db8ff0ad0aae0bd21be860d3d885269f0c52 -
Branch / Tag:
refs/tags/v0.11.18 - Owner: https://github.com/rvben
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3da9db8ff0ad0aae0bd21be860d3d885269f0c52 -
Trigger Event:
push
-
Statement type:
File details
Details for the file shinyhub_identity-0.4.0-py3-none-any.whl.
File metadata
- Download URL: shinyhub_identity-0.4.0-py3-none-any.whl
- Upload date:
- Size: 14.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6dfd1e6fb51fd12f1e4143dad16a20b0ecc30f327c96a052e407ce40a9fba6a7
|
|
| MD5 |
b6d1a231ede4bbc06a885cb7ea47ce1e
|
|
| BLAKE2b-256 |
09bc91c89bfc46064c86fc9ec285ee227ab04ea53866c4fbe2ade910ea754b1e
|
Provenance
The following attestation bundles were made for shinyhub_identity-0.4.0-py3-none-any.whl:
Publisher:
release.yml on rvben/shinyhub
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shinyhub_identity-0.4.0-py3-none-any.whl -
Subject digest:
6dfd1e6fb51fd12f1e4143dad16a20b0ecc30f327c96a052e407ce40a9fba6a7 - Sigstore transparency entry: 2527999749
- Sigstore integration time:
-
Permalink:
rvben/shinyhub@3da9db8ff0ad0aae0bd21be860d3d885269f0c52 -
Branch / Tag:
refs/tags/v0.11.18 - Owner: https://github.com/rvben
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3da9db8ff0ad0aae0bd21be860d3d885269f0c52 -
Trigger Event:
push
-
Statement type: