System Security
Overview
This page describes the architectural choices behind Kinotic's IAM. It is the why; for the operational how (signup flows, login flows, endpoint reference) see Organization Management, and for the layered enforcement applied to every request path see Defense in Depth.
The platform supports email/password and OIDC authentication, gives each person one account with a membership per organization, isolates application user pools by application, stores credentials separately from user records, and uses standalone OidcConfiguration entities that are referenced (not embedded) by the scopes that consume them.
Accounts, Memberships and Application Users
A person who uses the portal or the system console holds one account: a UserParticipantIdentity with neither organizationId nor applicationId set, unique by email among accounts. The account is where the person's sign-in methods live — an optional password (IdentityCredential) and any number of provider bindings (OidcBinding, one per (oidcConfigId, subject) among accounts) — and what they belong to is recorded beside it as Membership rows: one per organization the account is a member of, and one with no organization for a platform operator. A membership carries its own enabled flag, so an organization disables a member without touching the account, and removing a member removes the membership and the delegates approved in that organization, nothing else.
A sign-in therefore has two halves: a method proves which account is signing in, and a membership says what it may sign in to. An account's memberships are all of one kind: a platform operator's account holds the platform membership and nothing else, and an organization member's holds organizations' memberships and never the platform's (MembershipService.addMember refuses the other kind), so an operator never signs in to an organization and a member never to the system console. Every login is addressed to a scope — an organization's page, or the system console — and ParticipantIdentityService.resolveParticipant(identityId, organizationId, applicationId) is the one place an account and a scope become a Participant: the account's enabled membership of that organization yields an OrganizationParticipant, its platform membership a SystemParticipant, and an account that is not an enabled member of the scope is refused with the same generic answer a wrong password gets. The participant's id is the account's id in every scope, so the same person is the same sub in each organization they belong to.
An application user is different: a UserParticipantIdentity carrying both organizationId and applicationId, one row per application, owned by the application the way a customer record is. It has the same sign-in methods — a password and provider bindings — but no memberships; the application it carries is the only place it signs in.
Three Scope Layers
A Participant carries its scope structurally, in two optional string fields — organizationId and applicationId. Which of them is set determines the layer: neither set → SYSTEM, organizationId only → ORGANIZATION, both set → APPLICATION. For an account the scope comes from the membership the login resolved; for an application user, from its own row.
| Layer | Scope fields | Who | Auth Path |
|---|---|---|---|
| Organization | organizationId set, applicationId null | An account with a membership of the organization | Email/password or OIDC (platform-social or per-org SSO) at the organization's login — see Organization Management |
| Application | organizationId and applicationId both set | End-users of an application owned by an org, each a user row of that application | Email/password or OIDC — credentials and OIDC configs are scoped to the application |
| System | both null | An account with the platform membership: operators running the Kinotic servers | Email/password at the system server; planned to move to Microsoft Entra — see System Authentication |
An account with email jane@example.com and a user jane@example.com of Application B are unrelated identities. They are separate UserParticipantIdentity rows with separate IdentityCredential rows and separate bindings, and sign in on separate paths, so the same person can hold different methods at the two (a password at the portal, the application's federated SSO at the application), and authenticating at one grants nothing at the other.
How users are differentiated
An account is identified by its email, once across the platform: a sign-up or an invitation for an email that already has an account signs that account in rather than creating another, and a second sign-up by the same email or the same provider identity is refused. An application user is identified by email + organizationId + applicationId: the same email may be a user of any number of applications, each a separate row with its own methods and enabled state.
Within an account, a provider identity is matched to the account by the binding (oidcConfigId, subject), never by the email the provider asserts. A binding is created with the account — sign-up by GitHub, an invitation accepted with a provider, or an organization's own provider claiming the pending member an administrator added under the verified email, whose account no method signs in yet — or added later only from a session already signed in as that account, in the Sign-in methods section of the portal's Profile page, which offers the platform's registrations and the own configurations of the organizations the account may sign in to. A provider sign-in that no binding answers for is refused with no_account, however familiar its email, and an invitation accepted with a provider for an email that already has an unbound account is refused with account_exists: the account's holder proves it with the password instead, and connects the provider once signed in. An organization's own provider never reaches such an account: it signs in the accounts its identities are bound to and claims the pending members the organization's administrators added, whose accounts the organization created and nobody signs in to yet, and nothing else. Connecting an organization's configuration from the profile requires membership of that organization, so an organization's directory can vouch only for accounts the organization admitted, and no link can start a connect for anyone else's.
A login is addressed to an organization (/login/<organizationId>), and signs the account in there when it holds an enabled membership of that organization. The portal's landing page, which names none, signs the account in when it is a member of exactly one organization and otherwise offers the organizations it is a member of, which the account has just proven it owns, for the person to choose from. Once signed in, the account menu's Organizations list switches the session to another membership without signing in again.
Why three layers
Two layers (platform vs tenant) would force organizations and their applications to share a user pool. That doesn't work because:
- An organization's developers should not automatically be end-users of every application they manage.
- Application end-users should not have access to organization-level tooling.
- Different applications under the same organization may serve completely different user populations.
Three layers map directly to real-world trust boundaries: the platform operator trusts the infrastructure, the organization trusts its developers, and the application trusts its users.
Authentication Methods
Both methods are available at the Organization and Application layers:
- Email/Password — Credentials are verified against bcrypt hashes. Password hashes are stored in a separate
IdentityCredentialentity (a different Elasticsearch index) and are architecturally invisible to the rest of the system. User CRUD operations have no way to accidentally expose credentials. - OIDC — JWT tokens are validated against the
OidcConfigurationthe target scope offers. The platform verifies signature, issuer, audience, and expiration via JWKS.
A browser authenticates over HTTP and carries a session cookie; the STOMP WebSocket upgrade reads the logged-in Participant back out of that session, so the browser never handles a token. Each login in the session is bound to the page that made it, and the upgrade reads the login of the page opening the socket (see Defense in Depth). Scope is structural rather than header-supplied: an organization login establishes an OrganizationParticipant, an application login an ApplicationParticipant, and that participant type is what scopes the caller's authority for the rest of the connection. A non-browser client presents a Kinotic JWT instead, whose organizationId / applicationId claims carry the same scope. Each server names its session cookie __Host-kinotic-<server>-session (the management server's is __Host-kinotic-management-session) and keeps its sessions in a cluster map of its own, <server>.vertx-web.sessions, so a session one server issued is unknown to the others, even on a development machine where they share localhost and cookies ignore the port. Browsers accept a __Host- cookie only when it is Secure, scoped to path / and set without a Domain, so a page on a sibling host of the API, such as a published UI under the sites domain, can neither plant nor override it. The cookie is SameSite=Lax, which sends it from the API's own site, the one the sites domain shares with the application API hosts in production (<app>--<ui>.<org>.kinotic.io and <org>--<app>.apps-api.kinotic.io); kinotic.apiGateway.sessionCookieSameSite: NONE sends it from any origin the CORS pattern admits, for an environment whose published sites and API live on unrelated domains.
Each server authenticates only its own kind of caller. A client outside a browser presents a Kinotic access token (Authorization: Bearer), or a clientId and clientSecret: a user's email and password, checked in the scope the organizationId and applicationId headers select, or a machine's id and secret. The server resolves the identity the credentials prove and admits it only when it is one of its own; any other identity fails as invalid credentials, the same answer a wrong password gets:
| Server | Admits |
|---|---|
| org | An organization's users, the clients they have authorized, and its machines other than application runtimes (OrganizationParticipant) |
| system | Platform operators, the clients they have authorized, and the platform's machines such as the vm-manager (SystemParticipant) |
| app | An application's users, the clients they have authorized, and its machines (ApplicationParticipant), and the runtime workloads of an organization's applications (OrganizationParticipant); on an application's API host, only that application's identities and its organization's runtimes |
OIDC Configuration
Provider configurations share a shape but not an owner. Every kind extends the sealed OidcConfiguration — name, provider, clientId, authority, audience, enabled — and is stored the way the kinds of ParticipantIdentity are: one index, kinotic_oidc_configuration, holding every kind as a polymorphic document whose persisted type discriminator selects the subtype it is read back as, each subtype carrying the fields only its owner has. The platform operator's registrations are the abstract ManagedOidcConfiguration, which carries the operator-only fields (secretNameRef, and the explicit endpoints authorizationUri, tokenUri, userInfoUri, userEmailsUri, scopes for a provider such as GitHub with no discovery document); its two concrete kinds differ in whom they sign in. An organization's own are the abstract OwnedOidcConfiguration, which carries the organization's fields (organizationId, providerAccount, confidentialClient, createUserOnSignIn); its concrete kinds differ in which of the organization's sign-in scopes offers them.
| Type | type | Owner | Lifecycle | Client secret |
|---|---|---|---|---|
OrgSignupOidcConfiguration | ORG_SIGNUP | The platform operator, for organization users signing up and in; belongs to no organization | Seeded by SQL migration; saved by a system participant only | secretNameRef names one the operator placed in the platform's secret storage |
ApplicationSocialOidcConfiguration | APP_SOCIAL | The platform operator, for the users of any organization's applications; belongs to no organization | Seeded by SQL migration; saved by a system participant only. Offered at an application's login when the application names the registration's provider kind in its socialProviders | secretNameRef, as above |
OrganizationOidcConfiguration | ORGANIZATION | One organization (organizationId, enforced by OidcConfigurationService against the saving participant), for its own login | Managed by the owning org, which names its providerAccount at a provider kind offered to organizations; the authority is derived from the kind's template on every save | Held in the organization's scope of the secret storage, keyed by the configuration's id; the row's confidentialClient says one is held and nothing on it names it |
ApplicationOidcConfiguration | APPLICATION | One organization, for the login of one of its applications (applicationId, held to the organization's own) | As above | As above |
TenantOidcConfiguration | TENANT | One organization, for the login of one Tenant of one of its applications (applicationId, tenantId, held to the application's own tenants) | As above | As above |
Kinotic's own GitHub registration is an OrgSignupOidcConfiguration — it powers the "Continue with GitHub" button on org signup and org login, and belongs to no organization because signup happens before one exists. Kinotic's Google and Microsoft registrations are ApplicationSocialOidcConfigurations: one registration per provider, which any application switches on by naming the provider kind in its socialProviders, so the application's users sign in with Google or Microsoft without the organization registering with either. A user who revokes the app's access at the provider revokes it for every application offering that registration, the trade one shared registration makes. Because the registration is shared and a provider returns only to a redirect URI registered in advance, its sign-ins return to the app server's own host rather than the application's API host, and the verified identity is handed to the application's host under a single-use code — see the app routes in Organization Management. Everything an organization configures for itself is the third kind.
Why types rather than one with a flag
The difference between the kinds is ownership and scope, and those are exactly what must never be got wrong. The subtypes keep the fields apart: only a ManagedOidcConfiguration has a secretNameRef, so no row an organization can write has a field that names a platform secret, only an OwnedOidcConfiguration has an organizationId, only an ApplicationOidcConfiguration and a TenantOidcConfiguration have an applicationId, and only the latter a tenantId, each required rather than nullable because the kind is the scope. OidcConfigurationService holds every write to its kind's owner — a system participant for a managed registration, a participant of the named organization for an organization's, and for an application's kind an application of that organization — and its finders filter on the discriminator, so an org sign-up lookup never answers with an organization's SSO and an application's lookup never answers with the organization's portal SSO. A single type with an isPlatform boolean and nullable scope fields would put that guarantee back in the hands of every caller.
One configuration, one scope
An organization's configuration is offered at exactly the scope its kind names and nowhere else: an OrganizationOidcConfiguration at the organization's own login, an ApplicationOidcConfiguration at that application's login, a TenantOidcConfiguration at the login of that tenant of the application. A tenant's login is the application's narrowed to the tenant, so it offers the application's configurations too, and admits only the tenant's users through them; the tenant's own configuration is the one that may create a user into the tenant on first sign-in. There is no sharing across scopes. An organization that uses one Okta application for its portal and for one of its own applications copies the configuration with OwnedOidcConfigurationService.copy(sourceId, applicationId), the published service an organization manages its own configurations through, which creates a configuration of the target scope's kind carrying the source's provider, account, client id and client secret, under an id of its own. Each copy therefore has its own callback URL, which the provider must be given, and a client secret the provider rotates is updated on each copy. That is the trade for every callback trusting only the row it loaded: the configuration says where it is offered, and nothing but the row decides what a sign-in through it may do.
Each owned configuration also decides, with createUserOnSignIn, what happens to an identity its provider verifies that no user of the scope is bound to: refused, or created as a user of the scope from the provider's verified email and name and bound to the identity. An email the scope already holds under another method is refused either way. Off by default, and refused on an OrganizationOidcConfiguration: an organization's members are added by its administrators, whom its provider then signs in, so no account is ever created on an organization's provider's word. An application's or a tenant's configuration takes it for any kind, which with Google is sign-up with Google for that scope.
Resolving what a scope offers
OidcConfigurationService.findEnabledForScope(organizationId, applicationId, tenantId) is the single answer to "which providers may this login page show", keyed on the same scope the rest of the platform scopes by. The organization's own login offers the enabled org sign-up registrations followed by its enabled OrganizationOidcConfigurations; an application's login offers its enabled ApplicationOidcConfigurations followed by the platform's social registrations whose provider kinds the application's socialProviders names; a tenant's login offers the tenant's enabled TenantOidcConfigurations followed by what the application's login offers. The login, invite-accept, and signup pages all read it, so what a page renders and what its start endpoint accepts cannot drift apart. A sign-in start or callback that names a configuration asks findOffered(configurationId, organizationId, applicationId, tenantId) the matching question for one configuration, so a configuration an organization owns at one scope cannot sign anyone in at another.
Where client secrets live
Client secrets are never stored on a configuration row, and the kinds keep theirs apart, so a row an organization controls can never reach a secret of the platform's.
A managed registration's secretNameRef names a secret the operator placed in the platform's secret storage, which SecretReferenceResolver fetches at flow time — Azure Key Vault in a deployed environment, KINOTIC_AKV_* properties (environment variables or keys in any loaded config file) in development. Only ManagedOidcConfiguration has the field.
An OwnedOidcConfiguration has no secret name at all. Its secret is given on save, OwnedOidcConfigurationService.save(configuration, clientSecret) for an organization and the domain's OidcConfigurationService.save(configuration, clientSecret) beneath it, which stores it through SecretStorageService in the organization's scope (organizationId), keyed by the configuration's id, and sets the row's confidentialClient to say a secret is held; a save with a null or blank secret keeps the one held, and a new configuration saved without one is a public client. deleteClientSecret(id) deletes the stored secret and clears the flag, making the configuration a public client, and deleting the configuration deletes the secret too. The flag is always the server's: a save sets it from what the secret storage holds, whatever the configuration sent. A copy of a configuration stores the secret again under the copy's id. At flow time OidcFlowOrchestrator reads it back through OidcConfigurationService.findClientSecret, never through SecretReferenceResolver, and a public client's flag answers without a read of the secret storage. Because the secret is addressed by the owner and the row rather than by a name on the row, a configuration returned by any service carries nothing a client could use to find or name it, and ApplicationService.getLoginProviders answers with a LoginProviderSummary (id, name, provider kind, type) rather than the configuration at all.
Where the server is willing to connect
An organization never names a URL. It chooses a provider type that OidcProviderType.offeredToOrganizations() admits and gives its account at that provider in the form the type asks for: the directory (tenant) id for Entra, the Okta domain for Okta, nothing for Google. OidcConfigurationService fills the type's issuer template with that account on every save and stores the result as the authority, replacing whatever was sent, and each type's account pattern admits no scheme, port, path, userinfo or foreign host. So every host the sign-in flow fetches — the discovery document and JWKS at the authority, the token endpoint the discovery document names, and the identity endpoints — is one of the provider's own, and there is nothing an organization can point the server at. Types with no template, such as Keycloak or a generic OIDC server at a customer's own host, are not offered to organizations. The curated social rows are the operator's and may name endpoints explicitly.
The identity endpoints (userInfoUri, userEmailsUri) are fetched with no redirects followed. The deployment's egress policy denies the server pods the node's instance-metadata and WireServer addresses (see the terraform under deployment/terraform/azure/cluster), which is what protects the one kind of destination that answers a plain unauthenticated request with the node's own identity.
Sign-Up
Self-service signup is wired up for the Organization scope. Both email/password and social-IdP entry points create the Organization together with the founder's account — its password or GitHub binding — and the founder's membership of the new organization; a person who already has an account cannot sign up a second one and joins further organizations by invitation — see Organization Management for the step-by-step.
For the Application scope, end-user provisioning is admin-driven: an org admin (or the application's own admin tooling) creates user rows in the application's scope, or invites them — see Organization Management. There is no self-service sign-up form for an application scope. An application whose tenants the platform manages may instead let an ApplicationOidcConfiguration with createUserOnSignIn create a user on the first sign-in of an identity its provider verifies, which gives the new user a tenant of their own; under tenant administrators a user is never without a tenant, so there a user is created by an administrator, an invitation, or a TenantOidcConfiguration with createUserOnSignIn, which creates the identity its provider verifies as a user of that tenant on its first sign-in at the tenant's login.
Design Decisions
Credential separation
Authentication secrets — user password hashes and machine client-secret hashes — are stored in a separate IdentityCredential entity, keyed by identity id and not exposed through any published service interface. The identity entity is part of the public API — returned by CRUD operations, displayed in UIs, passed around in service calls. Storing credentials separately means secret hashes are architecturally invisible to the rest of the system.
Scope as string fields
The identity entity stores organizationId and applicationId as plain string fields rather than typed references, and the layer is read structurally from which ids are set. This avoids coupling ParticipantIdentity to Organization/Application, keeps queries simple (term filters on keyword fields), and lets the scope model evolve without migrating user records.
Single identity entity for all scopes
Accounts, application users, delegates and machines share the same ParticipantIdentity hierarchy, distinguished by the scope fields. Separate types per scope would triple the service interfaces and implementations with no behavioral difference — the authentication logic is identical across scopes.
Memberships and bindings as separate entities
An account's organizations and its provider identities are rows of their own, Membership (id <identityId>:<organizationId>, or <identityId>:system for the platform membership) and OidcBinding (id <oidcConfigId>:<subject> for an account, with <organizationId>/<applicationId>/ in front for an application's user, since the platform's Google registration is one configuration for every application and a person may be a user of several), rather than lists on the identity. Each is read by its id at an organization's login — the one lookup the login makes — so a membership or binding is visible the moment it is written, with no index refresh in the login path; a binding's id makes the rule that a provider identity signs in one account, and one user per application, a uniqueness constraint the store enforces; and an organization's member list is a filter on Membership.organizationId rather than a scan of every account's list.
Users, delegates, and machines
A ParticipantIdentity is a person (UserParticipantIdentity), a client a person has authorized to act on their behalf (DelegatingParticipantIdentity) — the Kinotic CLI via the device-code grant, or an MCP host such as an LLM assistant via the PKCE authorization-code grant — or a non-human caller with its own credential (MachineParticipantIdentity), such as a platform daemon or an external client of an organization's application API. A delegate carries the exact scope of the login that approved it — the organization the owner was signed in to, or the platform for a system login — and is unique per (owner, client, scope): approving the same client twice in the same organization reuses one delegate, each approval issuing a fresh refresh-token lineage under it, while an approval in another of the owner's organizations creates a delegate of that organization — and authenticates only with Kinotic-issued tokens whose sub is the delegate's own id, so every request records which client acted, not just whose authority it used.
Both grants are bound to the server that started them. The authorization request and the device-code flow record the OAuth issuer of the server whose endpoint began them, and on the app server each application's API host is an issuer of its own; only that issuer's token endpoint redeems the code: an authorization code presented to another server's or another application's token endpoint is refused and burned, and a device code polled there reads as unknown.
Revocation follows from the model: authentication re-reads the identity on every token validation, so disabling a delegate cuts that client off on its next request even if its access token has not expired, and a delegate authenticates only while its owner may still act in the delegate's scope — disabling an account, or its membership of the delegate's organization, revokes every client authorized on its behalf there with no cascade to miss. Removing a member deletes the delegates approved in that organization, and deleting an identity deletes its credential, bindings, memberships and delegates outright.
Each approval issues a refresh-token lineage under the delegate — a session, labeled with the device name the client supplied (the CLI sends one with device_name on the device-authorization request). The published DelegateService gives the signed-in user their own delegates and sessions — list them, end a single session, or revoke a delegate entirely. Alongside it, ProfileService reads and writes the caller's own account: findMyProfile returns it, updateDisplayName sets the name shown wherever they appear, findMySignInMethods lists the password and provider bindings the account signs in with, setPassword sets or changes the password (the current one is required when there is one), removeSignInMethod drops a method as long as another remains, and findMyOrganizations lists the memberships the account can switch to. Both services address the calling user rather than taking an id, and admit only a person — a delegate cannot read or edit the account that authorized it. The portal surfaces them as the Profile and Connected apps pages under the account menu. Because DelegateService keys on the calling participant alone, it serves each scope its servers admit: the system console carries the same Connected apps page under its account menu, so a platform operator revokes the clients authorized on their own account there. It is a management-api service, which the app server neither hosts nor reaches, so an application's users have no listing or revocation of the clients the app server authorizes on their behalf.
Machine identities
A machine connects the same way every Kinotic client does — over the STOMP gateway, authenticated by credentials on the WebSocket upgrade: its identity id as clientId and the secret issued at provisioning as clientSecret. A clientId containing @ is a user email; anything else resolves only to a MachineParticipantIdentity, so a user's id and password can never authenticate as a machine. The machine's scope comes structurally from its own identity — no scope headers needed. There is no token to manage: the client library owns connect and reconnect, re-presenting the same static credentials, so any crash or restart self-heals.
await Kinotic.connect({server: {host, port}, credentials: new BasicCredentialsResolver(machineId, machineSecret)})
Provisioning generates the secret — it is returned in plaintext exactly once and stored only as a hash, in the same credential store that holds user password hashes. The secret is verified on every handshake, so disabling the machine identity cuts it off on its next connection, and rotating the secret invalidates the old one immediately.
Every machine carries a machineType, set when it is provisioned, naming what its credential is for:
machineType | Scope | Provisioned by |
|---|---|---|
CLIENT | SYSTEM: a platform daemon such as the vm-manager | the system console, through SystemMemberService |
CLIENT | APPLICATION: an API client of one application, acting with that application's scope, exactly the position an application end-user occupies | an org member, through MachineService |
PROJECT_SYNC | ORGANIZATION: a project deployment's sync workload | the deployment |
APP_RUNTIME | ORGANIZATION: the runtime workload of one of a project's microservices | the deployment |
Org members manage the machines of their applications through the published MachineService — create (disclosing the generated secret once), list, rotate the secret, disable/enable, and remove — surfaced in the SPA as the Machines page under each application. Every operation proves the application belongs to the caller's organization; machines of other organizations are invisible.
SYSTEM-scope machines are the platform's own, so they are managed from the system console instead, at Members → Machines, through SystemMemberService in the system zone. It carries the same lifecycle as MachineService under the same method names — create, list, rotate the secret, disable/enable, and remove — so each console's Machines page is the same MachinesTable component, bound to whichever service owns the scope it lists. The page is how a worker node's vm-manager gets the KINOTIC_CLIENT_ID and KINOTIC_CLIENT_SECRET it connects with. SystemMemberService works on SYSTEM scope alone: an organization's machines are not reachable from it even though a SYSTEM participant may address it.
Provider identities keyed by (oidcConfigId, subject)
A provider identity is bound to an account by the OIDC sub claim plus the configId — not email. Email is mutable at the IdP and the same email across two IdP tenants is two different people. The sub is stable within an issuer, and pairing it with the configId disambiguates issuers. The email a provider asserts is used exactly once, when a binding is created: a sign-up or a createUserOnSignIn login takes it as the new account's email, and an invitation accepted with a provider requires it to match the invited address. After that, only the binding says whose identity it is.
Auto-derived organization id
The Organization.id is derived from the user-supplied name (slugified) at signup time. It is the organization's label on the customer domain, its own host <organizationId>.kinotic.io and the subdomain its sites are under, so no UUID appears in a URL. Every id beginning with kinotic is reserved, so no organization named Kinotic, or anything that slugifies to a kinotic prefix, can be founded or saved: the platform's name never appears on a customer's hosts, <org>--<app> under the apps-api domain or *.<org> under the sites domain. The platform owns no organization of its own. The id xn is reserved: an application's host labels begin with <organizationId>--, and DNS reserves labels beginning xn-- for internationalized names. system is reserved as well: it is the organization slot of a platform membership's id, <identityId>:system, so an organization of that id would share membership documents with the platform's operators. So are the segments the organization login's own routes claim under /api/auth/org/login/, find, social, sso, providers and choices, which an organization's id would be mistaken for, and the labels the platform holds under its domains, which the sites Worker refuses as hosts for the same reason.
Client identity is a domain, not a string
MCP hosts identify themselves with an OAuth Client ID Metadata Document: the client_id is an HTTPS URL, and the gateway fetches the JSON document it names on each authorization request (cached for kinotic.domain.oauth.clientMetadataCacheTtl). The document registers the client's redirect_uris and client_name.
The security property is that the client had to control that host to serve the document there, so the consent page displays the client_id host alongside the claimed name — a client cannot present itself as claude.ai without owning claude.ai. The page also names the account the approval would bind to: the login the browser session holds for the consent page's own origin. A system-scoped session carries no organization, so a delegate approved under it reaches every organization's MCP tools; the page warns about that before the operator approves. Under the dynamic registration this replaces, client_name was self-asserted by whoever called the registration endpoint.
Validation, per the draft: the URL must be https with a path component and no fragment, userinfo, or dot segments (§3); the host must not resolve to a special-use address (§6.5); the fetch must answer 200 with no redirects followed (§4) and stay under clientMetadataMaxBytes (§6.6); and the document must name its own URL as client_id and carry no client secret (§4.1). Failures abort the authorization request and are never cached (§4.3, §4.4).
kinotic.domain.oauth.allowedClientIds lists the document URLs that may start a flow, and is required — a deployment names the clients it accepts or fails to start. A client_id that is not listed is rejected, so clients are onboarded explicitly, the deployment pattern of §6.10. A host that validates against the rules above still needs its document URL added before it can connect.
Each Anthropic surface presents its own document URL, and the servers ship both:
| Client | client_id |
|---|---|
| Claude Code | https://claude.ai/oauth/claude-code-client-metadata |
| Claude web, desktop, and mobile custom connectors | https://claude.ai/oauth/mcp-oauth-client-metadata |
Bootstrap
kinotic-migration runs to completion before the servers start. Migrations are versioned SQL applied once per version: V1__init.sql creates the Elasticsearch indices and seeds the platform's registrations into kinotic_oidc_configuration: GitHub as the ORG_SIGNUP row, Google and Microsoft as APP_SOCIAL rows. A migration whose filename carries environment suffixes — V2__kinotic_test_users.development.test.sql — is applied only in those environments, which is how test fixtures stay out of production.
There is no startup bootstrap step for OIDC. A seeded provider row carries only a secretNameRef; the client secret itself is placed in external secret storage by the operator and read at flow time, so no secret passes through a migration or a config file.
Network Security
- All client-to-server communication occurs over WebSocket with TLS in any non-local environment.
- Each server's api-gateway port (default org
58503, system58504, app58505) carries the auth REST endpoints on/api/*and STOMP/WebSocket on/api/v1; the OAuth discovery documents stay at the root under/.well-known/. A later generation of the client protocol would be served at/api/v2beside the first, while the auth routes and their registered callback URLs never move. - TLS terminates in each server's gateway (Azure: a cert-manager certificate, behind a layer-4 LoadBalancer; the development server: a certificate per server, behind an SNI edge that passes TLS through unopened).
What This Design Does Not Cover
- Authorization — Roles and policies. The authenticated session currently carries an empty roles list; future work populates it from the policy system.
- Groups — Group entities and membership management.
- System-scope OIDC — Platform operators do not authenticate through any of the OIDC flows above. Their sign-in to the system console, planned to move to Microsoft Entra, is a separate thing from how organization users sign up and in, and will be its own kind of
OidcConfigurationbeside the two above rather than a reuse ofOrgSignupOidcConfiguration(deferred). - Account linking — Linking an existing local account to a social identity is on the roadmap but not built.