fix(openapi): whitelist Better Auth downloader Device Flow (#552)

Publish only explicitly registered Better Auth operations in the ZPan product OpenAPI contract while preserving runtime auth routes and discovery.
This commit is contained in:
Jasper Van
2026-08-04 16:39:54 -04:00
committed by GitHub
parent 50a9895fcf
commit 4ab0b0922a
11 changed files with 3528 additions and 26981 deletions
+2 -2
View File
@@ -416,7 +416,7 @@ func (c *Client) SeedingTasks(ctx context.Context) ([]DownloadTask, error) {
func (c *Client) RequestDeviceCode(ctx context.Context) (DeviceCode, error) {
scope := "downloader:register"
res, err := c.api.PostApiAuthDeviceCodeWithResponse(ctx, openapi.PostApiAuthDeviceCodeJSONRequestBody{
res, err := c.api.CreateDeviceAuthorizationWithResponse(ctx, openapi.CreateDeviceAuthorizationJSONRequestBody{
ClientId: "zpan-cli",
Scope: &scope,
})
@@ -440,7 +440,7 @@ func (c *Client) RequestDeviceCode(ctx context.Context) (DeviceCode, error) {
}
func (c *Client) PollDeviceToken(ctx context.Context, deviceCode string) (DeviceToken, error) {
res, err := c.api.PostApiAuthDeviceTokenWithResponse(ctx, openapi.PostApiAuthDeviceTokenJSONRequestBody{
res, err := c.api.CreateDeviceAccessTokenWithResponse(ctx, openapi.CreateDeviceAccessTokenJSONRequestBody{
GrantType: "urn:ietf:params:oauth:grant-type:device_code",
DeviceCode: deviceCode,
ClientId: "zpan-cli",
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -147,8 +147,11 @@ The server follows a ports-and-adapters shape.
### `server/app.ts`
Creates the Hono app, installs global middleware, exposes OpenAPI/Scalar docs,
mounts WebDAV, and mounts each API resource. It also merges Better Auth's
generated OpenAPI schema into `/api/openapi.json`.
mounts WebDAV, and mounts each API resource. `/api/openapi.json` includes ZPan's
explicit resource contracts plus operations admitted by the declarative Better
Auth OpenAPI registry. The registry currently contains only the two Downloader
Device Flow operations. Better Auth's full runtime schema remains available
only from its own reference endpoints.
### `server/http/`
@@ -311,8 +314,11 @@ external URLs that are not ZPan APIs, such as presigned S3 upload URLs.
- `/api/*` — primary JSON API, mounted from route modules in `server/http/`
- `/api/auth/*` — Better Auth routes
- `/api/openapi.json`combined OpenAPI document for ZPan routes and Better
Auth routes
- `/api/openapi.json`public ZPan product contract; Better Auth operations are
denied by default and currently only the registered Downloader Device Flow
operations are included
- `/api/auth/reference` and `/api/auth/open-api/generate-schema` — Better Auth's
complete reference UI and generated runtime schema
- `/api/docs` — Scalar API reference
- `/dav/*` — WebDAV endpoint
- `/api/events` — one resumable server-sent event stream for scoped durable
+17 -3
View File
@@ -56,9 +56,23 @@ OpenAPI uses standard `security` declarations. Every protected ZPan operation
declares its OAuth scopes, plus cookie and bearer alternatives. Role constraints
that OpenAPI cannot express use the narrow
`x-zpan-authorization-constraints` extension. Better Auth operations and their
generated OpenAPI definitions remain owned by Better Auth. ZPan augments the
dynamic-registration response and adds the RFC 7592 configuration endpoint that
is implemented at its auth boundary.
complete generated definitions remain owned by Better Auth and available from
its reference endpoints. The public product contract uses a deny-by-default
operation registry. Each admitted entry names the exact source path and method,
public path, stable operation ID, tags, and security policy; it may also declare
a narrow contract correction. The aggregator copies only path-item parameters
and the transitive local component closure reachable from that operation,
rejecting missing sources, collisions, dangling references, and external
references.
The registry currently imports only `POST /device/code` and `POST
/device/token`, under their `/api/auth` runtime mount, for the Downloader Device
Flow protocol; both explicitly require no existing session or bearer
credential. Adding a Better Auth operation to the product contract is therefore
an explicit contract and authorization decision made in one registry entry, not
an automatic consequence of installing or updating a plugin. ZPan separately
publishes its dynamic-registration and RFC 7592 configuration operations
implemented at the auth boundary.
## Workspace Authorization Details
+10 -10
View File
@@ -1,7 +1,9 @@
// Generates the downloader's Go OpenAPI client (cmd/internal/openapi/client.gen.go)
// straight from the complete, live /api/openapi.json — no committed intermediate
// spec, no hand-curated subset. The spec is written to a temp file only so
// oapi-codegen has something to read, then discarded.
// straight from the live public product contract at /api/openapi.json, including
// its explicitly registered Better Auth operations (currently Downloader
// Device Flow). Code generation applies no further endpoint subset and commits
// no intermediate spec; the temporary spec exists only as oapi-codegen input
// and is discarded.
//
// pnpm openapi:client regenerate the committed Go client
// pnpm openapi:client --check fail if the committed client is stale
@@ -36,10 +38,9 @@ function downconvertTo30(node: unknown): void {
for (const v of Object.values(obj)) downconvertTo30(v)
}
// The client attaches its bearer token manually via a RequestEditorFn, so it
// needs no security metadata. Strip it: better-auth's bearerAuth scheme otherwise
// makes oapi-codegen (client-only) emit a `BearerAuthScopes` const whose
// context-key type is only generated in server mode → undefined symbol.
// The client attaches credentials manually via a RequestEditorFn, so the
// generated transport needs no product-wide security metadata. Stripping it
// also avoids client-only oapi-codegen emitting server-mode scope helpers.
function stripSecurity(doc: Doc): void {
delete (doc as Record<string, unknown>).security
if (doc.components) delete (doc.components as Record<string, unknown>).securitySchemes
@@ -53,9 +54,8 @@ function stripSecurity(doc: Doc): void {
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'])
// better-auth omits path-parameter declarations on some operations (e.g.
// /api/auth/callback/{id}), which oapi-codegen rejects. Declare any `{param}`
// segment that an operation is missing. Whole-document, mechanical.
// Declare any `{param}` segment that an operation is missing because
// oapi-codegen rejects undeclared path parameters. Whole-document, mechanical.
function declareMissingPathParams(doc: Doc): void {
for (const [path, item] of Object.entries(doc.paths)) {
const names = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
+17 -40
View File
@@ -15,6 +15,7 @@ import { adminStats } from './http/admin-stats'
import { ARAZZO_DOCUMENT_PATH, ARAZZO_MEDIA_TYPE, createArazzoDocument } from './http/arazzo'
import { serveAvatarBlob } from './http/avatar-blobs'
import backgroundJobs from './http/background-jobs'
import { addRegisteredBetterAuthOpenApiOperations, DOWNLOADER_DEVICE_FLOW_TAG } from './http/better-auth-openapi'
import { configz } from './http/configz'
import downloadTasks, { downloaderTasksRoute } from './http/downloads/download-tasks'
import downloaders, { downloaderSelfRoute } from './http/downloads/downloaders'
@@ -224,10 +225,10 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
return c.newResponse(JSON.stringify(createArazzoDocument(new URL(c.req.url).origin)), 200, headers)
})
// Global OpenAPI document. Aggregates every route defined with `.openapi()`
// across all mounted sub-apps — a route appears here as soon as its resource is
// converted to OpenAPIHono, no curation needed. better-auth endpoints (incl. the
// device flow) document themselves separately at /api/auth/reference.
// Global OpenAPI document. ZPan routes defined with `.openapi()` are
// aggregated across all mounted sub-apps. Better Auth documents its complete
// runtime surface separately at /api/auth/reference; only the Downloader
// Device Flow protocol is explicitly admitted to this product contract.
app.get('/api/openapi.json', async (c) => {
const doc = app.getOpenAPIDocument({
openapi: '3.1.0',
@@ -243,41 +244,13 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
{ name: 'Events', description: 'Multiplexed server-sent event stream' },
{ name: 'Download Tasks', description: 'Remote download tasks' },
{ name: 'Downloaders', description: 'Download agents and their heartbeats' },
{
name: DOWNLOADER_DEVICE_FLOW_TAG,
description: 'Public device authorization protocol used by downloader clients',
},
],
})
// Merge better-auth's own auto-generated schema (sign-in/up, organization,
// the device-authorization flow, …) into the same document. Both halves are
// generated — nothing here is a hand-maintained endpoint definition; new
// better-auth endpoints appear automatically. Its paths are relative to the
// /api/auth mount, so prefix them.
const authDoc = (await c.get('auth').api.generateOpenAPISchema()) as {
paths?: Record<string, unknown>
components?: { schemas?: Record<string, unknown> }
}
for (const [path, item] of Object.entries(authDoc.paths ?? {})) {
doc.paths[`/api/auth${path}`] = item as (typeof doc.paths)[string]
}
// better-auth 1.7.0-rc.2 documents POST /device/token with its session
// response even though the handler returns an OAuth device token. Keep the
// generated contract aligned with the wire response until upstream fixes it.
const deviceTokenJson = (
doc.paths['/api/auth/device/token'] as
| { post?: { responses?: Record<string, { content?: Record<string, { schema?: unknown }> }> } }
| undefined
)?.post?.responses?.['200']?.content?.['application/json']
if (deviceTokenJson) {
deviceTokenJson.schema = {
type: 'object',
properties: {
access_token: { type: 'string' },
token_type: { type: 'string' },
expires_in: { type: 'integer' },
scope: { type: 'string' },
},
required: ['access_token', 'token_type', 'expires_in'],
}
}
doc.components ??= {}
doc.components.securitySchemes = {
...(doc.components.securitySchemes ?? {}),
@@ -298,10 +271,14 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
},
}
addOAuthClientRegistrationManagementOpenApi(doc)
doc.components.schemas = {
...(authDoc.components?.schemas as typeof doc.components.schemas),
...doc.components.schemas,
}
// Better Auth owns the runtime routes and its complete reference schema.
// The product contract imports only operations whose path, method, public
// identity, tags, security, and any narrow correction are registered
// explicitly. Reachable components are copied transitively; everything
// else remains private by default.
const authDoc = await c.get('auth').api.generateOpenAPISchema()
addRegisteredBetterAuthOpenApiOperations(doc, authDoc)
Object.assign(doc, {
'x-zpan-discovery': {
oauthAuthorizationServer: '/.well-known/oauth-authorization-server/api/auth',
+5 -4
View File
@@ -648,10 +648,11 @@ export async function createAuth(
},
plugins: [
admin(),
// Self-documents every better-auth endpoint (incl. the device-authorization
// flow) at GET /api/auth/reference (Scalar UI) and
// /api/auth/open-api/generate-schema. Replaces the old hand-written device
// route stubs; our own routes live in the global doc at /api/openapi.json.
// Self-documents Better Auth's complete runtime surface at
// GET /api/auth/reference (Scalar UI) and
// /api/auth/open-api/generate-schema. The global product contract at
// /api/openapi.json admits only explicitly registered path/methods
// (currently the Downloader Device Flow protocol).
openAPI(),
organization({
roles: {
@@ -62,6 +62,42 @@ const KNOWN_METADATA_FIELDS = new Set([
'resources',
'require_pkce',
])
const oauthClientMetadataOpenApiProperties = {
redirect_uris: { type: 'array', items: { type: 'string', format: 'uri' } },
token_endpoint_auth_method: { type: 'string' },
grant_types: { type: 'array', items: { type: 'string' } },
response_types: { type: 'array', items: { type: 'string' } },
client_name: { type: 'string' },
client_uri: { type: 'string', format: 'uri' },
logo_uri: { type: 'string', format: 'uri' },
scope: { type: 'string' },
contacts: { type: 'array', items: { type: 'string' } },
tos_uri: { type: 'string', format: 'uri' },
policy_uri: { type: 'string', format: 'uri' },
jwks_uri: { type: 'string', format: 'uri' },
jwks: {
oneOf: [
{ type: 'array', items: { type: 'object', additionalProperties: true } },
{
type: 'object',
additionalProperties: true,
properties: { keys: { type: 'array', items: { type: 'object', additionalProperties: true } } },
},
],
},
software_id: { type: 'string' },
software_version: { type: 'string' },
software_statement: { type: 'string' },
post_logout_redirect_uris: { type: 'array', items: { type: 'string', format: 'uri' } },
backchannel_logout_uri: { type: 'string', format: 'uri' },
backchannel_logout_session_required: { type: 'boolean' },
type: { type: 'string', enum: ['web', 'native', 'user-agent-based'] },
subject_type: { type: 'string', enum: ['public', 'pairwise'] },
dpop_bound_access_tokens: { type: 'boolean' },
authorization_details_types: { type: 'array', items: { type: 'string' } },
resources: { type: 'array', items: { type: 'string', format: 'uri' } },
require_pkce: { type: 'boolean' },
} as const
const absoluteUrl = z.string().url()
const updateSchema = z
@@ -102,37 +138,48 @@ const updateSchema = z
.passthrough()
export function addOAuthClientRegistrationManagementOpenApi(document: { paths: Record<string, unknown> }): void {
const registration = document.paths['/api/auth/oauth2/register'] as
| { post?: { responses?: Record<string, { content?: Record<string, { schema?: Record<string, unknown> }> }> } }
| undefined
const registrationSchema = registration?.post?.responses?.['201']?.content?.['application/json']?.schema
if (registrationSchema) {
const properties = (registrationSchema.properties ?? {}) as Record<string, unknown>
registrationSchema.properties = {
...properties,
registration_client_uri: { type: 'string', format: 'uri' },
registration_access_token: { type: 'string' },
}
registrationSchema.required = [
...new Set([
...(Array.isArray(registrationSchema.required) ? registrationSchema.required : []),
'registration_client_uri',
'registration_access_token',
]),
]
}
const clientInformationSchema = {
type: 'object',
additionalProperties: true,
properties: {
...oauthClientMetadataOpenApiProperties,
client_id: { type: 'string' },
client_secret: { type: 'string' },
client_id_issued_at: { type: 'integer' },
client_secret_expires_at: { type: 'integer' },
registration_client_uri: { type: 'string', format: 'uri' },
registration_access_token: { type: 'string' },
scope: { type: 'string' },
},
required: ['client_id', 'registration_client_uri', 'registration_access_token'],
}
document.paths['/api/auth/oauth2/register'] = {
post: {
operationId: 'createDynamicOAuthClientRegistration',
tags: ['OAuth'],
summary: 'Create a dynamic OAuth client registration',
security: [],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
additionalProperties: true,
properties: oauthClientMetadataOpenApiProperties,
},
},
},
},
responses: {
'201': {
description: 'OAuth client registration created',
content: { 'application/json': { schema: clientInformationSchema } },
},
'400': { description: 'Invalid client metadata' },
},
},
}
const bearerSecurity = [{ bearerAuth: [] }]
document.paths['/api/auth/oauth2/register/{clientId}'] = {
parameters: [{ name: 'clientId', in: 'path', required: true, schema: { type: 'string' } }],
+378
View File
@@ -0,0 +1,378 @@
import { describe, expect, it } from 'vitest'
import {
addRegisteredBetterAuthOpenApiOperations,
type BetterAuthOpenApiOperationRegistration,
DOWNLOADER_DEVICE_FLOW_TAG,
} from './better-auth-openapi'
type TestOperation = Record<string, unknown> & {
responses?: Record<string, { content?: Record<string, { schema?: unknown }> }>
}
type TestPathItem = {
parameters?: unknown[]
delete?: TestOperation
get?: TestOperation
patch?: TestOperation
post?: TestOperation
put?: TestOperation
}
type TestOpenApiDocument = {
paths: Record<string, TestPathItem>
components?: Record<string, Record<string, unknown>>
}
describe('Better Auth OpenAPI operation registry', () => {
it('publishes only the registered Device Flow methods with their declared contract policy', () => {
const doc: TestOpenApiDocument = { paths: { '/api/objects': { get: { operationId: 'listObjects' } } } }
const authDoc = {
paths: {
'/device/code': {
get: { operationId: 'futureDeviceCodeRead' },
post: { operationId: 'upstreamDeviceCode', security: [{ bearerAuth: [] }], responses: {} },
},
'/device/token': {
delete: { operationId: 'futureDeviceTokenDelete' },
post: {
operationId: 'upstreamDeviceToken',
security: [{ bearerAuth: [] }],
responses: {
'200': {
content: {
'application/json': { schema: { $ref: '#/components/schemas/Session' } },
},
},
},
},
},
'/sign-in/email': { post: { operationId: 'signInEmail' } },
},
components: { schemas: { Session: {}, User: {} } },
}
addRegisteredBetterAuthOpenApiOperations(doc, authDoc)
expect(Object.keys(doc.paths)).toEqual(['/api/objects', '/api/auth/device/code', '/api/auth/device/token'])
expect(Object.keys(doc.paths['/api/auth/device/code'] ?? {})).toEqual(['post'])
expect(Object.keys(doc.paths['/api/auth/device/token'] ?? {})).toEqual(['post'])
expect(doc.paths['/api/auth/device/code'].post).toMatchObject({
operationId: 'createDeviceAuthorization',
tags: [DOWNLOADER_DEVICE_FLOW_TAG],
security: [],
})
expect(doc.paths['/api/auth/device/token'].post).toMatchObject({
operationId: 'createDeviceAccessToken',
tags: [DOWNLOADER_DEVICE_FLOW_TAG],
security: [],
responses: {
'200': {
content: {
'application/json': {
schema: {
properties: {
access_token: { type: 'string' },
token_type: { type: 'string' },
expires_in: { type: 'integer' },
scope: { type: 'string' },
},
required: ['access_token', 'token_type', 'expires_in'],
},
},
},
},
},
})
expect(doc.components?.schemas?.Session).toBeUndefined()
expect(doc.components?.schemas?.User).toBeUndefined()
})
it('merges registered methods on one public path and applies security per operation', () => {
const doc: TestOpenApiDocument = { paths: {} }
const authDoc = {
paths: {
'/sessions': {
get: { responses: {} },
post: { responses: {} },
},
},
}
const registry = [
registration({
sourcePath: '/sessions',
method: 'get',
publicPath: '/api/auth/sessions',
operationId: 'listAuthSessions',
tags: ['Auth Sessions'],
security: { mode: 'requirements', requirements: [{ cookieAuth: [] }, { bearerAuth: [] }] },
}),
registration({
sourcePath: '/sessions',
method: 'post',
publicPath: '/api/auth/sessions',
operationId: 'createAuthSession',
tags: ['Auth Session Creation'],
security: { mode: 'public' },
}),
]
addRegisteredBetterAuthOpenApiOperations(doc, authDoc, registry)
expect(Object.keys(doc.paths['/api/auth/sessions'] ?? {})).toEqual(['get', 'post'])
expect(doc.paths['/api/auth/sessions'].get).toMatchObject({
operationId: 'listAuthSessions',
tags: ['Auth Sessions'],
security: [{ cookieAuth: [] }, { bearerAuth: [] }],
})
expect(doc.paths['/api/auth/sessions'].post).toMatchObject({
operationId: 'createAuthSession',
tags: ['Auth Session Creation'],
security: [],
})
})
it('copies path-item parameters and their reachable component closure', () => {
const doc: TestOpenApiDocument = { paths: {}, components: { schemas: { Existing: { type: 'string' } } } }
const authDoc = {
paths: {
'/devices/{deviceId}': {
parameters: [{ $ref: '#/components/parameters/DeviceId' }],
get: { responses: {} },
},
},
components: {
parameters: {
DeviceId: {
name: 'deviceId',
in: 'path',
required: true,
schema: { $ref: '#/components/schemas/DeviceIdentifier' },
},
},
schemas: {
DeviceIdentifier: { type: 'string', pattern: '^device_' },
Unrelated: { type: 'object' },
},
},
}
addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [
registration({ sourcePath: '/devices/{deviceId}', method: 'get', publicPath: '/api/auth/devices/{deviceId}' }),
])
expect(doc.paths['/api/auth/devices/{deviceId}'].parameters).toEqual([{ $ref: '#/components/parameters/DeviceId' }])
expect(doc.components).toEqual({
parameters: { DeviceId: authDoc.components.parameters.DeviceId },
schemas: {
Existing: { type: 'string' },
DeviceIdentifier: authDoc.components.schemas.DeviceIdentifier,
},
})
})
it('copies only the transitive component closure and handles circular references', () => {
const doc: TestOpenApiDocument = { paths: {} }
const authDoc = {
paths: {
'/source': {
post: {
responses: {
'200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Root' } } } },
},
},
},
},
components: {
schemas: {
Root: { type: 'object', properties: { child: { $ref: '#/components/schemas/Child' } } },
Child: { type: 'object', properties: { parent: { $ref: '#/components/schemas/Root' } } },
Session: { type: 'object' },
User: { type: 'object' },
},
},
}
addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])
expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Child', 'Root'])
expect(doc.components?.schemas).toMatchObject({
Root: authDoc.components.schemas.Root,
Child: authDoc.components.schemas.Child,
})
})
it('fails closed when a registered source operation is missing and names its actual method', () => {
const doc: TestOpenApiDocument = { paths: {} }
const registry = [registration({ sourcePath: '/source', method: 'delete', operationId: 'deleteAuthSession' })]
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, { paths: {} }, registry)).toThrow(
'Better Auth OpenAPI is missing DELETE /source',
)
expect(doc).toEqual({ paths: {} })
})
it('rejects duplicate source and target registrations before aggregation', () => {
const sourceDuplicate = [
registration(),
registration({ publicPath: '/api/auth/other', operationId: 'createOther' }),
]
const targetDuplicate = [registration(), registration({ sourcePath: '/other', operationId: 'createOther' })]
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, { paths: {} }, sourceDuplicate)).toThrow(
'Duplicate Better Auth OpenAPI source registration: POST /source',
)
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, { paths: {} }, targetDuplicate)).toThrow(
'Duplicate Better Auth OpenAPI target registration: POST /api/auth/source',
)
})
it('rejects duplicate registry and existing-document operationIds', () => {
const duplicateRegistry = [registration(), registration({ sourcePath: '/other', publicPath: '/api/auth/other' })]
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, { paths: {} }, duplicateRegistry)).toThrow(
'Duplicate Better Auth OpenAPI operationId registration: createAuthSource',
)
const doc = { paths: { '/api/existing': { get: { operationId: 'createAuthSource' } } } }
const authDoc = { paths: { '/source': { post: { responses: {} } } } }
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])).toThrow(
'OpenAPI operationId createAuthSource conflicts between GET /api/existing and POST /api/auth/source',
)
expect(doc).toEqual({ paths: { '/api/existing': { get: { operationId: 'createAuthSource' } } } })
})
it('rejects an existing target method without overwriting it', () => {
const doc = { paths: { '/api/auth/source': { post: { operationId: 'existingSource' } } } }
const authDoc = { paths: { '/source': { post: { responses: {} } } } }
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])).toThrow(
'OpenAPI target already defines POST /api/auth/source',
)
expect(doc.paths['/api/auth/source'].post).toEqual({ operationId: 'existingSource' })
})
it('rejects conflicting target path parameters', () => {
const doc = {
paths: {
'/api/auth/source': {
parameters: [{ name: 'tenantId', in: 'path' }],
get: { operationId: 'getExistingSource' },
},
},
}
const authDoc = {
paths: {
'/source': {
parameters: [{ name: 'sourceId', in: 'path' }],
post: { responses: {} },
},
},
}
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])).toThrow(
'OpenAPI path parameters conflict at /api/auth/source',
)
})
it('rejects a reachable component collision', () => {
const doc = { paths: {}, components: { schemas: { Shared: { type: 'integer' } } } }
const authDoc = {
paths: {
'/source': {
post: {
responses: {
'200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Shared' } } } },
},
},
},
},
components: { schemas: { Shared: { type: 'string' } } },
}
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])).toThrow(
'OpenAPI component collision at #/components/schemas/Shared',
)
expect(doc.components.schemas.Shared).toEqual({ type: 'integer' })
})
it.each([
['dangling local', '#/components/schemas/Missing', 'has a dangling reference'],
[
'external',
'https://example.com/schema.json',
'external reference https://example.com/schema.json is not allowed',
],
['non-component local', '#/paths/~1other', 'must target a component'],
])('rejects %s references', (_name, reference, error) => {
const authDoc = {
paths: {
'/source': {
post: {
responses: { '200': { content: { 'application/json': { schema: { $ref: reference } } } } },
},
},
},
}
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, authDoc, [registration()])).toThrow(error)
})
it('uses a registry-declared response schema override before resolving references', () => {
const doc: TestOpenApiDocument = { paths: {} }
const authDoc = {
paths: {
'/source': {
post: {
responses: {
'200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Incorrect' } } } },
},
},
},
},
components: { schemas: { Incorrect: { type: 'object' } } },
}
const registry = [
registration({
contract: {
responseSchemas: {
'200': { 'application/json': { type: 'object', required: ['access_token'] } },
},
},
}),
]
addRegisteredBetterAuthOpenApiOperations(doc, authDoc, registry)
expect(doc.paths['/api/auth/source']?.post?.responses?.['200']?.content?.['application/json']?.schema).toEqual({
type: 'object',
required: ['access_token'],
})
expect(doc.components?.schemas?.Incorrect).toBeUndefined()
})
it('fails closed when a declared response schema override cannot be applied', () => {
const authDoc = { paths: { '/source': { post: { responses: {} } } } }
const registry = [
registration({
contract: { responseSchemas: { '201': { 'application/problem+json': { type: 'object' } } } },
}),
]
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, authDoc, registry)).toThrow(
'Better Auth OpenAPI cannot apply the 201 application/problem+json response schema override for POST /source',
)
})
})
function registration(
overrides: Partial<BetterAuthOpenApiOperationRegistration> = {},
): BetterAuthOpenApiOperationRegistration {
return {
sourcePath: '/source',
method: 'post',
publicPath: '/api/auth/source',
operationId: 'createAuthSource',
tags: ['Auth Sources'],
security: { mode: 'public' },
...overrides,
}
}
+431
View File
@@ -0,0 +1,431 @@
type OpenApiObject = Record<string, unknown>
type OpenApiOperation = OpenApiObject
type OpenApiDocument = {
paths?: Record<string, unknown>
components?: object
}
type ZPanOpenApiDocument = OpenApiDocument & {
paths: Record<string, unknown>
}
export type BetterAuthOpenApiMethod = 'delete' | 'get' | 'head' | 'options' | 'patch' | 'post' | 'put' | 'trace'
type OpenApiSecurityRequirement = Record<string, readonly string[]>
export type BetterAuthOpenApiSecurityPolicy =
| { mode: 'public' }
| {
mode: 'requirements'
requirements: readonly OpenApiSecurityRequirement[]
}
type BetterAuthOpenApiContractNormalization = {
responseSchemas?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
}
export type BetterAuthOpenApiOperationRegistration = {
sourcePath: string
method: BetterAuthOpenApiMethod
publicPath: string
operationId: string
tags: readonly [string, ...string[]]
security: BetterAuthOpenApiSecurityPolicy
contract?: BetterAuthOpenApiContractNormalization
}
export const DOWNLOADER_DEVICE_FLOW_TAG = 'Downloader Device Flow'
const deviceAccessTokenResponseSchema = {
type: 'object',
properties: {
access_token: { type: 'string' },
token_type: { type: 'string' },
expires_in: { type: 'integer' },
scope: { type: 'string' },
},
required: ['access_token', 'token_type', 'expires_in'],
}
// This registry is the complete Better Auth boundary of the ZPan product
// contract. Adding an entry is an explicit compatibility and authorization
// decision; unregistered Better Auth operations remain runtime-only.
export const BETTER_AUTH_OPENAPI_OPERATION_REGISTRY = [
{
sourcePath: '/device/code',
method: 'post',
publicPath: '/api/auth/device/code',
operationId: 'createDeviceAuthorization',
tags: [DOWNLOADER_DEVICE_FLOW_TAG],
security: { mode: 'public' },
},
{
sourcePath: '/device/token',
method: 'post',
publicPath: '/api/auth/device/token',
operationId: 'createDeviceAccessToken',
tags: [DOWNLOADER_DEVICE_FLOW_TAG],
security: { mode: 'public' },
contract: {
responseSchemas: {
'200': {
'application/json': deviceAccessTokenResponseSchema,
},
},
},
},
] as const satisfies readonly BetterAuthOpenApiOperationRegistration[]
const OPENAPI_METHODS = new Set<BetterAuthOpenApiMethod>([
'delete',
'get',
'head',
'options',
'patch',
'post',
'put',
'trace',
])
export function addRegisteredBetterAuthOpenApiOperations(
doc: ZPanOpenApiDocument,
authDoc: OpenApiDocument,
registry: readonly BetterAuthOpenApiOperationRegistration[] = BETTER_AUTH_OPENAPI_OPERATION_REGISTRY,
): void {
validateRegistry(registry)
const nextPaths = structuredClone(doc.paths)
const nextComponents = structuredClone(doc.components ?? {}) as OpenApiObject
const importedComponentRoots = new Set<string>()
for (const registration of registry) {
const sourcePathValue = authDoc.paths?.[registration.sourcePath]
const sourcePathItem =
sourcePathValue === undefined
? undefined
: requireObject(sourcePathValue, `Better Auth OpenAPI path ${registration.sourcePath} is not a path item`)
const sourceOperationValue = sourcePathItem?.[registration.method]
if (!isObject(sourceOperationValue) || !sourcePathItem) {
throw new Error(`Better Auth OpenAPI is missing ${formatOperation(registration.method, registration.sourcePath)}`)
}
const sourceParameters = readPathParameters(sourcePathItem, registration)
const operation = structuredClone(sourceOperationValue) as OpenApiOperation
applyContractNormalization(operation, registration)
operation.operationId = registration.operationId
operation.tags = [...registration.tags]
operation.security = openApiSecurity(registration.security, registration)
importReachableComponents(
[operation, ...(sourceParameters === undefined ? [] : [sourceParameters])],
authDoc,
nextComponents,
importedComponentRoots,
registration,
)
const existingPathItem = nextPaths[registration.publicPath]
const targetPathItem =
existingPathItem === undefined
? {}
: requireObject(existingPathItem, `OpenAPI target path ${registration.publicPath} is not a path item`)
if (Object.hasOwn(targetPathItem, registration.method)) {
throw new Error(`OpenAPI target already defines ${formatOperation(registration.method, registration.publicPath)}`)
}
mergePathParameters(targetPathItem, sourceParameters, registration)
targetPathItem[registration.method] = operation
nextPaths[registration.publicPath] = targetPathItem
}
assertUniqueOperationIds(nextPaths)
assertImportedReferencesResolve(nextPaths, nextComponents, registry, importedComponentRoots)
doc.paths = nextPaths
doc.components = nextComponents
}
function validateRegistry(registry: readonly BetterAuthOpenApiOperationRegistration[]): void {
const sources = new Set<string>()
const targets = new Set<string>()
const operationIds = new Set<string>()
for (const registration of registry) {
const source = `${registration.method} ${registration.sourcePath}`
if (sources.has(source)) {
throw new Error(
`Duplicate Better Auth OpenAPI source registration: ${formatOperation(registration.method, registration.sourcePath)}`,
)
}
sources.add(source)
const target = `${registration.method} ${registration.publicPath}`
if (targets.has(target)) {
throw new Error(
`Duplicate Better Auth OpenAPI target registration: ${formatOperation(registration.method, registration.publicPath)}`,
)
}
targets.add(target)
if (operationIds.has(registration.operationId)) {
throw new Error(`Duplicate Better Auth OpenAPI operationId registration: ${registration.operationId}`)
}
operationIds.add(registration.operationId)
if (registration.tags.length === 0) {
throw new Error(`Better Auth OpenAPI registration ${registration.operationId} must declare at least one tag`)
}
if (registration.security.mode === 'requirements' && registration.security.requirements.length === 0) {
throw new Error(
`Better Auth OpenAPI registration ${registration.operationId} must use mode public for an empty security requirement`,
)
}
}
}
function readPathParameters(
sourcePathItem: OpenApiObject,
registration: BetterAuthOpenApiOperationRegistration,
): unknown[] | undefined {
if (!Object.hasOwn(sourcePathItem, 'parameters')) return undefined
if (!Array.isArray(sourcePathItem.parameters)) {
throw new Error(
`Better Auth OpenAPI path parameters for ${formatOperation(registration.method, registration.sourcePath)} must be an array`,
)
}
return structuredClone(sourcePathItem.parameters)
}
function mergePathParameters(
targetPathItem: OpenApiObject,
sourceParameters: unknown[] | undefined,
registration: BetterAuthOpenApiOperationRegistration,
): void {
const hasTargetParameters = Object.hasOwn(targetPathItem, 'parameters')
if (!hasTargetParameters && sourceParameters === undefined) return
if (!hasTargetParameters && sourceParameters !== undefined) {
targetPathItem.parameters = sourceParameters
return
}
if (sourceParameters === undefined || !jsonEqual(targetPathItem.parameters, sourceParameters)) {
throw new Error(`OpenAPI path parameters conflict at ${registration.publicPath}`)
}
}
function applyContractNormalization(
operation: OpenApiOperation,
registration: BetterAuthOpenApiOperationRegistration,
): void {
for (const [status, mediaTypes] of Object.entries(registration.contract?.responseSchemas ?? {})) {
for (const [mediaType, schema] of Object.entries(mediaTypes)) {
const response = requireObject(
requireObject(operation.responses, missingResponseSchemaError(registration, status, mediaType))[status],
missingResponseSchemaError(registration, status, mediaType),
)
const content = requireObject(response.content, missingResponseSchemaError(registration, status, mediaType))
const representation = requireObject(
content[mediaType],
missingResponseSchemaError(registration, status, mediaType),
)
representation.schema = structuredClone(schema)
}
}
}
function missingResponseSchemaError(
registration: BetterAuthOpenApiOperationRegistration,
status: string,
mediaType: string,
): string {
return `Better Auth OpenAPI cannot apply the ${status} ${mediaType} response schema override for ${formatOperation(registration.method, registration.sourcePath)}`
}
function openApiSecurity(
policy: BetterAuthOpenApiSecurityPolicy,
registration: BetterAuthOpenApiOperationRegistration,
): OpenApiSecurityRequirement[] {
if (policy.mode === 'public') return []
if (policy.requirements.length === 0) {
throw new Error(
`Better Auth OpenAPI registration ${registration.operationId} must use mode public for an empty security requirement`,
)
}
return structuredClone(policy.requirements) as OpenApiSecurityRequirement[]
}
function importReachableComponents(
values: readonly unknown[],
authDoc: OpenApiDocument,
targetComponents: OpenApiObject,
importedComponentRoots: Set<string>,
registration: BetterAuthOpenApiOperationRegistration,
): void {
for (const value of values) {
for (const reference of collectReferences(value)) {
const segments = parseComponentReference(reference, registration)
if (resolvePointer(authDoc, segments) === undefined) {
throw new Error(
`Better Auth OpenAPI has a dangling reference ${reference} in ${formatOperation(registration.method, registration.sourcePath)}`,
)
}
const [componentType, componentName] = [segments[1], segments[2]]
const rootKey = JSON.stringify([componentType, componentName])
if (importedComponentRoots.has(rootKey)) continue
const sourceRoot = resolvePointer(authDoc, ['components', componentType, componentName])
if (sourceRoot === undefined) {
throw new Error(
`Better Auth OpenAPI has a dangling component reference ${reference} in ${formatOperation(registration.method, registration.sourcePath)}`,
)
}
const targetSection = targetComponents[componentType]
const section =
targetSection === undefined
? {}
: requireObject(targetSection, `OpenAPI component section components.${componentType} is not an object`)
if (Object.hasOwn(section, componentName)) {
if (!jsonEqual(section[componentName], sourceRoot)) {
throw new Error(`OpenAPI component collision at #/components/${componentType}/${componentName}`)
}
} else {
section[componentName] = structuredClone(sourceRoot)
}
targetComponents[componentType] = section
importedComponentRoots.add(rootKey)
importReachableComponents([sourceRoot], authDoc, targetComponents, importedComponentRoots, registration)
}
}
}
function parseComponentReference(reference: string, registration: BetterAuthOpenApiOperationRegistration): string[] {
if (!reference.startsWith('#/')) {
throw new Error(
`Better Auth OpenAPI external reference ${reference} is not allowed in ${formatOperation(registration.method, registration.sourcePath)}`,
)
}
const segments = parseJsonPointer(reference)
if (segments.length < 3 || segments[0] !== 'components') {
throw new Error(
`Better Auth OpenAPI local reference ${reference} must target a component in ${formatOperation(registration.method, registration.sourcePath)}`,
)
}
return segments
}
function assertImportedReferencesResolve(
paths: Record<string, unknown>,
components: OpenApiObject,
registry: readonly BetterAuthOpenApiOperationRegistration[],
importedComponentRoots: ReadonlySet<string>,
): void {
const document = { paths, components }
const values: unknown[] = []
for (const registration of registry) {
const pathItem = requireObject(
paths[registration.publicPath],
`OpenAPI target path ${registration.publicPath} is missing`,
)
values.push(pathItem[registration.method])
if (Object.hasOwn(pathItem, 'parameters')) values.push(pathItem.parameters)
}
for (const rootKey of importedComponentRoots) {
const [componentType, componentName] = JSON.parse(rootKey) as [string, string]
values.push(resolvePointer(document, ['components', componentType, componentName]))
}
for (const value of values) {
for (const reference of collectReferences(value)) {
if (!reference.startsWith('#/')) {
throw new Error(`OpenAPI imported operation contains unsupported external reference ${reference}`)
}
if (resolvePointer(document, parseJsonPointer(reference)) === undefined) {
throw new Error(`OpenAPI imported operation contains dangling reference ${reference}`)
}
}
}
}
function assertUniqueOperationIds(paths: Record<string, unknown>): void {
const operationIds = new Map<string, string>()
for (const [path, value] of Object.entries(paths)) {
if (!isObject(value)) continue
for (const [method, operation] of Object.entries(value)) {
if (!OPENAPI_METHODS.has(method as BetterAuthOpenApiMethod) || !isObject(operation)) continue
if (typeof operation.operationId !== 'string') continue
const existing = operationIds.get(operation.operationId)
const current = formatOperation(method as BetterAuthOpenApiMethod, path)
if (existing) {
throw new Error(`OpenAPI operationId ${operation.operationId} conflicts between ${existing} and ${current}`)
}
operationIds.set(operation.operationId, current)
}
}
}
function collectReferences(value: unknown, seen = new WeakSet<object>()): string[] {
if (!value || typeof value !== 'object') return []
if (seen.has(value)) return []
seen.add(value)
if (Array.isArray(value)) return value.flatMap((item) => collectReferences(item, seen))
const object = value as OpenApiObject
return [
...(typeof object.$ref === 'string' ? [object.$ref] : []),
...Object.values(object).flatMap((item) => collectReferences(item, seen)),
]
}
function parseJsonPointer(reference: string): string[] {
try {
return reference
.slice(2)
.split('/')
.map((segment) => decodeURIComponent(segment).replaceAll('~1', '/').replaceAll('~0', '~'))
} catch {
throw new Error(`Invalid OpenAPI JSON reference: ${reference}`)
}
}
function resolvePointer(value: unknown, segments: readonly string[]): unknown {
return segments.reduce<unknown>((current, segment) => {
if (!isObject(current) && !Array.isArray(current)) return undefined
return (current as Record<string, unknown>)[segment]
}, value)
}
function requireObject(value: unknown, error: string): OpenApiObject {
if (!isObject(value)) throw new Error(error)
return value
}
function isObject(value: unknown): value is OpenApiObject {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function jsonEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (Array.isArray(left) || Array.isArray(right)) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((v, i) => jsonEqual(v, right[i]))
)
}
if (!isObject(left) || !isObject(right)) return false
const leftKeys = Object.keys(left)
const rightKeys = Object.keys(right)
return (
leftKeys.length === rightKeys.length &&
leftKeys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]))
)
}
function formatOperation(method: BetterAuthOpenApiMethod, path: string): string {
return `${method.toUpperCase()} ${path}`
}
+104 -8
View File
@@ -19,7 +19,7 @@ describe('global OpenAPI document', () => {
expect(doc.paths['/api/objects']?.get?.tags).toContain('Objects')
expect(doc.paths['/api/events']?.get?.tags).toContain('Events')
expect((doc.tags ?? []).map((t) => t.name)).toEqual(
expect.arrayContaining(['Objects', 'Events', 'Download Tasks', 'Downloaders']),
expect.arrayContaining(['Objects', 'Events', 'Download Tasks', 'Downloaders', 'Downloader Device Flow']),
)
// Every resource already converted to `.openapi()` shows up automatically.
expect(Object.keys(doc.paths)).toEqual(
@@ -788,27 +788,53 @@ describe('global OpenAPI document', () => {
})
})
it("merges better-auth's auto-generated schema (incl. the device flow) into the same doc", async () => {
it('publishes only the registered Better Auth Downloader Device Flow operations', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
const doc = (await res.json()) as {
paths: Record<
string,
{
get?: Record<string, unknown>
post?: {
operationId?: string
tags?: string[]
security?: Record<string, string[]>[]
responses?: Record<
string,
{ content?: { 'application/json'?: { schema?: { properties?: Record<string, unknown> } } } }
{
content?: {
'application/json'?: {
schema?: { properties?: Record<string, unknown>; required?: string[] }
}
}
}
>
}
}
>
components?: {
headers?: Record<string, unknown>
schemas?: Record<string, unknown>
}
}
// better-auth's device-authorization endpoints come from its openAPI plugin,
// not hand-written stubs — prefixed under /api/auth.
const authPaths = Object.keys(doc.paths).filter((p) => p.startsWith('/api/auth/'))
expect(authPaths.length).toBeGreaterThan(0)
expect(authPaths.some((p) => p.includes('/device/'))).toBe(true)
expect(Object.keys(doc.paths).filter((path) => path.startsWith('/api/auth/device/'))).toEqual([
'/api/auth/device/code',
'/api/auth/device/token',
])
expect(Object.keys(doc.paths['/api/auth/device/code'] ?? {})).toEqual(['post'])
expect(Object.keys(doc.paths['/api/auth/device/token'] ?? {})).toEqual(['post'])
expect(doc.paths['/api/auth/device/code']?.post).toMatchObject({
operationId: 'createDeviceAuthorization',
tags: ['Downloader Device Flow'],
security: [],
})
expect(doc.paths['/api/auth/device/token']?.post).toMatchObject({
operationId: 'createDeviceAccessToken',
tags: ['Downloader Device Flow'],
security: [],
})
expect(
doc.paths['/api/auth/device/token']?.post?.responses?.['200']?.content?.['application/json']?.schema?.properties,
).toMatchObject({
@@ -817,5 +843,75 @@ describe('global OpenAPI document', () => {
expires_in: { type: 'integer' },
scope: { type: 'string' },
})
expect(
doc.paths['/api/auth/device/token']?.post?.responses?.['200']?.content?.['application/json']?.schema?.required,
).toEqual(['access_token', 'token_type', 'expires_in'])
expect(doc.paths['/api/auth/sign-in/email']).toBeUndefined()
expect(doc.paths['/api/auth/organization/create']).toBeUndefined()
expect(doc.paths['/api/auth/admin/list-users']).toBeUndefined()
expect(doc.paths['/api/auth/api-key/create']).toBeUndefined()
expect(doc.components?.schemas?.Session).toBeUndefined()
expect(doc.components?.schemas?.User).toBeUndefined()
// The registered Better Auth operations need no imported components after
// their declared normalization. ZPan's later framework-level response
// decoration adds only the shared RequestId header reference.
const requestIdReference = '#/components/headers/RequestId'
for (const path of ['/api/auth/device/code', '/api/auth/device/token']) {
const references = collectOpenApiReferences(doc.paths[path])
expect(references.length).toBeGreaterThan(0)
expect([...new Set(references)]).toEqual([requestIdReference])
for (const reference of references) {
expect(resolveLocalOpenApiReference(doc, reference)).toBe(doc.components?.headers?.RequestId)
}
}
const operationIds = Object.values(doc.paths).flatMap((path) =>
Object.values(path).flatMap((operation) =>
operation && typeof operation === 'object' && 'operationId' in operation ? [operation.operationId] : [],
),
)
expect(operationIds.filter((id) => id === 'createDeviceAuthorization')).toHaveLength(1)
expect(operationIds.filter((id) => id === 'createDeviceAccessToken')).toHaveLength(1)
})
it('keeps Better Auth runtime login, reference, and schema routes mounted', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const [login, reference, schema] = await Promise.all([
app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'missing@example.com', password: 'wrong-password' }),
}),
app.request('/api/auth/reference'),
app.request('/api/auth/open-api/generate-schema'),
])
expect(login.status).not.toBe(404)
expect(reference.status).toBe(200)
expect(reference.headers.get('content-type')).toContain('text/html')
expect(schema.status).toBe(200)
await expect(schema.json()).resolves.toMatchObject({ paths: expect.any(Object) })
})
})
function collectOpenApiReferences(value: unknown): string[] {
if (Array.isArray(value)) return value.flatMap(collectOpenApiReferences)
if (!value || typeof value !== 'object') return []
const object = value as Record<string, unknown>
return [
...(typeof object.$ref === 'string' ? [object.$ref] : []),
...Object.values(object).flatMap(collectOpenApiReferences),
]
}
function resolveLocalOpenApiReference(document: unknown, reference: string): unknown {
if (!reference.startsWith('#/')) return undefined
return reference
.slice(2)
.split('/')
.map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~'))
.reduce<unknown>((value, segment) => {
if (!value || typeof value !== 'object') return undefined
return (value as Record<string, unknown>)[segment]
}, document)
}