api-service example
A standalone backend service whose API routes are gated by OAuth2 bearer tokens issued by an external identity provider.
Use this shape when you have a backend (microservice, M2M API, worker-fronting endpoint) that needs to accept tokens but has no frontend of its own.
What this example includes
HonoResourceServerwithIntrospectionTokenReadervalidating bearer tokens against the external IDP's/oauth2/introspectendpoint (RFC 7662).API routes demonstrating different scope requirements:
GET /api/public— no token requiredGET /api/private— any valid tokenGET /api/write— token must carrywritescopeGET /api/admin— token must carryadminscope
An interactive
GET /walkthrough page that drives every OAuth2 grant type against the companionapp-with-own-auth/example and tests the resulting token against/api/*.A dev-only
/dev/callbackredirect target and/dev/tokenproxy so the homepage can exercise the auth-code flow with server-side client credentials. Displayed curl commands use a client-secret placeholder for local terminal use. Real api-services don't host these. The auth-code flow uses PKCE (required by default under OAuth 2.1, even for confidential clients): the homepage mints a verifier, stashes it in an HttpOnly cookie, and puts only the S256 challenge in the authorize URL;/dev/callbackreads the verifier back to complete the token exchange.Tests that stub
tokenReader.getTokendirectly (no introspection wire format faked in test code — the framework owns that).
Running it
This example is a pnpm workspace member that depends on the package through
@udibo/oauth2: workspace:*, which resolves to the package's built dist/.
From the repository root, install and build once:
pnpm install
pnpm buildBy default this example points at app-with-own-auth/ on port 8001 as its IDP.
Start both:
# Terminal 1 — the IDP
pnpm --filter example-app-with-own-auth start
# Terminal 2 — this service
pnpm --filter example-api-service startThen open http://localhost:8002/. pnpm --filter example-api-service dev
restarts the service when a file changes (node --watch).
Configuration is read from the environment by config.ts, which validates it
at startup and lists every invalid variable at once. Every variable is optional;
the defaults pair this service with the local app-with-own-auth example.
| Variable | Default | Meaning |
|---|---|---|
PORT | 8002 | Port to listen on (0 picks a free one) |
PUBLIC_URL | http://localhost:PORT | Origin of this service, used to build redirect_uri |
AUTH_SERVER_URL | http://localhost:8001 | Origin of the identity provider |
CLIENT_ID | spa | Client this service introspects tokens as |
CLIENT_SECRET | spa-secret | Secret for CLIENT_ID |
Run the tests with pnpm --filter example-api-service test. They stub
tokenReader.getToken and call the app in-process; serve.test.ts also starts
the real Node server on a free port.
What to change to make this production-ready
The example deliberately skips operational concerns to keep the OAuth2 wiring readable. When you copy this to a real project:
Replace the demo credential defaults in
config.ts.CLIENT_IDandCLIENT_SECRETfall back to the demo client so the example stands up without setup; a real deployment sets them from your secret store and drops the fallbacks so a missing value fails at startup.Register this service as a client with the external IDP (e.g. Udibo, Auth0, Cognito). The
CLIENT_ID/CLIENT_SECREThere authenticate the introspection request — the IDP gates who can introspect tokens.Drop the demo
/walkthrough and/dev/*mounts. Token acquisition is a client-app concern; this service shouldn't host it.Persist anything you cache on the introspection response. The example calls introspection on every request; production deployments typically cache active tokens in memory (or Redis) until expiry to avoid hitting the IDP on every call.
Add rate limiting and logging on
/api/*— both standard hardening, neither shown here.Wire OpenTelemetry / your tracing system through the resource server's
getContext(c)so you can correlate API calls back to the client and user that made them.If you need user data beyond
subandusername, enrich it ingetUser(data)by joining against your own user table ondata.sub(the IDP's stable user identifier), or by calling the IDP's/userinfoendpoint (OIDC). A machine token (client credentials) carries nosub, sodata.subis present only when there is a real user to join against.
The introspection wire format itself is covered by
@udibo/oauth2/server/resource's IntrospectionTokenReader — you do not write
that code yourself, just the getClient / getUser projection mappers in
oauth2/server.ts.

