@okibi/partner-kit
v0.1.3
Published
Okibi Identity V1 verifier and partner integration kit
Readme
Okibi Partner Kit
@okibi/partner-kit is the maintained TypeScript/Node verifier for Okibi
Identity V1. It verifies ES256 access-token JWTs and RFC 9449 DPoP locally,
requires the configured issuer/audience and route scopes, then introspects an
unseen jti.
Installation
bun add @okibi/partner-kitThe package is ESM-only and requires Node.js 20 or newer.
Claim namespace migration
Version 0.1.3 reads capability claims from the owned
https://okibi.ai/claims/ namespace and temporarily accepts the legacy
https://okibi.dev/claims/ names. If a token carries both forms, their values
must agree. Upgrade every verifier to 0.1.3 before Okibi stops emitting the
legacy names; Partner Kit 0.1.0 through 0.1.2 cannot read capabilities that
contain only the new namespace.
Agent-assisted integration
The npm package includes an implementation skill for coding agents. After installing the package, ask the agent:
Read and follow
node_modules/@okibi/partner-kit/skills/implement-okibi-identity/SKILL.mdto integrate Okibi Identity into this API.
The skill covers both a database-free Better Auth design for tenant-level APIs and a durable subject-link design for applications that need a local user or fresh membership checks on every request.
DPoP is optional and off by default. The recommended factory accepts a dpop
setting and reports that setting in its signed resolver response, so Okibi
knows whether to issue Bearer or proof-bound capabilities. A Bearer integration
needs no replay store and accepts only unbound Authorization: Bearer <token>
capabilities. A DPoP integration verifies proofs automatically, but must
provide a durable shared replay store; the factory rejects dpop: true or
dpop: "required" at runtime when that store is missing. For compatibility
with integrations created before the dpop option existed, supplying a
replayStore while omitting dpop infers dpop: true; new integrations
should set their intended mode explicitly.
The lower-level PartnerKit.verify() understands both token shapes. It accepts
proof-bound capabilities only when constructed with a replay store. The
lower-level createAccountResolverHandler() defaults to reporting
{ dpop: false }; opt in explicitly only when the protected API really has a
durable DPoP verifier.
The two shapes cannot be mixed: a bound capability presented as Bearer, or an
unbound one presented as DPoP or carrying a proof, is rejected. That is what
stops a stolen bound capability from shedding its proof.
identity.installation.keyThumbprint is present only for bound capabilities.
A dpop: true verifier intentionally continues accepting correctly presented,
unbound Bearer capabilities during a rollout; the token's cnf claim, not a
caller-selected header, determines whether proof is required. After that
overlap has drained, dpop: "required" refuses every unbound Bearer
capability. Audit events record authorizationScheme and keyThumbprint so
that overlap is visible.
Positive active state is cached until the earliest of 60 seconds, token expiry, or the introspection response expiry. A warm entry survives an Identity outage only until that fixed deadline; unseen tokens and expired entries fail closed.
The kit exposes neutral Node middleware plus Express and Fastify adapters,
deterministic account-resolver outcomes, verified human/installation context,
self-reported actor context, and a secret-free audit adapter.
When present, the signed https://okibi.ai/claims/run_id is preserved beside
act.sub as self-reported actor metadata in verified context and audit events;
neither field is authorization authority.
Capabilities with neither field omit actor context entirely. If either field is
present, https://okibi.ai/claims/actor_trust must be self_reported; the kit
rejects missing or orphaned provenance labels.
Deployment verification
Mount the kit's secret-free manifest handler at
/.well-known/okibi-identity. The Okibi dashboard and okibi identity verify
use it to find the resolver and a bounded set of safe GET/HEAD routes that
must reject anonymous requests. This catches missing middleware, bad issuer
metadata, and wiring drift; it does not replace the service's account, role,
or object-authorization tests.
const identityManifest = createIdentityVerificationHandler({
schema_version: 1,
service_id: "projects",
audience: "https://api.projects.example",
issuer: process.env.OKIBI_ISSUER!,
resolver_url: "https://api.projects.example/api/v1/identity/account-resolution",
partner_kit_version: "0.1.3",
protected_resources: [
{ method: "GET", path: "/api/v1/projects", required_scopes: ["projects:read"] },
],
});Recommended integration
Partners own two application seams:
- Mount one account-resolution handler so Identity can ask which local account and tenant the human may use.
- Call one authorization function (or framework middleware) before protected API handlers, then continue through the service's existing role and object authorization.
createOkibiPartner wires both seams to one maintained verifier:
import { createOkibiPartner } from "@okibi/partner-kit";
const okibi = createOkibiPartner({
dpop: false,
issuer: process.env.OKIBI_ISSUER!,
audience: process.env.OKIBI_AUDIENCE!,
integrationId: process.env.OKIBI_PARTNER_INTEGRATION_ID!,
integrationSecret: process.env.OKIBI_PARTNER_CLIENT_SECRET!,
resolver: {
integrationSecret: process.env.OKIBI_RESOLVER_CLIENT_SECRET!,
supportedScopes: ["projects:read", "projects:write"],
resolve: (input) => accounts.resolveOkibiIdentity(input),
},
});
// POST /api/okibi/account-resolution
export const POST = okibi.accountResolver;
// Protected service route
return okibi.withIdentity(request, ["projects:write"], async (identity) => {
const localLink = await accounts.findOkibiLink({
issuer: identity.issuer,
pairwiseSubject: identity.human.pairwiseSubject,
tenantId: identity.tenant.id,
});
await permissions.requireProjectWrite(localLink, projectId);
return Response.json(await projects.get(projectId));
});The required V1 runtime values are issuer, audience, partner integration ID,
partner integration secret, and a separate per-partner resolver secret.
Identity's resolver caller ID defaults to okibi-identity. The introspection
endpoint defaults to <issuer>/oauth/introspect; override it only for a
nonstandard deployment. The two secrets are intentionally distinct:
introspection authenticates the partner to Identity, while the resolver secret
authenticates Identity to the partner and signs the partner's response.
For proof-of-possession hardening, set dpop: true and add
replayStore: durableDpopReplayStore. DPoP replay state must be shared across
production instances. createDevelopmentReplayStore() is available for tests
and a single-process local server only.
Adopt strict DPoP in two phases. First deploy dpop: true and advertise DPoP,
then wait at least the maximum capability lifetime plus clock skew so every
previously issued unbound capability has expired. Finally change the verifier
to dpop: "required". Switching directly from Bearer to strict mode would
reject legitimate capabilities minted before the adoption completed.
Do not remove DPoP support in one deployment while Okibi can still mint bound
capabilities. First stop advertising it while the DPoP verifier remains live:
for resolver-derived support, deploy dpop: true with
resolver.capabilities: { dpop: false } and complete an account-resolution;
for explicitly registered support, update the partner registration to false
(resolver reports intentionally cannot override it). Wait at least the maximum
capability lifetime plus clock skew, then deploy with dpop: false and remove
the replay store. This drains already-issued proof-bound capabilities instead
of turning the downgrade into an outage.
withIdentity returns 401 invalid_token or 403 insufficient_scope with the
correct Bearer/DPoP challenge for expected authorization failures. The scopes
argument may be omitted when a route needs authentication but no additional
scope. Errors thrown by the application handler, including the kit's exported
error classes, are rethrown. The Node, Express, and Fastify adapters emit the
same protocol responses; unexpected infrastructure errors go to Node/Express
next(error) or are rethrown from the Fastify pre-handler.
The verified tenant claim is not a replacement for local authorization. Match
issuer + pairwiseSubject + tenantId to the durable link created by the
resolver, then apply the service's existing membership, role, and object rules.
Account resolver
createAccountResolverHandler is the lower-level resolver HTTP boundary used
by createOkibiPartner. It accepts a Fetch Request, requires the
partner-registered HTTP Basic credential (including RFC 6749 form-decoding),
validates the frozen request fields and optional partner scope allowlist, then
returns the validated outcome with the response authentication required by
Identity Protocol v1:
const resolveAccount = createAccountResolverHandler({
integrationId: "okibi-identity",
integrationSecret: process.env.OKIBI_RESOLVER_INTEGRATION_SECRET!,
supportedScopes: ["projects:read", "projects:write"],
resolver: async (input) => {
return await projects.resolveOkibiIdentity(input);
},
});
export async function POST(request: Request) {
return await resolveAccount(request);
}Successful responses include x-okibi-resolver-timestamp and
x-okibi-resolver-signature. The signature is base64url
HMAC-SHA256(integrationSecret, timestamp + "." + exactResponseBody), prefixed
with v1=. Identity rejects missing, expired, or invalid signatures. Partners
must not stringify or modify the response body after the handler signs it.
OIDC relying party
OIDC is optional and separate from normal CLI delegation. Add it only when a resolver outcome needs the human to establish or resume a browser session at the partner. It is not required for the runtime capability middleware.
Simple application-owned login
When the application has no OIDC-capable auth stack, use
createOkibiOIDCLogin. It owns discovery, high-entropy state, nonce and PKCE,
issuer-bound callback validation, expiry, and single-use redemption. The
application supplies durable transaction storage and turns the verified Okibi
subject into its normal local account and session:
const okibiLogin = await createOkibiOIDCLogin({
issuer: "https://identity.okibi.ai",
clientId: process.env.OKIBI_OIDC_CLIENT_ID!,
clientSecret: process.env.OKIBI_OIDC_CLIENT_SECRET!,
redirectUri: "https://projects.example/auth/okibi/callback",
transactions: durableOIDCTransactionStore,
});
const start = await okibiLogin.begin({ returnTo: "/projects" });
return Response.redirect(start.authorizationURL);
const result = await okibiLogin.callback(request.url);
await establishApplicationSession(result.identity);
return Response.redirect(result.returnTo ?? "/");OIDCTransactionStore.consume must atomically remove the transaction. A
single-process createDevelopmentOIDCTransactionStore is included for local
development only.
Applications with a supported auth stack should run
okibi identity federation init. The CLI detects installed providers, derives
Better Auth and Auth.js callbacks, registers the relying party, stores the
one-time credentials in a new 0600 file, and emits the exact provider
configuration for those local libraries. Hosted providers require their
provider-owned callback via --redirect-uri; the CLI emits their
provider-specific configuration requirements.
discoverOIDCRelyingParty implements the cooperative partner connection
ceremony without external runtime dependencies. Discovery fails closed unless
the provider advertises Authorization Code, PKCE S256, pairwise subjects,
ES256 ID tokens, every requested scope, and the configured token-endpoint
authentication method. All discovered endpoints and the exact issuer must be
valid HTTPS URLs in production. The local worktree profile may use an HTTP
localhost, 127.0.0.1, or [::1] issuer; in that case every discovered
endpoint must use the exact same loopback origin, including its port.
The caller owns the short-lived OIDC transaction. Generate high-entropy
state, nonce, and a 43–128 character PKCE verifier, persist them in a
server-side single-use record, and pass only the derived challenge to the
authorization URL:
import { randomBytes } from "node:crypto";
import {
discoverOIDCRelyingParty,
pkceS256Challenge,
} from "@okibi/partner-kit";
const relyingParty = await discoverOIDCRelyingParty({
issuer: "https://identity.okibi.ai",
clientId: "projects-partner",
clientSecret: process.env.OKIBI_OIDC_CLIENT_SECRET,
redirectUri: "https://projects.example/oidc/callback",
maxAuthenticationAgeSeconds: 300,
});
const transaction = {
state: randomBytes(32).toString("base64url"),
nonce: randomBytes(32).toString("base64url"),
codeVerifier: randomBytes(32).toString("base64url"),
};
const redirectTo = relyingParty.authorizationURL({
state: transaction.state,
nonce: transaction.nonce,
codeChallenge: pkceS256Challenge(transaction.codeVerifier),
});On the callback, atomically consume that record and provide the received and
expected state separately. exchangeCode validates state before contacting
the token endpoint, sends the exact redirect URI and verifier, and verifies
the returned ID token against the discovered JWKS:
const connection = await relyingParty.exchangeCode({
code: callback.code,
state: callback.state,
expectedState: transaction.state,
nonce: transaction.nonce,
codeVerifier: transaction.codeVerifier,
});Verification requires ES256, a unique matching public JWKS key, exact issuer,
client audience and azp rules, nonce, valid exp/iat/auth_time, a
pairwise sub, and—when the email scope is requested—a syntactically valid
email with email_verified: true. An optional at_hash is checked when an
access token is returned. The result omits the raw ID token and exposes its
signature-verified claims, the normalized partner identity, and any returned
access token. Omit clientSecret only for a registered public partner whose
discovery metadata advertises token authentication method none.
