server/http/app.ts
Rama main · réplica de lectura del repositorio original.
// Install Zod's OpenAPI extension before shared schemas are constructed.
import '@hono/zod-openapi'
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider'
import { Scalar } from '@scalar/hono-api-reference'
import type { Auth } from '@server/auth'
import { ApiError, forbidden, notFound, oauthError } from '@server/domain/errors'
import { handleApiError } from '@server/http/errors'
import { issueAgentBootstrapAccessToken } from '@server/usecases/agent-oauth'
import { issueApplicationAccessToken } from '@server/usecases/application-oauth'
import { ensureRealmrootResourceServer } from '@server/usecases/authorization'
import type { Deps } from '@server/usecases/deps'
import {
exchangeToken,
introspectToken,
isTokenExchangeAccessToken,
parseBasicClientAuthorization,
refreshToken,
refreshTokenGrantType,
tokenExchangeGrantType,
} from '@server/usecases/token-exchange'
import { agentBootstrapScopes, realmrootOAuthScopes, resourceByRoutePrefix } from '@shared/authz'
import type { Context } from 'hono'
import { Hono } from 'hono'
import { decodeJwt } from 'jose'
import { readRealmrootAgentBinding } from './agent-token-claims'
import {
isIdentityProviderCallbackPath,
isPublicCorsPath,
mountAgentConfiguration,
oauthClientCorsOrigins,
publicIssuerMetadataPaths,
requireHostedAuthMethodEnabled,
requireLinkedSiweWallet,
} from './app-auth-mounts'
import { configzOptions } from './app-config'
import type { RpcSchema } from './app-rpc-schema'
import type { AgentConfiguration, AppConfig } from './app-types'
import { accessLog } from './middleware/access-log'
import { authn, getPrincipal, type SessionReader } from './middleware/authn'
import { authorizePlatformOrganization, authz } from './middleware/authz'
import { trustedOriginCors } from './middleware/cors'
import { depsMiddleware } from './middleware/deps'
import { hostedAuthErrors } from './middleware/hosted-auth-errors'
import { paginationLinkHeader, responsePagination } from './middleware/pagination'
import { requestContext } from './middleware/request-context'
import { requireSecurityPolicy } from './middleware/security-policy'
import { unifiedOpenApiLinkHeader, unifiedOpenApiPath } from './openapi/management-links'
import { accountRoutes } from './routes/account'
import { createAgentProtocolRoutes } from './routes/agent-protocol'
import { createAccountAssetRoutes, createAssetRoutes, createProtectedResourceAssetRoutes } from './routes/assets'
import type { ManagementAuthApi } from './routes/auth-api'
import { createConfigzRoutes } from './routes/configz'
import { createProtectedResourceRoutes } from './routes/management'
import { oauthConsentRoute } from './routes/oauth/consent'
import { onboardingRoutes } from './routes/onboarding'
import { createPublicProfileRoutes } from './routes/public-profiles'
import { createResourceConnectionRoutes } from './routes/resource-connections'
import { trustedRequestUrl } from './trusted-request-origin'
type AuthHandler = Pick<Auth, 'handler'> & {
api: {
getOAuthServerConfig: (context: { request: Request; asResponse: false }) => Promise<unknown>
getOpenIdConfig: (context: { request: Request; asResponse: false }) => Promise<unknown>
getAgentConfiguration?: (context: { request: Request; asResponse: false }) => Promise<AgentConfiguration>
getAgentSession?: (context: {
headers: Headers
asResponse: false
}) => Promise<import('@server/usecases/agent-session').ProtocolAgentSession | null>
signJWT?: (context: {
body: { payload: Record<string, unknown>; overrideOptions?: { jwt?: { type?: string } } }
asResponse?: false
}) => Promise<{ token: string }>
verifyJWT?: (context: {
body: { token: string; issuer?: string; audience?: string | string[] }
asResponse?: false
}) => Promise<{ payload: Record<string, unknown> | null }>
} & SessionReader['api']
}
// Liveness payload. Shared with the worker entry so it can answer `/api/health`
// before constructing deps — a liveness probe must not depend on the database.
export const healthStatus = { ok: true, service: 'realmroot' } as const
export function createApp(auth: AuthHandler, deps: Deps | ((c: Context) => Deps), config: AppConfig = {}) {
// Registration order is load-bearing: middleware only guards routes registered
// after it (public routes like /api/health stay public by registering before the
// auth/security walls), and static paths must precede parameter paths. Preserve
// this sequence when adding or moving routes. The deps middleware goes first so
// every route and middleware can read `deps` from context.
const app = new Hono()
app.use('*', depsMiddleware(deps))
app.use('*', requestContext())
app.use('*', accessLog())
app.use('*', hostedAuthErrors())
app.on(['GET', 'HEAD'], ['/.well-known/jwks.json', '/api/auth/jwks'], (c) => publishJwks(c, auth))
const cors = trustedOriginCors(config.trustedOrigins ?? [], {
isOriginExemptPath: isIdentityProviderCallbackPath,
isPublicPath: isPublicCorsPath,
resolveAllowedOrigins: oauthClientCorsOrigins(),
})
app.use('/api/*', cors)
for (const path of publicIssuerMetadataPaths) app.use(path, cors)
app.use('/api/*', authn(auth))
app.use('/api/*', (c, next) => requireSecurityPolicy(c.get('deps').security, config.securityPolicy)(c, next))
app.onError((error, c) => {
if (error instanceof ApiError && error.status === 401 && c.req.path.startsWith('/api/')) {
const scheme = c.req.header('Authorization')?.startsWith('DPoP ') ? 'DPoP' : 'Bearer'
c.header('WWW-Authenticate', `${scheme} resource_metadata="${protectedResourceMetadataUrl(config, c.req.url)}"`)
}
return handleApiError(error, c)
})
app.notFound((c) => handleApiError(notFound(), c))
mountApiRoutes(app, auth, config)
app.get('/api/auth/.well-known/openid-configuration', async (c) =>
extendAgentOAuthMetadata(await oauthProviderOpenIdConfigMetadata(auth)(c.req.raw), 'openid'),
)
app.get('/.well-known/openid-configuration/api/auth', async (c) =>
extendAgentOAuthMetadata(await oauthProviderOpenIdConfigMetadata(auth)(c.req.raw), 'openid'),
)
app.get('/.well-known/agent-configuration', (c) => {
if (!auth.api.getAgentConfiguration) throw notFound('Agent configuration is not available.')
return auth.api.getAgentConfiguration({ request: c.req.raw, asResponse: false }).then((configuration) => {
const issuer = oauthIssuer(config, c.req.url)
const mounted = mountAgentConfiguration({ ...configuration, issuer })
const endpoints = Object.fromEntries(
Object.entries(mounted.endpoints).filter(([name]) => name === 'register' || name === 'status'),
)
return c.json({
...mounted,
default_location: undefined,
capabilities: [],
endpoints,
agent_identity_issuer: issuer,
agent_enrollment_endpoint: new URL('/api/agent/enrollments', issuer).toString(),
agent_endpoint: new URL('/api/agent', issuer).toString(),
agent_profile_uri_template: publicAgentProfileUriTemplate(issuer),
agent_token_endpoint: `${issuer}/oauth2/token`,
agent_bootstrap_scopes_supported: agentBootstrapScopes,
agent_jwks_uri: `${issuer}/jwks`,
})
})
})
app.on(['GET', 'POST'], '/api/auth/*', async (c) => {
if (isBetterAuthRoleMutationPath(c.req.path)) throw notFound()
await requireOnboardingComplete(c.get('deps'))
await requireHostedAuthMethodEnabled(c, configzOptions(c, config.securityPolicy))
await requireLinkedSiweWallet(c, c.get('deps').wallets)
if (isLegacyAgentCapabilityPath(c.req.path)) throw notFound()
const tokenExchangeResponse = await maybeHandleTokenExchange(c, oauthIssuer(config, c.req.url), auth, config)
if (tokenExchangeResponse) return tokenExchangeResponse
return auth.handler(c.req.raw)
})
app.get('/.well-known/oauth-authorization-server/api/auth', async (c) =>
extendAgentOAuthMetadata(await oauthProviderAuthServerMetadata(auth)(c.req.raw), 'authorization-server'),
)
app.get('/.well-known/oauth-protected-resource/api', (c) => {
c.header('Access-Control-Allow-Origin', '*')
return c.json(protectedResourceMetadata(config, c.req.url))
})
app.route('/api', createUnifiedApiRoutes(auth, config))
return app
}
function isLegacyAgentCapabilityPath(path: string) {
return path.startsWith('/api/auth/capability/') || path === '/api/auth/agent/request-capability'
}
function isBetterAuthRoleMutationPath(path: string) {
return new Set([
'/api/auth/organization/create-role',
'/api/auth/organization/update-role',
'/api/auth/organization/delete-role',
'/api/auth/organization/update-member-role',
]).has(path)
}
async function publishJwks(c: Context, auth: AuthHandler) {
c.header('access-control-allow-origin', '*')
c.header('content-type', 'application/json; charset=UTF-8')
if (c.req.method === 'HEAD') return c.body(null)
const url = new URL(c.req.url)
url.pathname = '/api/auth/jwks'
const response = await auth.handler(new Request(url, { headers: c.req.raw.headers }))
if (!response.ok) return response
const jwks = (await response.json()) as { keys: JsonWebKey[] }
return c.json({
keys: jwks.keys.map((key) => ({
...key,
use: 'sig',
key_ops: ['verify'],
})),
})
}
export function createRpcApp(auth: AuthHandler, config: AppConfig = {}) {
return mountApiRoutes(new Hono(), auth, config).route('/api', createUnifiedApiRoutes(auth, config)) as Hono<
object,
RpcSchema
>
}
export type AppType = ReturnType<typeof createRpcApp>
function mountApiRoutes(app: Hono, auth: AuthHandler, config: AppConfig) {
const managementApi = auth.api as unknown as ManagementAuthApi
const canonicalOrigin = config.baseURL ?? ''
const issuer = canonicalOrigin ? `${canonicalOrigin}/api/auth` : ''
const api = app
.get('/api/health', (c) => c.json(healthStatus))
.route('/api/configz', createConfigzRoutes(config.securityPolicy))
.route('/api/assets', createAssetRoutes())
.use('/api/*', resourceLinkHeaders(config))
.route(
'/api/public',
createPublicProfileRoutes((requestUrl) => oauthIssuer(config, requestUrl)),
)
protectResourceRoutes(api, auth, config)
api.use('/api/agent', authn(auth, { allowAgent: true, required: true, oauth: realmrootOAuth(config) }))
api.use(
'/api/agent/access-requests',
authn(auth, { allowAgent: true, required: true, oauth: realmrootOAuth(config) }),
)
api.use(
'/api/agent/access-requests/*',
authn(auth, { allowAgent: true, required: true, oauth: realmrootOAuth(config) }),
)
return api
.route('/api', createProtectedResourceAssetRoutes())
.route(
'/api',
createProtectedResourceRoutes({
authApi: managementApi,
canonicalOrigin: config.baseURL,
trustedOrigins: config.trustedOrigins,
securityPolicy: config.securityPolicy,
}),
)
.route('/api/onboarding', onboardingRoutes(canonicalOrigin || undefined))
.route('/api/account', oauthConsentRoute)
.route('/api/account', accountRoutes(managementApi, config.securityPolicy, canonicalOrigin || undefined))
.route('/api/account', createAccountAssetRoutes(config.securityPolicy))
.route('/oauth/account-connection', createResourceConnectionRoutes(canonicalOrigin || undefined))
.route('/api', createAgentProtocolRoutes(auth.api, issuer || undefined, config.trustedOrigins))
}
export function protectResourceRoutes(app: Hono, auth: SessionReader, config: AppConfig = {}) {
for (const prefix of Object.keys(resourceByRoutePrefix)) {
app.use(
`/api/${prefix}`,
authn(auth, { allowAgent: true, allowApplication: true, required: true, oauth: realmrootOAuth(config) }),
)
app.use(
`/api/${prefix}/*`,
authn(auth, { allowAgent: true, allowApplication: true, required: true, oauth: realmrootOAuth(config) }),
)
}
const protectAssetCreation = async (c: Context, next: () => Promise<void>) => {
if ((c.req.method === 'GET' || c.req.method === 'HEAD') && /^\/api\/assets\/[^/]+$/.test(c.req.path)) {
await next()
return
}
await authn(auth, { allowAgent: true, allowApplication: true, required: true, oauth: realmrootOAuth(config) })(
c,
async () => {
if (getPrincipal(c).user) {
await authorizePlatformOrganization(c, 'applications:write')
await next()
return
}
await authz('applications')(c, next)
},
)
}
app.use('/api/assets', protectAssetCreation)
app.use('/api/assets/*', protectAssetCreation)
}
function realmrootOAuth(config: AppConfig) {
return {
issuer: (requestUrl: string) => oauthIssuer(config, requestUrl),
audience: (requestUrl: string) => `${(config.baseURL ?? new URL(requestUrl).origin).replace(/\/$/, '')}/api`,
resourceRequestUrl: (requestUrl: string) => trustedRequestUrl(config, requestUrl).toString(),
}
}
function createUnifiedApiRoutes(_auth: AuthHandler, _config: AppConfig) {
const app = new Hono()
app.get('/openapi.json', async (c) => {
const { getUnifiedOpenApi } = await import('./openapi/management')
return c.json(getUnifiedOpenApi())
})
app.get(
'/docs',
Scalar({
url: '/api/openapi.json',
pageTitle: 'Realmroot API Documentation',
theme: 'default',
telemetry: false,
cdn: 'https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.64.0',
}),
)
return app
}
function resourceLinkHeaders(config: AppConfig) {
return async (c: Context, next: () => Promise<void>) => {
await next()
if (c.req.path === unifiedOpenApiPath) return
const pagination = await responsePagination(c.res)
const paginationLinks = pagination
? paginationLinkHeader(trustedRequestUrl(config, c.req.url).toString(), pagination)
: null
const links = [c.res.headers.get('Link'), paginationLinks, unifiedOpenApiLinkHeader].filter(Boolean).join(', ')
c.header('Link', links)
}
}
async function requireOnboardingComplete(deps: Deps) {
if (!(await deps.onboarding.hasUsers())) {
throw forbidden('Complete first-admin onboarding before using auth flows.')
}
}
async function maybeHandleTokenExchange(c: Context, issuer: string, auth: AuthHandler, config: AppConfig) {
if (c.req.method !== 'POST') return null
if (c.req.path !== '/api/auth/oauth2/token' && c.req.path !== '/api/auth/oauth2/introspect') return null
const form = await c.req.raw
.clone()
.formData()
.catch(() => null)
if (!form) return null
const grantType = formString(form, 'grant_type')
if (c.req.path === '/api/auth/oauth2/token' && grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
return issueAgentToken(c, issuer, auth, form, config.realmrootResourceReconciled)
}
if (
c.req.path === '/api/auth/oauth2/token' &&
grantType === 'client_credentials' &&
formString(form, 'resource') === `${new URL(issuer).origin}/api`
) {
return issueApplicationToken(c, issuer, auth, form, config.realmrootResourceReconciled)
}
const introspectionClient =
c.req.path === '/api/auth/oauth2/introspect' ? readClientAuthentication(c.req.raw.headers, form) : null
if (c.req.path === '/api/auth/oauth2/introspect') {
const token = formString(form, 'token') ?? ''
if (!introspectionClient) {
if (!token.startsWith('fatx_')) return null
throw oauthError(
'invalid_client',
'Client authentication is required.',
401,
{},
{ 'WWW-Authenticate': 'Basic realm="Realmroot token endpoint"' },
)
}
if (!(await isTokenExchangeAccessToken(c.get('deps'), token))) return null
}
const tokenExchangeRefresh =
grantType === refreshTokenGrantType && (formString(form, 'refresh_token') ?? '').startsWith('fatr_')
if (c.req.path === '/api/auth/oauth2/token' && grantType !== tokenExchangeGrantType && !tokenExchangeRefresh) {
return null
}
const client = introspectionClient ?? readClientAuthentication(c.req.raw.headers, form)
if (!client) {
throw oauthError(
'invalid_client',
'Client authentication is required.',
401,
{},
{ 'WWW-Authenticate': 'Basic realm="Realmroot token endpoint"' },
)
}
const deps = c.get('deps')
if (c.req.path === '/api/auth/oauth2/token') {
if (!auth.api.signJWT) throw oauthError('temporarily_unavailable', 'OAuth token issuance is unavailable.', 503)
const signer = {
issuer,
sign: (payload: Record<string, unknown>, type: 'at+jwt' | 'JWT') =>
auth.api.signJWT!({ body: { payload, overrideOptions: { jwt: { type } } }, asResponse: false }).then(
({ token }) => token,
),
}
if (tokenExchangeRefresh) {
const response = await refreshToken(
deps,
{
grantType: grantType ?? '',
refreshToken: formString(form, 'refresh_token') ?? '',
scope: formString(form, 'scope') ?? undefined,
},
client,
signer,
)
return c.json(response)
}
const subjectToken = formString(form, 'subject_token') ?? ''
const subjectTokenType = formString(form, 'subject_token_type') ?? ''
const response = await exchangeToken(
deps,
{
grantType: grantType ?? '',
subjectToken,
subjectTokenType,
audience: formString(form, 'audience') ?? '',
scope: formString(form, 'scope') ?? undefined,
requestedTokenType: formString(form, 'requested_token_type') ?? undefined,
},
client,
signer,
auth.api.verifyJWT
? {
verify: (token, expectedIssuer, audience) =>
auth.api.verifyJWT!({
body: { token, issuer: expectedIssuer, audience },
asResponse: false,
}).then(({ payload }) => {
if (!payload) throw new Error('JWT payload is unavailable.')
return payload
}),
}
: undefined,
)
c.header('Cache-Control', 'no-store')
c.header('Pragma', 'no-cache')
return c.json(response)
}
if (!auth.api.verifyJWT) throw oauthError('temporarily_unavailable', 'OAuth token verification is unavailable.', 503)
const introspection = await introspectToken(deps, formString(form, 'token') ?? '', client, issuer, {
verify: (token, expectedIssuer, audience) =>
auth.api.verifyJWT!({ body: { token, issuer: expectedIssuer, audience }, asResponse: false }).then(
({ payload }) => {
if (!payload) throw new Error('JWT payload is unavailable.')
return payload
},
),
})
return c.json(introspection)
}
async function issueApplicationToken(
c: Context,
issuer: string,
auth: AuthHandler,
form: FormData,
realmrootResourceReconciled = false,
) {
if (!auth.api.signJWT) throw oauthError('temporarily_unavailable', 'OAuth token issuance is unavailable.', 503)
const client = readClientAuthentication(c.req.raw.headers, form)
if (!client) {
throw oauthError(
'invalid_client',
'Client authentication is required.',
401,
{},
{ 'WWW-Authenticate': 'Basic realm="Realmroot token endpoint"' },
)
}
if (!realmrootResourceReconciled) await ensureRealmrootResourceServer(c.get('deps'), new URL(issuer).origin)
const response = await issueApplicationAccessToken(
c.get('deps'),
{
...client,
scope: formString(form, 'scope') ?? undefined,
resource: formString(form, 'resource') ?? '',
expectedResource: `${new URL(issuer).origin}/api`,
},
{
issuer,
sign: (payload, type) =>
auth.api.signJWT!({ body: { payload, overrideOptions: { jwt: { type } } }, asResponse: false }).then(
({ token }) => token,
),
},
)
c.header('Cache-Control', 'no-store')
c.header('Pragma', 'no-cache')
return c.json(response)
}
async function issueAgentToken(
c: Context,
issuer: string,
auth: AuthHandler,
form: FormData,
realmrootResourceReconciled = false,
) {
if (!auth.api.getAgentSession || !auth.api.signJWT) {
throw oauthError('temporarily_unavailable', 'Agent OAuth token issuance is unavailable.', 503)
}
const assertion = formString(form, 'assertion')
if (!assertion) throw oauthError('invalid_request', 'The assertion parameter is required.')
const headers = new Headers({ authorization: `Bearer ${assertion}` })
const session = await auth.api.getAgentSession({ headers, asResponse: false }).catch(() => null)
if (!session) throw oauthError('invalid_grant', 'The Agent assertion is invalid.')
const assertionBinding = (() => {
try {
return readRealmrootAgentBinding(decodeJwt(assertion))
} catch {
throw oauthError('invalid_grant', 'The Agent assertion has an invalid runtime session binding.')
}
})()
if (
assertionBinding &&
(assertionBinding.protocol_agent_id !== session.agent.id || assertionBinding.host_id !== session.agent.hostId)
) {
throw oauthError('invalid_grant', 'The Agent assertion binding does not match its authenticated Agent and Host.')
}
const deps = c.get('deps')
if (!realmrootResourceReconciled) await ensureRealmrootResourceServer(deps, new URL(issuer).origin)
const active = await deps.agentIdentities.findActiveBindingByProtocolAgent(session.agent.id)
if (!active) throw oauthError('invalid_grant', 'The Agent is not enrolled.')
if (active.binding.hostId !== session.agent.hostId) {
throw oauthError('invalid_grant', 'The Agent host binding is inactive.')
}
const tokenEndpoint = `${issuer.replace(/\/$/, '')}/oauth2/token`
const resource = formString(form, 'resource') ?? ''
const dpopProof = c.req.header('DPoP')
if (!dpopProof) throw oauthError('invalid_dpop_proof', 'A DPoP proof is required.')
const response = await issueAgentBootstrapAccessToken(
c.get('deps'),
{
clientId: formString(form, 'client_id') ?? '',
scope: formString(form, 'scope') ?? undefined,
resource,
expectedResource: `${new URL(issuer).origin}/api`,
dpopProof,
tokenEndpoint,
},
{
issuer: active.identity.issuer,
subject: active.identity.subject,
identityId: active.identity.id,
protocolAgentId: session.agent.id,
hostId: session.agent.hostId,
runtime: assertionBinding?.runtime,
sessionId: assertionBinding?.session_id,
identity: active.identity,
binding: active.binding,
},
{
issuer,
sign: (payload, type) =>
auth.api.signJWT!({ body: { payload, overrideOptions: { jwt: { type } } }, asResponse: false }).then(
({ token }) => token,
),
},
)
c.header('Cache-Control', 'no-store')
c.header('Pragma', 'no-cache')
return c.json(response)
}
async function extendAgentOAuthMetadata(response: Response, metadataType: 'openid' | 'authorization-server') {
const metadata = (await response.json()) as Record<string, unknown>
const issuer = metadata.issuer
if (typeof issuer !== 'string') throw new Error('OAuth metadata has no issuer.')
const headers = new Headers(response.headers)
headers.delete('content-length')
return Response.json(
{
...metadata,
grant_types_supported: [
...new Set([
...(Array.isArray(metadata.grant_types_supported) ? metadata.grant_types_supported : []),
'urn:ietf:params:oauth:grant-type:jwt-bearer',
]),
],
dpop_signing_alg_values_supported: ['ES256', 'EdDSA'],
...(metadataType === 'authorization-server'
? { agent_profile_uri_template: publicAgentProfileUriTemplate(issuer) }
: {}),
},
{ status: response.status, headers },
)
}
function publicAgentProfileUriTemplate(issuer: string) {
return `${new URL(issuer).origin}/api/public/agents/{subject}`
}
function protectedResourceMetadata(config: AppConfig, requestUrl: string) {
const origin = (config.baseURL ?? new URL(requestUrl).origin).replace(/\/$/, '')
return {
resource: `${origin}/api`,
authorization_servers: [`${origin}/api/auth`],
scopes_supported: realmrootOAuthScopes,
bearer_methods_supported: ['header'],
dpop_signing_alg_values_supported: ['ES256', 'EdDSA'],
dpop_bound_access_tokens_required: false,
}
}
function protectedResourceMetadataUrl(config: AppConfig, requestUrl: string) {
const origin = (config.baseURL ?? new URL(requestUrl).origin).replace(/\/$/, '')
return `${origin}/.well-known/oauth-protected-resource/api`
}
function oauthIssuer(config: AppConfig, requestUrl: string) {
return `${(config.baseURL ?? new URL(requestUrl).origin).replace(/\/$/, '')}/api/auth`
}
function readClientAuthentication(headers: Headers, form: FormData) {
const authorization = headers.get('authorization')
if (authorization) return parseBasicClientAuthorization(authorization)
const clientId = formString(form, 'client_id')
const clientSecret = formString(form, 'client_secret')
return clientId && clientSecret ? { clientId, clientSecret } : null
}
function formString(form: FormData, key: string) {
const value = form.get(key)
return typeof value === 'string' ? value : null
}