Files
zpan/server/http/oauth-grants.ts
T
Jasper Van 2657f82ef1 feat(auth): add contextual OAuth workspace grants (#550)
* feat(auth): add contextual OAuth workspace grants

* fix(auth): register RFC 9396 detail types

* fix(openapi): restore delegated CLI auth profile

* fix(store): recover paid capacity fulfillment

* fix(auth): close OAuth contract gaps

* fix(store): resume verified x402 settlements

* chore(deps): update zpan cloud sdk to 2.5.2

* test(store): cover verified settlement recovery

* fix(auth): localize standard consent scopes

* fix(docker): include dependency patches before install

* fix(store): harden x402 purchase responses

* test(auth): cover OAuth authorization boundaries

* docs: add PR 550 verification screenshots

* chore: remove temporary verification screenshots

* docs(openapi): document exhausted capacity response
2026-08-02 13:33:15 -04:00

137 lines
4.6 KiB
TypeScript

import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import {
oauthConsentContextSchema,
oauthConsentResultSchema,
oauthConsentSubmitSchema,
oauthGrantListSchema,
} from '@shared/schemas'
import type { Env } from '../middleware/platform'
import { getOAuthConsentContext } from '../usecases/oauth-consent'
import { listOAuthGrants, revokeOAuthGrant } from '../usecases/oauth-grants'
import { badRequest } from '../usecases/ports'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
const paramsSchema = z.object({ grantId: z.string().min(1) })
const consentContextQuerySchema = z.object({ oauthQuery: z.string().min(1) })
const consentContextRoute = authRoute(
{ scopes: [AuthorizationScope.OAUTH_GRANTS_CREATE] },
{
operationId: 'getOAuthConsentContext',
summary: 'Get pending OAuth consent context',
tags: ['OAuth Apps'],
method: 'get',
path: '/oauth-consent',
request: { query: consentContextQuerySchema },
responses: {
200: jsonContent(oauthConsentContextSchema, 'OAuth consent context'),
400: errorResponse('Invalid OAuth request'),
403: errorResponse('Workspace access is required'),
},
},
)
const consentSubmitRoute = authRoute(
{ scopes: [AuthorizationScope.OAUTH_GRANTS_CREATE] },
{
operationId: 'submitOAuthConsent',
summary: 'Submit OAuth consent decision',
tags: ['OAuth Apps'],
method: 'post',
path: '/oauth-consent',
request: jsonBody(oauthConsentSubmitSchema),
responses: {
200: jsonContent(oauthConsentResultSchema, 'OAuth consent result'),
400: errorResponse('Invalid OAuth request'),
403: errorResponse('Workspace access is required'),
},
},
)
const listRoute = authRoute(
{ scopes: [AuthorizationScope.OAUTH_GRANTS_READ] },
{
operationId: 'listOAuthGrants',
summary: 'List OAuth grants',
tags: ['OAuth Apps'],
method: 'get',
path: '/oauth-grants',
responses: {
200: jsonContent(oauthGrantListSchema, 'OAuth grants'),
},
},
)
const revokeRoute = authRoute(
{ scopes: [AuthorizationScope.OAUTH_GRANTS_DELETE] },
{
operationId: 'revokeOAuthGrant',
summary: 'Revoke an OAuth grant',
tags: ['OAuth Apps'],
method: 'delete',
path: '/oauth-grants/{grantId}',
request: { params: paramsSchema },
responses: {
204: { description: 'Revoked' },
404: errorResponse('OAuth grant not found'),
},
},
)
export const oauthGrants = new OpenAPIHono<Env>()
.openapi(consentContextRoute, async (c) => {
const { oauthQuery } = c.req.valid('query')
const context = await getOAuthConsentContext(c.get('deps'), {
db: c.get('platform').db,
userId: c.get('userId')!,
oauthQuery,
})
return c.json(context, 200)
})
.openapi(consentSubmitRoute, async (c) => {
const { accept, oauthQuery, workspaceIds } = c.req.valid('json')
const context = await getOAuthConsentContext(c.get('deps'), {
db: c.get('platform').db,
userId: c.get('userId')!,
oauthQuery,
})
const availableWorkspaceIds = new Set(context.workspaces.map((workspace) => workspace.id))
if (accept && (workspaceIds.length === 0 || workspaceIds.some((id) => !availableWorkspaceIds.has(id)))) {
throw badRequest('Invalid workspace selection')
}
const headers = new Headers(c.req.raw.headers)
headers.set('content-type', 'application/json')
headers.delete('content-length')
const response = await c.get('auth').handler(
new Request(new URL('/api/auth/oauth2/consent', c.req.url), {
method: 'POST',
headers,
body: JSON.stringify({
accept,
oauth_query: oauthQuery,
authorization_details: workspaceIds.map((identifier) => ({
type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE,
identifier,
})),
}),
}),
)
const body = await response.json().catch(() => null)
if (!response.ok) return c.json(body ?? { error: response.statusText }, response.status as 400 | 403)
return c.json(oauthConsentResultSchema.parse(body), 200)
})
.openapi(listRoute, async (c) => {
const result = await listOAuthGrants(c.get('deps'), c.get('platform').db, { userId: c.get('userId')! })
return c.json(result, 200)
})
.openapi(revokeRoute, async (c) => {
const { grantId } = c.req.valid('param')
await revokeOAuthGrant(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
grantId,
})
return c.body(null, 204)
})