feat(oauth): authorization server metadata, JWKS, and OIDC discovery endpoints#152
feat(oauth): authorization server metadata, JWKS, and OIDC discovery endpoints#152
Conversation
Adds `exportPublicJwk` to @qauth-labs/server-jwt so callers can convert imported EdDSA public keys into RFC 7517 JWKs with `use: 'sig'`, `alg: 'EdDSA'`, and an optional `kid`. Defensive strip of the `d` member prevents accidental private-key leakage if a caller passes the wrong key. Prepares the JWT plugin for serving `/.well-known/jwks.json` (issue #148).
Adds `getIssuer()` and `getJwks()` to the Fastify JWT plugin so routes can publish RFC 8414 / OIDC Discovery metadata and RFC 7517 JWKS without reaching into the plugin internals. An optional `keyId` plugin option feeds the JWKS `kid` member so future key rotation is a config change. Supports issue #148 (discovery endpoints).
Publishes three unauthenticated well-known documents so clients (incl. MCP clients kicking off OAuth on 401) can auto-discover endpoints and signing keys: - GET /.well-known/oauth-authorization-server (RFC 8414) - GET /.well-known/openid-configuration (OIDC Discovery 1.0) - GET /.well-known/jwks.json (RFC 7517) All three return Cache-Control: public, max-age=3600, and the JWKS uses application/jwk-set+json. Endpoint URLs derive from JWT_ISSUER so operators configure discovery by setting the issuer, not per-endpoint env vars. Metadata advertises the OAuth 2.1 surface: response_types limited to code, code_challenge_methods limited to S256, grant_types including refresh_token (sibling branch) and registration_endpoint (#149) so discovery stays stable as those land. Closes #148.
|
View your CI Pipeline Execution ↗ for commit a662efc
☁️ Nx Cloud last updated this comment at |
There was a problem hiding this comment.
Code Review
This pull request implements OAuth 2.0 and OpenID Connect discovery endpoints, including metadata builders and a JWKS provider. The changes update the JWT plugin to expose necessary issuer and key information and include comprehensive tests for the new functionality. Feedback suggests using a regular expression for more robust trailing slash removal in the issuer URL and optimizing the discovery routes by pre-calculating static metadata and refining the JWKS content-type header for RFC compliance.
| function stripTrailingSlash(url: string): string { | ||
| return url.endsWith('/') ? url.slice(0, -1) : url; | ||
| } |
There was a problem hiding this comment.
The current implementation of stripTrailingSlash only removes a single trailing slash. Using a regular expression is more robust and ensures compliance with RFC 8414 §2, which recommends that the issuer identifier does not include a trailing slash, even if multiple are accidentally provided in the configuration.
| function stripTrailingSlash(url: string): string { | |
| return url.endsWith('/') ? url.slice(0, -1) : url; | |
| } | |
| function stripTrailingSlash(url: string): string { | |
| return url.replace(/\/+$/, ''); | |
| } |
| const buildInput = () => ({ issuer: fastify.jwtUtils.getIssuer() }); | ||
|
|
||
| fastify.get( | ||
| '/.well-known/oauth-authorization-server', | ||
| { | ||
| schema: { | ||
| description: | ||
| 'OAuth 2.0 Authorization Server Metadata (RFC 8414). Unauthenticated, cacheable.', | ||
| tags: ['Discovery'], | ||
| }, | ||
| }, | ||
| async (_request, reply) => { | ||
| reply | ||
| .header('Cache-Control', DISCOVERY_CACHE_CONTROL) | ||
| .header('Content-Type', 'application/json; charset=utf-8'); | ||
| return reply.send(buildAuthorizationServerMetadata(buildInput())); | ||
| } | ||
| ); | ||
|
|
||
| fastify.get( | ||
| '/.well-known/openid-configuration', | ||
| { | ||
| schema: { | ||
| description: | ||
| 'OpenID Connect Discovery 1.0 document. Superset of RFC 8414 AS metadata with OIDC-specific fields. Unauthenticated, cacheable.', | ||
| tags: ['Discovery'], | ||
| }, | ||
| }, | ||
| async (_request, reply) => { | ||
| reply | ||
| .header('Cache-Control', DISCOVERY_CACHE_CONTROL) | ||
| .header('Content-Type', 'application/json; charset=utf-8'); | ||
| return reply.send(buildOpenIdConfiguration(buildInput())); | ||
| } | ||
| ); | ||
|
|
||
| fastify.get( | ||
| '/.well-known/jwks.json', | ||
| { | ||
| schema: { | ||
| description: | ||
| 'JSON Web Key Set (RFC 7517) containing the active EdDSA public signing key(s). Used by clients and resource servers to verify JWT signatures.', | ||
| tags: ['Discovery'], | ||
| }, | ||
| }, | ||
| async (_request, reply) => { | ||
| // Public keys can be served by any cache; see RFC 7517 §8.5.1. | ||
| // `application/jwk-set+json` is the registered media type. | ||
| reply | ||
| .header('Cache-Control', DISCOVERY_CACHE_CONTROL) | ||
| .header('Content-Type', 'application/jwk-set+json; charset=utf-8'); | ||
| const jwks = await fastify.jwtUtils.getJwks(); | ||
| return reply.send(jwks); | ||
| } |
There was a problem hiding this comment.
The discovery metadata and OIDC configuration are static based on the server configuration. Re-building these objects on every request is inefficient. Pre-calculating them during route registration improves performance. Additionally, the charset=utf-8 parameter is removed from the application/jwk-set+json content type as it is not defined for that media type in RFC 7517.
const issuer = fastify.jwtUtils.getIssuer();
const asMetadata = buildAuthorizationServerMetadata({ issuer });
const oidcConfig = buildOpenIdConfiguration({ issuer });
fastify.get(
'/.well-known/oauth-authorization-server',
{
schema: {
description:
'OAuth 2.0 Authorization Server Metadata (RFC 8414). Unauthenticated, cacheable.',
tags: ['Discovery'],
},
},
async (_request, reply) => {
return reply
.header('Cache-Control', DISCOVERY_CACHE_CONTROL)
.send(asMetadata);
}
);
fastify.get(
'/.well-known/openid-configuration',
{
schema: {
description:
'OpenID Connect Discovery 1.0 document. Superset of RFC 8414 AS metadata with OIDC-specific fields. Unauthenticated, cacheable.',
tags: ['Discovery'],
},
},
async (_request, reply) => {
return reply
.header('Cache-Control', DISCOVERY_CACHE_CONTROL)
.send(oidcConfig);
}
);
fastify.get(
'/.well-known/jwks.json',
{
schema: {
description:
'JSON Web Key Set (RFC 7517) containing the active EdDSA public signing key(s). Used by clients and resource servers to verify JWT signatures.',
tags: ['Discovery'],
},
},
async (_request, reply) => {
const jwks = await fastify.jwtUtils.getJwks();
return reply
.header('Cache-Control', DISCOVERY_CACHE_CONTROL)
.header('Content-Type', 'application/jwk-set+json')
.send(jwks);
}
);
Closes #148.
Summary
/.well-known/oauth-authorization-server(RFC 8414),/.well-known/jwks.json(RFC 7517), and/.well-known/openid-configuration(OIDC Discovery 1.0), each unauthenticated and cacheable (Cache-Control: public, max-age=3600).jwkshelper inlibs/server/jwtand extends the JWT Fastify plugin withgetIssuer/getJwks/keyId.JWT_ISSUER(no new env vars).registration_endpointandrefresh_tokenare advertised per the issue (sibling PRs wire the actual backing).Caveats for reviewers
scopes_supportedis a compile-time default (openid profile email offline_access). Realm-aware lookup is flagged as a TODO — appropriate follow-up once the authorize route consults a realm-level scope registry.libs/server/jwt(+ JWT plugin), not in the route test — keptjoseout ofauth-server's direct deps.Test plan
pnpm nx test— 3 projects, 136 tests passing (17 new)pnpm nx lint/pnpm nx build auth-server/.well-known/oauth-authorization-serverand verify RFC 8414 shape via a public validator/.well-known/jwks.json, verify a qauth-issued JWT against it usingjose