# 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](https://www.udibo.com/docs/identity/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](https://www.udibo.com/docs/identity/applications). 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://.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: ```bash pnpm install --frozen-lockfile cp .env.example .env # then fill it in pnpm db:migrate pnpm dev # http://localhost:8011 ``` `pnpm access-token ` 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](https://www.udibo.com/docs/identity/permissions) 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 `. 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](https://www.udibo.com/docs/identity/production) before exposing your application. _Last verified 2026-10-13._