API Reference

Kizuna.identity

Define who can call your API with an OpenAPI security scheme, the context a passing guard provides, and the access fields routes may constrain.

An identity describes one kind of authenticated caller: an OpenAPI security scheme (how the credential travels), an optional context schema (what a passing guard provides to handlers), and an optional access schema (the fields the auth map may constrain per route). Omit context for an authentication-only identity, a pure gate with no data to hand the handler. For the full walkthrough, see the Auth guide.

pnpm add @ts-kizuna/core
bun add @ts-kizuna/core
npm install @ts-kizuna/core
import { Kizuna } from '@ts-kizuna/core';

Builders

One builder per authentication method, matching the OpenAPI security scheme types:

BuilderCredential sourceOpenAPI scheme
Kizuna.identity.bearerAuthorization: Bearer <token>{ type: 'http', scheme: 'bearer' }
Kizuna.identity.apiKeyA named header, query parameter, or cookie{ type: 'apiKey', name, in }
Kizuna.identity.basicAuthorization: Basic <base64>, decoded{ type: 'http', scheme: 'basic' }
Kizuna.identity.oauth2Authorization: Bearer <token>, with scopes{ type: 'oauth2', flows }
Kizuna.identity.openIdConnectAuthorization: Bearer <token>{ type: 'openIdConnect', ... }
Kizuna.identity.customRead by the guard (e.g. a path segment)none, emits x-kizuna-guarded

Every builder takes context (optional), access (optional), description (optional), and scheme (optional, where identities sharing one credential set the same scheme name and emit a single OpenAPI scheme). Method-specific fields:

BuilderExtra fields
Kizuna.identity.bearerbearerFormat? (e.g. 'JWT')
Kizuna.identity.apiKeyname, in: 'header' | 'query' | 'cookie'
Kizuna.identity.oauth2flows (OpenAPI OAuth Flows object)
Kizuna.identity.openIdConnectopenIdConnectUrl

Example

src/contract/identities.ts
import { z } from 'zod';
import { Kizuna } from '@ts-kizuna/core';

export const user = Kizuna.identity.bearer({
    context: z.object({
        userId: z.string(),
    }),
});

export const member = Kizuna.identity.apiKey({
    name: 'x-workspace-token',
    in: 'header',
    context: z.object({
        workspaceUserId: z.string(),
    }),
    access: z.object({
        role: z.enum(['owner', 'admin']),
    }),
});

context vs access

Both describe what the guard returns and the handler reads:

  • context is who the caller is. A guard must return it, and handlers of secured routes receive it under auth, keyed by the identity's name (auth.user, auth.member).
  • access is the fields the auth map may gate per route. Gating on { member: { role: 'owner' } } enforces a 403 at runtime and narrows the handler's type, so auth.member.role is 'owner', not 'owner' | 'admin'.

Skip access for identities that never gate on fields; the auth map can still require them ('user'), it just can't constrain them.

Authentication-only identities

Some identities are pure gates, such as an API key that only proves the caller is a known client, or a bearer token where "authenticated" is all the handler needs. These have no data to hand the handler, so omit context (and access) entirely:

src/contract/identities.ts
export const apiConsumer = Kizuna.identity.apiKey({
    name: 'x-api-key',
    in: 'header',
});

Its guard returns nothing on success (or deny(...) to reject), with no return {} boilerplate:

export const requireApiKey = server.guard('apiConsumer', ({ apiKey, deny }) => {
    if (!apiKey || !(await isKnownKey(apiKey.value))) return deny(401, 'Unauthorized');
});

A route secured only by a context-less identity gets no arg for it, because the identity contributes nothing to the handler args.

Custom identities

Some credentials don't fit any OpenAPI security scheme. A capability URL is the common case: you email or SMS a link like /invites/:token, and the path token is the credential. OpenAPI's apiKey can't describe it, since its in allows only header, query, and cookie with no path option, so declaring one would emit a scheme a generated client would act on incorrectly.

Kizuna.identity.custom is the escape hatch. It has no OpenAPI scheme; its guard reads the credential itself from wherever it lives:

src/contract/identities.ts
export const inviteToken = Kizuna.identity.custom({
    context: z.object({
        inviteId: z.string(),
    }),
});
export const requireInviteToken = server.guard('inviteToken', ({ params, deny }) => {
    const inviteId = await resolveInvite(params.token);
    if (!inviteId) return deny(404, 'Not found');
    return {
        inviteId,
    };
});

The guard receives the usual { params, deny, scopes } (plus your framework's handler context) with no credential key, since there is nothing for the runtime to pre-extract. Everything else is ordinary typed auth: the identity registers under identities, the auth map references it, handlers read auth.inviteToken.inviteId, and access gates work as usual.

Because it emits no security, a custom-guarded route would otherwise look public in the spec. To keep the distinction honest, generateOpenApi marks the operation with an x-kizuna-guarded extension listing the custom identities, so a genuinely public route (no security, no extension) stays distinguishable from one protected out-of-band.

Reach for custom only when the credential is truly inexpressible. A token belongs in bearer, a signature header in apiKey; mutual TLS has its own OpenAPI type.

Registering identities

Pass identities to new Kizuna() under identities. Their keys become the names everything else uses: auth map values, guard registrations, and handler context keys:

src/contract/k.ts
import { Kizuna } from '@ts-kizuna/core';
import { tags } from './tags';
import { user, member } from './identities';

export const k = new Kizuna({
    identities: {
        user,
        member,
    },
    tags,
});

Securing routes

Routes never mention identities directly. The contract's auth map assigns them per group (or per route via a '*' cascade), keyed by the names above:

src/contract/contract.ts
export const contract = k.contract({
    routes,
    auth: {
        users: false,
        health: false,
        members: 'user',
        workspace: {
            '*': 'member',
            deleteWorkspace: {
                member: {
                    role: 'owner',
                },
            },
        },
    },
});

See the auth map reference for every value form. From there, each identity needs exactly one guard, registered on server.api.

OpenAPI emission

generateOpenApi emits identities under components.securitySchemes, and each secured route references them in its security, with any OAuth scopes the auth map declares.

On this page