Skip to content
Udibo
On this page
View Markdown

Protect a JSON API and automate a grant report

Availability: this walkthrough describes a template available through repository access arranged with Udibo. It is not a public standalone starter. It needs an accessible Udibo Identity tenant. The template uses the published @udibo/oauth2 package.

The template contains a Hono API, run by Node.js 26 from its TypeScript sources with no build step, that serves every customer organization in your tenant from one deployment, plus a separate command that reads grants on one report. Each caller's organization comes from their verified access token and scopes every query. The API stores reports in Postgres. The command uses its own machine credentials and an explicit administrator-assigned permission.

What it demonstrates

The template shows a member reading their organization's reports, another organization's member seeing none of them, an organization whose slug spells another's id seeing only its own, and a member with no role reading the one report shared with them. For your own tests, the stand-in tenant createFakeTenant from @udibo/oauth2/testing can register a machine application: addClient with grants: ["client_credentials"], a secret, scopes and machinePermissions: ["resource_grants.read"].

Register the API

Choose the API's origin, such as https://api.example.com. It is the token audience for every route. Register the reports:read and reports:write customer permissions, create roles containing them, and assign the roles to people inside their organizations. Register the resource type report. Follow roles and permissions for that setup.

Register a public, first-party application with the authorization-code grant, JWT access tokens, the API's origin as audience, and your client's callback URL. Add http://127.0.0.1/callback too if you want a command-line tool to sign you in from a browser and print a token; loopback redirect URIs match on any port. Follow application registration. Your OAuth client must use PKCE S256 and state, request openid, and select the organization using the hosted authorization request's organization slug parameter.

Set these values in the API's environment:

  • UDIBO_ISSUER: your tenant origin, such as https://<tenant-id>.udibo.com.

  • API_URL: the API's origin, written exactly as the registered audience with no path or trailing slash.

  • DATABASE_URL: the Postgres database for reports.

  • UDIBO_CLIENT_ID: the public application's client ID, for a command-line tool that signs you in and prints a token.

  • PORT: optional; where the server listens, 8011 by default.

In your copy of the template, install, migrate the database and start it:

Shell
pnpm install --frozen-lockfile
cp .env.example .env    # then fill it in
pnpm db:migrate
pnpm dev                # http://localhost:8011

pnpm access-token <organization-slug> is the command-line tool: it prints an address to open in your browser, then an export TOKEN=… line for the organization you name.

A missing or invalid value stops the API before it listens, with one line naming every problem. The API reads configuration from the environment only. Terminate HTTPS at the proxy and preserve the canonical Host header; /healthz answers any host for load-balancer probes. HTTP URLs are accepted only for loopback development. The API refuses cross-origin browser requests, so a browser application calls it through its own server.

Checkpoint: sign in as a member of an organization, selecting it with the organization slug parameter, and obtain an access token. GET /reports without a bearer token returns 401. With the token, a permitted member gets 200 and their organization's ID and reports. An unpermitted member gets 403, and so does a token that names no organization. POST /reports with a JSON title returns 201 for a member holding reports:write; the organization is always the token's.

Share one report

A member without reports:read in their organization can read one report once an administrator grants them a role carrying reports:read on it: resource type report, and the report's ID. The API asks your tenant's /api/check for that report live, so revoking the grant ends access on the next request. Unknown reports, other organizations' reports and reports the caller may not read all answer the same 404.

Register the reporting bot

Create a separate confidential application with the client-credentials grant and an explicit identity:organizations:read scope allowlist. Keep the token format and audience at their tenant defaults. Generate a client secret and store it only on the backend running the command.

A tenant Owner or Admin then assigns resource_grants.read in the dashboard: on the application's detail page, under Machine permissions, check Read resource grants and choose Save machine permissions. This is separate from customer roles: registering a customer key of the same name confers no management authority. See resource-grant management for who can assign it and how revocation works.

Add two values to the command's environment, in .env or the host's environment:

  • UDIBO_BOT_CLIENT_ID: the reporting application's client ID.

  • UDIBO_BOT_CLIENT_SECRET: its secret; never expose it to a browser.

The command shares UDIBO_ISSUER with the API. Run it with a report ID: pnpm grant-report <report-id>. It obtains its own machine token and reads /api/resource-grants on your tenant origin for that report. It does not call the management API (/api/identity/*) with that token.

Checkpoint: stdout contains JSON naming the report and its grant holders. A report nobody has been granted produces an empty grants array. Revoke the bot's read permission and rerun: the command exits unsuccessfully, emits no report, and says on stderr that the bot is not assigned the permission. An unregistered resource type and rejected credentials each get their own explanation. Protect successful output as authorization data. The command never prints its credentials or token.

Extend the template safely

Replace the reports table and routes with your own, keeping every row keyed by organization ID and every query scoped to the verified organization ID from the token. New routes registered after the protected mount in server/app.ts are protected by default, and the template's route test fails when a route outside its public list answers without a token, or answers a member who holds no permission. It calls each route that takes a parameter on a row that exists, because a route with no permission check answers an unknown id with the same 404 as a refusal. JWT signature, issuer, audience and expiry are checked locally; permission and organization checks remain your application's responsibility. Local JWT validation does not check immediate revocation, so access may last until the token expires. Keep lifetimes short and clocks synchronized.

The reporting command reads grants only. It does not create organizations, invite members, or change sharing. Scheduling is an application choice. Use the production checklist before exposing your application.

Last verified 2026-10-13.