Auth

Declare identities and per-route auth on the contract. Runtime guards, typed handler context, OpenAPI security, and client access checks all follow.

Auth lives in the contract: identities say who can call your API, the auth map says which routes they can call. One declaration drives enforcement, handler types, the OpenAPI spec, and client access checks.

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(),
    }),
});
src/contract/k.ts
import { Kizuna } from '@ts-kizuna/core';
import { tags } from './tags';
import { user } from './identities';

export const k = new Kizuna({
    identities: {
        user,
    },
    tags,
});
src/contract/contract.ts
import { k } from './k';
import { routes } from './routes';

export const contract = k.contract({
    routes,
    auth: {
        health: false,
        members: 'user',
    },
});
src/server/guards.ts
import { server } from './server';

export const requireUser = server.guard('user', async ({ bearer, deny }) => {
    const session = bearer ? await verifySession(bearer.token) : undefined;
    if (!session) {
        return deny(401, 'Unauthorized');
    }
    return {
        userId: session.userId,
    };
});
src/server/api.ts
import { server } from './server';
import { router } from './router';
import { requireUser } from './guards';

export const api = server.api({
    router,
    guards: {
        user: requireUser,
    },
});

Every route under members now runs the guard, and its handler receives the guard's return, typed, under auth, keyed by the identity's name:

members: {
    listMembers: ({ auth }) => ({
        status: 200,
        body: {
            members: findMembersExcept(auth.user.userId),
        },
    }),
},

Split into its own file, a handler keeps the typed auth via RouteHandler<typeof contract.routes.members.listMembers>.

Identities

One builder per authentication method: bearer, apiKey, basic, oauth2, openIdConnect, and custom. Each optionally takes context (what a guard returns and handlers receive) and access (fields the auth map may gate per route):

src/contract/identities.ts
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']),
    }),
});

Omit context for an authentication-only identity, a pure gate like an API key that only proves the caller is a known client. Its guard returns nothing on success (or deny(...)), and routes secured by it receive no handler arg for it:

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

When the credential doesn't fit any OpenAPI scheme, a capability-URL token in a path segment for instance, use custom. Its guard reads the credential itself, and the route stays explicitly guarded instead of falling back to false:

src/contract/identities.ts
export const inviteToken = Kizuna.identity.custom({
    context: z.object({
        inviteId: z.string(),
    }),
});

All builders and their fields, including how custom shows up in the spec: Kizuna.identity.

The auth map

Keyed by route group; every group must appear, so public is always an explicit false. Each entry is one of:

ValueMeaning
falsePublic, no auth
'user'Requires the user identity
{ member: { role: 'owner' } }Access gate. Requires member with role limited to a value (or an array)
{ user: ['read:events'] }Requires user with these oauth2 scopes
{ user: true, member: { ... } }Multiple identities, all must pass
{ '*': ..., login: false }Cascade. '*' sets the group default, named keys override its routes and subgroups
src/contract/contract.ts
export const contract = k.contract({
    routes,
    auth: {
        users: false,
        health: false,
        members: 'user',
        workspace: {
            '*': 'member',
            deleteWorkspace: {
                member: {
                    role: 'owner',
                },
            },
            transfer: {
                member: {
                    role: 'owner',
                },
            },
        },
    },
});

A route entry inherits the '*' default and only states its delta. deleteWorkspace above keeps requiring member, tightened to role: 'owner'. Adding another identity ({ user: true }) requires both; false opts the route out. Identity names are type-checked, and security can't be written inline in k.routes.

Nested groups

A subgroup key covers its whole subtree, or nests its own cascade to opt out deeper. A key that matches nothing in the group is an error:

members: {
    '*': 'user',
    session: {
        '*': 'user',
        login: false,
    },
    invites: false,
},

Access gates

{ member: { role: 'owner' } } rejects other roles with a 403 before the handler runs, and inside the handler auth.member.role narrows to 'owner'. Array fields pass when they contain the value ({ member: { permissions: 'invoicing' } }):

workspace: {
    deleteWorkspace: ({ auth }) => ({
        status: 200,
        body: {
            ok: auth.member.role === 'owner',
        },
    }),
},

A route requiring several identities receives all of them under auth, as in transfer: ({ body, auth }) => ....

Guards

One guard per identity, not per route. The credential arrives extracted and typed, along with the route's path params. For a request to /workspaces/ws_42/members, params.workspaceId is 'ws_42', so the guard can check not just that the token is valid, but that the caller belongs to that workspace:

src/server/guards.ts
export const requireMember = server.guard('member', async ({ apiKey, params, deny }) => {
    const membership = apiKey ? await findMembership(apiKey.value, params.workspaceId) : undefined;
    if (!membership) {
        return deny(403, 'Forbidden');
    }
    return membership;
});

The return is checked against the identity's schemas. deny(status, detail) responds with RFC 9457 Problem Details. Guards also get the adapter's native request objects and oauth2 scopes. See server.guard. For request-scoped values that gate nothing (analytics ids, loggers), use Kizuna.requestContext instead.

What falls out for free

OpenAPI security

generateOpenApi emits securitySchemes and per-route security from the auth map:

# generated
components:
    securitySchemes:
        member:
            type: apiKey
            name: x-workspace-token
            in: header
paths:
    /workspace:
        get:
            security:
                - member: []

A custom identity has no scheme to emit, so its routes carry an x-kizuna-guarded extension instead of security. That is enough to tell a protected-out-of-band route from a public one.

Client

The new KizunaClient() client sends the credential in baseHeaders; the server's guards enforce the contract's auth and return the typed 401/403:

const apiClient = new KizunaClient(contract, {
    baseUrl,
    baseHeaders: {
        Authorization: `Bearer ${token}`,
    },
});

MCP

MCP endpoints run the same guards per tool call, reading credentials from the transport request's headers.

Reference

On this page