@teikyo/oauth
v0.2.0
Published
OAuth token verifiers and protected resource metadata for Sozai HTTP servers
Readme
@teikyo/oauth
OAuth access token verifiers and RFC 9728 protected resource metadata helpers for Sozai HTTP servers.
Install
pnpm add @teikyo/oauth @sozai/http-server honoUsage
import { createDIDVerifier, createIssuerVerifier, createJWKSVerifier } from '@teikyo/oauth'
const verifier = createIssuerVerifier({
'https://as.example.com': createJWKSVerifier({ issuer: 'https://as.example.com' }),
})
const didVerifier = createDIDVerifier({ allowedIssuers: ['did:key:...'] })createJWKSVerifierverifies RS256/ES256 JWTs against an issuer's JWKS. Fetches time out after 10 s by default (fetchTimeoutMs). Key lifetimes fromCache-Control: max-ageare floored at 30 s, and when a refresh fails the cached keys keep serving for up to an hour past expiry, without refetching for 30 s after each failed refresh. Concurrent requests share one refresh.createDIDVerifierverifies DID-signed tokens;allowedIssuersrestricts the accepted DIDs (a list or a predicate) and asubdiffering fromissis rejected.createIssuerVerifierroutes tokens to a verifier by theiriss, rejecting unknown issuers before any fetch.
Claim policies
The two verifiers apply different claim policies:
- JWKS verifier: standard claims checked against the configured
issuer--issmatches,audincludes the protectedresource,expis required,exp/nbfare checked with a clock-skew tolerance (30 s by default), and aniatfurther in the future than that tolerance is rejected -- andsubmust be a string. Only RS256 and ES256 signatures are accepted. Thetypheader must be absent,JWTorat+jwt(compared case-insensitively, with an optionalapplication/prefix), so other JWT kinds the authorisation server signs, such as logout or ID tokens, are rejected. - DID verifier: the signing DID is the issuer.
allowedIssuers(a list or a predicate, on the normalised DID) restricts it,submust equalisswhen present,audmust include theresource, andexpis required.exp,nbfandiatuse the same clock-skew tolerance as the JWKS verifier.
Verifiers throw TokenVerificationError for credential failures only. Its messages are fixed strings and never carry fetched or driver text.
Resource plugin
Plugin name: oauth:resource (exported as OAUTH_RESOURCE). oauthResourcePlugin registers the oauth:resource plugin on a @sozai/http-server. It serves the RFC 9728 metadata document and exposes requireBearer() middleware for per-route protection (hono is a peer dependency).
import { createServer, definePlugin } from '@sozai/http-server'
import { getAuthInfo, OAUTH_RESOURCE, oauthResourcePlugin } from '@teikyo/oauth'
const api = definePlugin({
name: 'api',
dependsOn: [OAUTH_RESOURCE],
setup(ctx) {
const { requireBearer } = ctx.use(OAUTH_RESOURCE)
ctx.route('post', '/mcp', requireBearer({ scopes: ['read'] }), (c) =>
c.json({ subject: getAuthInfo(c).subject }),
)
},
})
const server = await createServer({
plugins: [
oauthResourcePlugin({
resource: 'https://mcp.example.com/mcp',
authorizationServers: ['https://as.example.com'],
verifier,
}),
api,
],
})| Option | Type | Description |
| --- | --- | --- |
| resource | string | Absolute http(s) URL identifying the protected resource, without query or fragment. |
| authorizationServers | Array<string> | Authorization server issuers advertised in the metadata. Must not be empty, and each entry must be an absolute http(s) URL. |
| verifier | OAuthTokenVerifier | Verifies bearer tokens, for example the result of createIssuerVerifier(). |
resourcemust be an absolute http(s) URL andauthorizationServersnon-empty; the factory throws otherwise.- A missing token, a non-Bearer scheme, an empty token or several tokens answer
401withWWW-Authenticate: Bearer resource_metadata="..."and noerrorparameter (RFC 6750). Thebearerscheme is case-insensitive. - A
TokenVerificationErroranswers401witherror="invalid_token", its fixed message aserror_descriptionandresource_metadata. Missing scopes answer403witherror="insufficient_scope",scopeandresource_metadata. Every header value is quoted with\and"escaped. - Any other verifier error is logged and answers
503with a fixed body and noWWW-Authenticate; the cause is never returned. - Custom verifiers must import
TokenVerificationErrorfrom the same@teikyo/oauthinstance as the plugin (checked withinstanceof), otherwise their credential failures surface as503. Control and non-ASCII characters inerror_descriptionare replaced with?. resourcemust not contain a query or fragment. ATokenVerificationErrorwith codeinsufficient_scopeanswers403.- On success the
AuthInfois available throughgetAuthInfo(c), which throws on a route withoutrequireBearer().
Key-infrastructure failures are not credential failures: createJWKSVerifier throws a plain error and the plugin answers 503, so clients retry instead of discarding a valid token. This covers:
- the authorization server metadata or JWKS fetch failing (network error, timeout, non-200 status, oversized or non-JSON body) when no usable cached keys exist;
- a forced refresh after an unknown
kidfailing, even when other keys are cached; - metadata that does not match the issuer or lacks
jwks_uri, a JWKS without akeysarray or with too many keys; - a non-https
jwks_uridiscovered from the metadata (plain http is accepted only for loopback hosts).
Configuration errors fail at construction instead: createJWKSVerifier throws when issuer or jwksURI is not https (plain http is accepted only for loopback hosts), or when a numeric option is out of range.
Known behaviour: a malformed key published by the authorization server is the exception, reported as a TokenVerificationError, so clients see 401 rather than 503 in that case.
