Identity gives every user in your org one account that works on every app. They sign in once at identity.{org}.local; your app just asks “who is this, and what’s their role here?”. This page gets you from zero to a gated app — for the why behind it, see Sessions & SSO.
What works today
| Capability | Status |
|---|---|
| Server-side sessions (revocable, 8h TTL) | ✅ Live |
SSO across all *.{org}.local apps | ✅ Live |
| Per-app roles, default-deny | ✅ Live |
User self-registration at identity.{org}.local | ✅ Live — but new users have zero app access until an admin grants a role in the Console |
| Mobile Safari support (Bearer fallback) | ✅ Live — automatic in SDK ≥ 0.1.1 |
| OIDC / external identity providers | ⏳ Not yet — shared-cookie SSO only |
Get up and running
1. Install the SDK
npm install @nublestation/identity
# or
pnpm add @nublestation/identity
2. Create the client
Create it once at module level and reuse it everywhere:
import { createIdentityClient } from "@nublestation/identity";
export const auth = createIdentityClient({
url: import.meta.env.VITE_NUBLESTATION_URL, // Gateway — http://api.{org}.local
identityUrl: import.meta.env.VITE_NUBLESTATION_IDENTITY_URL, // Identity pages — http://identity.{org}.local
app: import.meta.env.VITE_NUBLESTATION_APP, // your app's slug from the Console
});
Set the three env vars in your .env (Vite bakes them in at build time):
| Variable | Example |
|---|---|
VITE_NUBLESTATION_URL | http://api.clinic.local |
VITE_NUBLESTATION_IDENTITY_URL | http://identity.clinic.local |
VITE_NUBLESTATION_APP | tasks |
Two URLs because programmatic calls (/v1/auth/*) go through the Gateway, while sign-in is a full-page navigation to the Identity pages.
3. Check the session
getSession() returns one of three states — handle all of them:
const session = await auth.getSession();
switch (session.status) {
case "authenticated":
// session.user.role is set — render the app
console.log(`Hi ${session.user.email}, role: ${session.user.role}`);
break;
case "forbidden":
// Signed in, but no role on THIS app — show "ask an admin", don't loop to login
break;
case "unauthenticated":
auth.login(); // redirect to the SSO sign-in, returns to the current URL after
break;
}
4. Wire sign-in and sign-out
auth.login(); // navigate to identity.{org}.local/authorize for this app
await auth.logout(); // revoke the session server-side, then go to the Identity login page
That’s it — your app is gated. Using React? Copy the canonical useIdentity hook and AuthGate component from the SDK reference.
Everyday usage
Guard a route imperatively
requireUser() resolves with the user when signed in and allowed on this app; otherwise it redirects to sign-in for you:
const user = await auth.requireUser();
// code below only runs for an authorized user
// Custom handling for signed-in-but-no-access:
await auth.requireUser({ onForbidden: (u) => showNoAccessScreen(u) });
Check a role
await auth.hasAccess(); // any role on this app
await auth.hasAccess("admin"); // require this exact role
Build a “share with…” picker
listAppUsers() returns everyone with access to this app, minus the caller — exactly the set you can share with:
const users = await auth.listAppUsers();
// [{ id, email, displayName, avatarUrl, role }, ...]
Handle the forbidden state
A user can be signed in yet denied: access is default-deny per app. Show them “ask an admin to grant you access” — an admin then grants a role in the Console under App → Users. Don’t redirect them to login; they’re already logged in.
Reference
SDK methods
| Method | Returns | Use for |
|---|---|---|
getUser() | IdentityUser | null | ”Who is signed in” UI, ignoring app access |
getSession() | { status, user? } — authenticated / forbidden / unauthenticated | Gating your app (primary call) |
isAuthenticated() | boolean | Quick “any session?” check |
hasAccess(role?) | boolean | Signed in + has access (optionally an exact role) |
requireUser(opts?) | IdentityUser (or redirects) | Imperative route guard |
listAppUsers() | AppUser[] | Share pickers, member lists |
loginUrl(redirectUri?) | string | Sign-in URL for an href |
login(redirectUri?) | void (navigates) | Sign-in button |
logout(redirectTo?) | Promise<void> (navigates) | Sign-out button |
HTTP endpoints
All JSON calls go through the Gateway at api.{org}.local. The session rides the cookie (or the Bearer fallback) — no API key needed on /v1/auth/*.
| Method | Path | Statuses | Description |
|---|---|---|---|
GET | /v1/auth/me | 200, 401 | Current user, no app context (role is null) |
GET | /v1/auth/me?app={slug} | 200, 401, 403, 404 | Current user + role on that app; 403 forbidden if no role, 404 unknown_app if the slug doesn’t exist |
GET | /v1/auth/app-users?app={slug} | 200, 401, 403, 404 | Users with access to the app, minus the caller |
POST | /v1/auth/logout | 200 | Revoke the session, clear the cookie |
User-facing pages (/login, /register, /authorize, /account) are served directly at identity.{org}.local — your app never renders them.
Limits & gotchas
- No
localhostauth. The session cookie is scoped to.{org}.localand is never sent from Vite’s dev server. Develop UI with mocked state; test the real flow on your deployed app at{app}.{org}.local. - Mobile Safari drops the cross-subdomain cookie (
.localisn’t a registrable TLD for it). SDK ≥ 0.1.1 handles this automatically via a Bearer-token fallback — nothing to do on your side. Details in Sessions & SSO. - Granting access is manual. Self-registration works, but a new user sees
forbiddenon every app until an admin grants a role in the Console. There is no self-serve access request yet. 404 unknown_appfrom any call means yourappslug doesn’t match the Console — checkVITE_NUBLESTATION_APP.