Kinotic OS

Organization Management

How organizations are created, how their users authenticate, and how OIDC providers are wired up.

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) have no login path today; it is planned to move to Microsoft Entra, separate from everything described here.

Mental Model

Four persistent entities carry the auth state, and one short-lived entity bridges each signup:

EntityPurposeLifecycle
OrgSignupOidcConfigurationA Kinotic-curated social provider (GitHub, Google, Microsoft) shown as a login/signup button to everyone. Belongs to no organizationSeeded by SQL migration
OrganizationA customer org. ssoConfigId names its single enterprise SSO provider, or nullCreated at the end of signup
OidcConfigurationA provider record (clientId, authority, …) owned by one organizationCreated by org admins
ParticipantIdentityA scoped identity carried structurally by organizationId / applicationId. One row per (person, scope)Created during signup, by invitation, or by an admin
PendingSignUpHolds a signup in flight — the email-verification state for a local signup, or the verified identity for a social one, discriminated by authTypeShort-lived; deleted once /api/auth/org/signup/complete or /api/auth/org/signup/social/complete succeeds

Configurations are referenced, never embedded, so one row can serve more than one purpose: an org's ssoConfigId and one of its applications' oidcConfigurationIds may name the same OidcConfiguration when the same Okta tenant serves both.

Two distinct OIDC roles

RoleEntityReferenced fromWhat the user sees
Platform OIDC (social)OrgSignupOidcConfigurationNothing — every enabled row is offeredA "Continue with GitHub/Google/…" button on the login and signup pages
Per-org SSO (enterprise)OidcConfigurationOrganization.ssoConfigIdNo visible button — reached via the email-first lookup flow when their org has SSO configured

The roles are separate types in separate indices rather than one type with a flag, because the difference between them is scope. An org-scoped query cannot return a platform provider and a platform lookup cannot reach another org's SSO, so an org admin configuring SSO can never make it appear as a global button, and a seeded social provider can never affect an org's SSO settings.

Org Creation

There are two entry points, both producing an Organization and an admin ParticipantIdentity scoped to it. The SPA's /signup page currently offers GitHub only (a hardcoded button — every Kinotic project is backed by a GitHub repository, so the founding user signs up with GitHub and installs the GitHub App right after). The email/password signup entry point remains fully mounted on the backend; its previous UI (Signup.vue) is kept in the tree unrouted until other methods return. Login is not restricted: invitees must be able to sign back in however they joined, so the login page keeps every enabled provider plus a password form.

