Organization Management
Overview
A Kinotic deployment hosts many customer organizations. Each org has its own users, applications, and (optionally) its own enterprise SSO configuration. This page describes how an org is created, who can log in to it, and how the OIDC plumbing is shared across orgs without leaking access between them.
System-level platform operators (the people who run kinotic-server itself) authenticate through a separate path that is not OIDC-based and is out of scope here.
Mental Model
Three persistent entities carry the auth state, and one short-lived entity bridges the social-signup flow:
| Entity | Purpose | Lifecycle |
|---|---|---|
KinoticSystem (singleton, id kinotic-system) | Holds the list of platform-wide OIDC configs (e.g. Google, Microsoft) shown as login/signup buttons to everyone | Bootstrapped from helm config at startup |
Organization | A customer org. Holds the list of its OIDC configs (typically one — the org's enterprise SSO) | Created at the end of signup |
IamUser | A scoped identity carried structurally by organizationId / applicationId. One row per (person, org) | Created during signup or auto-provisioned on first OIDC login |
OidcConfiguration | A reusable OIDC provider record (clientId, authority, etc.). Referenced by zero or more entities via oidcConfigurationIds | Created by PlatformOidcBootstrap (platform configs) or by org admins (per-org SSO) |
PendingRegistration | Holds the verified OIDC identity between an IdP callback and the user supplying an org name | Short-lived; deleted after /api/auth/org/signup/social/complete succeeds |
SignUpRequest | Holds the email-verification state between form submission and the verification click | Short-lived; deleted after /api/auth/org/signup/complete succeeds |
The relationship between OidcConfiguration and the scope that uses it is by reference, never embedded. The same Google config can be referenced by KinoticSystem.oidcConfigurationIds (so it shows as a button) and by no orgs, or by one org's SSO list and by no system entries — the config itself does not know which scope it serves.
Two distinct OIDC roles
| Role | Where the configId lives | What the user sees |
|---|---|---|
| Platform OIDC (social) | KinoticSystem.oidcConfigurationIds | A "Continue with Google/Microsoft/…" button on the login and signup pages |
| Per-org SSO (enterprise) | Organization.oidcConfigurationIds | No visible button — reached via the email-first lookup flow when their org has SSO configured |
There is no boolean flag distinguishing the two. The scope that references the config determines the role. An org admin who configures an SSO provider does not see it appear as a global login button; the platform operator who bootstraps a social provider does not affect any org's SSO settings.
Org Creation
There are two entry points, both producing an Organization and an admin IamUser scoped to it:
Email/password signup
1. User loads /signup, enters email + displayName
2. POST /api/auth/org/signup
3. SignUpService.initiateLocalSignUp:
- rejects if a sign-up is already pending for this email, or
if an IamUser already exists at ORGANIZATION scope for this email
- creates a SignUpRequest with a 24h verification token
- EmailService sends the verification link (logs it instead when email is disabled)
4. User clicks /signup/verify?token=<verificationToken> in their inbox
5. /signup/verify form (VerifyEmail.vue) prompts for orgName + password + confirm
6. POST /api/auth/org/signup/complete { token, orgName, orgDescription?, password }
7. SignUpService.completeLocalSignUp:
- validates token, rejects if expired
- creates Organization (auto-derived id from name)
- creates IamUser (authType=LOCAL, organizationId=org.id, applicationId=null, enabled=true)
- links Organization.createdBy = user.id
- creates IamCredential (bcrypt hash, separate index keyed by user.id)
- deletes the SignUpRequest
8. The gateway establishes the browser session (204 + Set-Cookie); the frontend then calls
userState.login() to open the realtime connection, authenticated by that session cookie.
Email verification is the security gate — no Organization or IamUser exists until the link is clicked. With KINOTIC_EMAIL_ENABLED=false (the local default) the verification URL is logged to the kinotic-server console instead of sent; copy it into the browser to finish the flow.
Social-IdP signup
1. User loads /signup, clicks "Continue with Microsoft"
2. POST /api/auth/org/signup/social/start/azure-ad
3. OrganizationSignupHandler.handleSocialStart:
- picks the platform OidcConfiguration whose provider key matches
(orgSignupOidcConfigurationService.findEnabledByProvider)
- generates state/nonce/PKCE, stashes them on the session cookie
- 302 to <authority>/authorize?...
4. User authenticates at the IdP
5. IdP returns to GET /api/auth/org/signup/social/callback/<configId>
6. OrganizationSignupHandler.handleSocialCallback → createPendingSignUp:
- validates state/PKCE, exchanges code for id_token + access_token
- rejects if email_verified=false in the id_token
- rejects with AccountExistsException if an IamUser already exists for (sub, configId)
- creates a PendingRegistration with the verified subject, configId, email, displayName
- 302 to /register?token=<verificationToken>
7. /register prompts for orgName (CompleteOrg.vue)
8. POST /api/auth/org/signup/social/complete { token, orgName, orgDescription? }
9. SignUpService.completeOidcWithNewOrg:
- validates the pending token
- creates Organization
- creates IamUser (authType=OIDC, organizationId=org.id, applicationId=null,
oidcSubject + oidcConfigId set, primary=true, enabled=true)
- links Organization.createdBy
- deletes the PendingRegistration
10. The gateway establishes the browser session (204 + Set-Cookie). CompleteOrg.vue then
calls userState.login() to open the realtime connection, authenticated by that session
cookie. No token travels in the URL.
The PendingRegistration is consumed once. The ?token= in the redirect to /register is the short-lived PendingRegistration verification token, not an auth credential — the actual login is established by the session cookie set when the org-naming POST succeeds.
User Login
Once an org exists, members log in through one of three converging paths:
Email-first lookup → password or SSO
The login page shows a single email field plus the platform OIDC buttons. Typing an email and submitting drives this:
1. POST /api/auth/org/login/lookup { email }
2. OrganizationLoginHandler.handleLookup → resolveSsoOrPassword:
- finds the user's IamUser at ORGANIZATION scope (iamUserService.findByEmail)
- if user.authType=OIDC AND the org has a live ORG_LOGIN OidcConfiguration:
generate state/nonce/PKCE, stash on session, return
{ "type": "sso", "redirect": "<authority>/authorize?..." }
(frontend follows the redirect via window.location)
- otherwise:
return { "type": "password" }
(frontend reveals the password field)
3. The "password" branch is deliberately ambiguous — it covers unknown email,
a local user, and a user whose SSO config has been deleted. This avoids
leaking which orgs use SSO via timing/responses.
A user may hold multiple IamUser rows (multi-org membership keyed by (oidcSubject, oidcConfigId)). The org switcher (post-login) is where they hop between them.
Completing the password branch
When lookup returns {type: "password"}, the frontend reveals the password field and posts email + password to the gateway, which verifies the credential and establishes a browser session — there is no client-held token. The fetch uses credentials: 'include' so the gateway's Set-Cookie is stored even cross-origin:
1. POST /api/auth/org/login { email, password } (credentials: 'include')
2. OrganizationLoginHandler.handleLogin → AuthEndpointSupport.handlePasswordLogin:
- LocalAuthenticationService.authenticateLocal(email, password)
finds the IamUser, requires authType=LOCAL + enabled,
loads IamCredential, verifies the bcrypt hash
- on success: establishSession(ctx, user) puts the user's Participant on the
Vert.x session (regenerating the session id), then 204 + Set-Cookie
- on any failure: 401 "Invalid credentials"
(deliberately generic — covers unknown email, wrong password,
OIDC user, disabled user)
3. Login.vue calls userState.login() → Kinotic.connect(createConnectionInfo()).
createConnectionInfo() sets only host/port/useSSL (no webSocketFactory), so the
WebSocket upgrade is authenticated by the session cookie set in step 2.
The SPA never exchanges the password for a token and never sends raw passwords over the WebSocket. Non-UI clients (CLI, automation) do not use this path — they authenticate directly at the upgrade instead: a Node client sets ConnectionInfo.webSocketFactory to send clientId / clientSecret plus the organizationId / applicationId scope headers.
Social button
The buttons are populated from GET /api/auth/org/login/providers, which lists the unique provider keys present in the platform social configs. Clicking a button:
1. POST /api/auth/org/login/social/start/google
2. OrganizationLoginHandler.handleSocialStart:
- finds the platform OidcConfiguration with provider="google"
(orgSignupOidcConfigurationService.findEnabledByProvider)
- same state/PKCE setup as signup, then 302 to the IdP
3. IdP returns to GET /api/auth/org/login/social/callback/<configId>
4. OrganizationLoginHandler.handleSocialCallback → AuthEndpointSupport.completeOidcLogin:
- validates state, exchanges code, validates id_token (sub + email_verified)
- looks up an IamUser by (oidcSubject, oidcConfigId)
- if none exists: 302 /login?error=no_account so the frontend can show
a "no account, sign up?" CTA (signup is a separate flow)
- if one exists (matched by (oidcSubject, oidcConfigId) at org scope):
establishSession(ctx, user), then redirectSuccess → 302 to the SPA root
with Set-Cookie. No token travels in the URL.
5. The SPA loads with the session cookie set; userState.login() opens the realtime
connection, authenticated by that cookie.
The email-first SSO branch (type: "sso") returns to GET /api/auth/org/login/sso/callback/:configId instead, but finishes the same way — establishSession + redirectSuccess.
The WebSocket upgrade (the final step in every path)
Authentication happens at the WebSocket upgrade (handshake), not in a STOMP CONNECT frame. How the upgrade is authenticated depends on the client:
| Client | Upgrade credentials |
|---|---|
| Browser SPA | Session cookie established by the REST login; ConnectionInfo carries only host/port/useSSL, no webSocketFactory |
| Node client, credentials | clientId, clientSecret, plus organizationId / applicationId scope headers (via KinoticOsCredentialsAuthProvider) |
| Node client, Bearer token | Authorization: Bearer <jwt> (via BearerTokenAuthProvider); a Kinotic JWT carries the organizationId / applicationId claims |
The browser SPA never holds a JWT — its login establishes a session cookie and that cookie authenticates the upgrade. The Bearer path is for non-browser clients: the CLI obtains a short-lived access token through the device-authorization grant (POST /api/auth/device/token), which mints a Kinotic JWT carrying sub / email / organizationId / applicationId. For that path the kinotic-server validates the JWT signature against its signing keys, asserts aud=kinotic (every Kinotic-minted JWT carries it, so a non-Kinotic token is rejected even if it shares a signing key), and creates the Session. The JWT TTL is 60s — long enough to open the WebSocket once, not long enough to be useful if leaked; the CLI persists a rotating refresh token to mint fresh access tokens.
Provider-Specific Quirks
OIDC is a standard, but providers diverge on a few details. The validation helpers in OAuth2AuthFactory (isIssuerValid, isEmailVerified) handle these declaratively — the provider key on OidcConfiguration selects the right behaviour. No provider needs handler-level branching.
| Provider key | iss shape | email_verified claim | Other notes |
|---|---|---|---|
google | Fixed https://accounts.google.com | Emitted as boolean — required true to accept | sub is per-OAuth-client pairwise (different Kinotic deployments see different subs for the same person — fine since we key on (sub, configId)) |
azure-ad (single tenant) | Fixed https://login.microsoftonline.com/<tenant-id>/v2.0 | Not emitted — email-presence is treated as verified (Entra verifies via tenant domain ownership) | Used by per-org SSO configs that pin to a specific Entra tenant |
azure-ad (multi-tenant /common or /organizations) | Per-user — substitutes user's home tenant id; we re-validate by extracting tid from the same signed JWT | Same as single-tenant — not emitted, presence trusted | Discovery doc returns a literal {tenantid} placeholder; we set validateIssuer=false and clear jwtOptions.issuer for this case so Vert.x's strict comparison doesn't reject |
apple | Fixed https://appleid.apple.com | Not emitted — presence trusted | Email is only present on first sign-in; later tokens omit it. Returning users are recognised by stable sub. May be a …@privaterelay.appleid.com private-relay address — still verified |
keycloak, auth0, okta, salesforce, amazon-cognito, oidc (generic) | Fixed (issuer URL of the realm/tenant) | Emitted as boolean — required true | Discovery + standard validation |
isEmailVerified and isIssuerValid are the only places these differences live. Adding a new provider that follows the standard set of conventions doesn't require code changes; only providers with non-standard quirks (Apple's first-login-only email, Microsoft's /common issuer template) need to be classified explicitly in those helpers.
Per-Org SSO Configuration
The data model already supports per-org SSO: an OidcConfiguration row whose configId is on Organization.oidcConfigurationIds will be picked up by the email-first lookup flow. The piece that's not built yet is the admin UI for an org admin to create that row and link it to their org.
For now, per-org SSO can be wired manually:
- Create the
OidcConfigurationdirectly in Elasticsearch (POST through the OpenAPI endpoint or via a migration). - Append its id to the org's
oidcConfigurationIds. - Add the redirect URI
https://<apiBaseUrl>/api/auth/org/login/sso/callback/<configId>to the IdP app registration. For same-origin deploys (kinotic.domain.apiBaseUrlunset) this falls back to<appBaseUrl>; for split-origin deploys (SPA on Static Web Apps, backend on AKS) it must be the backend's hostname so the IdP returns the browser to the kinotic-server pod, not the SPA.
A user who logs in via this path lands at the /api/auth/org/login/sso/callback/:configId handler — the IdP doesn't care that the configId is org-scoped instead of platform.
System Authentication
Kinotic does not use OIDC for system-level operators. The deferred plan is a separate authentication path (likely tied to infrastructure-level credentials) that does not flow through any of the routes documented above. Platform OIDC providers (KinoticSystem.oidcConfigurationIds) are intentionally limited to social providers for end-user self-service signup; they grant org-scoped access only.
Endpoint Reference
All routes mount under /api/* on the api-gateway port (default 58503). CORS for the SPA origin is applied at the router root. A Vert.x SessionHandler covers every /api/* route (and the STOMP WebSocket path), so the same session cookie carries the OIDC roundtrip state and the post-login identity; the cookie is HttpOnly, Secure, SameSite=Lax, with a configurable timeout (kinotic.api-gateway.session-timeout).
Routes are namespaced under /api/auth/.... Organization login and signup are the SPA's paths; the application-login, CLI device-grant, and invite routes are listed for completeness.
| Method | Path | Owner | Purpose |
|---|---|---|---|
GET | /api/auth/org/login/providers | OrganizationLoginHandler | Unique platform social provider keys for the button row |
POST | /api/auth/org/login/lookup | OrganizationLoginHandler | Email-first lookup; {type: "sso", redirect} or {type: "password"} |
POST | /api/auth/org/login | OrganizationLoginHandler | Email + password; on success establishes the browser session (204 + Set-Cookie) |
POST | /api/auth/org/login/social/start/:provider | OrganizationLoginHandler | Begin social-button login; redirects to the IdP |
GET | /api/auth/org/login/social/callback/:configId | OrganizationLoginHandler | Social IdP returns here; establishes session, 302 to the SPA root |
GET | /api/auth/org/login/sso/callback/:configId | OrganizationLoginHandler | Per-org SSO IdP returns here; establishes session, 302 to the SPA root |
POST | /api/auth/org/signup | OrganizationSignupHandler | Submit email + displayName; sends verification email |
POST | /api/auth/org/signup/complete | OrganizationSignupHandler | Verify token + orgName + password; creates Organization + admin IamUser; establishes session |
POST | /api/auth/org/signup/social/start/:provider | OrganizationSignupHandler | Begin social-IdP signup; redirects to the IdP |
GET | /api/auth/org/signup/social/callback/:configId | OrganizationSignupHandler | IdP returns here; creates PendingRegistration; redirects to /register |
POST | /api/auth/org/signup/social/complete | OrganizationSignupHandler | Consume PendingRegistration; create Org + IamUser; establishes session |
GET | /api/auth/app/:orgId/:appId/login/providers | ApplicationLoginHandler | Enabled OIDC configs the application references |
POST | /api/auth/app/:orgId/:appId/login/lookup | ApplicationLoginHandler | App-scoped email-first lookup |
POST | /api/auth/app/:orgId/:appId/login | ApplicationLoginHandler | App-scoped email + password; establishes session |
GET | /api/auth/app/:orgId/:appId/login/oidc/callback/:configId | ApplicationLoginHandler | App IdP returns here; establishes session |
POST | /api/auth/device/start | CliDeviceLoginHandler | OAuth 2.0 Device Authorization Grant — issue device/user codes |
POST | /api/auth/device/token | CliDeviceLoginHandler | Poll for approval; returns a Kinotic JWT access token + refresh token |
POST | /api/auth/device/refresh | CliDeviceLoginHandler | Rotate the refresh token for a fresh access/refresh pair |
GET | /api/auth/invite/details | InviteHandler | Invitation details + the scope's live provider list |
POST | /api/auth/invite/accept | InviteHandler | Accept by setting a password; establishes session |
POST | /api/auth/invite/oidc/start/:configId | InviteHandler | Accept via OIDC; redirects to the IdP |
GET | /api/auth/invite/oidc/callback/:configId | InviteHandler | IdP returns here; accepts the invite, establishes session |
GET | /api/auth/me | SessionEndpointHandler | 204 if the session cookie authenticates the caller, else 401 |
POST | /api/auth/logout | SessionEndpointHandler | Destroys the browser session |
For the underlying architectural rationale (scope isolation, credential separation, why standalone OidcConfiguration), see System Security.