# @c9up/visa

> Visa — OAuth 2.1 authorization server for the Ream framework: authorization code with PKCE, refresh rotation, revocation and introspection, on a store contract

Latest version **0.1.2** (published 2026-09-24) · MIT license · 0 weekly downloads

## Install

```sh
npm install @c9up/visa
pnpm add @c9up/visa
yarn add @c9up/visa
bun add @c9up/visa
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.1.2 |
| Published | 2026-09-24 |
| First published | 2026-09-23 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=24.0.0 |
| Dependencies | 0 |
| Unpacked size | 446.1 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| GitHub stars | 0 |
| Maintainers | kaen25 |

## Links

- npm: https://www.npmjs.com/package/@c9up/visa
- Repository: https://github.com/C9up/visa
- Homepage: https://github.com/C9up/visa#readme
- Issues: https://github.com/C9up/visa/issues
- npm.io page: https://npm.io/package/@c9up/visa

## Recent versions

- 0.1.2 (latest) — 2026-09-24
- 0.1.1 — 2026-09-24
- 0.1.0 — 2026-09-23

## README

# @c9up/visa

An OAuth 2.1 authorization server for the Ream ecosystem. It issues tokens to
**other** applications on a user's behalf.

It does not authenticate anyone — that stays warden's job. Visa asks your
application who is signed in, and everything else is its business.

No dependencies: `node:crypto` only. A package in the path that mints and
compares credentials is a package that can replace them.

```bash
ream configure @c9up/visa
```

## What it implements

| | |
|---|---|
| Grants | `authorization_code` (PKCE required), `refresh_token`, `client_credentials` |
| Endpoints | `/oauth/token`, `/oauth/revoke`, `/oauth/introspect`, `/.well-known/oauth-authorization-server` |
| Removed | `implicit` and `password` — gone from OAuth 2.1, and not available under any option |

`/authorize` is **not** mounted, deliberately: it needs a signed-in user and a
consent screen, and both belong to your application. See below.

## Security decisions, and why

- **PKCE on every client**, confidential ones included — a code on the front
  channel is interceptable whoever asked for it.
- **`S256` only.** `plain` puts the verifier in the authorization request, so
  anything that can read that request can complete the exchange, which is what
  PKCE exists to stop. `allowPlainChallenge: true` brings it back; don't.
- **Exact redirect URI matching**, with the one exception the spec names (a
  loopback port). A prefix match is how an open redirect on the client's own
  domain becomes a stolen code.
- **An error is never redirected to an unverified URI.** A bad `redirect_uri`
  is shown to the user, not bounced.
- **Everything is hashed at rest** — codes, access tokens, refresh tokens,
  client secrets. A database dump is not a set of working credentials.
- **Refresh rotation with replay detection.** Every use mints a new token; a
  spent one coming back proves a leak, and since there is no telling the thief
  from the victim, the whole family goes.
- **One sentence per failure.** "No such code", "expired", "already used" and
  "wrong client" are `invalid_grant: The code is not valid.` — telling them
  apart is how a code space gets probed.

## Wiring `/authorize`

The decision is visa's; the page is yours.

```ts
// start/routes.ts
import visa from '@c9up/visa/services/main'

router.get('/oauth/authorize', async (ctx) => {
  const outcome = await visa.authorize(ctx.request.qs(), ctx.auth.user?.id)

  if (outcome.type === 'redirect') return ctx.response.redirect(outcome.url)
  if (outcome.type === 'error') {
    // NOT a redirect: the redirect URI is what failed validation.
    return ctx.view.render('oauth/error', { error: outcome.error.toResponse() })
  }
  if (!ctx.auth.user) return ctx.response.redirect('/login?next=' + encodeURIComponent(ctx.request.url(true)))

  return ctx.view.render('oauth/consent', {
    client: outcome.request.client,
    scopes: outcome.request.scopes,
  })
})

router.post('/oauth/consent', async (ctx) => {
  const outcome = await visa.authorize(ctx.request.all(), ctx.auth.user.id)
  if (outcome.type !== 'consent') return ctx.response.redirect('/')
  const url = ctx.request.input('approve')
    ? await visa.grant(outcome.request, ctx.auth.user.id)
    : visa.deny(outcome.request)
  return ctx.response.redirect(url)
})
```

## Registering a client

```ts
const { client, secret } = await visa.registerClient({
  id: 'invoices',
  name: 'Invoices',
  redirectUris: ['https://invoices.example.com/callback'],
  scopes: ['profile', 'invoices:read'],
})
// `secret` is shown once and never stored in plaintext. A client that loses
// it gets a new one.
```

A public client — a SPA, a native app — registers with
`tokenEndpointAuthMethod: 'none'` and gets no secret. It may not use
`client_credentials`: "the client itself" means nothing when anyone can read
its id out of a browser.

## Protecting a resource

```ts
const grant = await visa.verify(bearerToken)
if (!grant) return ctx.response.unauthorized({ error: 'invalid_token' })
if (!grant.scopes.includes('invoices:read')) {
  return ctx.response.forbidden({ error: 'insufficient_scope' })
}
```

## Testing

`@c9up/visa/testing` hands back the REAL server on a memory store, with a
client already registered:

```ts
import { testVisa } from '@c9up/visa/testing'

const t = await testVisa({ scopes: ['profile', 'invoices:read'] })
const tokens = await t.tokensFor('user-7', 'invoices:read')
const grant = await t.visa.verify(tokens.access_token)
```

`tokensFor()` walks authorize → consent → code → exchange rather than minting a
token directly. Not a lenient double on purpose: a helper that granted whatever
was asked would teach applications to ship a consent screen nobody has seen
refuse.

## The issuer

It identifies the SERVER, and that is all it does here. An access token is
opaque — a random string with no claims inside — so nothing is "bound" to an
issuer cryptographically; the only thing that can vouch for such a token is the
server that minted it. That is why `/oauth/introspect` answers with `iss`, and
why the metadata document names it.

It is validated at boot rather than at the first request: it must be a URL,
`https` (localhost excepted), with no query string and no fragment, and a
trailing slash is dropped — every endpoint is built by concatenation, so
`https://auth.test/` would otherwise produce `https://auth.test//oauth/token`.

## The store

`MemoryStore` is for tests and a single development process. Everything else
implements `VisaStore` — twelve methods. Two of them, `consumeAuthorizationCode`
and `consumeRefreshToken`, **must be atomic**: two requests racing with the same
code must not both succeed, and that single-use guarantee is what replay
detection is built on.

## What is not here yet

OpenID Connect — `id_token`, discovery, JWKS, `/userinfo`. The `nonce` is
already carried through the authorization code for it.

## License

MIT

---
_Source: https://npm.io/package/@c9up/visa · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