Email/password signup (backend flow — not currently reachable from the SPA)

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 ParticipantIdentity already exists at ORGANIZATION scope for this email
   - creates a PendingSignUp 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 ParticipantIdentity (authType=LOCAL, organizationId=org.id, applicationId=null, enabled=true)
   - links Organization.createdBy = user.id
   - creates IdentityCredential (bcrypt hash, separate index keyed by user.id)
   - deletes the PendingSignUp
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 ParticipantIdentity 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 "Sign up with GitHub" (the page's only button today)
2. POST /api/auth/org/signup/social/start/github
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/nonce/PKCE, exchanges code for id_token + access_token
   - rejects if email_verified=false in the id_token
   - rejects with AccountExistsException if an ParticipantIdentity already exists for (sub, configId)
   - creates a PendingSignUp 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 ParticipantIdentity (authType=OIDC, organizationId=org.id, applicationId=null,
                      oidcSubject + oidcConfigId set, enabled=true)
   - links Organization.createdBy
   - deletes the PendingSignUp
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.
11. CompleteOrg.vue shows a "Connect GitHub" step explaining that the next GitHub
    round-trip authorizes repository access (distinct from the sign-in, which only
    proved identity). Clicking "Continue to GitHub" calls
    githubAppInstallations.startInstall("/applications") and redirects the whole tab to
    the returned GitHub install URL. The App requests user authorization (OAuth) during
    installation, so GitHub returns to /github/install/callback — the App's first
    registered callback URL — with code, installation_id, and the state minted by
    startInstall. The callback runs completeInstall, which consumes the state, exchanges
    the code with the github-platform credential for the authorizing user's access
    token, and binds the installation only when GitHub's /user/installations reports
    that user can access it (see [Defense in Depth](/platform/defense-in-depth)). It
    then lands on /applications — project creation requires the install, so the new org
    arrives ready. If the install can't start (e.g. a kinotic.disableManagement deployment)
    the page falls back to /applications directly.

The PendingSignUp is consumed once. The ?token= in the redirect to /register is the short-lived PendingSignUp verification token, not an auth credential — the actual login is established by the session cookie set when the org-naming POST succeeds.

GitHub follows the same flow with one difference in step 6: GitHub is OAuth 2.0 only and issues no id_token, so its configuration row sets the endpoints explicitly instead of relying on discovery — authorizationUri/tokenUri drive the code flow, userInfoUri supplies sub and the display name, and userEmailsUri supplies the email plus its verified flag (primary address preferred). Any provider whose row sets these fields takes this path; nothing about it is GitHub-specific in code. Issuer, audience, and nonce validation don't apply (there is no token to carry them); the state match and the direct TLS code exchange are the security boundary.

User Login

Once an org exists, members log in through one of three converging paths. The SPA's login page (LoginPage.vue) renders one button per enabled platform provider plus a single-step email + password form that posts straight to POST /api/auth/org/login — it does not call the email-first lookup below, which stays mounted for the future SSO-discovery UX.

Email-first lookup → password or SSO (backend flow — not currently reachable from the SPA)

The unrouted Login.vue 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 ParticipantIdentity at ORGANIZATION scope (iamUserService.findByEmail)
   - if user.authType=OIDC AND the org's ssoConfigId names an enabled 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 ParticipantIdentity 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 ParticipantIdentity, requires authType=LOCAL + enabled,
       loads IdentityCredential, 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().
   The default resolution supplies no auth headers in a browser, 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; the table in "The WebSocket upgrade" below covers what each client type presents.

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 (the invite-hint UI on MembersPage reads the same endpoint). Clicking a button:

1. POST /api/auth/org/login/social/start/github?referer=<spa-path>
   - the router guard bounced an unauthenticated navigation to /login?referer=<spa-path>,
     and the login page forwards it here; a social login leaves the SPA, so the path
     cannot be kept client side the way the password path keeps it
2. OrganizationLoginHandler.handleSocialStart:
   - finds the platform OidcConfiguration with provider="github"
     (orgSignupOidcConfigurationService.findEnabledByProvider)
   - stores referer on the session when it names a path within the SPA
   - same state/nonce/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/nonce, exchanges code, validates id_token (sub + email_verified)
   - looks up an ParticipantIdentity 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 stored
       referer, or the SPA root when the flow stored none, 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.

Platform operators (SYSTEM scope)

Platform operators are not organization members — their IamUser has neither organizationId nor applicationId. They sign in through a separate, password-only route used by the system console (there is no signup, SSO, or social path for SYSTEM scope):

1. POST /api/auth/system/login { email, password }   (credentials: 'include')
2. SystemLoginHandler.handleLogin → AuthEndpointSupport.handlePasswordLogin:
   - LocalAuthenticationService.authenticateLocal(email, password, null, null)
       scoped to SYSTEM, so an ORGANIZATION- or APPLICATION-scope user with the
       same email can never authenticate here
   - success/failure behave exactly like the org password branch:
     204 + Set-Cookie, or a generic 401

The session established here is the same browser-session mechanism as every other login path; /api/auth/me and /api/auth/logout apply unchanged.

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:

ClientUpgrade credentials
Browser SPASession cookie established by the REST login; the default credential resolution supplies no headers
Node client, credentialsclientId, clientSecret, plus organizationId / applicationId scope headers (via BasicCredentialsResolver or the KINOTIC_CLIENT_* environment variables)
Node client, Bearer tokenAuthorization: Bearer <jwt> (via BearerCredentialsResolver); 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 an access token through the OAuth device-code grant at POST /api/auth/oauth/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 and creates the Session. The CLI persists a rotating refresh token to mint fresh access tokens before each connect.

Provider-Specific Quirks

OIDC is a standard, but providers diverge on a few details. The validation helpers in OAuth2Util (isIssuerValid, isEmailVerified) handle these declaratively — the provider key on OidcConfiguration selects the right behaviour. No provider needs handler-level branching, and no provider's endpoints live in code: a row either names an authority for OIDC discovery or sets authorizationUri/tokenUri/userInfoUri/userEmailsUri/scopes explicitly, and every handler consumes the same claims shape either way.

Provider keyiss shapeemail_verified claimOther notes
githubNo id_token — identity is fetched from the row's userInfoUri with the exchanged access token, so issuer/nonce validation doesn't applySynthesized from the row's userEmailsUri — the primary verified address is preferred, any verified address acceptedOAuth 2.0 only, so the row sets every endpoint explicitly. sub is the numeric GitHub account id. Uses the kinotic-ai GitHub App's user-authorization OAuth credential — the same credential that verifies installation ownership when an org links GitHub, so the row must never point at a different OAuth client. The app needs the "Email addresses: read-only" permission, and its scope param is ignored (GitHub App permissions govern access)
googleFixed https://accounts.google.comEmitted as boolean — required true to acceptsub 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.0Not 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 JWTSame as single-tenant — not emitted, presence trustedDiscovery 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
appleFixed https://appleid.apple.comNot emitted — presence trustedEmail 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 trueDiscovery + standard validation

isEmailVerified and isIssuerValid are the only places these differences live in code. Adding a new provider doesn't require code changes — an OIDC-compliant provider seeds a row with an authority, and a plain-OAuth2 provider seeds a row with explicit endpoint fields; only providers with non-standard claim 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 named by Organization.ssoConfigId 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:

  1. Create the OidcConfiguration directly in Elasticsearch (via a migration).
  2. Append its id to the org's oidcConfigurationIds.
  3. Add the redirect URI https://<apiBaseUrl>/api/auth/org/login/sso/callback/<configId> to the IdP app registration. For same-origin deploys (kinotic.domain.apiBaseUrl unset) 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 has no login path for system-level operators today; the plan is to move it to Microsoft Entra, separate from the routes documented above. The curated social providers are intentionally limited to end-user self-service signup and grant org-scoped access only, so no route here can produce a SYSTEM-scoped session.

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, OAuth, and invite routes are listed for completeness.

MethodPathOwnerPurpose
GET/api/auth/org/login/providersOrganizationLoginHandlerUnique platform social provider keys for the button row
POST/api/auth/org/login/lookupOrganizationLoginHandlerEmail-first lookup; {type: "sso", redirect} or {type: "password"}
POST/api/auth/org/loginOrganizationLoginHandlerEmail + password; on success establishes the browser session (204 + Set-Cookie)
POST/api/auth/org/login/social/start/:providerOrganizationLoginHandlerBegin social-button login; redirects to the IdP
GET/api/auth/org/login/social/callback/:configIdOrganizationLoginHandlerSocial IdP returns here; establishes session, 302 to the SPA root
GET/api/auth/org/login/sso/callback/:configIdOrganizationLoginHandlerPer-org SSO IdP returns here; establishes session, 302 to the SPA root
POST/api/auth/org/signupOrganizationSignupHandlerSubmit email + displayName; sends verification email
POST/api/auth/org/signup/completeOrganizationSignupHandlerVerify token + orgName + password; creates Organization + admin ParticipantIdentity; establishes session
POST/api/auth/org/signup/social/start/:providerOrganizationSignupHandlerBegin social-IdP signup; redirects to the IdP
GET/api/auth/org/signup/social/callback/:configIdOrganizationSignupHandlerIdP returns here; creates PendingSignUp; redirects to /register
POST/api/auth/org/signup/social/completeOrganizationSignupHandlerConsume PendingSignUp; create Org + ParticipantIdentity; establishes session
GET/api/auth/app/:orgId/:appId/login/providersApplicationLoginHandlerEnabled OIDC configs the application references
POST/api/auth/app/:orgId/:appId/login/lookupApplicationLoginHandlerApp-scoped email-first lookup
POST/api/auth/app/:orgId/:appId/loginApplicationLoginHandlerApp-scoped email + password; establishes session
GET/api/auth/app/:orgId/:appId/login/oidc/callback/:configIdApplicationLoginHandlerApp IdP returns here; establishes session
GET/.well-known/oauth-authorization-serverOAuthServerHandlerRFC 8414 authorization-server metadata
GET/.well-known/oauth-protected-resource[/mcp]OAuthServerHandlerRFC 9728 resource metadata for /mcp
GET/api/auth/oauth/authorizeOAuthServerHandlerPKCE authorization-code flow; redirects to the SPA /oauth/consent page
POST/api/auth/oauth/device_authorizationOAuthServerHandlerRFC 8628 device grant — issue device/user codes; requires the CLI's client_id
POST/api/auth/oauth/tokenOAuthServerHandlerToken endpoint: authorization_code, refresh_token, and device-code grants
GET/api/auth/invite/detailsInviteHandlerInvitation details + the scope's live provider list
POST/api/auth/invite/acceptInviteHandlerAccept by setting a password; establishes session
POST/api/auth/invite/oidc/start/:configIdInviteHandlerAccept via OIDC; redirects to the IdP
GET/api/auth/invite/oidc/callback/:configIdInviteHandlerIdP returns here; accepts the invite, establishes session
GET/api/auth/meSessionEndpointHandler204 if the session cookie authenticates the caller, else 401
POST/api/auth/logoutSessionEndpointHandlerDestroys the browser session

For the underlying architectural rationale (scope isolation, credential separation, why standalone OidcConfiguration), see System Security.

Copyright © 2026