From 453fbe41a360b1b4a49e3b8fdfff966cb9c80386 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Tue, 15 Sep 2026 13:32:15 -0700 Subject: [PATCH 01/12] feat(sites): add private sites and owner access --- .../pages-api/operationalRoutes.test.ts | 72 ++- src/app/api/v2/config/sites/route.test.ts | 11 +- src/app/api/v2/config/sites/route.ts | 2 +- src/app/api/v2/me/tokens/route.ts | 1 + src/app/api/v2/sites/[siteId]/access/route.ts | 71 +++ .../api/v2/sites/[siteId]/content/route.ts | 12 +- src/app/api/v2/sites/[siteId]/extend/route.ts | 16 +- src/app/api/v2/sites/[siteId]/route.ts | 20 +- src/app/api/v2/sites/browser/bind/route.ts | 60 ++ src/app/api/v2/sites/browser/mint/route.ts | 85 +++ .../v2/sites/browser/open/[siteId]/route.ts | 67 +++ src/app/api/v2/sites/browser/revoke/route.ts | 56 ++ src/app/api/v2/sites/capabilities/route.ts | 35 ++ src/app/api/v2/sites/route.test.ts | 99 +++- src/app/api/v2/sites/route.ts | 26 +- src/pages/api/v1/config/cache.ts | 14 + .../db/migrations/035_sites_access_control.ts | 49 ++ src/server/lib/__tests__/get-user.test.ts | 41 ++ src/server/lib/__tests__/principal.test.ts | 38 ++ .../__tests__/v2RoutePolicyManifest.test.ts | 8 +- src/server/lib/apiTokenScopes.ts | 22 + src/server/lib/get-user.ts | 26 + src/server/lib/principal.ts | 23 +- src/server/lib/sites/browserAuth.test.ts | 462 +++++++++++++++ src/server/lib/sites/browserAuth.ts | 505 ++++++++++++++++ src/server/lib/sites/config.test.ts | 12 + src/server/lib/sites/config.ts | 46 +- src/server/lib/sites/gateway.test.ts | 228 ++++++++ src/server/lib/sites/gateway.ts | 222 ++++++++ src/server/lib/sites/oauthRevocation.test.ts | 209 +++++++ src/server/lib/sites/policy.test.ts | 155 +++++ src/server/lib/sites/policy.ts | 130 +++++ src/server/lib/sites/routeHelpers.test.ts | 64 ++- src/server/lib/sites/routeHelpers.ts | 119 +++- src/server/lib/sites/validation.test.ts | 18 + src/server/lib/sites/validation.ts | 3 + src/server/lib/v2RoutePolicyManifest.ts | 16 +- src/server/lib/verifiedOAuthBearer.ts | 14 + .../mcp/__tests__/toolHandlers.sites.test.ts | 27 +- src/server/mcp/auth.ts | 6 +- .../mcp/tools/sites/__tests__/shared.test.ts | 7 + src/server/mcp/tools/sites/getSite.ts | 6 +- src/server/mcp/tools/sites/listSites.ts | 19 +- src/server/mcp/tools/sites/schemas.ts | 35 +- src/server/mcp/tools/sites/shared.ts | 12 +- src/server/middlewares/auth.test.ts | 19 + src/server/middlewares/auth.ts | 15 +- src/server/middlewares/requestId.test.ts | 35 ++ src/server/middlewares/requestId.ts | 8 +- src/server/models/ApiToken.ts | 2 + src/server/models/Site.ts | 11 + .../services/__tests__/sites.behavior.test.ts | 537 ++++++++++++++++-- src/server/services/__tests__/sites.test.ts | 131 +++-- src/server/services/apiToken.ts | 24 +- .../services/keycloak/adminClient.test.ts | 30 + src/server/services/keycloak/adminClient.ts | 24 + .../services/keycloak/principalStatus.test.ts | 15 + .../services/keycloak/principalStatus.ts | 17 + src/server/services/sites.ts | 528 ++++++++++++----- src/shared/__tests__/openApiSecurity.test.ts | 105 +++- src/shared/openApiSpec.test.ts | 12 +- src/shared/openApiSpec.ts | 133 ++++- ws-server.ts | 48 +- 63 files changed, 4397 insertions(+), 466 deletions(-) create mode 100644 src/app/api/v2/sites/[siteId]/access/route.ts create mode 100644 src/app/api/v2/sites/browser/bind/route.ts create mode 100644 src/app/api/v2/sites/browser/mint/route.ts create mode 100644 src/app/api/v2/sites/browser/open/[siteId]/route.ts create mode 100644 src/app/api/v2/sites/browser/revoke/route.ts create mode 100644 src/app/api/v2/sites/capabilities/route.ts create mode 100644 src/server/db/migrations/035_sites_access_control.ts create mode 100644 src/server/lib/apiTokenScopes.ts create mode 100644 src/server/lib/sites/browserAuth.test.ts create mode 100644 src/server/lib/sites/browserAuth.ts create mode 100644 src/server/lib/sites/gateway.test.ts create mode 100644 src/server/lib/sites/gateway.ts create mode 100644 src/server/lib/sites/oauthRevocation.test.ts create mode 100644 src/server/lib/sites/policy.test.ts create mode 100644 src/server/lib/sites/policy.ts create mode 100644 src/server/lib/verifiedOAuthBearer.ts diff --git a/src/__tests__/pages-api/operationalRoutes.test.ts b/src/__tests__/pages-api/operationalRoutes.test.ts index 6dc94498..605520b6 100644 --- a/src/__tests__/pages-api/operationalRoutes.test.ts +++ b/src/__tests__/pages-api/operationalRoutes.test.ts @@ -1,6 +1,8 @@ import { request, response } from 'src/test-utils/pagesApi'; const mockGetAllConfigs = jest.fn(); +const mockVerifyBearerToken = jest.fn(); +jest.mock('server/lib/auth', () => ({ verifyBearerToken: (...args: unknown[]) => mockVerifyBearerToken(...args) })); const mockTTLQueueAdd = jest.fn(); const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); const mockNanoid = jest.fn(() => 'fixed-id'); @@ -124,20 +126,51 @@ describe('legacy operational API routes', () => { }); describe('/config/cache', () => { + const priorAuth = process.env.ENABLE_AUTH; + const priorIssuer = process.env.KEYCLOAK_ISSUER; + const adminRequest = (overrides: Parameters[0] = {}) => + request({ ...overrides, headers: { authorization: 'Bearer verified-test-token' } }); + beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = 'https://idp.test/realms/lifecycle'; + mockVerifyBearerToken.mockResolvedValue({ + success: true, + payload: { sub: 'admin-1', iss: 'https://idp.test/realms/lifecycle', realm_access: { roles: ['admin'] } }, + }); + }); + afterAll(() => { + if (priorAuth === undefined) delete process.env.ENABLE_AUTH; + else process.env.ENABLE_AUTH = priorAuth; + if (priorIssuer === undefined) delete process.env.KEYCLOAK_ISSUER; + else process.env.KEYCLOAK_ISSUER = priorIssuer; + }); + it('denies ordinary/missing identity before loading configuration', async () => { + const res = response(); + await cacheHandler(request(), res); + expect(res.statusCode).toBe(403); + expect(mockGetAllConfigs).not.toHaveBeenCalled(); + }); + it('denies auth-off even with cached admin claims', async () => { + process.env.ENABLE_AUTH = 'false'; + const res = response(); + await cacheHandler(adminRequest(), res); + expect(res.statusCode).toBe(403); + expect(mockGetAllConfigs).not.toHaveBeenCalled(); + }); it.each([ ['GET', false], ['PUT', true], ])('%s returns cached configuration with the expected refresh flag', async (method, refresh) => { mockGetAllConfigs.mockResolvedValueOnce({ feature: 'value' }); const res = response(); - await cacheHandler(request({ method }), res); + await cacheHandler(adminRequest({ method }), res); expect(mockGetAllConfigs).toHaveBeenCalledWith(refresh); expect(res.body).toEqual({ configs: { feature: 'value' } }); }); it('advertises allowed methods', async () => { const res = response(); - await cacheHandler(request({ method: 'POST' }), res); + await cacheHandler(adminRequest({ method: 'POST' }), res); expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'PUT']); expect(res.statusCode).toBe(405); }); @@ -145,19 +178,38 @@ describe('legacy operational API routes', () => { it('maps config retrieval failures to the route-specific error', async () => { mockGetAllConfigs.mockRejectedValueOnce(new Error('config unavailable')); const res = response(); - await cacheHandler(request(), res); + await cacheHandler(adminRequest(), res); expect(res.statusCode).toBe(500); expect(res.body).toEqual({ error: 'Unable to retrieve global config values' }); }); - it('maps response failures to the outer stable error', async () => { + it('ignores forged x-user admin claims even with an invalid bearer', async () => { + mockVerifyBearerToken.mockResolvedValue({ success: false }); const res = response(); - (res.setHeader as jest.Mock).mockImplementationOnce(() => { - throw new Error('response unavailable'); - }); - await cacheHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred.' }); + await cacheHandler( + request({ + headers: { + authorization: 'Bearer forged', + 'x-user': Buffer.from( + JSON.stringify({ + sub: 'admin-1', + iss: 'https://idp.test/realms/lifecycle', + realm_access: { roles: ['admin'] }, + }) + ).toString('base64'), + }, + }), + res + ); + expect(res.statusCode).toBe(403); + expect(mockGetAllConfigs).not.toHaveBeenCalled(); + expect(mockVerifyBearerToken).toHaveBeenCalledWith('forged'); + }); + it('denies malformed claims before configuration reads', async () => { + const res = response(); + await cacheHandler(request({ headers: { 'x-user': 'malformed' } }), res); + expect(res.statusCode).toBe(403); + expect(mockGetAllConfigs).not.toHaveBeenCalled(); }); }); }); diff --git a/src/app/api/v2/config/sites/route.test.ts b/src/app/api/v2/config/sites/route.test.ts index 29c508d3..bf00d0a8 100644 --- a/src/app/api/v2/config/sites/route.test.ts +++ b/src/app/api/v2/config/sites/route.test.ts @@ -77,7 +77,7 @@ describe('/api/v2/config/sites', () => { } }); - it('allows any session to read config', async () => { + it('requires admin to read full configuration', async () => { mockGetUser.mockReturnValue({ sub: 'sample-user', realm_access: { @@ -88,12 +88,9 @@ describe('/api/v2/config/sites', () => { const response = await GET(makeRequest()); const body = await response.json(); - expect(response.status).toBe(200); - expect(body.data.config).toMatchObject({ - enabled: false, - domain: 'localhost', - hostPrefix: 'site', - }); + expect(response.status).toBe(403); + expect(body.error).toBeDefined(); + expect(mockGetSitesConfig).not.toHaveBeenCalled(); }); it('requires admin access before updating config', async () => { diff --git a/src/app/api/v2/config/sites/route.ts b/src/app/api/v2/config/sites/route.ts index 27c9c063..fd7e9ff0 100644 --- a/src/app/api/v2/config/sites/route.ts +++ b/src/app/api/v2/config/sites/route.ts @@ -106,5 +106,5 @@ const putHandler = async (req: NextRequest) => { return successResponse({ config }, { status: 200 }, req); }; -export const GET = createApiHandler(getHandler, { auth: 'session' }); +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); export const PUT = createApiHandler(putHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/me/tokens/route.ts b/src/app/api/v2/me/tokens/route.ts index c9e2f7ae..2329860b 100644 --- a/src/app/api/v2/me/tokens/route.ts +++ b/src/app/api/v2/me/tokens/route.ts @@ -108,6 +108,7 @@ const postHandler = async (req: NextRequest) => { email: identity.email, preferredUsername: identity.preferredUsername, displayName: identity.displayName, + issuer: identity.issuer ?? null, roleAtIssue: identity.roles.includes('admin') ? 'admin' : 'user', }, }); diff --git a/src/app/api/v2/sites/[siteId]/access/route.ts b/src/app/api/v2/sites/[siteId]/access/route.ts new file mode 100644 index 00000000..c11fcc28 --- /dev/null +++ b/src/app/api/v2/sites/[siteId]/access/route.ts @@ -0,0 +1,71 @@ +import { NextRequest } from 'next/server'; +import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; +import type { Principal } from 'server/lib/principal'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; +import { readSiteRevision, readSiteVisibility, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import SitesService, { SitesServiceError } from 'server/services/sites'; + +/** + * @openapi + * /api/v2/sites/{siteId}/access: + * patch: + * summary: Change Site visibility + * operationId: setSiteVisibility + * tags: [Sites] + * security: + * - BearerAuth: [] + * - LifecycleApiKey: [] + * parameters: + * - in: path + * name: siteId + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: false + * properties: + * visibility: { type: string, enum: [private, public] } + * expectedAccessRevision: { type: integer, minimum: 1, maximum: 2147483647 } + * required: [visibility, expectedAccessRevision] + * responses: + * '200': + * description: Visibility changed; private transition retires the previous content URL. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SiteSuccessResponse' + * '409': + * description: Access revision changed; refetch before retrying. + */ +export const PATCH = createPrincipalApiHandler( + { scope: 'sites:write' }, + async (req: NextRequest, principal: Principal, { params }: { params: Promise<{ siteId: string }> }) => { + try { + let body: unknown; + try { + body = await req.json(); + } catch { + throw new SitesServiceError('Invalid JSON.', 400); + } + if ( + !body || + typeof body !== 'object' || + Array.isArray(body) || + Object.keys(body).some((key) => !['visibility', 'expectedAccessRevision'].includes(key)) + ) { + throw new SitesServiceError('Invalid access request.', 400); + } + const input = body as Record; + const visibility = readSiteVisibility(input.visibility); + const revision = readSiteRevision(input.expectedAccessRevision, true)!; + const site = await new SitesService().setVisibility((await params).siteId, visibility, principal, revision); + return successResponse({ site }, { status: 200 }, req); + } catch (error) { + return sitesErrorResponse(error, req); + } + } +); diff --git a/src/app/api/v2/sites/[siteId]/content/route.ts b/src/app/api/v2/sites/[siteId]/content/route.ts index a90688ed..00f24c56 100644 --- a/src/app/api/v2/sites/[siteId]/content/route.ts +++ b/src/app/api/v2/sites/[siteId]/content/route.ts @@ -17,8 +17,9 @@ import { NextRequest } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; import type { Principal } from 'server/lib/principal'; -import { successResponse } from 'server/lib/response'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; import { readUploadFile, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import { SitesServiceError } from 'server/services/sites'; import SitesService from 'server/services/sites'; export const runtime = 'nodejs'; @@ -76,11 +77,16 @@ type RouteContext = { const putHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { const routeParams = await params; try { - const upload = await readUploadFile(req); const service = new SitesService(); + const existing = await service.getSite(routeParams.siteId, principal); + if (!existing.permissions.canEdit) throw new SitesServiceError('Site content editing is not permitted.', 403); + const capabilities = await service.getCapabilities(principal); + const upload = await readUploadFile(req, capabilities.upload.maxUploadBytes); + if (upload.visibility !== undefined) + throw new SitesServiceError('Use the access endpoint to change visibility.', 400); const site = await service.replaceSiteContent(routeParams.siteId, { ...upload, - user: principal.identity, + principal, }); return successResponse({ site }, { status: 200 }, req); } catch (error) { diff --git a/src/app/api/v2/sites/[siteId]/extend/route.ts b/src/app/api/v2/sites/[siteId]/extend/route.ts index 8880bfb4..390a1218 100644 --- a/src/app/api/v2/sites/[siteId]/extend/route.ts +++ b/src/app/api/v2/sites/[siteId]/extend/route.ts @@ -17,8 +17,8 @@ import { NextRequest } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; import type { Principal } from 'server/lib/principal'; -import { successResponse } from 'server/lib/response'; -import { sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; +import { readSiteRevision, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; import SitesService from 'server/services/sites'; type RouteContext = { @@ -44,6 +44,10 @@ type RouteContext = { * required: true * schema: * type: string + * - in: query + * name: expectedAccessRevision + * required: false + * schema: { type: integer, minimum: 1, maximum: 2147483647 } * responses: * '200': * description: Hosted static site expiration extended. @@ -64,11 +68,15 @@ type RouteContext = { * schema: * $ref: '#/components/schemas/ApiErrorResponse' */ -const postHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { +const postHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { const routeParams = await params; try { const service = new SitesService(); - const site = await service.extendSite(routeParams.siteId); + const site = await service.extendSite( + routeParams.siteId, + principal, + readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision')) + ); return successResponse({ site }, { status: 200 }, req); } catch (error) { return sitesErrorResponse(error, req); diff --git a/src/app/api/v2/sites/[siteId]/route.ts b/src/app/api/v2/sites/[siteId]/route.ts index 4483414a..6e828293 100644 --- a/src/app/api/v2/sites/[siteId]/route.ts +++ b/src/app/api/v2/sites/[siteId]/route.ts @@ -17,8 +17,8 @@ import { NextRequest } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; import type { Principal } from 'server/lib/principal'; -import { successResponse } from 'server/lib/response'; -import { sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; +import { readSiteRevision, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; import SitesService from 'server/services/sites'; type RouteContext = { @@ -71,6 +71,10 @@ type RouteContext = { * required: true * schema: * type: string + * - in: query + * name: expectedAccessRevision + * required: false + * schema: { type: integer, minimum: 1, maximum: 2147483647 } * responses: * '200': * description: Hosted static site deleted. @@ -85,22 +89,26 @@ type RouteContext = { * schema: * $ref: '#/components/schemas/ApiErrorResponse' */ -const getHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { +const getHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { const routeParams = await params; try { const service = new SitesService(); - const site = await service.getSite(routeParams.siteId); + const site = await service.getSite(routeParams.siteId, principal); return successResponse({ site }, { status: 200 }, req); } catch (error) { return sitesErrorResponse(error, req); } }; -const deleteHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { +const deleteHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { const routeParams = await params; try { const service = new SitesService(); - const site = await service.deleteSite(routeParams.siteId); + const site = await service.deleteSite( + routeParams.siteId, + principal, + readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision')) + ); return successResponse({ site }, { status: 200 }, req); } catch (error) { return sitesErrorResponse(error, req); diff --git a/src/app/api/v2/sites/browser/bind/route.ts b/src/app/api/v2/sites/browser/bind/route.ts new file mode 100644 index 00000000..17b2bb77 --- /dev/null +++ b/src/app/api/v2/sites/browser/bind/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; +import { getSitesBrowserAuth, readSitesBridgeBody, SitesBrowserError } from 'server/lib/sites/browserAuth'; + +// The normal middleware verifies this bearer. Binding requires the UI's signed +// server-to-server envelope as well, and cannot revive a revoked application login. +/** + * @openapi + * /api/v2/sites/browser/bind: + * post: + * operationId: bindSitesBrowserLogin + * summary: Bind a UI OAuth token to an application login + * description: Internal server-to-server operation for the Lifecycle UI. Requires a verified user OAuth bearer from the configured UI client AND the signed bind request header. Lifecycle API keys and browser cookies are not accepted. Explicit sign-in creates a fresh application login; refresh must match an existing unrevoked login. Binding does not revoke or create an identity-provider SSO session. + * tags: [Sites] + * security: + * - BearerAuth: [] + * SitesBrowserBridge: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SitesBrowserBindRequest' + * responses: + * '200': + * description: Operation completed. Response must not be cached. + * content: + * application/json: + * schema: + * type: object + * required: [data] + * properties: + * data: + * type: object + * required: [bound] + * properties: + * bound: + * type: boolean + * enum: [true] + * '400': + * description: Invalid signed request body. + * '401': + * description: Missing, invalid, expired, or replayed credentials, or an invalid application login. + * '403': + * description: The authenticated principal is not permitted to use this operation. + * '413': + * description: Signed request body exceeds 4096 bytes. + * '500': + * description: Unexpected server or storage dependency failure; completion could not be confirmed. + * '503': + * description: Required authorization or storage dependency is unavailable; no access is granted. + */ +export const POST = createPrincipalApiHandler({ scope: 'sites:read', kinds: ['user'] }, async (request, principal) => { + if (!principal.issuer || !principal.userId || !principal.oauth) throw new SitesBrowserError(401); + const auth = getSitesBrowserAuth(); + const raw = await readSitesBridgeBody(request); + const body = await auth.verifyBridge('bind', raw, request.headers.get('x-lfc-sites-bridge')); + await auth.bind({ issuer: principal.issuer, subject: principal.userId, oauth: principal.oauth }, body); + return NextResponse.json({ data: { bound: true } }, { headers: { 'Cache-Control': 'no-store' } }); +}); diff --git a/src/app/api/v2/sites/browser/mint/route.ts b/src/app/api/v2/sites/browser/mint/route.ts new file mode 100644 index 00000000..a8934d26 --- /dev/null +++ b/src/app/api/v2/sites/browser/mint/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server'; +import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; +import { getUser } from 'server/lib/get-user'; +import { getVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; +import { + getSitesBrowserAuth, + readSitesBridgeBody, + assertPrivateSitesReady, + SitesBrowserError, +} from 'server/lib/sites/browserAuth'; +import { assertSitesPrincipal } from 'server/lib/sites/policy'; +import SitesService from 'server/services/sites'; + +/** + * @openapi + * /api/v2/sites/browser/mint: + * post: + * operationId: mintSitesBrowserLogin + * summary: Mint a private-site browser bootstrap ticket + * description: Internal server-to-server operation for the Lifecycle UI. Requires a live owner OAuth bearer AND the signed mint request header, an existing bound application login, and a matching gateway challenge. Lifecycle API keys and browser cookies are not accepted. Returns a single-use ticket valid for at most 60 seconds for POST consumption at the supplied content-host URL; it is not an OAuth token. + * tags: [Sites] + * security: + * - BearerAuth: [] + * SitesBrowserBridge: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SitesBrowserMintRequest' + * responses: + * '200': + * description: Operation completed. Response must not be cached. + * content: + * application/json: + * schema: + * type: object + * required: [data] + * properties: + * data: + * type: object + * required: [ticket, consumeUrl] + * properties: + * ticket: + * type: string + * consumeUrl: + * type: string + * format: uri + * '400': + * description: Invalid signed request body. + * '401': + * description: Missing, invalid, expired, or replayed credentials, or an invalid application login. + * '403': + * description: The authenticated principal is not permitted to use this operation. + * '404': + * description: Site is unavailable to this owner. + * '413': + * description: Signed request body exceeds 4096 bytes. + * '500': + * description: Unexpected server or storage dependency failure; completion could not be confirmed. + * '503': + * description: Required authorization or storage dependency is unavailable; no access is granted. + * '429': + * description: Browser ticket mint rate limit exceeded. + */ +export const POST = createPrincipalApiHandler({ scope: 'sites:read', kinds: ['user'] }, async (request, principal) => { + assertPrivateSitesReady(); + await assertSitesPrincipal(principal); + const raw = await readSitesBridgeBody(request); + const auth = getSitesBrowserAuth(); + const body = await auth.verifyBridge('mint', raw, request.headers.get('x-lfc-sites-bridge')); + const challenge = await auth.readChallenge(body.state || ''); + const { site } = await new SitesService().getGatewaySite(challenge.host); + const bearer = getVerifiedOAuthBearer(principal); + if (!principal.issuer || !principal.userId || !bearer) throw new SitesBrowserError(401); + await auth.rateLimit(`mint:${principal.issuer}:${principal.userId}`); + const result = await auth.mint( + site, + { issuer: principal.issuer, subject: principal.userId, oauth: principal.oauth }, + body, + Number(getUser(request)?.exp), + bearer + ); + return NextResponse.json({ data: result }, { headers: { 'Cache-Control': 'no-store' } }); +}); diff --git a/src/app/api/v2/sites/browser/open/[siteId]/route.ts b/src/app/api/v2/sites/browser/open/[siteId]/route.ts new file mode 100644 index 00000000..250a783b --- /dev/null +++ b/src/app/api/v2/sites/browser/open/[siteId]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import SitesService from 'server/services/sites'; + +// URL resolution only, never a list or a private metadata projection. +/** + * @openapi + * /api/v2/sites/browser/open/{siteId}: + * get: + * operationId: resolvePublicSiteBrowserUrl + * summary: Resolve a public site's current content URL + * description: Anonymous stable-link resolution. Returns only the current public content URL; private, deleted, and unknown sites receive the same not-found response without private metadata. + * tags: [Sites] + * security: [] + * parameters: + * - in: path + * name: siteId + * required: true + * schema: + * type: string + * pattern: '^[a-z0-9-]{1,64}$' + * responses: + * '200': + * description: Public content URL. Response must not be cached. + * content: + * application/json: + * schema: + * type: object + * required: [data] + * properties: + * data: + * type: object + * required: [url] + * properties: + * url: + * type: string + * format: uri + * '404': + * description: Site not found or not public. Response must not be cached. + * content: + * application/json: + * schema: + * type: object + * required: [error] + * properties: + * error: + * type: object + * required: [message] + * properties: + * message: + * type: string + * enum: ['Site not found.'] + * '500': + * description: Unexpected server or storage dependency failure. + * '503': + * description: Required dependency is unavailable. + */ +export const GET = createPublicApiHandler(async (_request, context: { params: Promise<{ siteId: string }> }) => { + const { siteId } = await context.params; + const headers = { 'Cache-Control': 'no-store' }; + if (!/^[a-z0-9-]{1,64}$/.test(siteId)) + return NextResponse.json({ error: { message: 'Site not found.' } }, { status: 404, headers }); + const url = await new SitesService().resolvePublicSiteUrl(siteId); + return url + ? NextResponse.json({ data: { url } }, { headers }) + : NextResponse.json({ error: { message: 'Site not found.' } }, { status: 404, headers }); +}); diff --git a/src/app/api/v2/sites/browser/revoke/route.ts b/src/app/api/v2/sites/browser/revoke/route.ts new file mode 100644 index 00000000..f5bf9dd5 --- /dev/null +++ b/src/app/api/v2/sites/browser/revoke/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { getSitesBrowserAuth, readSitesBridgeBody } from 'server/lib/sites/browserAuth'; + +// This exact route uses the dedicated server-to-server signature, including when the +// user's OAuth token has expired. It never accepts arbitrary x-user or browser cookies. +/** + * @openapi + * /api/v2/sites/browser/revoke: + * post: + * operationId: revokeSitesBrowserLogin + * summary: Revoke a UI application login + * description: Internal server-to-server logout operation for the Lifecycle UI. Requires the signed revoke request header only; no OAuth bearer or browser cookie is required, so logout remains available after OAuth expiry. Revokes the application login and its Sites access without ending the separate identity-provider SSO session. A Lifecycle API key cannot authorize this operation. + * tags: [Sites] + * security: + * - SitesBrowserBridge: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SitesBrowserLoginRequest' + * responses: + * '200': + * description: Operation completed. Response must not be cached. + * content: + * application/json: + * schema: + * type: object + * required: [data] + * properties: + * data: + * type: object + * required: [revoked] + * properties: + * revoked: + * type: boolean + * enum: [true] + * '400': + * description: Invalid signed request body. + * '401': + * description: Missing, invalid, expired, or replayed credentials, or an invalid application login. + * '413': + * description: Signed request body exceeds 4096 bytes. + * '500': + * description: Unexpected server or storage dependency failure; completion could not be confirmed. + * '503': + * description: Required authorization or storage dependency is unavailable; no access is granted. + */ +export const POST = createPublicApiHandler(async (request) => { + const raw = await readSitesBridgeBody(request); + const auth = getSitesBrowserAuth(); + const body = await auth.verifyBridge('revoke', raw, request.headers.get('x-lfc-sites-bridge')); + await auth.revoke(body); + return NextResponse.json({ data: { revoked: true } }, { headers: { 'Cache-Control': 'no-store' } }); +}); diff --git a/src/app/api/v2/sites/capabilities/route.ts b/src/app/api/v2/sites/capabilities/route.ts new file mode 100644 index 00000000..c382b36e --- /dev/null +++ b/src/app/api/v2/sites/capabilities/route.ts @@ -0,0 +1,35 @@ +import { NextRequest } from 'next/server'; +import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; +import type { Principal } from 'server/lib/principal'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; +import { sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import SitesService from 'server/services/sites'; + +/** + * @openapi + * /api/v2/sites/capabilities: + * get: + * summary: Get sanitized Sites upload capabilities + * operationId: getSitesCapabilities + * tags: [Sites] + * security: + * - BearerAuth: [] + * - LifecycleApiKey: [] + * responses: + * '200': + * description: Capabilities for the authenticated principal; no storage credentials or endpoints. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SitesCapabilitiesSuccessResponse' + */ +export const GET = createPrincipalApiHandler( + { scope: 'sites:read' }, + async (req: NextRequest, principal: Principal) => { + try { + return successResponse(await new SitesService().getCapabilities(principal), { status: 200 }, req); + } catch (error) { + return sitesErrorResponse(error, req); + } + } +); diff --git a/src/app/api/v2/sites/route.test.ts b/src/app/api/v2/sites/route.test.ts index 7020decd..ba4fe6db 100644 --- a/src/app/api/v2/sites/route.test.ts +++ b/src/app/api/v2/sites/route.test.ts @@ -11,6 +11,8 @@ const mockGetSite = jest.fn(); const mockDeleteSite = jest.fn(); const mockReplaceSiteContent = jest.fn(); const mockExtendSite = jest.fn(); +const mockGetCapabilities = jest.fn(); +const mockSetVisibility = jest.fn(); const mockLogger = { error: jest.fn(), info: jest.fn() }; jest.mock('server/lib/principal', () => ({ @@ -43,6 +45,8 @@ jest.mock('server/services/sites', () => { __esModule: true, SitesServiceError, default: jest.fn(() => ({ + getCapabilities: (...args: unknown[]) => mockGetCapabilities(...args), + setVisibility: (...args: unknown[]) => mockSetVisibility(...args), listSites: (...args: unknown[]) => mockListSites(...args), createSite: (...args: unknown[]) => mockCreateSite(...args), getSite: (...args: unknown[]) => mockGetSite(...args), @@ -58,6 +62,8 @@ import { GET as listSites, POST as createSite } from './route'; import { DELETE as deleteSite, GET as getSite } from './[siteId]/route'; import { PUT as replaceContent } from './[siteId]/content/route'; import { POST as extendSite } from './[siteId]/extend/route'; +import { GET as capabilities } from './capabilities/route'; +import { PATCH as visibility } from './[siteId]/access/route'; const identity: NonNullable = { userId: 'user-1', @@ -103,15 +109,28 @@ const site = { name: 'Docs', url: 'https://docs-abc123.sites.example.com', createdBy: 'octo@example.com', + permissions: { canView: true, canEdit: true, canDelete: true, canChangeVisibility: true }, }; -function request(url: string, options: { method?: string; file?: unknown; name?: unknown } = {}): NextRequest { +function request( + url: string, + options: { + method?: string; + file?: unknown; + name?: unknown; + body?: unknown; + expectedAccessRevision?: string; + expectedContentRevision?: string; + } = {} +): NextRequest { const values: Record = { + ...options, ...(options.file === undefined ? {} : { file: options.file }), ...(options.name === undefined ? {} : { name: options.name }), }; return { method: options.method ?? 'GET', + json: jest.fn().mockResolvedValue(options.body), headers: new Headers([['x-request-id', 'req-sites']]), nextUrl: new URL(url), formData: jest.fn().mockResolvedValue({ @@ -138,6 +157,14 @@ describe('hosted sites API routes', () => { sites: [site], pagination: { page: 2, limit: 10, total: 21, totalPages: 3 }, }); + mockGetCapabilities.mockResolvedValue({ + enabled: true, + canCreate: true, + defaultVisibility: 'private', + allowedVisibilities: ['private', 'public'], + upload: { maxUploadBytes: 1024, maxExtractedBytes: 1024, maxFiles: 10, allowedExtensions: ['html', 'zip'] }, + }); + mockSetVisibility.mockResolvedValue(site); mockCreateSite.mockResolvedValue(site); mockGetSite.mockResolvedValue(site); mockDeleteSite.mockResolvedValue({ ...site, deletedAt: '2026-08-27T00:00:00.000Z' }); @@ -146,6 +173,8 @@ describe('hosted sites API routes', () => { }); it.each([ + ['capabilities', capabilities, 'sites:read', undefined], + ['visibility', visibility, 'sites:write', context()], ['list', listSites, 'sites:read', undefined], ['create', createSite, 'sites:write', undefined], ['get', getSite, 'sites:read', context()], @@ -180,7 +209,7 @@ describe('hosted sites API routes', () => { const body = await response.json(); expect(response.status).toBe(200); - expect(mockListSites).toHaveBeenCalledWith({ user: 'octo@example.com', page: 2, limit: 10 }); + expect(mockListSites).toHaveBeenCalledWith({ view: 'mine', page: 2, limit: 10 }, sessionPrincipal); expect(body.data).toEqual({ sites: [site] }); expect(body.metadata).toEqual({ pagination: { page: 2, limit: 10, total: 21, totalPages: 3 } }); }); @@ -188,8 +217,8 @@ describe('hosted sites API routes', () => { it('omits blank and invalid list filters instead of forwarding sentinel values', async () => { const response = await listSites(request('http://localhost/api/v2/sites?user=%20%20&page=nope&limit=')); - expect(response.status).toBe(200); - expect(mockListSites).toHaveBeenCalledWith({}); + expect(response.status).toBe(400); + expect(mockListSites).not.toHaveBeenCalled(); }); it.each([ @@ -216,7 +245,7 @@ describe('hosted sites API routes', () => { fileName: 'site.zip', content: Buffer.from([80, 75, 3, 4]), name: 'Docs', - user: identity, + principal: sessionPrincipal, }); }); @@ -242,7 +271,7 @@ describe('hosted sites API routes', () => { expect(response.status).toBe(200); expect((await response.json()).data).toEqual({ site }); - expect(mockGetSite).toHaveBeenCalledWith('docs-abc123'); + expect(mockGetSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal); }); it('deletes a site by route id and returns its tombstoned representation', async () => { @@ -255,7 +284,7 @@ describe('hosted sites API routes', () => { expect((await response.json()).data.site).toEqual( expect.objectContaining({ id: site.id, deletedAt: expect.any(String) }) ); - expect(mockDeleteSite).toHaveBeenCalledWith('docs-abc123'); + expect(mockDeleteSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal, undefined); }); it.each([ @@ -284,7 +313,7 @@ describe('hosted sites API routes', () => { fileName: 'index.html', content: Buffer.from('

Docs

'), name: undefined, - user: identity, + principal: sessionPrincipal, }); }); @@ -321,7 +350,7 @@ describe('hosted sites API routes', () => { expect((await response.json()).data.site).toEqual( expect.objectContaining({ id: site.id, expiresAt: expect.any(String) }) ); - expect(mockExtendSite).toHaveBeenCalledWith('docs-abc123'); + expect(mockExtendSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal, undefined); }); it('maps an extension failure when TTL is disabled', async () => { @@ -335,4 +364,56 @@ describe('hosted sites API routes', () => { expect(response.status).toBe(400); expect((await response.json()).error.message).toBe('TTL is disabled for hosted sites.'); }); + it('denies replacement before reading an unauthorized upload', async () => { + mockGetSite.mockResolvedValue({ ...site, permissions: { canEdit: false } }); + const req = request('http://localhost/api/v2/sites/docs-abc123/content', { method: 'PUT', file: uploadFile() }); + expect((await replaceContent(req, context())).status).toBe(403); + expect(req.formData).not.toHaveBeenCalled(); + expect(mockReplaceSiteContent).not.toHaveBeenCalled(); + }); + it('returns sanitized capabilities with no-store', async () => { + const res = await capabilities(request('http://localhost/api/v2/sites/capabilities')); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect((await res.json()).data).toMatchObject({ defaultVisibility: 'private', canCreate: true }); + expect(mockGetCapabilities).toHaveBeenCalledWith(sessionPrincipal); + }); + it.each([ + {}, + { visibility: 'private' }, + { visibility: 'public', expectedAccessRevision: 0 }, + { visibility: 'public', expectedAccessRevision: 1, ownerSubject: 'attacker' }, + ])('rejects invalid access body %p before the service', async (body) => { + expect( + ( + await visibility( + request('http://localhost/api/v2/sites/docs-abc123/access', { method: 'PATCH', body }), + context() + ) + ).status + ).toBe(400); + expect(mockSetVisibility).not.toHaveBeenCalled(); + }); + it('passes visibility and required revision together with the verified principal', async () => { + const body = { visibility: 'private', expectedAccessRevision: 7 }; + const res = await visibility( + request('http://localhost/api/v2/sites/docs-abc123/access', { method: 'PATCH', body }), + context() + ); + expect(res.status).toBe(200); + expect(mockSetVisibility).toHaveBeenCalledWith('docs-abc123', 'private', sessionPrincipal, 7); + }); + it('passes deletion revision and rejects malformed revisions', async () => { + expect( + (await deleteSite(request('http://localhost/api/v2/sites/docs-abc123?expectedAccessRevision=3'), context())) + .status + ).toBe(200); + expect(mockDeleteSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal, 3); + mockDeleteSite.mockClear(); + expect( + (await deleteSite(request('http://localhost/api/v2/sites/docs-abc123?expectedAccessRevision=3oops'), context())) + .status + ).toBe(400); + expect(mockDeleteSite).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/v2/sites/route.ts b/src/app/api/v2/sites/route.ts index e606752f..5d663fdd 100644 --- a/src/app/api/v2/sites/route.ts +++ b/src/app/api/v2/sites/route.ts @@ -17,8 +17,9 @@ import { NextRequest } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; import type { Principal } from 'server/lib/principal'; -import { successResponse } from 'server/lib/response'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; import { readSitesListFilters, readUploadFile, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import { SitesServiceError } from 'server/services/sites'; import SitesService from 'server/services/sites'; export const runtime = 'nodejs'; @@ -31,18 +32,17 @@ export const runtime = 'nodejs'; * security: * - BearerAuth: [] * - LifecycleApiKey: [] - * description: Returns all non-deleted hosted static sites. + * description: Returns only authorized hosted sites; private metadata is visible only to its owner. * tags: * - Sites * operationId: listSites * parameters: - * - name: user + * - name: view * in: query - * required: false - * description: Filters to sites created or last updated by the supplied user email. - * schema: - * type: string - * example: user@example.com + * schema: { type: string, enum: [mine, public, all] } + * - name: q + * in: query + * schema: { type: string, maxLength: 200 } * - name: page * in: query * required: false @@ -108,10 +108,10 @@ export const runtime = 'nodejs'; * schema: * $ref: '#/components/schemas/ApiErrorResponse' */ -const getHandler = async (req: NextRequest) => { +const getHandler = async (req: NextRequest, principal: Principal) => { try { const service = new SitesService(); - const result = await service.listSites(readSitesListFilters(req.nextUrl.searchParams)); + const result = await service.listSites(readSitesListFilters(req.nextUrl.searchParams), principal); return successResponse({ sites: result.sites }, { status: 200, metadata: { pagination: result.pagination } }, req); } catch (error) { return sitesErrorResponse(error, req); @@ -120,11 +120,13 @@ const getHandler = async (req: NextRequest) => { const postHandler = async (req: NextRequest, principal: Principal) => { try { - const upload = await readUploadFile(req); const service = new SitesService(); + const capabilities = await service.getCapabilities(principal); + if (!capabilities.canCreate) throw new SitesServiceError('Site creation is unavailable for this credential.', 403); + const upload = await readUploadFile(req, capabilities.upload.maxUploadBytes); const site = await service.createSite({ ...upload, - user: principal.identity, + principal, }); return successResponse({ site }, { status: 201 }, req); } catch (error) { diff --git a/src/pages/api/v1/config/cache.ts b/src/pages/api/v1/config/cache.ts index 7dc5f538..78c0e248 100644 --- a/src/pages/api/v1/config/cache.ts +++ b/src/pages/api/v1/config/cache.ts @@ -16,6 +16,8 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { getLogger } from 'server/lib/logger'; +import { verifyBearerToken } from 'server/lib/auth'; +import { getIdentityFromClaims } from 'server/lib/get-user'; import GlobalConfigService from 'server/services/globalConfig'; /** @@ -96,6 +98,18 @@ import GlobalConfigService from 'server/services/globalConfig'; */ // eslint-disable-next-line import/no-anonymous-default-export export default async (req: NextApiRequest, res: NextApiResponse) => { + res.setHeader('Cache-Control', 'no-store'); + // V1 bypasses auth middleware: verify bearer cryptographically here; x-user is attacker-controlled. + if (process.env.ENABLE_AUTH !== 'true') return res.status(403).json({ error: 'Administrator session required.' }); + const authorization = req.headers.authorization; + const bearer = + typeof authorization === 'string' && authorization.length <= 16384 ? /^Bearer\s+(\S+)$/i.exec(authorization) : null; + if (!bearer) return res.status(403).json({ error: 'Administrator session required.' }); + const verified = await verifyBearerToken(bearer[1]); + const identity = verified.success ? getIdentityFromClaims(verified.payload ?? null) : null; + if (!identity?.issuer || !identity.roles.includes('admin')) { + return res.status(403).json({ error: 'Administrator session required.' }); + } try { switch (req.method) { case 'GET': diff --git a/src/server/db/migrations/035_sites_access_control.ts b/src/server/db/migrations/035_sites_access_control.ts new file mode 100644 index 00000000..b7cd2366 --- /dev/null +++ b/src/server/db/migrations/035_sites_access_control.ts @@ -0,0 +1,49 @@ +import type { Knex } from 'knex'; + +export const config = { transaction: true }; + +/** Run only behind the Sites writer/gateway upgrade barrier. Legacy rows remain public and unassigned. */ +export async function up(knex: Knex): Promise { + await knex.raw('LOCK TABLE sites IN ACCESS EXCLUSIVE MODE'); + await knex.schema.alterTable('sites', (table) => { + table.string('visibility', 16).notNullable().defaultTo('public'); + table.string('ownerKind', 16).notNullable().defaultTo('unresolved'); + table.string('ownerIssuer', 2048).nullable(); + table.string('ownerSubject', 255).nullable(); + // No FK: deleting or rotating a key must not delete or transfer the original owner's Sites. + table.integer('creatorTokenId').nullable(); + table.string('servingGeneration', 32).nullable(); + table.integer('accessRevision').notNullable().defaultTo(1); + table.integer('contentRevision').notNullable().defaultTo(1); + table.index(['ownerIssuer', 'ownerSubject'], 'sites_owner_identity_idx'); + table.index(['creatorTokenId'], 'sites_creator_token_idx'); + table.index(['visibility', 'deletedAt'], 'sites_visibility_idx'); + }); + await knex.raw(`ALTER TABLE sites ALTER COLUMN visibility SET DEFAULT 'private'`); + await knex.raw(`ALTER TABLE sites ADD CONSTRAINT sites_access_consistency CHECK ( + visibility IN ('private', 'public') AND "accessRevision" > 0 AND "contentRevision" > 0 AND ( + ("ownerKind" = 'user' AND "ownerIssuer" IS NOT NULL AND length("ownerIssuer") > 0 AND "ownerSubject" IS NOT NULL AND length("ownerSubject") > 0 AND "creatorTokenId" IS NULL) OR + ("ownerKind" = 'service_key' AND "ownerIssuer" IS NULL AND "ownerSubject" IS NULL AND "creatorTokenId" IS NOT NULL AND "creatorTokenId" > 0) OR + ("ownerKind" = 'unresolved' AND "ownerIssuer" IS NULL AND "ownerSubject" IS NULL AND "creatorTokenId" IS NULL) + ) AND (visibility = 'public' OR ("ownerKind" = 'user' AND "servingGeneration" IS NOT NULL AND length("servingGeneration") > 0)) + )`); + await knex.raw(`CREATE FUNCTION sites_preserve_owner() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF ROW(OLD."ownerKind", OLD."ownerIssuer", OLD."ownerSubject", OLD."creatorTokenId") IS DISTINCT FROM + ROW(NEW."ownerKind", NEW."ownerIssuer", NEW."ownerSubject", NEW."creatorTokenId") THEN + RAISE EXCEPTION 'Site ownership is immutable; use the explicit audited operator recovery transaction'; + END IF; + RETURN NEW; + END $$`); + await knex.raw( + `CREATE TRIGGER sites_preserve_owner BEFORE UPDATE ON sites FOR EACH ROW EXECUTE FUNCTION sites_preserve_owner()` + ); + await knex.schema.alterTable('api_tokens', (table) => table.string('ownerIssuer', 2048).nullable()); + // Existing personal keys remain unbound. Reissue rather than guess their historical realm. +} + +export async function down(): Promise { + throw new Error( + 'Sites ACL rollback is unsafe: preserve enforcement and use the documented forward recovery procedure.' + ); +} diff --git a/src/server/lib/__tests__/get-user.test.ts b/src/server/lib/__tests__/get-user.test.ts index fc858663..eae10521 100644 --- a/src/server/lib/__tests__/get-user.test.ts +++ b/src/server/lib/__tests__/get-user.test.ts @@ -56,6 +56,23 @@ describe('get-user helpers', () => { restoreEnv(); }); + it('retains only the configured external issuer from verified claims', () => { + const previous = process.env.KEYCLOAK_ISSUER; + try { + process.env.KEYCLOAK_ISSUER = 'https://idp.test/realms/lifecycle'; + expect(getIdentityFromClaims({ sub: 'owner', iss: process.env.KEYCLOAK_ISSUER })?.issuer).toBe( + process.env.KEYCLOAK_ISSUER + ); + expect( + getIdentityFromClaims({ sub: 'owner', iss: 'http://internal-idp/realms/lifecycle' })?.issuer + ).toBeUndefined(); + expect(getIdentityFromClaims({ sub: 'owner' })?.issuer).toBeUndefined(); + } finally { + if (previous === undefined) delete process.env.KEYCLOAK_ISSUER; + else process.env.KEYCLOAK_ISSUER = previous; + } + }); + it('decodes x-user payloads', () => { const payload = getUser(makeRequest({ sub: 'user-123', github_username: 'sample-user' })); expect(payload?.sub).toBe('user-123'); @@ -204,3 +221,27 @@ describe('get-user helpers', () => { ); }); }); + +describe('verified OAuth credential metadata', () => { + const claims = { sid: 'sid', session_state: 'legacy-sid', jti: 'jti', azp: 'cli', exp: 2000000000 }; + test('uses sid, with legacy session_state compatibility', () => { + const { getOAuthCredentialFromClaims } = require('../get-user'); + expect(getOAuthCredentialFromClaims(claims)).toEqual({ + sessionId: 'sid', + tokenId: 'jti', + clientId: 'cli', + expiresAt: 2000000000, + }); + expect(getOAuthCredentialFromClaims({ ...claims, sid: undefined }).sessionId).toBe('legacy-sid'); + }); + test.each([ + { jti: undefined }, + { azp: undefined }, + { sid: undefined, session_state: undefined }, + { exp: Infinity }, + { exp: '2000000000' }, + ])('fails closed for missing/malformed metadata %j', (overrides) => { + const { getOAuthCredentialFromClaims } = require('../get-user'); + expect(getOAuthCredentialFromClaims({ ...claims, ...overrides })).toBeUndefined(); + }); +}); diff --git a/src/server/lib/__tests__/principal.test.ts b/src/server/lib/__tests__/principal.test.ts index 27850aba..1cdb6628 100644 --- a/src/server/lib/__tests__/principal.test.ts +++ b/src/server/lib/__tests__/principal.test.ts @@ -111,6 +111,7 @@ describe('resolvePrincipal with API keys', () => { kind: 'personal_key', authMethod: 'api_key', userId: 'sub-1', + issuer: null, actor: 'sub-1', roles: [], scopes: ['env:read', 'env:write'], @@ -119,6 +120,7 @@ describe('resolvePrincipal with API keys', () => { repositoryAllowlistRepoIds: [42], identity: { userId: 'sub-1', + issuer: null, githubUsername: 'octo', preferredUsername: 'octo-pref', email: 'owner@corp.com', @@ -132,6 +134,20 @@ describe('resolvePrincipal with API keys', () => { }); }); + it('uses only the persisted key issuer and never binds a legacy key to current config', async () => { + const originalIssuer = process.env.KEYCLOAK_ISSUER; + try { + process.env.KEYCLOAK_ISSUER = 'https://new-issuer.test/realms/lifecycle'; + query.findOne.mockResolvedValueOnce(personalRecord({ ownerIssuer: 'https://old-issuer.test/realms/lifecycle' })); + expect((await resolvePrincipal(keyRequest(PAT_TOKEN))).issuer).toBe('https://old-issuer.test/realms/lifecycle'); + query.findOne.mockResolvedValueOnce(personalRecord({ ownerIssuer: null })); + expect((await resolvePrincipal(keyRequest(PAT_TOKEN))).issuer).toBeNull(); + } finally { + if (originalIssuer === undefined) delete process.env.KEYCLOAK_ISSUER; + else process.env.KEYCLOAK_ISSUER = originalIssuer; + } + }); + it('rebuilds the display name through the snapshot chain and falls back to the sub', async () => { query.findOne.mockResolvedValueOnce(personalRecord({ ownerDisplayName: null })); expect((await resolvePrincipal(keyRequest(LEGACY_TOKEN))).identity?.displayName).toBe('octo'); @@ -325,3 +341,25 @@ describe('resolvePrincipal with sessions', () => { expect(principal.identity?.displayName).toBe('local-dev-user'); }); }); + +test('REST principal retains revocation identity only from verified middleware claims', async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 300; + const principal = await resolvePrincipal( + request({ + 'x-user': encodeUser({ + sub: 'owner', + sid: 'login-sid', + jti: 'access-token-id', + azp: 'lifecycle-ui', + exp: expiresAt, + realm_access: { roles: ['user'] }, + }), + }) + ); + expect(principal.oauth).toEqual({ + sessionId: 'login-sid', + tokenId: 'access-token-id', + clientId: 'lifecycle-ui', + expiresAt, + }); +}); diff --git a/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts b/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts index 1afde2f0..8ae0eb66 100644 --- a/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts +++ b/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts @@ -43,9 +43,13 @@ describe('v2 route policy manifest', () => { expect({ unclassified, stale }).toEqual({ unclassified: [], stale: [] }); }); - it('declares the OAuth callback as the only public method', () => { + it('declares only the OAuth callback anonymous public-Site locator and internally HMAC-authenticated browser revocation outside platform auth', () => { const publicKeys = V2_ROUTE_POLICY_MANIFEST.filter((e) => e.policy === 'public').map(keyOf); - expect(publicKeys).toEqual(['GET /api/v2/ai/agent/mcp-connections/{slug}/oauth/callback']); + expect(publicKeys).toEqual([ + 'GET /api/v2/ai/agent/mcp-connections/{slug}/oauth/callback', + 'POST /api/v2/sites/browser/revoke', + 'GET /api/v2/sites/browser/open/{siteId}', + ]); }); it('keeps every admin-guarded method session-only with the admin role', () => { diff --git a/src/server/lib/apiTokenScopes.ts b/src/server/lib/apiTokenScopes.ts new file mode 100644 index 00000000..e37c942f --- /dev/null +++ b/src/server/lib/apiTokenScopes.ts @@ -0,0 +1,22 @@ +import type { ApiTokenScope } from 'server/models/ApiToken'; + +export const API_TOKEN_SCOPES: ApiTokenScope[] = [ + 'env:read', + 'env:write', + 'env:admin', + 'sites:read', + 'sites:write', + 'repos:read', + 'repos:write', +]; + +/** write ⊃ read within one resource; legacy env:admin covers env:* only; never cross-resource. */ +export function scopeSatisfies(granted: readonly string[], required: ApiTokenScope): boolean { + return granted.some((scope) => { + if (!API_TOKEN_SCOPES.includes(scope as ApiTokenScope)) return false; + if (scope === required) return true; + if (scope === 'env:admin') return required === 'env:read' || required === 'env:write'; + const [resource, action] = scope.split(':'); + return action === 'write' && required === `${resource}:read`; + }); +} diff --git a/src/server/lib/get-user.ts b/src/server/lib/get-user.ts index fc04fb4f..324fc50e 100644 --- a/src/server/lib/get-user.ts +++ b/src/server/lib/get-user.ts @@ -66,7 +66,30 @@ function buildGitFallbackEmail(identifier: string): string { return `${identifier.replace(/[^A-Za-z0-9._-]+/g, '-') || 'local-dev-user'}@local.lifecycle`; } +/** Revocation metadata extracted only from middleware/JWKS-verified OAuth claims. */ +export interface OAuthCredential { + sessionId: string; + tokenId: string; + clientId: string; + expiresAt: number; +} + +export function getOAuthCredentialFromClaims(payload: JWTPayload | null): OAuthCredential | undefined { + const sessionId = normalizeClaim(payload?.sid) || normalizeClaim(payload?.session_state); + const tokenId = normalizeClaim(payload?.jti); + const clientId = normalizeClaim(payload?.azp); + if (!sessionId || !tokenId || !clientId || !Number.isSafeInteger(payload?.exp)) return undefined; + return { + sessionId, + tokenId, + clientId, + expiresAt: Number(payload!.exp), + }; +} + export interface RequestUserIdentity { + /** Verified external identity issuer; never an internal transport URL. */ + issuer?: string | null; userId: string; githubUsername: string | null; preferredUsername: string | null; @@ -102,6 +125,9 @@ function buildUserIdentity(payload: JWTPayload | null, userId: string): RequestU return { userId, + ...(normalizeClaim(claims.iss) && normalizeClaim(claims.iss) === process.env.KEYCLOAK_ISSUER?.trim() + ? { issuer: normalizeClaim(claims.iss) } + : {}), githubUsername, preferredUsername, email, diff --git a/src/server/lib/principal.ts b/src/server/lib/principal.ts index 10030b05..3b6cfa66 100644 --- a/src/server/lib/principal.ts +++ b/src/server/lib/principal.ts @@ -21,14 +21,24 @@ import type ApiToken from 'server/models/ApiToken'; import type { ApiTokenScope } from 'server/models/ApiToken'; import { AppError } from './appError'; import { bearerApiKey } from './apiTokenShape'; -import { getRequestUserIdentity, type RequestUserIdentity } from './get-user'; +import { + getRequestUserIdentity, + getUser, + getOAuthCredentialFromClaims, + type OAuthCredential, + type RequestUserIdentity, +} from './get-user'; import type { LifecycleRole } from './roles'; +import { rememberVerifiedOAuthBearer } from './verifiedOAuthBearer'; export type PrincipalKind = 'user' | 'personal_key' | 'service_key'; export interface Principal { + issuer?: string | null; + /** Verified OAuth token/session identity; absent on API keys and legacy claims. */ + oauth?: OAuthCredential; kind: PrincipalKind; - authMethod: 'session' | 'oauth' | 'api_key'; + authMethod: 'session' | 'oauth' | 'api_key' | 'sites_viewer'; /** OAuth subject; owner subject for personal keys; null for service keys. */ userId: string | null; /** Subject, or `token:` for service keys — audit/attribution string. */ @@ -62,6 +72,7 @@ function personalKeyIdentity(record: ApiToken, ownerUserId: string): RequestUser record.ownerDisplayName ?? record.ownerGithubUsername ?? record.ownerPreferredUsername ?? ownerUserId; return { userId: ownerUserId, + issuer: record.ownerIssuer ?? null, githubUsername: record.ownerGithubUsername ?? null, preferredUsername: record.ownerPreferredUsername ?? null, email: record.ownerEmail ?? null, @@ -106,6 +117,7 @@ async function resolveKeyPrincipal(token: string): Promise { kind: 'personal_key', authMethod: 'api_key', userId: ownerUserId, + issuer: record.ownerIssuer ?? null, actor: ownerUserId, roles: [], scopes: record.scopes, @@ -140,10 +152,12 @@ export async function resolvePrincipal(req: NextRequest): Promise { const identity = getRequestUserIdentity(req); if (identity) { - return { + const principal: Principal = { kind: 'user', authMethod: 'session', userId: identity.userId, + issuer: identity.issuer ?? null, + oauth: getOAuthCredentialFromClaims(getUser(req)), actor: identity.userId, roles: identity.roles, scopes: null, @@ -152,6 +166,9 @@ export async function resolvePrincipal(req: NextRequest): Promise { repositoryAllowlistRepoIds: null, identity, }; + const bearer = /^Bearer\s+(\S+)$/i.exec(req.headers.get('authorization') || '')?.[1]; + if (bearer) rememberVerifiedOAuthBearer(principal, bearer); + return principal; } throw new AppError({ httpStatus: 401, code: 'authentication_required', message: 'Authentication is required.' }); diff --git a/src/server/lib/sites/browserAuth.test.ts b/src/server/lib/sites/browserAuth.test.ts new file mode 100644 index 00000000..39d5841e --- /dev/null +++ b/src/server/lib/sites/browserAuth.test.ts @@ -0,0 +1,462 @@ +import { createHmac } from 'crypto'; +import { + SitesBrowserAuth, + SitesBrowserError, + randomSiteToken, + siteTokenHash, + challengeCookieName, + assertPrivateSitesReady, + assertViewerOwns, + safeSiteReturnPath, + parseSitesCookies, + type BrowserSite, +} from './browserAuth'; + +const mockTokenStatus = jest.fn(); +jest.mock('server/services/keycloak/principalStatus', () => ({ + getOAuthTokenStatus: (...args: unknown[]) => mockTokenStatus(...args), +})); + +jest.mock('server/lib/redisClient', () => ({ __esModule: true, default: { getInstance: jest.fn() } })); + +// Atomic test store shared by two protocol instances; actual Redis/ingress acceptance +// remains required. Expiration is modeled so tests do not pretend cached state is live. +class Store { + rows = new Map(); + async get(key: string) { + const row = this.rows.get(key); + return row && row.until > Date.now() ? row.value : null; + } + async set(key: string, value: string, _ex: string, ttl: number, nx?: string) { + const current = this.rows.get(key); + if (nx && current && current.until > Date.now()) return null; + this.rows.set(key, { value, until: Date.now() + ttl * 1000 }); + return 'OK'; + } + async eval(script: string, _count: number, key: string, value?: string, ttl?: number, mode?: string) { + // Read and mutate without yielding: emulate the atomic Redis execution boundary. + const row = this.rows.get(key); + const current = row && row.until > Date.now() ? row.value : null; + if (script.includes('sites-consume')) { + this.rows.delete(key); + return current; + } + if (script.includes('sites-login')) { + if (mode === 'existing' && !current) return 0; + if (current && current !== value) return 0; + this.rows.set(key, { value: value!, until: Date.now() + ttl! * 1000 }); + return 1; + } + const count = Number(current || 0) + 1; + this.rows.set(key, { value: String(count), until: row?.until || Date.now() + 60_000 }); + return count; + } +} +const actor = { + issuer: 'https://identity.example.com/realms/lifecycle', + subject: 'owner', + oauth: { + sessionId: 'sid', + tokenId: 'initial-token-id', + clientId: 'lifecycle-ui', + expiresAt: Math.floor(Date.now() / 1000) + 1200, + }, +}; +const site: BrowserSite = { + siteId: 'abc123', + ownerKind: 'user', + ownerIssuer: actor.issuer, + ownerSubject: actor.subject, + visibility: 'private', + servingGeneration: 'abcdef012345', + accessRevision: 1, +}; +const host = 'site-abc123--g-abcdef012345.sites.example.net'; +let store: Store; +let auth: SitesBrowserAuth; +const originalEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = actor.issuer; + process.env.SITES_UI_OAUTH_CLIENT_ID = actor.oauth.clientId; + actor.oauth.expiresAt = Math.floor(Date.now() / 1000) + 1200; + mockTokenStatus.mockReset().mockResolvedValue('active'); + process.env.SITES_PRIVATE_ENABLED = 'true'; + process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; + process.env.SITES_BROWSER_BRIDGE_SECRET = 'x'.repeat(40); + store = new Store(); + auth = new SitesBrowserAuth(store as any); +}); +afterEach(() => { + process.env = { ...originalEnv }; + jest.useRealTimers(); +}); +async function setup() { + const challenge = await auth.challenge(site, host, '/docs/page.html?example=1'); + const login = { + loginId: randomSiteToken(), + loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, + siteId: site.siteId, + state: challenge.state, + }; + await auth.bind(actor, { ...login, createLogin: true }); + const minted = await auth.mint(site, actor, login, Math.floor(Date.now() / 1000) + 1200, 'authorizing-bearer-secret'); + const cookies = { [challengeCookieName(challenge.state)]: challenge.secret }; + return { challenge, login, minted, cookies }; +} +function signature(action: string, body: string, timestamp = Math.floor(Date.now() / 1000)) { + const nonce = randomSiteToken(); + const hash = createHmac('sha256', process.env.SITES_BROWSER_BRIDGE_SECRET!) + .update(`sites-v1\n${action}\n${timestamp}\n${nonce}\n${siteTokenHash(body)}`) + .digest('hex'); + return `${timestamp}.${nonce}.${hash}`; +} +it('consumes only once across two gateway instances and creates an owner-scoped session', async () => { + const { minted, cookies } = await setup(); + const second = new SitesBrowserAuth(store as any); + const authorize = jest.fn(async (viewer) => { + assertViewerOwns(site, viewer); + }); + const outcomes = await Promise.allSettled([ + auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, authorize), + second.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, authorize), + ]); + expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + const value = (outcomes.find((result) => result.status === 'fulfilled') as PromiseFulfilledResult).value; + expect(value.path).toBe('/docs/page.html?example=1'); + expect(await second.viewer(value.sessionId, host)).toMatchObject(actor); + expect(authorize).toHaveBeenCalledTimes(1); +}); +it.each(['wrong-origin', 'wrong-host', 'wrong-cookie'])('rejects %s before authorizing', async (scenario) => { + const { minted, cookies } = await setup(); + const authorize = jest.fn(); + await expect( + auth.consume( + minted.ticket, + scenario === 'wrong-host' ? 'another.sites.example.net' : host, + scenario === 'wrong-cookie' ? {} : cookies, + scenario === 'wrong-origin' ? 'https://evil.example.org' : process.env.SITES_UI_ORIGIN, + authorize + ) + ).rejects.toBeInstanceOf(SitesBrowserError); + expect(authorize).not.toHaveBeenCalled(); +}); +it('revokes existing viewers and pending tickets on logout', async () => { + const { minted, cookies, login } = await setup(); + const result = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); + await auth.revoke(login); + await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 401 }); + await expect(auth.mint(site, actor, login, login.loginExpiresAt, 'authorizing-bearer-secret')).rejects.toMatchObject({ + statusCode: 401, + }); +}); +it('a revocation tombstone wins over a concurrent first-time login registration', async () => { + const challenge = await auth.challenge(site, host, '/'); + const login = { + loginId: randomSiteToken(), + loginExpiresAt: Math.floor(Date.now() / 1000) + 300, + siteId: site.siteId, + state: challenge.state, + }; + await auth.revoke(login); + await expect(auth.mint(site, actor, login, login.loginExpiresAt, 'authorizing-bearer-secret')).rejects.toMatchObject({ + statusCode: 401, + }); +}); +it('expires one-use tickets after 60 seconds', async () => { + jest.useFakeTimers(); + const { minted, cookies } = await setup(); + jest.advanceTimersByTime(61_000); + await expect( + auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, jest.fn()) + ).rejects.toMatchObject({ statusCode: 401 }); +}); +it('never lets a different actor or service-key owner mint a viewer', async () => { + const { login } = await setup(); + await expect( + auth.mint(site, { ...actor, subject: 'other' }, login, login.loginExpiresAt, 'authorizing-bearer-secret') + ).rejects.toMatchObject({ + statusCode: 404, + }); + await expect( + auth.mint({ ...site, ownerKind: 'service_key' }, actor, login, login.loginExpiresAt, 'authorizing-bearer-secret') + ).rejects.toMatchObject({ statusCode: 404 }); +}); +it('runs current authorization on consume and returns no session when ownership/generation changes', async () => { + const { minted, cookies } = await setup(); + await expect( + auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => { + throw new SitesBrowserError(404); + }) + ).rejects.toMatchObject({ statusCode: 404 }); + expect(Array.from(store.rows.keys()).filter((key) => key.includes(':viewer:'))).toHaveLength(0); +}); +it('auth-off denies already issued cookies; restored authentication does not undo logout', async () => { + const { minted, cookies, login } = await setup(); + const result = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); + process.env.ENABLE_AUTH = 'false'; + await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 503 }); + await auth.revoke(login); + process.env.ENABLE_AUTH = 'true'; + await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 401 }); +}); +it('fails closed on Redis failure', async () => { + const { minted, cookies } = await setup(); + store.eval = jest.fn().mockRejectedValue(new Error('redis down')); + await expect(auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, jest.fn())).rejects.toThrow( + 'redis down' + ); +}); +it('verifies action-bound bridge signatures, rejects replay/tampering and accepts revoke without a bearer', async () => { + const body = JSON.stringify({ loginId: randomSiteToken(), loginExpiresAt: Math.floor(Date.now() / 1000) + 300 }); + const signed = signature('revoke', body); + expect(await auth.verifyBridge('revoke', body, signed)).toEqual(JSON.parse(body)); + await expect(auth.verifyBridge('revoke', body, signed)).rejects.toMatchObject({ statusCode: 401 }); + await expect(auth.verifyBridge('mint', body, signature('revoke', body))).rejects.toMatchObject({ statusCode: 401 }); + await expect(auth.verifyBridge('mint', `${body} `, signature('mint', body))).rejects.toMatchObject({ + statusCode: 401, + }); + await expect(auth.verifyBridge('mint', body, signature('mint', body, 1))).rejects.toMatchObject({ statusCode: 401 }); +}); +it('requires explicitly enabled HTTPS, separate registrable domain and a dedicated secret', () => { + expect(() => assertPrivateSitesReady(`https://${host}`)).not.toThrow(); + expect(() => assertPrivateSitesReady('https://sites.example.com')).toThrow(); + expect(() => assertPrivateSitesReady('http://sites.example.net')).toThrow(); + process.env.SITES_BROWSER_BRIDGE_SECRET = 'short'; + expect(() => assertPrivateSitesReady()).toThrow(); +}); +it('rejects unsafe return paths and ambiguous cookies', () => { + for (const path of [ + '//evil.test', + '/%2f%2fevil.test', + '/%5cevil', + '/../secret', + '/%2e%2e/secret', + '/_lfc-sites/consume', + ]) + expect(() => safeSiteReturnPath(path)).toThrow(); + expect(parseSitesCookies('a=first; a=shadow').a).toBe(''); +}); + +it('bounds chunked bridge request bodies before parsing without Content-Length', async () => { + const { readSitesBridgeBody } = await import('./browserAuth'); + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4097)); + }, + cancel() { + cancelled = true; + }, + }); + await expect(readSitesBridgeBody({ headers: new Headers(), body: stream })).rejects.toMatchObject({ + statusCode: 413, + }); + expect(cancelled).toBe(true); +}); + +describe('OAuth application-login binding', () => { + const oauthActor = (tokenId = 'initial') => ({ + ...actor, + oauth: { + tokenId, + sessionId: 'same-sso-session', + clientId: 'lifecycle-ui', + expiresAt: Math.floor(Date.now() / 1000) + 1200, + }, + }); + const login = () => ({ + loginId: randomSiteToken(), + loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, + createLogin: true, + }); + beforeEach(() => { + process.env.KEYCLOAK_ISSUER = actor.issuer; + process.env.SITES_UI_OAUTH_CLIENT_ID = 'lifecycle-ui'; + }); + it('denies replay of all still-unexpired same-login tokens after logout, across instances', async () => { + const first = oauthActor(); + const refreshed = oauthActor('refreshed'); + const body = login(); + await auth.bind(first, body); + await auth.bind(refreshed, { ...body, createLogin: false }); + const otherInstance = new SitesBrowserAuth(store as any); + await otherInstance.assertOAuthLogin(first); + await otherInstance.assertOAuthLogin(refreshed); + await auth.revoke(body); + await expect(otherInstance.assertOAuthLogin(first)).rejects.toMatchObject({ statusCode: 401 }); + await expect(otherInstance.assertOAuthLogin(refreshed)).rejects.toMatchObject({ statusCode: 401 }); + await expect(otherInstance.bind(oauthActor('late-refresh'), body)).rejects.toMatchObject({ statusCode: 401 }); + }); + it('allows a fresh independent login using the same SSO session, without reviving old tokens', async () => { + const old = login(); + await auth.bind(oauthActor(), old); + await auth.revoke(old); + const fresh = login(); + await auth.bind(oauthActor('fresh-login-token'), fresh); + await auth.assertOAuthLogin(oauthActor('fresh-login-token')); + await expect(auth.bind(oauthActor(), fresh)).rejects.toMatchObject({ statusCode: 401 }); + await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 401 }); + }); + it('denies legacy unbound UI tokens but allows separate CLI OAuth clients', async () => { + await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 401 }); + const cli = oauthActor(); + cli.oauth.clientId = 'lifecycle-cli'; + await auth.assertOAuthLogin(cli); + await expect(auth.bind(cli, login())).rejects.toMatchObject({ statusCode: 401 }); + }); + it('fails closed if binding configuration or Redis authorization state is unavailable', async () => { + delete process.env.SITES_UI_OAUTH_CLIENT_ID; + await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 503 }); + process.env.SITES_UI_OAUTH_CLIENT_ID = 'lifecycle-ui'; + jest.spyOn(store, 'get').mockRejectedValue(new Error('redis unavailable')); + await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 503 }); + }); + it('keeps an existing token binding authoritative after the configured UI client changes', async () => { + const body = login(); + await auth.bind(oauthActor(), body); + await auth.revoke(body); + process.env.SITES_UI_OAUTH_CLIENT_ID = 'new-ui-client'; + await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 401 }); + }); + it('missing login state or expired token never establishes access', async () => { + const body = login(); + await auth.bind(oauthActor(), body); + store.rows.delete(`sites:browser:v1:login:${siteTokenHash(body.loginId)}`); + await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 401 }); + await expect( + auth.bind(oauthActor('refresh-after-state-loss'), { ...body, createLogin: false }) + ).rejects.toMatchObject({ statusCode: 401 }); + const expired = oauthActor(); + expired.oauth.expiresAt = Math.floor(Date.now() / 1000) - 1; + await expect(auth.bind(expired, body)).rejects.toMatchObject({ statusCode: 401 }); + }); + it('bind envelopes cannot be replayed or substituted for mint/revoke', async () => { + const body = JSON.stringify(login()); + const signed = signature('bind', body); + await expect(auth.verifyBridge('mint', body, signed)).rejects.toMatchObject({ statusCode: 401 }); + await expect(auth.verifyBridge('bind', body, signed)).resolves.toEqual(JSON.parse(body)); + await expect(auth.verifyBridge('bind', body, signed)).rejects.toMatchObject({ statusCode: 401 }); + }); +}); + +describe('encrypted viewer authorizing-token retention', () => { + function vaultRows() { + return [...store.rows.entries()].filter(([key]) => key.startsWith('sites:browser:v1:viewer-bearer:')); + } + it('introspects the exact minting bearer without putting bearer or ciphertext in viewer metadata', async () => { + const { minted, cookies } = await setup(); + const consumed = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); + const viewer = await auth.viewer(consumed.sessionId, host); + await expect(auth.getViewerOAuthTokenStatus(viewer)).resolves.toBe('active'); + expect(mockTokenStatus).toHaveBeenLastCalledWith('authorizing-bearer-secret'); + expect(vaultRows()).toHaveLength(1); + expect(JSON.stringify([...store.rows.values()])).not.toContain('authorizing-bearer-secret'); + expect(JSON.stringify(viewer)).not.toContain('ciphertext'); + expect(JSON.stringify(minted)).not.toContain('ciphertext'); + mockTokenStatus.mockResolvedValue('revoked'); + await auth.assertLogin(viewer); // Same live app login; only the exact access token was revoked. + await expect(auth.getViewerOAuthTokenStatus(viewer)).resolves.toBe('revoked'); + }); + it('keeps same-token retry ciphertext/deadline stable, including concurrent mint calls', async () => { + jest.useFakeTimers(); + const { login } = await setup(); + const [key, original] = vaultRows()[0]; + jest.advanceTimersByTime(300_000); + const challenge = await auth.challenge(site, host, '/'); + const updated = { ...login, state: challenge.state }; + const results = await Promise.all([ + auth.mint(site, actor, updated, actor.oauth.expiresAt, 'authorizing-bearer-secret'), + auth.mint(site, actor, updated, actor.oauth.expiresAt, 'authorizing-bearer-secret'), + ]); + expect(vaultRows()).toHaveLength(1); + expect(store.rows.get(key)).toEqual(original); + for (const result of results) { + const ticket = JSON.parse((await store.get(`sites:browser:v1:ticket:${siteTokenHash(result.ticket)}`))!); + expect(ticket.expiresAt).toBe(JSON.parse(original.value).expiresAt); + } + }); + it('preserves previous token records on refresh and isolates exact-token revocation', async () => { + const { login } = await setup(); + const refreshed = { ...actor, oauth: { ...actor.oauth, tokenId: 'refreshed-token-id' } }; + await auth.bind(refreshed, { ...login, createLogin: false }); + await auth.mint(site, refreshed, login, refreshed.oauth.expiresAt, 'refreshed-bearer-secret'); + expect(vaultRows()).toHaveLength(2); + mockTokenStatus.mockImplementation(async (bearer) => + bearer === 'authorizing-bearer-secret' ? 'revoked' : 'active' + ); + await expect(auth.getViewerOAuthTokenStatus(actor)).resolves.toBe('revoked'); + await expect(auth.getViewerOAuthTokenStatus(refreshed)).resolves.toBe('active'); + await auth.revoke(login); + await expect(auth.getViewerOAuthTokenStatus(refreshed)).rejects.toMatchObject({ httpStatus: 401 }); + }); + it('keeps a separate login on the same SSO session independent', async () => { + const first = await setup(); + const secondActor = { ...actor, oauth: { ...actor.oauth, tokenId: 'separate-login-token' } }; + const challenge = await auth.challenge(site, host, '/'); + const secondLogin = { + loginId: randomSiteToken(), + loginExpiresAt: first.login.loginExpiresAt, + state: challenge.state, + siteId: site.siteId, + }; + await auth.bind(secondActor, { ...secondLogin, createLogin: true }); + await auth.mint(site, secondActor, secondLogin, secondActor.oauth.expiresAt, 'separate-login-bearer'); + await auth.revoke(first.login); + await expect(auth.getViewerOAuthTokenStatus(actor)).rejects.toMatchObject({ httpStatus: 401 }); + await expect(auth.getViewerOAuthTokenStatus(secondActor)).resolves.toBe('active'); + }); + it.each(['missing-vault', 'missing-binding', 'missing-key', 'rotated-key', 'corrupt-vault', 'changed-metadata'])( + 'fails closed for %s without a session-list fallback', + async (failure) => { + await setup(); + const [key, record] = vaultRows()[0]; + let presented = actor; + if (failure === 'missing-vault') store.rows.delete(key); + if (failure === 'missing-binding') + store.rows.delete(`sites:browser:v1:oauth:${siteTokenHash(`${actor.issuer}\0${actor.oauth.tokenId}`)}`); + if (failure === 'missing-key') delete process.env.SITES_BROWSER_BRIDGE_SECRET; + if (failure === 'rotated-key') process.env.SITES_BROWSER_BRIDGE_SECRET = 'y'.repeat(40); + if (failure === 'corrupt-vault') { + const value = JSON.parse(record.value); + value.tag = Buffer.alloc(16).toString('base64url'); + store.rows.set(key, { ...record, value: JSON.stringify(value) }); + } + if (failure === 'changed-metadata') + presented = { ...actor, oauth: { ...actor.oauth, sessionId: 'other-session' } }; + await expect(auth.getViewerOAuthTokenStatus(presented)).rejects.toBeInstanceOf(SitesBrowserError); + expect(mockTokenStatus).not.toHaveBeenCalled(); + } + ); + it('caps retention at 15 minutes, token expiry and login expiry, and rejects expired records', async () => { + jest.useFakeTimers(); + await setup(); + expect(vaultRows()[0][1].until - Date.now()).toBeLessThanOrEqual(900_000); + jest.advanceTimersByTime(901_000); + await expect(auth.getViewerOAuthTokenStatus(actor)).rejects.toMatchObject({ httpStatus: 401 }); + const short = { + ...actor, + oauth: { ...actor.oauth, tokenId: 'short', expiresAt: Math.floor(Date.now() / 1000) + 40 }, + }; + const challenge = await auth.challenge(site, host, '/'); + const login = { + loginId: randomSiteToken(), + loginExpiresAt: Math.floor(Date.now() / 1000) + 20, + state: challenge.state, + siteId: site.siteId, + }; + await auth.bind(short, { ...login, createLogin: true }); + await auth.mint(site, short, login, short.oauth.expiresAt, 'short-bearer'); + const shortRow = vaultRows().find(([_, row]) => row.until > Date.now())![1]; + expect(shortRow.until - Date.now()).toBeLessThanOrEqual(20_000); + jest.advanceTimersByTime(21_000); + await expect(auth.getViewerOAuthTokenStatus(short)).rejects.toMatchObject({ httpStatus: 401 }); + }); + it('fails closed for unavailable Redis or unknown introspection', async () => { + await setup(); + mockTokenStatus.mockResolvedValue('unknown'); + await expect(auth.getViewerOAuthTokenStatus(actor)).resolves.toBe('unknown'); + jest.spyOn(store, 'get').mockRejectedValue(new Error('unavailable')); + await expect(auth.getViewerOAuthTokenStatus(actor)).rejects.toMatchObject({ httpStatus: 503 }); + }); +}); diff --git a/src/server/lib/sites/browserAuth.ts b/src/server/lib/sites/browserAuth.ts new file mode 100644 index 00000000..3aba32b8 --- /dev/null +++ b/src/server/lib/sites/browserAuth.ts @@ -0,0 +1,505 @@ +import { createHash, createHmac, createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'; +import * as psl from 'psl'; +import type Redis from 'ioredis'; +import RedisClient from 'server/lib/redisClient'; +import { AppError } from 'server/lib/appError'; +import type { OAuthCredential } from 'server/lib/get-user'; +import { getOAuthTokenStatus } from 'server/services/keycloak/principalStatus'; + +export const SITES_AUTH_PATH = '/_lfc-sites/'; +export const SITES_VIEWER_COOKIE = '__Host-lfc-sites-viewer'; +export const MAX_LOGIN_SECONDS = 30 * 24 * 60 * 60; +const TOKEN = /^[A-Za-z0-9_-]{43}$/; +const PREFIX = 'sites:browser:v1:'; +const CONSUME = `-- sites-consume +local value = redis.call('GET', KEYS[1]) +if value then redis.call('DEL', KEYS[1]) end +return value`; +const LOGIN = `-- sites-login +local existing = redis.call('GET', KEYS[1]) +if ARGV[3] == 'existing' and not existing then return 0 end +if existing and existing ~= ARGV[1] then return 0 end +redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) +return 1`; +const LIMIT = `-- sites-limit +local count = redis.call('INCR', KEYS[1]) +if count == 1 then redis.call('EXPIRE', KEYS[1], 60) end +return count`; + +export class SitesBrowserError extends AppError { + constructor(public statusCode: number, message = 'Site access is unavailable.') { + super({ httpStatus: statusCode, code: 'sites_browser_unavailable', message }); + } +} +export type BrowserActor = { issuer: string; subject: string; oauth?: OAuthCredential }; +export type BrowserSite = { + siteId: string; + ownerKind: string; + ownerIssuer?: string | null; + ownerSubject?: string | null; + visibility: string; + servingGeneration?: string | null; + accessRevision: number; +}; +type Challenge = { siteId: string; host: string; generation: string | null; secretHash: string; path: string }; +export type Viewer = BrowserActor & { + siteId: string; + host: string; + generation: string | null; + accessRevision: number; + loginId: string; + loginExpiresAt: number; + expiresAt: number; +}; +type Ticket = Viewer & { challengeId: string; challenge: Challenge }; +type OAuthLogin = { issuer: string; subject: string; loginId: string; loginExpiresAt: number }; +type ViewerBearerRecord = { version: 1; expiresAt: number; iv: string; tag: string; ciphertext: string }; +const VIEWER_BEARER_PURPOSE = 'sites-viewer-bearer:v1'; + +export type BridgeBody = { + loginId: string; + loginExpiresAt: number; + /** True only for an explicit OAuth sign-in; refresh must find its existing login. */ + createLogin?: boolean; + state?: string; + siteId?: string; +}; +export const randomSiteToken = () => randomBytes(32).toString('base64url'); +export const siteTokenHash = (value: string) => createHash('sha256').update(value).digest('hex'); +const nowSeconds = () => Math.floor(Date.now() / 1000); + +export function sitesUiOrigin(): string { + const raw = process.env.SITES_UI_ORIGIN; + try { + const url = new URL(raw || ''); + if (url.protocol !== 'https:' || url.username || url.password || url.origin !== raw) throw new Error(); + return url.origin; + } catch { + throw new SitesBrowserError(503); + } +} +function bridgeSecret(): string { + const secret = process.env.SITES_BROWSER_BRIDGE_SECRET || ''; + if (Buffer.byteLength(secret) < 32) throw new SitesBrowserError(503); + return secret; +} +export function assertPrivateSitesReady(contentUrl?: string): void { + if (process.env.ENABLE_AUTH !== 'true' || process.env.SITES_PRIVATE_ENABLED !== 'true') + throw new SitesBrowserError(503); + const ui = new URL(sitesUiOrigin()); + bridgeSecret(); + if (contentUrl) { + const content = new URL(contentUrl); + const uiDomain = psl.get(ui.hostname); + const contentDomain = psl.get(content.hostname); + if (content.protocol !== 'https:' || !uiDomain || !contentDomain || uiDomain === contentDomain) + throw new SitesBrowserError(503); + } +} +export function safeSiteReturnPath(path: string): string { + if (path.length > 2048 || !path.startsWith('/') || path.startsWith('//') || /[\\\r\n\0]/.test(path)) + throw new SitesBrowserError(400); + let decoded: string; + try { + decoded = decodeURIComponent(path.split('?')[0]); + } catch { + throw new SitesBrowserError(400); + } + if ( + /[\\\r\n\0]/.test(decoded) || + decoded.startsWith('//') || + decoded.split('/').some((part) => part === '.' || part === '..') || + decoded.startsWith(SITES_AUTH_PATH) + ) + throw new SitesBrowserError(400); + return path; +} +export function challengeCookieName(state: string): string { + if (!TOKEN.test(state)) throw new SitesBrowserError(400); + return `__Host-lfc-sites-challenge-${state}`; +} +export function parseSitesCookies(raw?: string): Record { + const result: Record = Object.create(null); + for (const part of (raw || '').split(';')) { + const index = part.indexOf('='); + if (index < 0) continue; + const name = part.slice(0, index).trim(); + if (name in result) { + result[name] = ''; + continue; + } + result[name] = part.slice(index + 1).trim(); + } + return result; +} +export function assertViewerOwns(site: BrowserSite, actor: BrowserActor): void { + assertPrivateSitesReady(); + if (site.ownerKind !== 'user' || site.ownerIssuer !== actor.issuer || site.ownerSubject !== actor.subject) + throw new SitesBrowserError(404); +} + +export async function readSitesBridgeBody(request: { + headers: Headers; + body: ReadableStream | null; +}): Promise { + const limit = 4096; + if (Number(request.headers.get('content-length') || 0) > limit) throw new SitesBrowserError(413); + if (!request.body) throw new SitesBrowserError(400); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const result = await reader.read(); + if (result.done) break; + length += result.value.byteLength; + if (length > limit) throw new SitesBrowserError(413); + chunks.push(result.value); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + return Buffer.concat(chunks).toString('utf8'); +} + +export class SitesBrowserAuth { + constructor(private readonly redis: Pick) {} + private key(kind: string, value: string) { + return `${PREFIX}${kind}:${siteTokenHash(value)}`; + } + async rateLimit(identity: string, maximum = 60): Promise { + if (Number(await this.redis.eval(LIMIT, 1, this.key('rate', identity))) > maximum) throw new SitesBrowserError(429); + } + async verifyBridge(action: 'mint' | 'revoke' | 'bind', rawBody: string, header: string | null): Promise { + // Revocation must still work after private creation is disabled or a user's OAuth token expires. + const parts = (header || '').split('.'); + if ( + parts.length !== 3 || + !/^\d+$/.test(parts[0]) || + !TOKEN.test(parts[1]) || + !/^[a-f0-9]{64}$/.test(parts[2]) || + Math.abs(nowSeconds() - Number(parts[0])) > 30 + ) + throw new SitesBrowserError(401); + const expected = createHmac('sha256', bridgeSecret()) + .update(`sites-v1\n${action}\n${parts[0]}\n${parts[1]}\n${siteTokenHash(rawBody)}`) + .digest(); + if (!timingSafeEqual(expected, Buffer.from(parts[2], 'hex'))) throw new SitesBrowserError(401); + if ((await this.redis.set(this.key('bridge', parts[1]), 'used', 'EX', 65, 'NX')) !== 'OK') + throw new SitesBrowserError(401); + let body: BridgeBody; + try { + body = JSON.parse(rawBody); + } catch { + throw new SitesBrowserError(400); + } + if ( + !body || + !TOKEN.test(body.loginId) || + !Number.isSafeInteger(body.loginExpiresAt) || + body.loginExpiresAt > nowSeconds() + MAX_LOGIN_SECONDS + 60 || + (body.createLogin !== undefined && typeof body.createLogin !== 'boolean') + ) + throw new SitesBrowserError(400); + return body; + } + private oauthTokenKey(actor: BrowserActor): string { + return this.key('oauth', `${actor.issuer}\0${actor.oauth!.tokenId}`); + } + private loginValue(actor: BrowserActor, expiresAt: number): string { + return JSON.stringify({ issuer: actor.issuer, subject: actor.subject, expiresAt }); + } + /** Register only the exact verified access token; never revoke an entire SSO session. */ + async bind(actor: BrowserActor, body: BridgeBody): Promise { + const clientId = process.env.SITES_UI_OAUTH_CLIENT_ID?.trim(); + if (!clientId) throw new SitesBrowserError(503); + if ( + actor.issuer !== process.env.KEYCLOAK_ISSUER?.trim() || + !actor.subject || + !actor.oauth || + actor.oauth.clientId !== clientId || + actor.oauth.expiresAt <= nowSeconds() || + body.loginExpiresAt <= nowSeconds() + ) + throw new SitesBrowserError(401); + const loginValue = this.loginValue(actor, body.loginExpiresAt); + if ( + Number( + await this.redis.eval( + LOGIN, + 1, + this.key('login', body.loginId), + loginValue, + body.loginExpiresAt - nowSeconds(), + body.createLogin === true ? 'create' : 'existing' + ) + ) !== 1 + ) + throw new SitesBrowserError(401); + const value = JSON.stringify({ + issuer: actor.issuer, + subject: actor.subject, + loginId: body.loginId, + loginExpiresAt: body.loginExpiresAt, + }); + // An already-issued token cannot migrate into a fresh login and escape its logout. + const key = this.oauthTokenKey(actor); + if (Number(await this.redis.eval(LOGIN, 1, key, value, actor.oauth.expiresAt - nowSeconds() + 60)) !== 1) + throw new SitesBrowserError(401); + await this.assertOAuthLogin(actor); + } + /** All UI-client tokens require a registration, including pre-upgrade tokens. */ + async assertOAuthLogin(actor: BrowserActor): Promise { + const clientId = process.env.SITES_UI_OAUTH_CLIENT_ID?.trim(); + if (!clientId) throw new SitesBrowserError(503); + if (!actor.oauth || actor.oauth.expiresAt <= nowSeconds()) throw new SitesBrowserError(401); + try { + const raw = await this.redis.get(this.oauthTokenKey(actor)); + if (!raw) { + if (actor.oauth.clientId === clientId) throw new SitesBrowserError(401); + return; + } + const bound = JSON.parse(raw) as OAuthLogin; + if (bound.issuer !== actor.issuer || bound.subject !== actor.subject || bound.loginExpiresAt <= nowSeconds()) + throw new SitesBrowserError(401); + const expected = this.loginValue(actor, bound.loginExpiresAt); + if ((await this.redis.get(this.key('login', bound.loginId))) !== expected) throw new SitesBrowserError(401); + return bound; + } catch (error) { + if (error instanceof SitesBrowserError) throw error; + throw new SitesBrowserError(503); + } + } + private viewerBearerKey(actor: BrowserActor, login: OAuthLogin): string { + return this.key('viewer-bearer', JSON.stringify([actor.issuer, actor.oauth!.tokenId, login.loginId])); + } + private viewerBearerAad(actor: BrowserActor, login: OAuthLogin, expiresAt: number): Buffer { + const oauth = actor.oauth!; + return Buffer.from( + JSON.stringify([ + VIEWER_BEARER_PURPOSE, + actor.issuer, + actor.subject, + oauth.sessionId, + oauth.tokenId, + oauth.clientId, + oauth.expiresAt, + login.loginId, + login.loginExpiresAt, + expiresAt, + ]) + ); + } + private viewerBearerEncryptionKey(): Buffer { + return createHmac('sha256', bridgeSecret()).update(`${VIEWER_BEARER_PURPOSE}:encryption`).digest(); + } + private decryptViewerBearer( + raw: string, + actor: BrowserActor, + login: OAuthLogin + ): { bearer: string; expiresAt: number } { + if (raw.length > 24576) throw new SitesBrowserError(503); + const record = JSON.parse(raw) as ViewerBearerRecord; + if ( + record.version !== 1 || + !Number.isSafeInteger(record.expiresAt) || + record.expiresAt <= nowSeconds() || + record.expiresAt > Math.min(actor.oauth!.expiresAt, login.loginExpiresAt, nowSeconds() + 900) + ) + throw new SitesBrowserError(401); + const iv = Buffer.from(record.iv, 'base64url'); + const tag = Buffer.from(record.tag, 'base64url'); + if (iv.length !== 12 || tag.length !== 16) throw new SitesBrowserError(503); + const decipher = createDecipheriv('aes-256-gcm', this.viewerBearerEncryptionKey(), iv); + decipher.setAAD(this.viewerBearerAad(actor, login, record.expiresAt)); + decipher.setAuthTag(tag); + const bearer = Buffer.concat([ + decipher.update(Buffer.from(record.ciphertext, 'base64url')), + decipher.final(), + ]).toString('utf8'); + if (!bearer || Buffer.byteLength(bearer) > 16384) throw new SitesBrowserError(503); + return { bearer, expiresAt: record.expiresAt }; + } + /** Mint-only retention. Randomized ciphertext never enters immutable LOGIN metadata. */ + private async retainViewerBearer( + actor: BrowserActor, + login: OAuthLogin, + bearer: string, + expiresAt: number + ): Promise { + try { + if (!bearer || Buffer.byteLength(bearer) > 16384) throw new SitesBrowserError(401); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.viewerBearerEncryptionKey(), iv); + cipher.setAAD(this.viewerBearerAad(actor, login, expiresAt)); + const ciphertext = Buffer.concat([cipher.update(bearer, 'utf8'), cipher.final()]); + const record: ViewerBearerRecord = { + version: 1, + expiresAt, + iv: iv.toString('base64url'), + tag: cipher.getAuthTag().toString('base64url'), + ciphertext: ciphertext.toString('base64url'), + }; + const key = this.viewerBearerKey(actor, login); + const serialized = JSON.stringify(record); + // Same-token retries retain the original ciphertext and deadline. A later + // viewer is capped to that deadline; a refreshed jti gets its own record. + const inserted = await this.redis.set(key, serialized, 'EX', expiresAt - nowSeconds(), 'NX'); + const saved = inserted === 'OK' ? serialized : await this.redis.get(key); + if (!saved) throw new SitesBrowserError(401); + const retained = this.decryptViewerBearer(saved, actor, login); + if (retained.bearer !== bearer) throw new SitesBrowserError(401); + return Math.min(expiresAt, retained.expiresAt); + } catch (error) { + if (error instanceof SitesBrowserError) throw error; + throw new SitesBrowserError(503); + } + } + /** Decrypt only inside the authorization operation; callers never receive a bearer. */ + async getViewerOAuthTokenStatus(actor: BrowserActor): Promise<'active' | 'revoked' | 'unknown'> { + try { + const login = await this.assertOAuthLogin(actor); + if (!login) throw new SitesBrowserError(401); + const raw = await this.redis.get(this.viewerBearerKey(actor, login)); + if (!raw) throw new SitesBrowserError(401); + const { bearer } = this.decryptViewerBearer(raw, actor, login); + return await getOAuthTokenStatus(bearer); + } catch (error) { + if (error instanceof SitesBrowserError) throw error; + // Corruption, a lost/rotated bridge key, or unavailable Redis never downgrade + // to a session-list check. No encryption error/cause or bearer is logged. + throw new SitesBrowserError(503); + } + } + async challenge(site: Pick, host: string, path: string) { + assertPrivateSitesReady(`https://${host}`); + const state = randomSiteToken(); + const secret = randomSiteToken(); + const challenge: Challenge = { + siteId: site.siteId, + host, + generation: site.servingGeneration ?? null, + secretHash: siteTokenHash(secret), + path: safeSiteReturnPath(path), + }; + await this.redis.set(this.key('challenge', state), JSON.stringify(challenge), 'EX', 300); + return { state, secret }; + } + async readChallenge(state: string): Promise { + if (!TOKEN.test(state)) throw new SitesBrowserError(400); + const raw = await this.redis.get(this.key('challenge', state)); + if (!raw) throw new SitesBrowserError(401); + return JSON.parse(raw) as Challenge; + } + async mint( + site: BrowserSite, + actor: BrowserActor, + body: BridgeBody, + tokenExpiresAt: number, + authorizingBearer: string + ): Promise<{ ticket: string; consumeUrl: string }> { + assertViewerOwns(site, actor); + if (!body.state || body.siteId !== site.siteId || body.loginExpiresAt <= nowSeconds()) + throw new SitesBrowserError(401); + const challenge = await this.readChallenge(body.state); + if (challenge.siteId !== site.siteId || challenge.generation !== (site.servingGeneration ?? null)) + throw new SitesBrowserError(401); + assertPrivateSitesReady(`https://${challenge.host}`); + const login = await this.assertOAuthLogin(actor); + if (!login || login.loginId !== body.loginId || login.loginExpiresAt !== body.loginExpiresAt) + throw new SitesBrowserError(401); + let expiresAt = Math.min(nowSeconds() + 900, tokenExpiresAt, actor.oauth!.expiresAt, body.loginExpiresAt); + if (!Number.isSafeInteger(expiresAt) || expiresAt <= nowSeconds()) throw new SitesBrowserError(401); + expiresAt = await this.retainViewerBearer(actor, login, authorizingBearer, expiresAt); + const value: Ticket = { + ...actor, + siteId: site.siteId, + host: challenge.host, + generation: site.servingGeneration ?? null, + accessRevision: site.accessRevision, + loginId: body.loginId, + loginExpiresAt: body.loginExpiresAt, + expiresAt, + challengeId: body.state, + challenge, + }; + const ticket = randomSiteToken(); + await this.redis.set( + this.key('ticket', ticket), + JSON.stringify(value), + 'EX', + Math.min(60, expiresAt - nowSeconds()) + ); + return { ticket, consumeUrl: `https://${challenge.host}${SITES_AUTH_PATH}consume` }; + } + async revoke(body: BridgeBody): Promise { + // A tombstone also beats concurrent first-time minting; expiry never extends a login. + await this.redis.set( + this.key('login', body.loginId), + 'revoked', + 'EX', + Math.max(1, Math.min(MAX_LOGIN_SECONDS + 60, body.loginExpiresAt - nowSeconds() + 60)) + ); + } + async assertLogin(viewer: Viewer): Promise { + if (viewer.expiresAt <= nowSeconds() || viewer.loginExpiresAt <= nowSeconds()) throw new SitesBrowserError(401); + const expected = JSON.stringify({ + issuer: viewer.issuer, + subject: viewer.subject, + expiresAt: viewer.loginExpiresAt, + }); + if ((await this.redis.get(this.key('login', viewer.loginId))) !== expected) throw new SitesBrowserError(401); + } + async consume( + ticket: string, + host: string, + cookies: Record, + origin: string | undefined, + authorize: (viewer: Viewer) => Promise + ) { + assertPrivateSitesReady(`https://${host}`); + if (origin !== sitesUiOrigin() || !TOKEN.test(ticket)) throw new SitesBrowserError(401); + const raw = await this.redis.eval(CONSUME, 1, this.key('ticket', ticket)); + if (typeof raw !== 'string') throw new SitesBrowserError(401); + const value = JSON.parse(raw) as Ticket; + const cookie = cookies[challengeCookieName(value.challengeId)]; + if (value.host !== host || !cookie || siteTokenHash(cookie) !== value.challenge.secretHash) + throw new SitesBrowserError(401); + await this.assertLogin(value); + await authorize(value); + // Deleting challenge prevents a second separately minted ticket reusing this bootstrap. + const challenge = await this.redis.eval(CONSUME, 1, this.key('challenge', value.challengeId)); + if (typeof challenge !== 'string') throw new SitesBrowserError(401); + const sessionId = randomSiteToken(); + await this.redis.set( + this.key('viewer', sessionId), + JSON.stringify(value), + 'EX', + Math.max(1, value.expiresAt - nowSeconds()) + ); + return { + sessionId, + path: safeSiteReturnPath(value.challenge.path), + maxAge: value.expiresAt - nowSeconds(), + challengeName: challengeCookieName(value.challengeId), + }; + } + async viewer(sessionId: string | undefined, host: string): Promise { + assertPrivateSitesReady(`https://${host}`); + if (!sessionId || !TOKEN.test(sessionId)) throw new SitesBrowserError(401); + const raw = await this.redis.get(this.key('viewer', sessionId)); + if (!raw) throw new SitesBrowserError(401); + const value = JSON.parse(raw) as Viewer; + if (value.host !== host) throw new SitesBrowserError(401); + await this.assertLogin(value); + return value; + } + async logoutViewer(sessionId: string | undefined): Promise { + if (sessionId && TOKEN.test(sessionId)) await this.redis.eval(CONSUME, 1, this.key('viewer', sessionId)); + } +} +let instance: SitesBrowserAuth | undefined; +export function getSitesBrowserAuth(): SitesBrowserAuth { + return (instance ??= new SitesBrowserAuth(RedisClient.getInstance().getRedis())); +} diff --git a/src/server/lib/sites/config.test.ts b/src/server/lib/sites/config.test.ts index 2eb26a3c..6c71dd34 100644 --- a/src/server/lib/sites/config.test.ts +++ b/src/server/lib/sites/config.test.ts @@ -158,3 +158,15 @@ describe('sites storage configuration', () => { expect(config.storage.secretAccessKey).toBeUndefined(); }); }); + +describe('serving generations', () => { + it('preserves legacy URLs and extracts a generation without changing the site id', () => { + const config = resolveSitesConfig({ domain: 'sites.example.net' }); + const generated = buildSiteUrl('abc123', config, 'abcdef012345'); + expect(generated).toBe('https://site-abc123--g-abcdef012345.sites.example.net'); + expect(parseSiteIdFromHost(new URL(generated).host, config)).toBe('abc123'); + expect(parseSiteIdFromHost('site-abc123--g-x.sites.example.net', config)).toBeNull(); + expect(parseSiteIdFromHost('site-abc123--g-abcdef012345--g-abcdef012345.sites.example.net', config)).toBeNull(); + expect(parseSiteIdFromHost('site-abc123.sites.example.net@evil.test', config)).toBeNull(); + }); +}); diff --git a/src/server/lib/sites/config.ts b/src/server/lib/sites/config.ts index 6b67080a..8a01d08c 100644 --- a/src/server/lib/sites/config.ts +++ b/src/server/lib/sites/config.ts @@ -180,33 +180,35 @@ export function resolveSitesConfig(config?: SitesConfig | null): ResolvedSitesCo }; } -export function buildSiteUrl(siteId: string, config: ResolvedSitesConfig): string { - const protocol = config.domain === 'localhost' || config.domain.endsWith('.localhost') ? 'http' : 'https'; +export function buildSiteUrl(siteId: string, config: ResolvedSitesConfig, generation?: string | null): string { + const protocol = + process.env.SITES_GATEWAY_HTTPS === 'true' + ? 'https' + : config.domain === 'localhost' || config.domain.endsWith('.localhost') + ? 'http' + : 'https'; const port = config.port ? `:${config.port}` : ''; - return `${protocol}://${config.hostPrefix}-${siteId}.${config.domain}${port}`; + return `${protocol}://${config.hostPrefix}-${siteId}${generation ? `--g-${generation}` : ''}.${config.domain}${port}`; } -export function parseSiteIdFromHost(hostHeader: string | undefined, config: ResolvedSitesConfig): string | null { - if (!hostHeader) { - return null; - } - - const host = hostHeader.split(':')[0]?.toLowerCase(); - if (!host) { - return null; - } - +export function parseSiteHost( + hostHeader: string | undefined, + config: ResolvedSitesConfig +): { siteId: string; generation: string | null } | null { + if (!hostHeader || !/^[a-z0-9.-]+(?::[0-9]+)?$/i.test(hostHeader)) return null; + const host = hostHeader.split(':')[0].toLowerCase(); const suffix = `.${config.domain.toLowerCase()}`; - if (!host.endsWith(suffix)) { - return null; - } - + if (!host.endsWith(suffix)) return null; const label = host.slice(0, -suffix.length); const prefix = `${config.hostPrefix}-`; - if (!label.startsWith(prefix)) { - return null; - } + if (!label.startsWith(prefix)) return null; + const locator = label.slice(prefix.length); + const parts = locator.split('--g-'); + if (parts.length > 2 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(parts[0])) return null; + if (parts.length === 2 && !/^[a-z0-9]{12,40}$/.test(parts[1])) return null; + return { siteId: parts[0], generation: parts[1] || null }; +} - const siteId = label.slice(prefix.length); - return /^[a-z0-9-]+$/.test(siteId) ? siteId : null; +export function parseSiteIdFromHost(hostHeader: string | undefined, config: ResolvedSitesConfig): string | null { + return parseSiteHost(hostHeader, config)?.siteId ?? null; } diff --git a/src/server/lib/sites/gateway.test.ts b/src/server/lib/sites/gateway.test.ts new file mode 100644 index 00000000..86e15170 --- /dev/null +++ b/src/server/lib/sites/gateway.test.ts @@ -0,0 +1,228 @@ +import { PassThrough } from 'stream'; +import { handleSitesRequest, authorizeSitesViewer } from './gateway'; +import { SitesBrowserError } from './browserAuth'; +import { assertSitesPrincipal } from './policy'; + +const browser = { + viewer: jest.fn(), + assertLogin: jest.fn(), + challenge: jest.fn(), + rateLimit: jest.fn(), + consume: jest.fn(), + logoutViewer: jest.fn(), +}; +jest.mock('./policy', () => ({ assertSitesPrincipal: jest.fn() })); +jest.mock('./browserAuth', () => ({ ...jest.requireActual('./browserAuth'), getSitesBrowserAuth: () => browser })); +jest.mock('server/lib/redisClient', () => ({ __esModule: true, default: { getInstance: jest.fn() } })); +const site = { + siteId: 'abc123', + ownerKind: 'user', + ownerIssuer: 'https://id.example.com', + ownerSubject: 'owner', + servingGeneration: 'abcdef012345', + accessRevision: 2, + visibility: 'private', +}; +const viewer = { + issuer: site.ownerIssuer, + subject: site.ownerSubject, + siteId: site.siteId, + generation: site.servingGeneration, + accessRevision: 2, + host: 'site-abc123--g-abcdef012345.sites.example.net', + loginId: 'login', + loginExpiresAt: 1e12, + expiresAt: 1e12, + oauth: { sessionId: 'session', tokenId: 'jwt', clientId: 'ui', expiresAt: 1e12 }, +}; +function request(method = 'GET', headers = {}, url = '/') { + return { + method, + headers: { host: viewer.host, ...headers }, + url, + socket: { remoteAddress: 'test' }, + async *[Symbol.asyncIterator]() {}, + } as any; +} +function response() { + return { + statusCode: 200, + headers: {} as Record, + headersSent: false, + setHeader(name: string, value: unknown) { + this.headers[name] = value; + }, + writeHead(status: number, headers?: Record) { + this.statusCode = status; + Object.assign(this.headers, headers); + return this; + }, + end: jest.fn(), + } as any; +} +function service(current = site) { + return { + getGatewayLocator: jest.fn(async () => ({ siteId: current.siteId, servingGeneration: current.servingGeneration })), + getGatewaySite: jest.fn(async () => ({ site: current, config: {} })), + getGatewayObject: jest.fn(async (_host, _path, authorize) => { + if (current.visibility === 'private') await authorize(current); + return { statusCode: 200, contentType: 'text/plain', contentLength: 4, body: new PassThrough() }; + }), + } as any; +} +const originalEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.SITES_PRIVATE_ENABLED = 'true'; + process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; + process.env.SITES_BROWSER_BRIDGE_SECRET = 'x'.repeat(40); + jest.clearAllMocks(); + browser.viewer.mockResolvedValue(viewer); + browser.assertLogin.mockResolvedValue(undefined); + (assertSitesPrincipal as jest.Mock).mockResolvedValue(undefined); +}); +afterEach(() => { + process.env = { ...originalEnv }; +}); +it.each(['/', '/style.css', '/app.js', '/private.json'])( + 'denies unauthenticated %s without fetching storage', + async (path) => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + const api = service(); + const res = response(); + await handleSitesRequest(request('GET', {}, path), res, api); + expect(res.statusCode).toBe(404); + expect(api.getGatewayObject).not.toHaveBeenCalled(); + expect(res.headers['Cache-Control']).toBe('no-store'); + } +); +it('denies another authenticated user and changed generation', async () => { + await expect(authorizeSitesViewer(site as any, { ...viewer, subject: 'other' })).rejects.toMatchObject({ + statusCode: 404, + }); + await expect(authorizeSitesViewer(site as any, { ...viewer, generation: 'old' })).rejects.toMatchObject({ + statusCode: 401, + }); +}); +it('authorizes HEAD, including another current account/login check before storage', async () => { + const api = service(); + const res = response(); + await handleSitesRequest(request('HEAD'), res, api); + expect(res.statusCode).toBe(200); + expect(res.headers['Cross-Origin-Opener-Policy']).toBe('same-origin'); + expect(res.headers['Origin-Agent-Cluster']).toBe('?1'); + expect(res.headers['Cross-Origin-Resource-Policy']).toBe('same-origin'); + expect(api.getGatewayObject).toHaveBeenCalledTimes(1); + expect(assertSitesPrincipal).toHaveBeenCalledTimes(2); + expect(browser.assertLogin).toHaveBeenCalled(); +}); +it('never returns a login page or redirect for an unauthenticated asset/HEAD', async () => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + const api = service(); + const res = response(); + await handleSitesRequest(request('HEAD', { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }), res, api); + expect(res.statusCode).toBe(404); + expect(res.headers.Location).toBeUndefined(); + expect(browser.challenge).not.toHaveBeenCalled(); +}); +it('only navigations get a browser-bound challenge redirect', async () => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + browser.challenge.mockResolvedValue({ state: 's'.repeat(43), secret: 'c'.repeat(43) }); + const res = response(); + await handleSitesRequest( + request('GET', { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }), + res, + service() + ); + expect(res.statusCode).toBe(303); + expect(res.headers.Location).toContain('https://ui.example.com/sites/open/abc123?state='); + expect(res.headers['Set-Cookie']).toContain('SameSite=None'); +}); +it('blocks service workers and unsupported methods without storage', async () => { + for (const req of [request('GET', { 'service-worker': 'script' }), request('POST')]) { + const api = service(); + await handleSitesRequest(req, response(), api); + expect(api.getGatewayObject).not.toHaveBeenCalled(); + } +}); +it('never falls back to public bytes when account lookup or Redis fails', async () => { + (assertSitesPrincipal as jest.Mock).mockRejectedValue(new Error('directory down')); + const api = service(); + const res = response(); + await handleSitesRequest(request(), res, api); + expect(res.statusCode).toBe(404); + expect(api.getGatewayObject).not.toHaveBeenCalled(); +}); +it('public HEAD does not inspect viewer credentials', async () => { + const api = service({ ...site, visibility: 'public' }); + const res = response(); + await handleSitesRequest(request('HEAD'), res, api); + expect(res.statusCode).toBe(200); + expect(browser.viewer).not.toHaveBeenCalled(); + expect(assertSitesPrincipal).not.toHaveBeenCalled(); +}); + +it('ignores forged forwarding headers from an untrusted peer and follows only configured proxies', () => { + const { sitesClientAddress } = require('./gateway'); + process.env.SITES_TRUSTED_PROXY_ADDRESSES = '127.0.0.1'; + const direct = request('GET', { 'x-forwarded-for': '1.1.1.1' }); + direct.socket.remoteAddress = '203.0.113.5'; + expect(sitesClientAddress(direct)).toBe('203.0.113.5'); + direct.socket.remoteAddress = '::ffff:127.0.0.1'; + direct.headers['x-forwarded-for'] = '1.1.1.1, 203.0.113.5'; + expect(sitesClientAddress(direct)).toBe('203.0.113.5'); +}); + +it.each([ + ['GET', {}, '/'], + ['HEAD', {}, '/'], + ['GET', { 'service-worker': 'script' }, '/sw.js'], + ['GET', { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }, '/'], + ['POST', {}, '/_lfc-sites/consume'], + ['POST', { 'content-type': 'application/x-www-form-urlencoded' }, '/_lfc-sites/consume'], + ['POST', { origin: 'https://unrelated.example' }, '/_lfc-sites/logout'], +])('matches missing and private denial responses for %s %j %s', async (method, headers, path) => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + browser.challenge.mockResolvedValue({ state: 's'.repeat(43), secret: 'c'.repeat(43) }); + const existing = service(); + const missing = service(); + missing.getGatewaySite.mockRejectedValue(new SitesBrowserError(404)); + const first = response(); + const second = response(); + await handleSitesRequest(request(method, headers, path), first, existing); + await handleSitesRequest(request(method, headers, path), second, missing); + expect(first.statusCode).toBe(second.statusCode); + expect(first.headers).toEqual(second.headers); + expect(first.end.mock.calls).toEqual(second.end.mock.calls); + expect(existing.getGatewayObject).not.toHaveBeenCalled(); + expect(missing.getGatewayObject).not.toHaveBeenCalled(); +}); +it('propagates the verified OAuth session to both private authorization checks', async () => { + await handleSitesRequest(request('HEAD'), response(), service()); + expect(assertSitesPrincipal).toHaveBeenCalledWith(expect.objectContaining({ oauth: viewer.oauth })); +}); + +it.each([new SitesBrowserError(401), new Error('Redis unavailable'), new SitesBrowserError(503)])( + 'keeps missing/private responses identical when viewer authorization fails: %s', + async (failure) => { + browser.viewer.mockRejectedValue(failure); + browser.challenge.mockRejectedValue(new SitesBrowserError(503)); + for (const headers of [ + { cookie: 'lfc-viewer=' + 'x'.repeat(43) }, + { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }, + ]) { + const existing = service(); + const missing = service(); + missing.getGatewaySite.mockRejectedValue(new SitesBrowserError(404)); + const a = response(); + const b = response(); + await handleSitesRequest(request('GET', headers, '/asset.js'), a, existing); + await handleSitesRequest(request('GET', headers, '/asset.js'), b, missing); + expect(a.statusCode).toBe(b.statusCode); + expect(a.headers).toEqual(b.headers); + expect(a.end.mock.calls).toEqual(b.end.mock.calls); + expect(existing.getGatewayObject).not.toHaveBeenCalled(); + expect(missing.getGatewayObject).not.toHaveBeenCalled(); + } + } +); diff --git a/src/server/lib/sites/gateway.ts b/src/server/lib/sites/gateway.ts new file mode 100644 index 00000000..9caf879b --- /dev/null +++ b/src/server/lib/sites/gateway.ts @@ -0,0 +1,222 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { isIP } from 'net'; +import type Site from 'server/models/Site'; +import type { Principal } from 'server/lib/principal'; +import { assertSitesPrincipal } from './policy'; +import { + assertViewerOwns, + challengeCookieName, + getSitesBrowserAuth, + parseSitesCookies, + SITES_AUTH_PATH, + SITES_VIEWER_COOKIE, + SitesBrowserError, + sitesUiOrigin, + type Viewer, +} from './browserAuth'; +import type SitesService from 'server/services/sites'; + +export function sitesClientAddress(req: Pick): string { + const normalize = (value: string) => + value.startsWith('::ffff:') && isIP(value.slice(7)) === 4 ? value.slice(7) : value; + const peer = normalize(req.socket.remoteAddress || 'unknown'); + const trusted = new Set( + (process.env.SITES_TRUSTED_PROXY_ADDRESSES || '') + .split(',') + .map((value) => normalize(value.trim())) + .filter((value) => isIP(value)) + ); + if (!trusted.has(peer)) return peer; + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded !== 'string' || forwarded.length > 2048) return peer; + const chain = forwarded.split(',').map((value) => normalize(value.trim())); + if (chain.some((value) => !isIP(value))) return peer; + for (const address of chain.reverse()) if (!trusted.has(address)) return address; + return peer; +} + +export function sitesViewerPrincipal(viewer: Pick): Principal { + // The caller supplies only a viewer recovered from a server-side session originally minted + // from a verified JWT. This is never built from browser headers or unsigned claim input. + return { + kind: 'user', + authMethod: 'sites_viewer', + userId: viewer.subject, + issuer: viewer.issuer, + actor: viewer.subject, + roles: ['user'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + oauth: viewer.oauth, + }; +} +export async function authorizeSitesViewer(site: Site, viewer: Viewer): Promise { + assertViewerOwns(site, viewer); + if ( + site.siteId !== viewer.siteId || + (site.servingGeneration ?? null) !== viewer.generation || + site.accessRevision !== viewer.accessRevision + ) + throw new SitesBrowserError(401); + await assertSitesPrincipal(sitesViewerPrincipal(viewer)); +} +function securityHeaders(res: ServerResponse) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); +} +export async function readSmallBody(req: IncomingMessage, limit = 4096): Promise { + const chunks: Buffer[] = []; + let length = 0; + for await (const chunk of req) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + length += bytes.length; + if (length > limit) throw new SitesBrowserError(413); + chunks.push(bytes); + } + return Buffer.concat(chunks).toString('utf8'); +} +export async function handleSitesRequest( + req: IncomingMessage, + res: ServerResponse, + service: SitesService +): Promise { + securityHeaders(res); + try { + const host = (req.headers.host || '').toLowerCase(); + const url = new URL(req.url || '/', `https://${host}`); + const navigate = + req.method === 'GET' && + req.headers['sec-fetch-mode'] === 'navigate' && + req.headers['sec-fetch-dest'] === 'document' && + !url.pathname.startsWith(SITES_AUTH_PATH); + const bootstrap = async () => { + // A locator is syntax, not proof that a Site exists. Missing and inaccessible + // private hosts take the same browser flow; mint still authorizes the real Site. + const locator = await service.getGatewayLocator(host); + const browser = getSitesBrowserAuth(); + await browser.rateLimit(`challenge:${sitesClientAddress(req)}`); + const challenge = await browser.challenge(locator, host, `${url.pathname}${url.search}`); + res.setHeader( + 'Set-Cookie', + `${challengeCookieName(challenge.state)}=${ + challenge.secret + }; Secure; HttpOnly; SameSite=None; Path=/; Max-Age=300` + ); + const open = new URL(`/sites/open/${encodeURIComponent(locator.siteId)}`, sitesUiOrigin()); + open.searchParams.set('state', challenge.state); + res.writeHead(303, { Location: open.toString() }).end(); + }; + const cookies = parseSitesCookies(req.headers.cookie); + if (url.pathname.startsWith(SITES_AUTH_PATH)) { + const auth = getSitesBrowserAuth(); + await service.getGatewayLocator(host); + if (url.pathname === `${SITES_AUTH_PATH}consume` && req.method === 'POST') { + if (!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) + throw new SitesBrowserError(415); + await auth!.rateLimit(`consume:${sitesClientAddress(req)}`, 240); + const values = new URLSearchParams(await readSmallBody(req)); + if (values.getAll('ticket').length !== 1) throw new SitesBrowserError(400); + const result = await auth!.consume( + values.get('ticket') || '', + host, + cookies, + typeof req.headers.origin === 'string' ? req.headers.origin : undefined, + async (viewer) => authorizeSitesViewer((await service.getGatewaySite(host)).site, viewer) + ); + res.setHeader('Set-Cookie', [ + `${SITES_VIEWER_COOKIE}=${result.sessionId}; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=${result.maxAge}`, + `${result.challengeName}=; Secure; HttpOnly; SameSite=None; Path=/; Max-Age=0`, + ]); + res.writeHead(303, { Location: result.path }); + res.end(); + return; + } + if (url.pathname === `${SITES_AUTH_PATH}logout` && req.method === 'POST') { + if (req.headers.origin !== `https://${host}` && req.headers.origin !== sitesUiOrigin()) + throw new SitesBrowserError(403); + await auth!.logoutViewer(cookies[SITES_VIEWER_COOKIE]); + res.setHeader('Set-Cookie', `${SITES_VIEWER_COOKIE}=; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`); + res.writeHead(204).end(); + return; + } + throw new SitesBrowserError(404); + } + if (req.method !== 'GET' && req.method !== 'HEAD') throw new SitesBrowserError(404); + let site: Site; + try { + ({ site } = await service.getGatewaySite(host)); + } catch (error) { + if (navigate && (error as { statusCode?: number }).statusCode === 404) { + await bootstrap(); + return; + } + throw error; + } + const auth = site.visibility === 'private' ? getSitesBrowserAuth() : undefined; + + let viewer: Viewer | undefined; + if (site.visibility === 'private') { + try { + viewer = await auth!.viewer(cookies[SITES_VIEWER_COOKIE], host); + await authorizeSitesViewer(site, viewer); + } catch { + // Until access is established, dependency failures must not distinguish a + // private host from a missing one. Navigations share the same bootstrap. + if (!navigate) throw new SitesBrowserError(404); + await bootstrap(); + return; + } + if ( + req.headers['service-worker'] === 'script' || + ['serviceworker', 'sharedworker', 'worker'].includes(String(req.headers['sec-fetch-dest'])) + ) + throw new SitesBrowserError(403); + } + const object = await service.getGatewayObject(host, url.pathname, async (currentSite) => { + if (!viewer) throw new SitesBrowserError(401); + // Check login again immediately before storage, including a concurrent logout. + await auth!.assertLogin(viewer); + await authorizeSitesViewer(currentSite, viewer); + }); + if (site.visibility === 'private') { + // Add private isolation only after both authorization checks, so denial headers + // cannot distinguish private hosts from missing ones. + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Origin-Agent-Cluster', '?1'); + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + res.setHeader('Content-Security-Policy', "worker-src 'none'; frame-ancestors 'none'"); + } + res.statusCode = object.statusCode; + res.setHeader('Content-Type', object.contentType); + if (object.contentLength !== undefined) res.setHeader('Content-Length', String(object.contentLength)); + if (req.method === 'HEAD') { + (object.body as NodeJS.ReadableStream & { destroy?: () => void }).destroy?.(); + res.end(); + return; + } + object.body.on('error', () => { + if (!res.headersSent) res.statusCode = 502; + res.end(); + }); + object.body.pipe(res); + } catch (error) { + const status = + (error as { statusCode?: number; httpStatus?: number })?.statusCode ?? + (error as { httpStatus?: number })?.httpStatus ?? + 503; + res.statusCode = + (error as { code?: string }).code === 'private_sites_unavailable' + ? 404 + : [401, 403].includes(status) + ? 404 + : [400, 404, 413, 415, 429, 503].includes(status) + ? status + : 503; + // Do not log tokens, return paths, storage details or account identifiers here. + res.end(res.statusCode === 404 ? 'not found' : 'Site access is unavailable.'); + } +} diff --git a/src/server/lib/sites/oauthRevocation.test.ts b/src/server/lib/sites/oauthRevocation.test.ts new file mode 100644 index 00000000..82bbb1e9 --- /dev/null +++ b/src/server/lib/sites/oauthRevocation.test.ts @@ -0,0 +1,209 @@ +/** Signed JWT integration with in-memory authorization dependencies; no servers or real accounts. */ +import type { IncomingMessage } from 'http'; +import { NextRequest } from 'next/server'; +import { createRemoteJWKSet, generateKeyPair, SignJWT } from 'jose'; +import { verifyBearerToken } from 'server/lib/auth'; +import { resolvePrincipal } from 'server/lib/principal'; +import { getVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; +import { authenticateMcpRequest } from 'server/mcp/auth'; +import { SitesBrowserAuth, randomSiteToken, challengeCookieName } from './browserAuth'; +import { assertSitesPrincipal } from './policy'; + +jest.mock('jose', () => ({ ...jest.requireActual('jose'), createRemoteJWKSet: jest.fn() })); +jest.mock('server/services/apiToken', () => ({ __esModule: true, default: {} })); +jest.mock('server/models/ApiToken', () => ({ __esModule: true, default: {} })); +jest.mock('server/services/globalConfig', () => ({ __esModule: true, default: {} })); +jest.mock('server/lib/logger', () => ({ getLogger: () => ({ warn: jest.fn(), error: jest.fn() }) })); +const mockDirectorySession = jest.fn(); +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: async () => 'active', + getUserSessionStatus: (...args: unknown[]) => mockDirectorySession(...args), + getOAuthTokenStatus: (...args: unknown[]) => mockDirectorySession(...args), +})); +const mockRows = new Map(); +const mockRedis = { + get: async (key: string) => mockRows.get(key) ?? null, + set: async (key: string, value: string, _ex: string, _ttl: number, nx?: string) => { + if (nx && mockRows.has(key)) return null; + mockRows.set(key, value); + return 'OK'; + }, + eval: async (_script: string, _count: number, key: string, value: string, _ttl: number, mode?: string) => { + const current = mockRows.get(key); + if (_script.includes('sites-consume')) { + mockRows.delete(key); + return current ?? null; + } + if (mode === 'existing' && !current) return 0; + if (current && current !== value) return 0; + mockRows.set(key, value); + return 1; + }, +}; +jest.mock('server/lib/redisClient', () => ({ + __esModule: true, + default: { getInstance: () => ({ getRedis: () => mockRedis }) }, +})); +const issuer = 'https://identity.example/realms/lifecycle'; +let privateKey: Awaited>['privateKey']; +const originalEnv = { ...process.env }; +beforeAll(async () => { + const keys = await generateKeyPair('RS256'); + privateKey = keys.privateKey; + (createRemoteJWKSet as jest.Mock).mockReturnValue(keys.publicKey); + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = issuer; + process.env.KEYCLOAK_CLIENT_ID = 'lifecycle-api'; + process.env.KEYCLOAK_JWKS_URL = 'https://identity.example/test-jwks'; + process.env.SITES_UI_OAUTH_CLIENT_ID = 'lifecycle-ui'; + process.env.SITES_PRIVATE_ENABLED = 'true'; + process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; + process.env.SITES_BROWSER_BRIDGE_SECRET = 'x'.repeat(40); + process.env.APP_HOST = 'https://api.example'; +}); +beforeEach(() => { + mockRows.clear(); + mockDirectorySession.mockResolvedValue('active'); +}); +afterAll(() => { + process.env = originalEnv; +}); + +async function sign(tokenId: string, clientId = 'lifecycle-ui', offlineAccess = false) { + return new SignJWT({ + sid: 'shared-sso-session', + azp: clientId, + scope: offlineAccess ? 'openid mcp offline_access' : 'openid mcp', + realm_access: { roles: ['user'] }, + }) + .setProtectedHeader({ alg: 'RS256' }) + .setSubject('owner') + .setIssuer(issuer) + .setAudience(['lifecycle-api', 'https://api.example/mcp']) + .setJti(tokenId) + .setIssuedAt() + .setExpirationTime('10m') + .sign(privateKey); +} +async function principals(token: string) { + const verified = await verifyBearerToken(token); + expect(verified.success).toBe(true); + const request = new NextRequest('https://api.example/api/v2/sites', { + headers: { + authorization: `Bearer ${token}`, + 'x-user': Buffer.from(JSON.stringify(verified.payload)).toString('base64url'), + }, + }); + const rest = await resolvePrincipal(request); + const mcp = await authenticateMcpRequest({ headers: { authorization: `Bearer ${token}` } } as IncomingMessage); + if (!mcp.ok) throw new Error(`MCP authentication failed: ${mcp.message}`); + expect(mcp.principal.oauth).toEqual(rest.oauth); + expect(getVerifiedOAuthBearer(rest)).toBe(token); + expect(getVerifiedOAuthBearer(mcp.principal)).toBe(token); + expect(JSON.stringify(rest)).not.toContain(token); + expect(getVerifiedOAuthBearer({ ...rest })).toBeUndefined(); + return [rest, mcp.principal]; +} + +test('unexpired bearer replay after app logout fails Sites policy through REST and MCP', async () => { + const auth = new SitesBrowserAuth(mockRedis as any); + const login = { loginId: randomSiteToken(), loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, createLogin: true }; + const bearer = await sign('initial-token'); + const refreshed = await sign('refreshed-token'); + for (const token of [bearer, refreshed]) { + const [rest, mcp] = await principals(token); + await auth.bind({ issuer, subject: 'owner', oauth: rest.oauth }, login); + await assertSitesPrincipal(rest, 'write'); + await assertSitesPrincipal(mcp, 'read'); + } + await auth.revoke(login); + // JWT verification still succeeds; Sites authorization must independently deny it. + for (const token of [bearer, refreshed]) { + for (const principal of await principals(token)) + await expect(assertSitesPrincipal(principal, 'write')).rejects.toMatchObject({ httpStatus: 401 }); + } + const freshLogin = { ...login, loginId: randomSiteToken() }; + for (const principal of await principals(await sign('new-login-token'))) { + await auth.bind({ issuer, subject: 'owner', oauth: principal.oauth }, freshLogin); + await assertSitesPrincipal(principal, 'write'); + } +}); + +test('unbound old UI tokens fail closed while separate CLI OAuth remains supported', async () => { + for (const principal of await principals(await sign('legacy-unbound'))) + await expect(assertSitesPrincipal(principal)).rejects.toMatchObject({ httpStatus: 401 }); + for (const principal of await principals(await sign('cli-token', 'lifecycle-cli'))) + await assertSitesPrincipal(principal); +}); + +test('IdP session revocation denies signed CLI OAuth tokens with an otherwise enabled account', async () => { + const token = await sign('cli-token', 'lifecycle-cli'); + mockDirectorySession.mockResolvedValue('revoked'); + for (const principal of await principals(token)) + await expect(assertSitesPrincipal(principal)).rejects.toMatchObject({ + httpStatus: 401, + code: 'oauth_session_unavailable', + }); + expect(mockDirectorySession).toHaveBeenCalledWith(token); +}); + +test('offline OAuth selects live offline validation without bypassing application logout', async () => { + const auth = new SitesBrowserAuth(mockRedis as any); + const login = { loginId: randomSiteToken(), loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, createLogin: true }; + const token = await sign('offline-ui', 'lifecycle-ui', true); + for (const principal of await principals(token)) { + await auth.bind({ issuer, subject: 'owner', oauth: principal.oauth }, login); + await assertSitesPrincipal(principal); + } + expect(mockDirectorySession).toHaveBeenCalledWith(token); + await auth.revoke(login); + for (const principal of await principals(token)) + await expect(assertSitesPrincipal(principal)).rejects.toMatchObject({ httpStatus: 401 }); +}); + +test('a token-level revoke with live login blocks the next protected viewer authorization', async () => { + const bearer = await sign('viewer-token'); + const [rest] = await principals(bearer); + const auth = new SitesBrowserAuth(mockRedis as any); + const site = { + siteId: 'test-site', + ownerKind: 'user', + ownerIssuer: issuer, + ownerSubject: 'owner', + visibility: 'private', + servingGeneration: 'abcdef012345', + accessRevision: 1, + }; + const host = 'site-test--g-abcdef012345.sites.example.net'; + const challenge = await auth.challenge(site, host, '/'); + const login = { loginId: randomSiteToken(), loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, createLogin: true }; + const actor = { issuer, subject: 'owner', oauth: rest.oauth }; + await auth.bind(actor, login); + const minted = await auth.mint( + site, + actor, + { ...login, state: challenge.state, siteId: site.siteId }, + rest.oauth!.expiresAt, + bearer + ); + const viewerPrincipal = { ...rest, authMethod: 'sites_viewer' as const }; + const consumed = await auth.consume( + minted.ticket, + host, + { [challengeCookieName(challenge.state)]: challenge.secret }, + process.env.SITES_UI_ORIGIN, + async () => { + await assertSitesPrincipal(viewerPrincipal); + } + ); + const viewer = await auth.viewer(consumed.sessionId, host); + expect(JSON.stringify(viewer)).not.toContain(bearer); + expect(JSON.stringify(viewer)).not.toContain('ciphertext'); + expect(Array.from(mockRows.values()).some((row) => row.includes(bearer))).toBe(false); + await assertSitesPrincipal(viewerPrincipal); + // Only this token's status changes: the user, browser login and sid remain live. + mockDirectorySession.mockImplementation(async (token) => (token === bearer ? 'revoked' : 'active')); + await auth.assertLogin(viewer); + await expect(assertSitesPrincipal(rest)).rejects.toMatchObject({ httpStatus: 401 }); + await expect(assertSitesPrincipal(viewerPrincipal)).rejects.toMatchObject({ httpStatus: 401 }); +}); diff --git a/src/server/lib/sites/policy.test.ts b/src/server/lib/sites/policy.test.ts new file mode 100644 index 00000000..aee0baca --- /dev/null +++ b/src/server/lib/sites/policy.test.ts @@ -0,0 +1,155 @@ +import type { Principal } from 'server/lib/principal'; +import type Site from 'server/models/Site'; +import { assertSitesPrincipal, assertSiteOwner, isSiteOwner } from './policy'; +import { rememberVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; +const mockStatus = jest.fn(); +const mockToken = jest.fn(); +const mockSession = jest.fn(); +const mockOAuthLogin = jest.fn(); +jest.mock('./browserAuth', () => ({ + getSitesBrowserAuth: () => ({ + assertOAuthLogin: (...args: unknown[]) => mockOAuthLogin(...args), + getViewerOAuthTokenStatus: (...args: unknown[]) => mockSession(...args), + }), +})); +jest.mock('server/models/ApiToken', () => ({ + __esModule: true, + default: { query: () => ({ findById: (...args: unknown[]) => mockToken(...args) }) }, +})); +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: () => ({ getConfig: async () => ({ personalAuthEnabled: true, serviceAuthEnabled: true }) }), + }, +})); +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: (...args: unknown[]) => mockStatus(...args), + getOAuthTokenStatus: (...args: unknown[]) => mockSession(...args), +})); +const issuer = 'https://identity.example/realms/lifecycle'; +const human = (overrides: Partial = {}): Principal => ({ + kind: 'user', + authMethod: 'sites_viewer', + issuer, + oauth: { sessionId: 'sid', tokenId: 'jti', clientId: 'ui', expiresAt: Math.floor(Date.now() / 1000) + 300 }, + userId: 'alice', + actor: 'alice', + roles: ['admin'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + ...overrides, +}); +const site = { ownerKind: 'user', ownerIssuer: issuer, ownerSubject: 'alice', creatorTokenId: null } as Site; +const previousEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = issuer; + mockStatus.mockReset().mockResolvedValue('active'); + mockSession.mockReset().mockResolvedValue('active'); + mockOAuthLogin.mockReset().mockResolvedValue(undefined); + mockToken + .mockReset() + .mockResolvedValue({ kind: 'personal', ownerUserId: 'alice', ownerIssuer: issuer, scopes: ['sites:write'] }); +}); +afterEach(() => { + process.env = { ...previousEnv }; +}); + +test('admin status, matching display email, or a different issuer never grant ownership', () => { + expect(isSiteOwner(site, human())).toBe(true); + expect(isSiteOwner(site, human({ userId: 'bob' }))).toBe(false); + expect(isSiteOwner(site, human({ issuer: 'https://other.example/realm' }))).toBe(false); + expect(() => assertSiteOwner(site, human({ userId: 'bob' }))).toThrow('Site not found'); + expect(isSiteOwner({ ...site, ownerKind: 'unresolved' } as Site, human())).toBe(false); +}); +test('machine ownership is the immutable token id and cannot impersonate a human', async () => { + const machine = human({ kind: 'service_key', userId: null, issuer: null, tokenId: 7, scopes: ['sites:write'] }); + mockToken.mockResolvedValue({ kind: 'service', scopes: ['sites:write'] }); + await assertSitesPrincipal(machine, 'write'); + expect(mockStatus).not.toHaveBeenCalled(); + const machineSite = { + ...site, + ownerKind: 'service_key', + ownerSubject: null, + ownerIssuer: null, + creatorTokenId: 7, + } as Site; + expect(isSiteOwner(machineSite, machine)).toBe(true); + expect(isSiteOwner(machineSite, { ...machine, tokenId: 8 })).toBe(false); + expect(isSiteOwner(site, machine)).toBe(false); +}); +test.each(['disabled', 'deleted', 'no_base_role', 'unknown'])( + 'a %s account fails closed, including personal keys', + async (status) => { + mockStatus.mockResolvedValue(status); + await expect( + assertSitesPrincipal(human({ kind: 'personal_key', tokenId: 7, scopes: ['sites:write'] }), 'write') + ).rejects.toMatchObject({ code: 'principal_unavailable', httpStatus: status === 'unknown' ? 503 : 403 }); + } +); +test('auth-off cannot authenticate a synthetic development admin or an existing viewer session', async () => { + process.env.ENABLE_AUTH = 'false'; + await expect(assertSitesPrincipal(human())).rejects.toMatchObject({ httpStatus: 401 }); + expect(mockStatus).not.toHaveBeenCalled(); +}); +test('legacy personal keys without issuer binding fail closed for Sites', async () => { + await expect(assertSitesPrincipal(human({ kind: 'personal_key', issuer: null }))).rejects.toMatchObject({ + httpStatus: 401, + }); +}); +test('scopes cap ownership rights and write implies read', async () => { + await expect(assertSitesPrincipal(human({ scopes: ['sites:read'] }), 'write')).rejects.toMatchObject({ + code: 'insufficient_scope', + }); + await expect(assertSitesPrincipal(human({ scopes: ['env:write'] }))).rejects.toMatchObject({ + code: 'insufficient_scope', + }); + await expect(assertSitesPrincipal(human({ scopes: ['sites:write'] }))).resolves.toBeUndefined(); +}); + +test('a key revoked during a staged upload cannot authorize its commit', async () => { + const key = human({ kind: 'personal_key', tokenId: 7, scopes: ['sites:write'] }); + await assertSitesPrincipal(key, 'write'); + mockToken.mockResolvedValue({ + kind: 'personal', + ownerUserId: 'alice', + ownerIssuer: issuer, + scopes: ['sites:write'], + revokedAt: new Date().toISOString(), + }); + await expect(assertSitesPrincipal(key, 'write')).rejects.toMatchObject({ code: 'invalid_credential' }); +}); + +test.each(['revoked', 'unknown'])('a %s OAuth session fails closed despite an enabled account', async (status) => { + mockSession.mockResolvedValue(status); + await expect(assertSitesPrincipal(human())).rejects.toMatchObject({ + code: 'oauth_session_unavailable', + httpStatus: status === 'unknown' ? 503 : 401, + }); +}); +test('a legacy OAuth token missing revocation claims cannot access Sites', async () => { + await expect(assertSitesPrincipal(human({ oauth: undefined }))).rejects.toMatchObject({ httpStatus: 401 }); +}); +test('application logout prevents REST and MCP write authorization even with a live IdP session', async () => { + mockOAuthLogin.mockRejectedValue(new Error('revoked application login')); + for (const authMethod of ['session', 'oauth'] as const) { + const principal = human({ authMethod }); + rememberVerifiedOAuthBearer(principal, 'test-bearer'); + await expect(assertSitesPrincipal(principal, 'write')).rejects.toThrow('revoked application login'); + } +}); +test('personal API keys keep their independent lifetime after application or IdP logout', async () => { + mockSession.mockResolvedValue('revoked'); + mockOAuthLogin.mockRejectedValue(new Error('revoked application login')); + await expect(assertSitesPrincipal(human({ kind: 'personal_key', tokenId: 7 }))).resolves.toBeUndefined(); + expect(mockSession).not.toHaveBeenCalled(); + expect(mockOAuthLogin).not.toHaveBeenCalled(); +}); + +test('REST/MCP principals missing their request-only bearer association fail closed', async () => { + await expect(assertSitesPrincipal(human({ authMethod: 'oauth' }))).rejects.toMatchObject({ httpStatus: 401 }); + await expect(assertSitesPrincipal(human({ authMethod: 'session' }))).rejects.toMatchObject({ httpStatus: 401 }); +}); diff --git a/src/server/lib/sites/policy.ts b/src/server/lib/sites/policy.ts new file mode 100644 index 00000000..3f148531 --- /dev/null +++ b/src/server/lib/sites/policy.ts @@ -0,0 +1,130 @@ +/** Sites ownership is independent of realm administration and display attribution. */ +import type { Transaction } from 'objection'; +import ApiToken from 'server/models/ApiToken'; +import GlobalConfigService from 'server/services/globalConfig'; +import type Site from 'server/models/Site'; +import type { Principal } from 'server/lib/principal'; +import { AppError } from 'server/lib/appError'; +import { scopeSatisfies } from 'server/lib/apiTokenScopes'; +import { getUserStatus, getOAuthTokenStatus } from 'server/services/keycloak/principalStatus'; +import { getSitesBrowserAuth } from './browserAuth'; +import { getVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; + +export function hasSitesScope(principal: Principal, operation: 'read' | 'write'): boolean { + return principal.scopes === null || scopeSatisfies(principal.scopes, `sites:${operation}`); +} + +export async function assertSitesPrincipal( + principal: Principal | null | undefined, + operation: 'read' | 'write' = 'read', + trx?: Transaction +): Promise { + if (process.env.ENABLE_AUTH !== 'true' || !principal) { + throw new AppError({ + httpStatus: 401, + code: 'authentication_required', + message: 'Sites management requires authentication.', + }); + } + if (!hasSitesScope(principal, operation)) { + throw new AppError({ + httpStatus: 403, + code: 'insufficient_scope', + message: `The credential requires sites:${operation}.`, + }); + } + if (principal.kind !== 'user') { + const tokenQuery = principal.tokenId ? ApiToken.query(trx).findById(principal.tokenId) : undefined; + if (trx) tokenQuery?.forShare(); + const token = await tokenQuery; + const config = (await GlobalConfigService.getInstance().getConfig('api_keys')) as + | { personalAuthEnabled?: boolean; serviceAuthEnabled?: boolean } + | undefined; + const personal = principal.kind === 'personal_key'; + if ( + !token || + token.revokedAt || + (token.expiresAt && new Date(token.expiresAt).getTime() <= Date.now()) || + token.kind !== (personal ? 'personal' : 'service') || + !(personal ? config?.personalAuthEnabled : config?.serviceAuthEnabled) || + (personal && (token.ownerUserId !== principal.userId || token.ownerIssuer !== principal.issuer)) || + !hasSitesScope({ ...principal, scopes: token.scopes }, operation) + ) { + throw new AppError({ + httpStatus: 401, + code: 'invalid_credential', + message: 'The API key is no longer valid for this operation.', + }); + } + } + if (principal.kind === 'service_key') { + if (!Number.isSafeInteger(principal.tokenId) || Number(principal.tokenId) <= 0) { + throw new AppError({ httpStatus: 401, code: 'invalid_credential', message: 'A valid service key is required.' }); + } + return; + } + const issuer = process.env.KEYCLOAK_ISSUER?.trim(); + if (!issuer || !principal.issuer || principal.issuer !== issuer || !principal.userId) { + throw new AppError({ + httpStatus: 401, + code: 'invalid_credential', + message: 'A verified identity from the configured issuer is required.', + }); + } + if (principal.kind === 'user') { + const oauth = principal.oauth; + if (!oauth || oauth.expiresAt <= Math.floor(Date.now() / 1000)) { + throw new AppError({ httpStatus: 401, code: 'invalid_credential', message: 'A live OAuth session is required.' }); + } + const bearer = getVerifiedOAuthBearer(principal); + const session = + principal.authMethod === 'sites_viewer' + ? await getSitesBrowserAuth().getViewerOAuthTokenStatus({ + issuer: principal.issuer, + subject: principal.userId, + oauth, + }) + : bearer + ? await getOAuthTokenStatus(bearer) + : 'revoked'; + if (session !== 'active') { + throw new AppError({ + httpStatus: session === 'unknown' ? 503 : 401, + code: 'oauth_session_unavailable', + message: 'The OAuth session cannot currently access Sites.', + retryable: session === 'unknown', + }); + } + await getSitesBrowserAuth().assertOAuthLogin({ issuer: principal.issuer, subject: principal.userId, oauth }); + } + const status = await getUserStatus(principal.userId); + if (status !== 'active') { + throw new AppError({ + httpStatus: status === 'unknown' ? 503 : 403, + code: 'principal_unavailable', + message: 'The account cannot currently access Sites.', + retryable: status === 'unknown', + }); + } +} + +export function isSiteOwner(site: Site, principal: Principal | null | undefined): boolean { + if (!principal) return false; + if (site.ownerKind === 'service_key') { + return principal.kind === 'service_key' && site.creatorTokenId != null && site.creatorTokenId === principal.tokenId; + } + return ( + site.ownerKind === 'user' && + principal.kind !== 'service_key' && + Boolean(site.ownerIssuer && site.ownerSubject) && + site.ownerIssuer === principal.issuer && + site.ownerSubject === principal.userId + ); +} + +export function assertSiteOwner(site: Site, principal: Principal): void { + if (!isSiteOwner(site, principal)) { + // Same response for missing and inaccessible private resources. + throw new AppError({ httpStatus: 404, code: 'site_not_found', message: 'Site not found.' }); + } +} diff --git a/src/server/lib/sites/routeHelpers.test.ts b/src/server/lib/sites/routeHelpers.test.ts index ee8fd084..ad29df36 100644 --- a/src/server/lib/sites/routeHelpers.test.ts +++ b/src/server/lib/sites/routeHelpers.test.ts @@ -22,7 +22,7 @@ jest.mock('server/lib/logger', () => ({ import { NextRequest } from 'next/server'; import { SitesServiceError } from 'server/services/sites'; -import { readSitesListFilters, readUploadFile, sitesErrorResponse } from './routeHelpers'; +import { readSiteRevision, readSitesListFilters, readUploadFile, sitesErrorResponse } from './routeHelpers'; function uploadRequest(values: Record): NextRequest { return { @@ -38,6 +38,41 @@ describe('Sites route helpers', () => { }); describe('readUploadFile', () => { + it.each([undefined, '1'])('cancels an oversized stream before parsing with content-length %p', async (length) => { + const cancel = jest.fn(); + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024 * 1024 + 11)); + }, + cancel, + }); + const req = new NextRequest('http://localhost/api/v2/sites', { + method: 'POST', + body, + duplex: 'half', + headers: { + 'content-type': 'multipart/form-data; boundary=fixture', + ...(length ? { 'content-length': length } : {}), + }, + } as ConstructorParameters[1]); + const parse = jest.spyOn(req, 'formData'); + await expect(readUploadFile(req, 10)).rejects.toMatchObject({ statusCode: 400 }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(parse).not.toHaveBeenCalled(); + }); + + it('parses a bounded multipart stream without a content-length', async () => { + const body = new FormData(); + body.set('file', new Blob(['hello']), 'index.html'); + body.set('visibility', 'private'); + const req = new NextRequest('http://localhost/api/v2/sites', { method: 'POST', body }); + await expect(readUploadFile(req, 10)).resolves.toMatchObject({ + fileName: 'index.html', + content: Buffer.from('hello'), + visibility: 'private', + }); + }); + it('reads the uploaded bytes, filename, and optional display name', async () => { const bytes = new Uint8Array([0, 1, 2, 255]); const file = { @@ -77,14 +112,14 @@ describe('Sites route helpers', () => { describe('readSitesListFilters', () => { it('trims the user filter and parses integer pagination values', () => { expect(readSitesListFilters(new URLSearchParams('user=%20Alice%40Example.com%20&page=2&limit=50'))).toEqual({ - user: 'Alice@Example.com', + view: 'mine', page: 2, limit: 50, }); }); it('omits blank and non-numeric filters', () => { - expect(readSitesListFilters(new URLSearchParams('user=%20%20&page=not-a-page&limit='))).toEqual({}); + expect(readSitesListFilters(new URLSearchParams('user=%20%20&limit='))).toEqual({}); }); it('returns no filters when the query string is empty', () => { @@ -92,6 +127,29 @@ describe('Sites route helpers', () => { }); }); + it.each(['2oops', '-1', '0', '1.5', '2147483648', {}, true])('rejects malformed revision %p', (value) => { + expect(() => readSiteRevision(value)).toThrow(); + }); + it('requires an explicit access precondition for visibility changes', () => { + expect(() => readSiteRevision(undefined, true)).toThrow(); + expect(readSiteRevision('12', true)).toBe(12); + }); + it.each(['view=shared', 'page=2oops', 'limit=101'])('rejects invalid filters %s', (query) => { + expect(() => readSitesListFilters(new URLSearchParams(query))).toThrow(); + }); + it('checks upload file size before reading bytes', async () => { + const file = { name: 'large.zip', size: 50, arrayBuffer: jest.fn() }; + await expect(readUploadFile(uploadRequest({ file }), 10)).rejects.toMatchObject({ statusCode: 400 }); + expect(file.arrayBuffer).not.toHaveBeenCalled(); + }); + it('rejects invalid visibility before reading upload bytes', async () => { + const file = { name: 'site.zip', arrayBuffer: jest.fn() }; + await expect(readUploadFile(uploadRequest({ file, visibility: 'shared' }))).rejects.toMatchObject({ + statusCode: 400, + }); + expect(file.arrayBuffer).not.toHaveBeenCalled(); + }); + describe('sitesErrorResponse', () => { const request = () => new NextRequest('http://localhost/api/v2/sites', { diff --git a/src/server/lib/sites/routeHelpers.ts b/src/server/lib/sites/routeHelpers.ts index 0bebddae..a6a04978 100644 --- a/src/server/lib/sites/routeHelpers.ts +++ b/src/server/lib/sites/routeHelpers.ts @@ -15,39 +15,122 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { errorResponse } from 'server/lib/response'; +import { errorResponse, successResponse } from 'server/lib/response'; import { SitesServiceError } from 'server/services/sites'; import type { ListSitesFilters } from 'server/services/sites'; -export async function readUploadFile(req: NextRequest): Promise<{ fileName: string; content: Buffer; name?: string }> { - const formData = await req.formData(); - const file = formData.get('file'); - if (!file || typeof file === 'string') { - throw new SitesServiceError('A file upload is required.', 400); +export function readSiteVisibility(value: unknown): 'private' | 'public' { + if (value !== 'private' && value !== 'public') + throw new SitesServiceError('visibility must be private or public.', 400); + return value; +} + +export function readSiteRevision(value: unknown, required = false): number | undefined { + if (value === null || value === undefined || value === '') { + if (required) throw new SitesServiceError('expectedAccessRevision is required.', 400); + return undefined; } + if ((typeof value !== 'string' && typeof value !== 'number') || !/^[1-9][0-9]*$/.test(String(value))) { + throw new SitesServiceError('Revision must be a positive integer.', 400); + } + const revision = Number(value); + if (!Number.isSafeInteger(revision) || revision > 2147483647) throw new SitesServiceError('Invalid revision.', 400); + return revision; +} +export async function readUploadFile( + req: NextRequest, + maxUploadBytes?: number +): Promise<{ + fileName: string; + content: Buffer; + name?: string; + visibility?: 'private' | 'public'; + expectedAccessRevision?: number; + expectedContentRevision?: number; +}> { + // Bound bytes while reading: Content-Length can be absent or dishonest, and formData buffers the whole body. + const length = Number(req.headers?.get('content-length')); + if (maxUploadBytes && Number.isFinite(length) && length > maxUploadBytes + 1024 * 1024) { + throw new SitesServiceError('Upload exceeds the configured size limit.', 400); + } + let formData: FormData; + if (maxUploadBytes && req.body) { + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let finished = false; + try { + while (!finished) { + const chunk = await reader.read(); + if (chunk.done) { + finished = true; + continue; + } + total += chunk.value.byteLength; + if (total > maxUploadBytes + 1024 * 1024) { + await reader.cancel(); + throw new SitesServiceError('Upload exceeds the configured size limit.', 400); + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + formData = await new Response(Buffer.concat(chunks), { + headers: { 'Content-Type': req.headers.get('content-type') || '' }, + }).formData(); + } else { + formData = await req.formData(); + } + const file = formData.get('file'); + if (!file || typeof file === 'string') throw new SitesServiceError('A file upload is required.', 400); + if (maxUploadBytes && file.size > maxUploadBytes) + throw new SitesServiceError('Upload exceeds the configured size limit.', 400); const nameValue = formData.get('name'); - const name = typeof nameValue === 'string' ? nameValue : undefined; - const arrayBuffer = await file.arrayBuffer(); + const visibilityValue = formData.get('visibility'); + const expectedAccessRevision = readSiteRevision(formData.get('expectedAccessRevision')); + const expectedContentRevision = readSiteRevision(formData.get('expectedContentRevision')); + const visibility = + visibilityValue === null || visibilityValue === undefined ? undefined : readSiteVisibility(visibilityValue); return { fileName: file.name || 'upload', - content: Buffer.from(arrayBuffer), - name, + content: Buffer.from(await file.arrayBuffer()), + name: typeof nameValue === 'string' ? nameValue : undefined, + ...(visibility ? { visibility } : {}), + ...(expectedAccessRevision === undefined ? {} : { expectedAccessRevision }), + ...(expectedContentRevision === undefined ? {} : { expectedContentRevision }), }; } export function readSitesListFilters(searchParams: URLSearchParams): ListSitesFilters { - const user = searchParams.get('user')?.trim(); - const page = Number.parseInt(searchParams.get('page') || '', 10); - const limit = Number.parseInt(searchParams.get('limit') || '', 10); - + const view = searchParams.get('view') || (searchParams.get('user')?.trim() ? 'mine' : undefined); + if (view && !['mine', 'public', 'all'].includes(view)) throw new SitesServiceError('Invalid Sites view.', 400); + const q = searchParams.get('q')?.trim(); + if (q && q.length > 200) throw new SitesServiceError('Search is too long.', 400); + const page = readSiteRevision(searchParams.get('page')); + const limit = readSiteRevision(searchParams.get('limit')); + if (limit && limit > 100) throw new SitesServiceError('limit must be at most 100.', 400); return { - ...(user ? { user } : {}), - ...(Number.isNaN(page) ? {} : { page }), - ...(Number.isNaN(limit) ? {} : { limit }), + ...(view ? { view: view as 'mine' | 'public' | 'all' } : {}), + ...(q ? { q } : {}), + ...(page === undefined ? {} : { page }), + ...(limit === undefined ? {} : { limit }), }; } export function sitesErrorResponse(error: unknown, req: NextRequest): NextResponse { - return errorResponse(error, { status: error instanceof SitesServiceError ? error.statusCode : 500 }, req); + const response = errorResponse(error, { status: error instanceof SitesServiceError ? error.statusCode : 500 }, req); + response.headers.set('Cache-Control', 'no-store'); + return response; +} + +export function sitesSuccessResponse( + data: T, + options: Parameters[1], + req: NextRequest +): NextResponse { + const response = successResponse(data, options, req); + response.headers.set('Cache-Control', 'no-store'); + return response; } diff --git a/src/server/lib/sites/validation.test.ts b/src/server/lib/sites/validation.test.ts index 6686907b..1e60584a 100644 --- a/src/server/lib/sites/validation.test.ts +++ b/src/server/lib/sites/validation.test.ts @@ -413,3 +413,21 @@ describe('normalizeGatewayPath', () => { expect(normalizeGatewayPath('///docs/My%20Page.html?preview=true')).toBe('docs/My Page.html'); }); }); + +describe('reserved Sites authentication paths', () => { + it.each(['_lfc-sites/consume.html', 'folder/../_lfc-sites/consume.html'])('rejects archive entry %s', (entry) => { + expect(() => + validateSiteUpload({ + ...DEFAULT_OPTIONS, + fileName: 'site.zip', + content: zip({ 'index.html': 'ok', [entry]: 'bad' }), + }) + ).toThrow('reserved for site authentication'); + }); + it.each(['/_lfc-sites/consume', '/%5flfc-sites/consume', '/_lfc-sites%2fconsume'])( + 'rejects normalized object lookup %s', + (path) => { + expect(() => normalizeGatewayPath(path)).toThrow('reserved for site authentication'); + } + ); +}); diff --git a/src/server/lib/sites/validation.ts b/src/server/lib/sites/validation.ts index 75832026..1cec951a 100644 --- a/src/server/lib/sites/validation.ts +++ b/src/server/lib/sites/validation.ts @@ -79,6 +79,9 @@ function normalizeArchivePath(input: string): string { reject('Archive contains a path traversal entry.'); } + if (normalized === '_lfc-sites' || normalized.startsWith('_lfc-sites/')) { + reject('The _lfc-sites path is reserved for site authentication.'); + } return normalized; } diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index 47c039c6..50626bf3 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -24,7 +24,13 @@ export type V2RoutePolicyEntry = | { method: V2HttpMethod; route: string; policy: 'public' } | { method: V2HttpMethod; route: string; policy: 'session'; roles?: readonly ['admin'] } /* scope null = any authenticated principal, reserved for pure functions with no data access */ - | { method: V2HttpMethod; route: string; policy: 'principal'; scope: V2KeyScope | null }; + | { + method: V2HttpMethod; + route: string; + policy: 'principal'; + scope: V2KeyScope | null; + kinds?: readonly ('user' | 'personal_key' | 'service_key')[]; + }; export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ { method: 'GET', route: '/api/v2/ai/admin/agent/capabilities', policy: 'session', roles: ['admin'] }, @@ -230,7 +236,7 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ policy: 'session', roles: ['admin'], }, - { method: 'GET', route: '/api/v2/config/sites', policy: 'session' }, + { method: 'GET', route: '/api/v2/config/sites', policy: 'session', roles: ['admin'] }, { method: 'PUT', route: '/api/v2/config/sites', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/environments', policy: 'principal', scope: 'env:read' }, { method: 'POST', route: '/api/v2/environments', policy: 'principal', scope: 'env:write' }, @@ -250,6 +256,12 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ { method: 'POST', route: '/api/v2/repositories', policy: 'principal', scope: 'repos:write' }, { method: 'DELETE', route: '/api/v2/repositories/{fullName+}', policy: 'principal', scope: 'repos:write' }, { method: 'GET', route: '/api/v2/schema/validate', policy: 'principal', scope: 'repos:read' }, + { method: 'POST', route: '/api/v2/sites/browser/mint', policy: 'principal', scope: 'sites:read', kinds: ['user'] }, + { method: 'POST', route: '/api/v2/sites/browser/bind', policy: 'principal', scope: 'sites:read', kinds: ['user'] }, + { method: 'POST', route: '/api/v2/sites/browser/revoke', policy: 'public' }, + { method: 'GET', route: '/api/v2/sites/browser/open/{siteId}', policy: 'public' }, + { method: 'GET', route: '/api/v2/sites/capabilities', policy: 'principal', scope: 'sites:read' }, + { method: 'PATCH', route: '/api/v2/sites/{siteId}/access', policy: 'principal', scope: 'sites:write' }, { method: 'GET', route: '/api/v2/sites', policy: 'principal', scope: 'sites:read' }, { method: 'POST', route: '/api/v2/sites', policy: 'principal', scope: 'sites:write' }, { method: 'DELETE', route: '/api/v2/sites/{siteId}', policy: 'principal', scope: 'sites:write' }, diff --git a/src/server/lib/verifiedOAuthBearer.ts b/src/server/lib/verifiedOAuthBearer.ts new file mode 100644 index 00000000..44048d62 --- /dev/null +++ b/src/server/lib/verifiedOAuthBearer.ts @@ -0,0 +1,14 @@ +import type { Principal } from './principal'; + +// Request-lifetime only. Bearers must never become enumerable principal fields, +// audit data, or serialized browser Viewer records. Viewer mint may separately +// retain an encrypted, short-lived copy for exact-token introspection. +const requestBearers = new WeakMap(); + +export function rememberVerifiedOAuthBearer(principal: Principal, bearer: string): void { + requestBearers.set(principal, bearer); +} + +export function getVerifiedOAuthBearer(principal: Principal): string | undefined { + return requestBearers.get(principal); +} diff --git a/src/server/mcp/__tests__/toolHandlers.sites.test.ts b/src/server/mcp/__tests__/toolHandlers.sites.test.ts index 08d37797..f5b8d873 100644 --- a/src/server/mcp/__tests__/toolHandlers.sites.test.ts +++ b/src/server/mcp/__tests__/toolHandlers.sites.test.ts @@ -26,6 +26,13 @@ const SITE: SiteResponse = { name: 'launch-page', url: 'https://sites.example.com/launch-page', status: 'active', + visibility: 'public', + contentUrl: 'https://sites.example.com/launch-page', + openUrl: 'https://ui.example.com/sites/site_abc123', + accessRevision: 1, + contentRevision: 1, + currentRole: 'owner', + permissions: { canView: true, canEdit: true, canDelete: true, canChangeVisibility: true }, createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', expiresAt: '2026-08-01T00:00:00.000Z', @@ -108,7 +115,8 @@ describe('list_sites', () => { }), ]); expect(typeof output!.nextCursor).toBe('string'); - expect(listSites.mock.calls[0][0]).toEqual({ page: 1, limit: 25 }); + expect(listSites.mock.calls[0][1]).toBe(PRINCIPAL); + expect(listSites.mock.calls[0][0]).toEqual({ view: 'all', page: 1, limit: 25 }); }); it('filters to the signed-in user for mineOnly', async () => { @@ -118,18 +126,23 @@ describe('list_sites', () => { }); const { call } = harness({ listSites }); await call('list_sites', { mineOnly: true }); - expect(listSites.mock.calls[0][0]).toMatchObject({ user: 'user@example.com' }); + expect(listSites.mock.calls[0][0]).toMatchObject({ view: 'mine' }); }); - it('returns nothing for mineOnly without a known email', async () => { - const listSites = jest.fn(); + it('delegates immutable ownership even without an email', async () => { + const listSites = jest + .fn() + .mockResolvedValue({ sites: [], pagination: { current: 1, total: 1, items: 0, limit: 25 } }); const { call } = harness({ listSites }); const { output } = await call('list_sites', { mineOnly: true }, { ...PRINCIPAL, identity: null, } as unknown as Principal); expect(output).toMatchObject({ sites: [] }); - expect(listSites.mock.calls).toHaveLength(0); + expect(listSites).toHaveBeenCalledWith( + { view: 'mine', page: 1, limit: 25 }, + expect.objectContaining({ userId: 'user-1', identity: null }) + ); }); it('reports storage outages as retryable', async () => { @@ -143,9 +156,11 @@ describe('list_sites', () => { describe('get_site', () => { it('returns one site', async () => { - const { call } = harness({ getSite: async () => SITE }); + const getSite = jest.fn().mockResolvedValue(SITE); + const { call } = harness({ getSite }); const { output } = await call('get_site', { siteId: 'site_abc123' }); expect(output!.site).toMatchObject({ siteId: 'site_abc123', status: 'active' }); + expect(getSite).toHaveBeenCalledWith('site_abc123', PRINCIPAL); }); it('normalizes Date-backed site timestamps before output validation', async () => { diff --git a/src/server/mcp/auth.ts b/src/server/mcp/auth.ts index 82a9fa30..073b3ee9 100644 --- a/src/server/mcp/auth.ts +++ b/src/server/mcp/auth.ts @@ -17,9 +17,10 @@ import type { IncomingMessage } from 'http'; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; import { AppError, isAppError } from 'server/lib/appError'; -import { getIdentityFromClaims } from 'server/lib/get-user'; +import { getIdentityFromClaims, getOAuthCredentialFromClaims } from 'server/lib/get-user'; import { getLogger } from 'server/lib/logger'; import type { Principal } from 'server/lib/principal'; +import { rememberVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; import { getMcpResourceMetadataUrl, getMcpResourceUrl, isAuthEnabled, MCP_SCOPE } from './config'; export interface McpAuthSuccess { @@ -125,6 +126,8 @@ function oauthPrincipal(payload: JWTPayload, roles: Array<'user' | 'admin'>): Pr kind: 'user', authMethod: 'oauth', userId: identity.userId, + issuer: identity.issuer ?? null, + oauth: getOAuthCredentialFromClaims(payload), actor: identity.userId, roles, scopes: null, @@ -206,6 +209,7 @@ export async function authenticateMcpRequest(req: IncomingMessage): Promise = {}): SiteResponse { name: 'docs', url: 'https://docs.example.test', status: 'ready', + visibility: 'public', + contentUrl: 'https://docs.example.test', + openUrl: 'https://ui.example.test/sites/site-1', + accessRevision: 1, + contentRevision: 1, + currentRole: null, + permissions: { canView: true, canEdit: false, canDelete: false, canChangeVisibility: false }, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-02T00:00:00.000Z', expiresAt: null, diff --git a/src/server/mcp/tools/sites/getSite.ts b/src/server/mcp/tools/sites/getSite.ts index 4c5effb3..a8673c1d 100644 --- a/src/server/mcp/tools/sites/getSite.ts +++ b/src/server/mcp/tools/sites/getSite.ts @@ -19,7 +19,7 @@ import { getSiteInputSchema, getSiteOutputSchema } from './schemas'; import { mapSiteServiceError, siteSummary, type ResolvedSiteToolDependencies } from './shared'; const DESCRIPTION = - 'Gets one hosted site, including its public URL, status, size, and expiry. Use a `siteId` returned by list_sites.'; + 'Gets one hosted site, including its visibility and authenticated opening URL, status, size, and expiry. Use a `siteId` returned by list_sites.'; export function createGetSiteToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition { return { @@ -36,9 +36,9 @@ export function createGetSiteToolDefinition(dependencies: ResolvedSiteToolDepend }, capabilityId: 'view-hosted-sites', access: 'read', - async handler(input): Promise { + async handler(input, context): Promise { try { - const site = await dependencies.service().getSite(input.siteId as string); + const site = await dependencies.service().getSite(input.siteId as string, context.principal); return { site: siteSummary(site), }; diff --git a/src/server/mcp/tools/sites/listSites.ts b/src/server/mcp/tools/sites/listSites.ts index 2e1b380b..7cca3822 100644 --- a/src/server/mcp/tools/sites/listSites.ts +++ b/src/server/mcp/tools/sites/listSites.ts @@ -20,7 +20,7 @@ import { listSitesInputSchema, listSitesOutputSchema } from './schemas'; import { mapSiteServiceError, siteSummary, type ResolvedSiteToolDependencies } from './shared'; const DESCRIPTION = - 'Lists hosted sites in Lifecycle. Use `mineOnly` to return only sites created or updated by the authenticated user.'; + 'Lists hosted sites in Lifecycle. Use `mineOnly` to return only sites owned by the authenticated principal.'; export function createListSitesToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition { return { @@ -46,15 +46,14 @@ export function createListSitesToolDefinition(dependencies: ResolvedSiteToolDepe typeof input.cursor === 'string' ? decodeListCursor(input.cursor, cursorFilters, limit, dependencies.nowSeconds()) : null; - const user = mineOnly ? context.principal.identity?.email : undefined; - if (mineOnly && !user) { - return { sites: [] }; - } - const result = await dependencies.service().listSites({ - ...(user ? { user } : {}), - page: cursor ? cursor.position + 1 : 1, - limit, - }); + const result = await dependencies.service().listSites( + { + view: mineOnly ? 'mine' : 'all', + page: cursor ? cursor.position + 1 : 1, + limit, + }, + context.principal + ); const nextCursor = result.pagination.current < result.pagination.total ? encodeListCursor( diff --git a/src/server/mcp/tools/sites/schemas.ts b/src/server/mcp/tools/sites/schemas.ts index e9db7078..62eed45b 100644 --- a/src/server/mcp/tools/sites/schemas.ts +++ b/src/server/mcp/tools/sites/schemas.ts @@ -29,6 +29,21 @@ const siteSummarySchema = closedObjectSchema( siteId: siteIdSchema, name: { type: 'string', minLength: 1, maxLength: 200 }, url: { type: 'string', format: 'uri', minLength: 1, maxLength: 2048 }, + visibility: { type: 'string', enum: ['private', 'public'] }, + contentUrl: { type: 'string', format: 'uri', minLength: 1, maxLength: 2048 }, + openUrl: { type: 'string', format: 'uri', minLength: 1, maxLength: 2048 }, + accessRevision: { type: 'integer', minimum: 1 }, + contentRevision: { type: 'integer', minimum: 1 }, + currentRole: { type: ['string', 'null'], enum: ['owner', null] }, + permissions: closedObjectSchema( + { + canView: { type: 'boolean' }, + canEdit: { type: 'boolean' }, + canDelete: { type: 'boolean' }, + canChangeVisibility: { type: 'boolean' }, + }, + ['canView', 'canEdit', 'canDelete', 'canChangeVisibility'] + ), status: { type: 'string', minLength: 1, maxLength: 50 }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, @@ -38,13 +53,29 @@ const siteSummarySchema = closedObjectSchema( createdBy: { type: 'string', minLength: 1, maxLength: 512 }, updatedBy: { type: 'string', minLength: 1, maxLength: 512 }, }, - ['siteId', 'name', 'url', 'status', 'createdAt', 'updatedAt', 'fileCount', 'sizeBytes'] + [ + 'siteId', + 'name', + 'url', + 'status', + 'createdAt', + 'updatedAt', + 'fileCount', + 'sizeBytes', + 'visibility', + 'contentUrl', + 'openUrl', + 'accessRevision', + 'contentRevision', + 'currentRole', + 'permissions', + ] ); export const listSitesInputSchema = closedObjectSchema({ mineOnly: { type: 'boolean', - description: 'Return only sites created or updated by the authenticated Lifecycle user.', + description: 'Return only sites owned by the authenticated Lifecycle principal.', }, cursor: { type: 'string', maxLength: 500 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 25 }, diff --git a/src/server/mcp/tools/sites/shared.ts b/src/server/mcp/tools/sites/shared.ts index 0635a73c..55a6d747 100644 --- a/src/server/mcp/tools/sites/shared.ts +++ b/src/server/mcp/tools/sites/shared.ts @@ -23,11 +23,12 @@ import SitesService, { type ListSitesResult, type SiteResponse, } from 'server/services/sites'; +import type { Principal } from 'server/lib/principal'; import { safeCoreText } from '../core/listRepositories'; export interface SiteToolService { - listSites(filters?: ListSitesFilters): Promise; - getSite(siteId: string): Promise; + listSites(filters: ListSitesFilters, principal: Principal): Promise; + getSite(siteId: string, principal: Principal): Promise; } export interface SiteToolDependencies { @@ -95,6 +96,13 @@ export function siteSummary(site: SiteResponse): McpJsonObject { name: requiredString(site.name, 200), url: requiredString(site.url, 2048), status: requiredString(site.status, 50), + visibility: site.visibility, + contentUrl: requiredString(site.contentUrl, 2048), + openUrl: requiredString(site.openUrl, 2048), + accessRevision: site.accessRevision, + contentRevision: site.contentRevision, + currentRole: site.currentRole, + permissions: { ...site.permissions }, createdAt: requiredDateTime(site.createdAt), updatedAt: requiredDateTime(site.updatedAt), ...(expiresAt ? { expiresAt } : {}), diff --git a/src/server/middlewares/auth.test.ts b/src/server/middlewares/auth.test.ts index 759ee72d..ac6b451e 100644 --- a/src/server/middlewares/auth.test.ts +++ b/src/server/middlewares/auth.test.ts @@ -23,6 +23,7 @@ jest.mock('server/lib/auth', () => ({ })); import { authMiddleware } from './auth'; +import { requestIdMiddleware } from './requestId'; const VALID_TOKEN = `lfc_${'a'.repeat(40)}`; const originalEnableAuth = process.env.ENABLE_AUTH; @@ -51,6 +52,24 @@ afterAll(() => { }); describe('authMiddleware x-user stripping', () => { + it('preserves a signed revoke POST through the real middleware chain and strips spoofed identity', async () => { + const body = '{"loginId":"signed-test-value"}'; + const request = new NextRequest('https://example.test/api/v2/sites/browser/revoke', { + method: 'POST', + body, + headers: { 'x-user': 'spoofed', 'x-lfc-sites-bridge': 'bridge-signature' }, + }); + const next = jest.fn().mockResolvedValue(NextResponse.next()); + await requestIdMiddleware(request, (forwarded) => authMiddleware(forwarded, next)); + expect(mockVerifyAuth).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + const forwarded = forwardedRequest(next); + expect(forwarded.method).toBe('POST'); + expect(await forwarded.text()).toBe(body); + expect(forwarded.headers.get('x-user')).toBeNull(); + expect(forwarded.headers.get('x-lfc-sites-bridge')).toBe('bridge-signature'); + expect(forwarded.headers.has('x-request-id')).toBe(true); + }); it('strips a crafted x-user when ENABLE_AUTH is off', async () => { process.env.ENABLE_AUTH = 'false'; const req = makeRequest('http://localhost/api/v2/repositories', { 'x-user': 'spoofed' }); diff --git a/src/server/middlewares/auth.ts b/src/server/middlewares/auth.ts index fecce6f7..b47370be 100644 --- a/src/server/middlewares/auth.ts +++ b/src/server/middlewares/auth.ts @@ -26,7 +26,7 @@ const MCP_OAUTH_CALLBACK_PATH = /^\/api\/v2\/ai\/agent\/mcp-connections\/[^/]+\/ const MAX_AUTHORIZATION_HEADER_LENGTH = 16384; function authFailure(request: NextRequest, status: 401 | 500, message: string, code?: string): NextResponse { - const headers: Record = { 'Content-Type': 'application/json' }; + const headers: Record = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }; if (status === 401) { headers['WWW-Authenticate'] = bearerChallenge(code); } @@ -45,7 +45,9 @@ function forwardWithoutUser(request: NextRequest, next: NextMiddleware, mutate?: const headers = new Headers(request.headers); headers.delete('x-user'); // prevent spoofing mutate?.(headers); - return next(new NextRequest(request.url, { ...request, headers })); + return next( + new NextRequest(request, { method: request.method, body: request.body, signal: request.signal, headers }) + ); } export const authMiddleware: Middleware = async (request, next) => { @@ -62,6 +64,15 @@ export const authMiddleware: Middleware = async (request, next) => { return forwardWithoutUser(request, next); } + if (request.method === 'GET' && /^\/api\/v2\/sites\/browser\/open\/[a-z0-9-]{1,64}$/.test(request.nextUrl.pathname)) { + return forwardWithoutUser(request, next); + } + + // Sites logout uses a dedicated bridge signature and must work after OAuth expiry. + if (request.method === 'POST' && request.nextUrl.pathname === '/api/v2/sites/browser/revoke') { + return forwardWithoutUser(request, next); + } + if (MCP_OAUTH_CALLBACK_PATH.test(request.nextUrl.pathname)) { return forwardWithoutUser(request, next); } diff --git a/src/server/middlewares/requestId.test.ts b/src/server/middlewares/requestId.test.ts index 3917bbb5..3408befc 100644 --- a/src/server/middlewares/requestId.test.ts +++ b/src/server/middlewares/requestId.test.ts @@ -60,4 +60,39 @@ describe('requestIdMiddleware', () => { expect((next.mock.calls[0][0] as NextRequest).headers.get('x-request-id')).toBe(`toke_${UUID}`); expect(response.headers.get('x-request-id')).toBe(`toke_${UUID}`); }); + + it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])('preserves native %s request semantics', async (method) => { + const body = ['GET', 'HEAD'].includes(method) ? undefined : '{"action":"signed-payload"}'; + const request = new NextRequest('https://example.test/api/v2/sites/browser/revoke', { + method, + body, + headers: { 'x-lfc-sites-bridge': 'signature-value' }, + }); + const next = jest.fn().mockResolvedValue(NextResponse.next()); + await requestIdMiddleware(request, next); + const forwarded = next.mock.calls[0][0] as NextRequest; + expect(forwarded.method).toBe(method); + expect(await forwarded.text()).toBe(body || ''); + expect(forwarded.headers.get('x-lfc-sites-bridge')).toBe('signature-value'); + }); + + it('preserves a POST arriving from the installed Edge Request constructor realm', async () => { + // NextRequest's constructor branches on instanceof Request. Node-only probes + // miss Edge instances that otherwise silently become GET without a body. + const { EdgeRuntime } = require('next/dist/compiled/edge-runtime'); + const runtime = new EdgeRuntime(); + const body = '{"action":"cross-realm-signed-payload"}'; + const foreignRequest = new runtime.context.Request('https://example.test/api/v2/sites/browser/revoke', { + method: 'POST', + body, + headers: { 'x-lfc-sites-bridge': 'signature-value' }, + }); + expect(foreignRequest instanceof Request).toBe(false); + const next = jest.fn().mockResolvedValue(NextResponse.next()); + await requestIdMiddleware(foreignRequest as NextRequest, next); + const forwarded = next.mock.calls[0][0] as NextRequest; + expect(forwarded.method).toBe('POST'); + expect(await forwarded.text()).toBe(body); + expect(forwarded.headers.get('x-lfc-sites-bridge')).toBe('signature-value'); + }); }); diff --git a/src/server/middlewares/requestId.ts b/src/server/middlewares/requestId.ts index 2b742f61..4e8e3394 100644 --- a/src/server/middlewares/requestId.ts +++ b/src/server/middlewares/requestId.ts @@ -40,8 +40,12 @@ export const requestIdMiddleware: Middleware = async (request, next) => { const requestHeaders = new Headers(request.headers); requestHeaders.set('x-request-id', xRequestId); - const newRequest = new NextRequest(request.url, { - ...request, + // Request properties are native getters, so object spread loses method/body. + // Pass them explicitly too: Edge Request instances can cross constructor realms. + const newRequest = new NextRequest(request, { + method: request.method, + body: request.body, + signal: request.signal, headers: requestHeaders, }); diff --git a/src/server/models/ApiToken.ts b/src/server/models/ApiToken.ts index 82509395..8fa3ffad 100644 --- a/src/server/models/ApiToken.ts +++ b/src/server/models/ApiToken.ts @@ -39,6 +39,7 @@ export default class ApiToken extends Model { /* Identity binding for user-provisioned tokens; all null for admin-minted org tokens. */ ownerUserId!: string | null; + ownerIssuer!: string | null; ownerGithubUsername!: string | null; ownerEmail!: string | null; ownerPreferredUsername!: string | null; @@ -79,6 +80,7 @@ export default class ApiToken extends Model { expiresAt: { type: ['string', 'null'] }, revokedAt: { type: ['string', 'null'] }, ownerUserId: { type: ['string', 'null'] }, + ownerIssuer: { type: ['string', 'null'], maxLength: 2048 }, ownerGithubUsername: { type: ['string', 'null'] }, ownerEmail: { type: ['string', 'null'] }, ownerPreferredUsername: { type: ['string', 'null'] }, diff --git a/src/server/models/Site.ts b/src/server/models/Site.ts index 34383ae0..8b5ee688 100644 --- a/src/server/models/Site.ts +++ b/src/server/models/Site.ts @@ -16,12 +16,23 @@ import Model from './_Model'; +export type SiteVisibility = 'private' | 'public'; +export type SiteOwnerKind = 'user' | 'service_key' | 'unresolved'; + export type SiteStatus = 'active' | 'deleted' | 'expired'; export default class Site extends Model { siteId!: string; name!: string; status!: SiteStatus; + visibility!: SiteVisibility; + ownerKind!: SiteOwnerKind; + ownerIssuer!: string | null; + ownerSubject!: string | null; + creatorTokenId!: number | null; + servingGeneration!: string | null; + accessRevision!: number; + contentRevision!: number; activeVersionId?: string | null; fileCount!: number; sizeBytes!: number | string; diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index 2de9a60c..20c5f500 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -17,6 +17,26 @@ import { Readable } from 'stream'; const mockGetAllConfigs = jest.fn(); +const mockTokenState = { id: 7, kind: 'service', scopes: ['sites:write'], revokedAt: null as string | null }; +jest.mock('server/models/ApiToken', () => ({ + __esModule: true, + default: { + query: () => ({ + findById: (id: number) => ({ + forShare() { + return this; + }, + then(resolve: (v: unknown) => unknown) { + return Promise.resolve(id === mockTokenState.id ? mockTokenState : undefined).then(resolve); + }, + }), + }), + }, +})); +const mockAudit = jest.fn, unknown[]>(async () => undefined); +jest.mock('server/services/authAudit', () => ({ + recordAuthAuditEventInTransaction: (...args: unknown[]) => mockAudit(...args), +})); const mockPutFiles = jest.fn(); const mockDeletePrefix = jest.fn(); const mockGetObject = jest.fn(); @@ -68,6 +88,7 @@ jest.mock('server/services/globalConfig', () => ({ default: { getInstance: jest.fn(() => ({ getAllConfigs: (...args: unknown[]) => mockGetAllConfigs(...args), + getConfig: async () => ({ personalAuthEnabled: true, serviceAuthEnabled: true }), })), }, })); @@ -97,11 +118,56 @@ jest.mock('server/lib/sites/validation', () => { }; }); +import type { Principal } from 'server/lib/principal'; +const principal: Principal = { + kind: 'user', + authMethod: 'session', + userId: 'owner', + issuer: 'https://identity.example/realms/lifecycle', + actor: 'owner', + roles: ['user'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + oauth: { sessionId: 'session', tokenId: 'jwt', clientId: 'cli', expiresAt: 1e12 }, +}; +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: jest.fn(async () => 'active'), + getUserSessionStatus: jest.fn(async () => 'active'), + getOAuthTokenStatus: jest.fn(async () => 'active'), +})); +jest.mock('server/lib/verifiedOAuthBearer', () => ({ getVerifiedOAuthBearer: jest.fn(() => 'fixture-bearer') })); +jest.mock('server/lib/sites/browserAuth', () => ({ + ...jest.requireActual('server/lib/sites/browserAuth'), + getSitesBrowserAuth: () => ({ assertOAuthLogin: async () => undefined }), +})); +const originalSitesEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = principal.issuer!; + process.env.SITES_PRIVATE_ENABLED = 'true'; + process.env.SITES_UI_ORIGIN = 'https://lifecycle.example.net'; + process.env.SITES_BROWSER_BRIDGE_SECRET = 'fixture-bridge-secret-at-least-thirty-two-bytes'; +}); +afterEach(() => { + process.env = { ...originalSitesEnv }; +}); + import SitesService, { SitesServiceError } from 'server/services/sites'; import { SitesObjectNotFoundError } from 'server/lib/sites/storage'; import { SiteUploadValidationError } from 'server/lib/sites/validation'; type SiteData = { + visibility: 'private' | 'public'; + ownerKind: 'user' | 'service_key' | 'unresolved'; + ownerIssuer: string | null; + ownerSubject: string | null; + creatorTokenId: number | null; + servingGeneration: string | null; + accessRevision: number; + contentRevision: number; siteId: string; name: string; status: string; @@ -131,7 +197,7 @@ type VersionData = { deletedAt: string | null; }; -type VersionRow = VersionData; +type VersionRow = VersionData & { $query: jest.Mock }; type FakeState = { sites: SiteRow[]; @@ -169,6 +235,14 @@ function attachSite(state: FakeState, data: SiteData): SiteRow { function addSite(state: FakeState, overrides: Partial = {}): SiteRow { const row = attachSite(state, { + visibility: 'public', + ownerKind: 'user', + ownerIssuer: principal.issuer!, + ownerSubject: principal.userId, + creatorTokenId: null, + servingGeneration: null, + accessRevision: 1, + contentRevision: 1, siteId: 'site-1', name: 'site', status: 'active', @@ -199,8 +273,15 @@ function addVersion(state: FakeState, overrides: Partial = {}): Ver deletedAt: null, ...overrides, }; - state.versions.push(version); - return version; + const row = version as VersionRow; + row.$query = jest.fn(() => ({ + patch: async (patch: Partial) => { + Object.assign(row, patch); + return 1; + }, + })); + state.versions.push(row); + return row; } class SiteQuery { @@ -211,6 +292,18 @@ class SiteQuery { constructor(private readonly state: FakeState) {} + forUpdate() { + return this; + } + orWhere(scope: Partial) { + const existing = [...this.filters]; + this.filters = [ + (row) => + existing.every((f) => f(row)) || + Object.entries(scope).every(([key, value]) => row[key as keyof SiteRow] === value), + ]; + return this; + } whereNull(field: keyof SiteData) { this.filters.push((row) => row[field] == null); return this; @@ -221,7 +314,17 @@ class SiteQuery { return this; } - where(scopeOrField: Partial | keyof SiteData, operationOrValue?: unknown, expectedValue?: unknown) { + where( + scopeOrField: Partial | keyof SiteData | ((query: SiteQuery) => unknown), + operationOrValue?: unknown, + expectedValue?: unknown + ) { + if (typeof scopeOrField === 'function') { + const group = new SiteQuery(this.state); + scopeOrField(group); + this.filters.push((row) => group.filters.every((f) => f(row))); + return this; + } if (typeof scopeOrField === 'object') { this.filters.push((row) => Object.entries(scopeOrField).every(([key, value]) => row[key as keyof SiteData] === value) @@ -251,7 +354,7 @@ class SiteQuery { } orderBy(field: keyof SiteData, direction: string) { - this.sortBy = { field, direction }; + if (!this.sortBy) this.sortBy = { field, direction }; return this; } @@ -266,6 +369,11 @@ class SiteQuery { return { results: rows.slice(start, start + pageSize), total: rows.length }; } + async patch(patch: Partial) { + const rows = this.filteredRows(); + rows.forEach((row) => Object.assign(row, patch)); + return rows.length; + } async insert(input: Partial) { if (this.state.siteInsertError) throw this.state.siteInsertError; return addSite(this.state, { @@ -300,18 +408,40 @@ class VersionQuery { constructor(private readonly state: FakeState) {} + whereRaw() { + this.filters.push((version) => { + const site = this.state.sites.find((site) => site.siteId === version.siteId); + return Boolean( + site && (['deleted', 'expired'].includes(site.status) || site.activeVersionId !== version.versionId) + ); + }); + return this; + } + join() { + return this; + } + select() { + return this; + } + limit() { + return this; + } where(scope: Partial) { this.filters.push((row) => Object.entries(scope).every(([key, value]) => row[key as keyof VersionData] === value)); return this; } - whereNull(field: keyof VersionData) { - this.filters.push((row) => row[field] == null); + whereNull(field: keyof VersionData | 'site_versions.deletedAt') { + this.filters.push((row) => row[field === 'site_versions.deletedAt' ? 'deletedAt' : field] == null); return this; } - whereIn(field: keyof VersionData, values: unknown[]) { - this.filters.push((row) => values.includes(row[field])); + whereIn(field: keyof VersionData | 'sites.status', values: unknown[]) { + this.filters.push((row) => + values.includes( + field === 'sites.status' ? this.state.sites.find((site) => site.siteId === row.siteId)?.status : row[field] + ) + ); return this; } @@ -397,6 +527,8 @@ describe('SitesService behavior', () => { beforeEach(() => { state = { sites: [], versions: [] }; + mockAudit.mockReset().mockResolvedValue(undefined); + mockTokenState.revokedAt = null; database = createDatabase(state); queueAdd = jest.fn(); queueManager = { registerQueue: jest.fn(() => ({ add: queueAdd })) }; @@ -430,7 +562,9 @@ describe('SitesService behavior', () => { it('fails closed when Sites is disabled without touching validation, storage, or the database', async () => { mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); - await expect(service.createSite({ fileName: 'index.html', content: Buffer.from('site') })).rejects.toMatchObject({ + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'index.html', content: Buffer.from('site') }) + ).rejects.toMatchObject({ message: 'Sites hosting is disabled.', statusCode: 404, }); @@ -445,12 +579,14 @@ describe('SitesService behavior', () => { await expect( service.createSite({ + principal: { ...principal, identity: { email: 'author@example.com' } as any }, + visibility: 'public', fileName: 'docs.html', content: Buffer.from('source upload'), name: ' Product docs ', user: { email: 'author@example.com' } as any, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ id: 'site000001', name: 'Product docs', url: 'https://site-site000001.sites.example.com', @@ -502,6 +638,8 @@ describe('SitesService behavior', () => { enabledConfig({ ttl: { enabled: false } }); const result = await service.createSite({ + principal, + visibility: 'public', fileName: 'index.html', content: Buffer.from('site'), name: ' ', @@ -522,7 +660,9 @@ describe('SitesService behavior', () => { throw new SiteUploadValidationError('Only HTML uploads are supported.'); }); - await expect(service.createSite({ fileName: 'site.exe', content: Buffer.from('bad') })).rejects.toMatchObject({ + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'site.exe', content: Buffer.from('bad') }) + ).rejects.toMatchObject({ message: 'Only HTML uploads are supported.', statusCode: 400, }); @@ -536,9 +676,9 @@ describe('SitesService behavior', () => { throw unexpected; }); - await expect(service.createSite({ fileName: 'site.html', content: Buffer.from('site') })).rejects.toBe( - unexpected - ); + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'site.html', content: Buffer.from('site') }) + ).rejects.toBe(unexpected); expect(database.models.Site.transact).not.toHaveBeenCalled(); }); @@ -546,9 +686,9 @@ describe('SitesService behavior', () => { const insertError = new Error('version insert failed'); state.versionInsertError = insertError; - await expect(service.createSite({ fileName: 'site.html', content: Buffer.from('site') })).rejects.toBe( - insertError - ); + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'site.html', content: Buffer.from('site') }) + ).rejects.toBe(insertError); expect(mockDeletePrefix).toHaveBeenCalledWith('sites/site000001/versions/version00001'); expect(state.sites).toEqual([]); @@ -561,9 +701,9 @@ describe('SitesService behavior', () => { mockPutFiles.mockRejectedValueOnce(uploadError); mockDeletePrefix.mockRejectedValueOnce(cleanupError); - await expect(service.createSite({ fileName: 'site.html', content: Buffer.from('site') })).rejects.toBe( - uploadError - ); + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'site.html', content: Buffer.from('site') }) + ).rejects.toBe(uploadError); expect(state.sites).toEqual([]); expect(mockWarn).toHaveBeenCalledWith( @@ -579,8 +719,8 @@ describe('SitesService behavior', () => { addSite(state, { siteId: `site-${String(index).padStart(3, '0')}`, updatedAt: `2026-06-${index}` }); } - const defaults = await service.listSites({ page: Number.NaN, limit: -1 }); - const capped = await service.listSites({ page: 1.9, limit: 101.8 }); + const defaults = await service.listSites({ page: Number.NaN, limit: -1 }, principal); + const capped = await service.listSites({ page: 1.9, limit: 101.8 }, principal); expect(defaults.pagination).toEqual({ current: 1, total: 5, items: 105, limit: 25 }); expect(defaults.sites).toHaveLength(25); @@ -600,7 +740,7 @@ describe('SitesService behavior', () => { updatedBy: '', }); - await expect(service.getSite('site-nullables')).resolves.toEqual({ + await expect(service.getSite('site-nullables', principal)).resolves.toMatchObject({ id: 'site-nullables', name: 'Nullable row', url: 'https://site-site-nullables.sites.example.com', @@ -618,20 +758,29 @@ describe('SitesService behavior', () => { it('returns not found for a missing or soft-deleted site', async () => { addSite(state, { siteId: 'deleted', deletedAt: UPDATED_AT }); - await expect(service.getSite('missing')).rejects.toMatchObject({ message: 'Site not found.', statusCode: 404 }); - await expect(service.getSite('deleted')).rejects.toMatchObject({ message: 'Site not found.', statusCode: 404 }); + await expect(service.getSite('missing', principal)).rejects.toMatchObject({ + message: 'Site not found.', + statusCode: 404, + }); + await expect(service.getSite('deleted', principal)).rejects.toMatchObject({ + message: 'Site not found.', + statusCode: 404, + }); }); it('does not classify an invalid expiry timestamp as elapsed', async () => { addSite(state, { expiresAt: 'not-a-date' }); - await expect(service.getSite('site-1')).resolves.toMatchObject({ status: 'active', expiresAt: 'not-a-date' }); + await expect(service.getSite('site-1', principal)).resolves.toMatchObject({ + status: 'active', + expiresAt: 'not-a-date', + }); }); it('reports zero counters for a new empty site record', async () => { addSite(state, { fileCount: 0, sizeBytes: 0 }); - await expect(service.getSite('site-1')).resolves.toMatchObject({ fileCount: 0, sizeBytes: 0 }); + await expect(service.getSite('site-1', principal)).resolves.toMatchObject({ fileCount: 0, sizeBytes: 0 }); }); }); @@ -645,7 +794,7 @@ describe('SitesService behavior', () => { if (overrides) addSite(state, overrides); await expect( - service.replaceSiteContent('site-1', { fileName: 'site.html', content: Buffer.from('site') }) + service.replaceSiteContent('site-1', { principal, fileName: 'site.html', content: Buffer.from('site') }) ).rejects.toMatchObject({ message: 'Site not found.', statusCode: 404 }); expect(mockValidateSiteUpload).not.toHaveBeenCalled(); expect(mockPutFiles).not.toHaveBeenCalled(); @@ -661,6 +810,7 @@ describe('SitesService behavior', () => { }); const result = await service.replaceSiteContent('site-1', { + principal, fileName: 'replacement.html', content: Buffer.from('replacement'), }); @@ -669,7 +819,7 @@ describe('SitesService behavior', () => { id: 'site-1', fileCount: 1, sizeBytes: 14, - updatedBy: 'previous@example.com', + updatedBy: null, }); expect(site.activeVersionId).toBe('version00001'); expect(state.versions.find((version) => version.versionId === 'old-1')?.deletedAt).toEqual(expect.any(String)); @@ -689,9 +839,9 @@ describe('SitesService behavior', () => { addSite(state, { updatedBy: null }); const result = await service.replaceSiteContent('site-1', { + principal: { ...principal, identity: { email: 'editor@example.com' } as any }, fileName: 'replacement.html', content: Buffer.from('replacement'), - user: { email: 'editor@example.com' } as any, }); expect(result.updatedBy).toBe('editor@example.com'); @@ -702,6 +852,7 @@ describe('SitesService behavior', () => { addSite(state, { updatedBy: null }); const result = await service.replaceSiteContent('site-1', { + principal, fileName: 'replacement.html', content: Buffer.from('replacement'), }); @@ -718,7 +869,11 @@ describe('SitesService behavior', () => { state.sitePatchAndFetchError = patchError; await expect( - service.replaceSiteContent('site-1', { fileName: 'replacement.html', content: Buffer.from('replacement') }) + service.replaceSiteContent('site-1', { + principal, + fileName: 'replacement.html', + content: Buffer.from('replacement'), + }) ).rejects.toBe(patchError); expect(mockDeletePrefix).toHaveBeenCalledWith('sites/site-1/versions/version00001'); @@ -736,7 +891,7 @@ describe('SitesService behavior', () => { const now = jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-06-10T00:00:00.000Z')); addSite(state, { expiresAt: '2026-06-20T00:00:00.000Z' }); - const result = await service.extendSite('site-1'); + const result = await service.extendSite('site-1', principal); expect(result.expiresAt).toBe('2026-06-27T00:00:00.000Z'); expect(state.sites[0].expiresAt).toBe('2026-06-27T00:00:00.000Z'); @@ -747,7 +902,7 @@ describe('SitesService behavior', () => { const now = jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-06-10T00:00:00.000Z')); addSite(state, { expiresAt: null }); - await expect(service.extendSite('site-1')).resolves.toMatchObject({ + await expect(service.extendSite('site-1', principal)).resolves.toMatchObject({ expiresAt: '2026-06-17T00:00:00.000Z', }); now.mockRestore(); @@ -757,7 +912,7 @@ describe('SitesService behavior', () => { enabledConfig({ ttl: { enabled: false } }); addSite(state, { expiresAt: null }); - await expect(service.extendSite('site-1')).rejects.toMatchObject({ + await expect(service.extendSite('site-1', principal)).rejects.toMatchObject({ message: 'TTL is disabled for hosted sites.', statusCode: 400, }); @@ -765,40 +920,38 @@ describe('SitesService behavior', () => { }); describe('deleteSite', () => { - it('deletes stored versions before atomically soft-deleting site records', async () => { + it('tombstones the site before cleaning remaining versions', async () => { addSite(state); addVersion(state, { versionId: 'version-1', storagePrefix: 'prefix/version-1' }); addVersion(state, { versionId: 'version-2', storagePrefix: 'prefix/version-2', deletedAt: CREATED_AT }); - const result = await service.deleteSite('site-1'); + const result = await service.deleteSite('site-1', principal); - expect(mockDeletePrefix.mock.calls).toEqual([['prefix/version-1'], ['prefix/version-2']]); + expect(mockDeletePrefix.mock.calls).toEqual([['prefix/version-1']]); expect(result).toMatchObject({ id: 'site-1', status: 'deleted' }); expect(state.sites[0]).toMatchObject({ status: 'deleted', deletedAt: expect.any(String) }); - expect(state.versions[0].deletedAt).toBe(state.sites[0].deletedAt); + expect(state.versions[0].deletedAt).not.toBeNull(); expect(state.versions[1].deletedAt).toBe(CREATED_AT); }); it('returns not found without touching storage for an unknown site', async () => { - await expect(service.deleteSite('missing')).rejects.toMatchObject({ + await expect(service.deleteSite('missing', principal)).rejects.toMatchObject({ message: 'Site not found.', statusCode: 404, }); expect(mockDeletePrefix).not.toHaveBeenCalled(); - expect(database.models.Site.transact).not.toHaveBeenCalled(); }); - it('does not mark database rows deleted when object cleanup fails', async () => { + it('keeps access tombstoned and leaves storage for retry when cleanup fails', async () => { const site = addSite(state); const version = addVersion(state, { storagePrefix: 'prefix/version-1' }); const deleteError = new Error('delete failed'); mockDeletePrefix.mockRejectedValueOnce(deleteError); - await expect(service.deleteSite('site-1')).rejects.toBe(deleteError); - expect(site.status).toBe('active'); - expect(site.deletedAt).toBeNull(); + await expect(service.deleteSite('site-1', principal)).resolves.toMatchObject({ status: 'deleted' }); + expect(site.status).toBe('deleted'); + expect(site.deletedAt).not.toBeNull(); expect(version.deletedAt).toBeNull(); - expect(database.models.Site.transact).not.toHaveBeenCalled(); }); }); @@ -912,9 +1065,9 @@ describe('SitesService behavior', () => { await expect(service.getGatewayObject('site-site-1.sites.example.com', '/index.html')).rejects.toBe(outage); }); - it('matches only configured gateway hosts while Sites is enabled', async () => { + it('intercepts the configured namespace, including unknown hosts', async () => { await expect(service.matchesGatewayHost('site-abc123.sites.example.com')).resolves.toBe(true); - await expect(service.matchesGatewayHost('other-abc123.sites.example.com')).resolves.toBe(false); + await expect(service.matchesGatewayHost('other-abc123.sites.example.com')).resolves.toBe(true); await expect(service.matchesGatewayHost(undefined)).resolves.toBe(false); mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); @@ -925,7 +1078,6 @@ describe('SitesService behavior', () => { describe('expiration cleanup', () => { it.each([ ['Sites is disabled', { enabled: false }], - ['TTL is disabled', { enabled: true, ttl: { enabled: false } }], ['cleanup is disabled', { enabled: true, ttl: { enabled: true }, cleanup: { enabled: false } }], ])('does no work when %s', async (_case, sites) => { mockGetAllConfigs.mockResolvedValue({ sites }); @@ -969,15 +1121,27 @@ describe('SitesService behavior', () => { await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 2, cleaned: 1, errors: 1 }); expect(cleanedSite).toMatchObject({ status: 'expired', deletedAt: '2026-06-10T00:00:00.000Z' }); expect(cleanedVersion.deletedAt).toBe('2026-06-10T00:00:00.000Z'); - expect(failedSite).toMatchObject({ status: 'active', deletedAt: null }); + expect(failedSite).toMatchObject({ status: 'expired', deletedAt: '2026-06-10T00:00:00.000Z' }); expect(failedVersion.deletedAt).toBeNull(); - expect(mockError).toHaveBeenCalledWith( + expect(mockWarn).toHaveBeenCalledWith( { error: cleanupError, siteId: 'expired-failed' }, - 'Sites: cleanup failed' + 'Sites: terminal storage cleanup deferred' ); toISOString.mockRestore(); }); + it('retries superseded storage cleanup even while the active site has no expiry', async () => { + enabledConfig({ ttl: { enabled: false } }); + addSite(state); + addVersion(state); + mockDeletePrefix.mockRejectedValueOnce(new Error('storage unavailable')); + await service.replaceSiteContent('site-1', { principal, fileName: 'index.html', content: Buffer.from('new') }); + expect(state.versions[0].deletedAt).toBeNull(); + await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 0, cleaned: 1, errors: 0 }); + expect(state.versions[0].deletedAt).not.toBeNull(); + expect(state.versions[1].deletedAt).toBeNull(); + }); + it('returns zero counts when no active site has elapsed', async () => { addSite(state, { expiresAt: '2099-01-01T00:00:00.000Z' }); @@ -986,6 +1150,268 @@ describe('SitesService behavior', () => { }); }); + describe('V1 access enforcement', () => { + const stranger = { ...principal, userId: 'stranger', actor: 'stranger', roles: ['admin'] } as Principal; + it('creates user sites private by default with stable issuer ownership', async () => { + const result = await service.createSite({ principal, fileName: 'index.html', content: Buffer.from('site') }); + expect(result).toMatchObject({ + visibility: 'private', + currentRole: 'owner', + accessRevision: 1, + contentRevision: 1, + permissions: { canEdit: true }, + }); + expect(result.url).toContain('--g-version00001'); + expect(result.openUrl).toBe('https://lifecycle.example.net/sites/open/site000001'); + expect(state.sites[0]).toMatchObject({ ownerKind: 'user', ownerSubject: 'owner', ownerIssuer: principal.issuer }); + }); + it('creates public service-key sites and binds writes to that token only', async () => { + const machine = { + ...principal, + kind: 'service_key', + userId: null, + issuer: null, + tokenId: 7, + scopes: ['sites:write'], + } as Principal; + const result = await service.createSite({ + principal: machine, + fileName: 'index.html', + content: Buffer.from('site'), + }); + expect(result).toMatchObject({ + visibility: 'public', + currentRole: 'owner', + permissions: { canEdit: true, canChangeVisibility: false }, + }); + expect(state.sites[0]).toMatchObject({ + ownerKind: 'service_key', + creatorTokenId: 7, + ownerSubject: null, + ownerIssuer: null, + }); + await expect(service.deleteSite(result.id, principal)).rejects.toMatchObject({ httpStatus: 404 }); + await expect( + service.createSite({ + principal: machine, + visibility: 'private', + fileName: 'index.html', + content: Buffer.from('site'), + }) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + it('does not commit a machine upload if its token was revoked during storage staging', async () => { + const machine = { + ...principal, + kind: 'service_key', + userId: null, + issuer: null, + tokenId: 7, + scopes: ['sites:write'], + } as Principal; + mockPutFiles.mockImplementationOnce(async () => { + mockTokenState.revokedAt = new Date().toISOString(); + }); + await expect( + service.createSite({ principal: machine, fileName: 'index.html', content: Buffer.from('site') }) + ).rejects.toMatchObject({ code: 'invalid_credential' }); + expect(state.sites).toHaveLength(0); + expect(mockDeletePrefix).toHaveBeenCalled(); + }); + it('commits visibility audit with the mutation and rolls back if auditing fails', async () => { + addSite(state); + mockAudit.mockRejectedValueOnce(new Error('audit unavailable')); + await expect(service.setVisibility('site-1', 'private', principal, 1)).rejects.toThrow('audit unavailable'); + expect(state.sites[0].visibility).toBe('public'); + await service.setVisibility('site-1', 'private', principal, 1); + expect(mockAudit).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + event: 'sites.visibility_changed', + meta: expect.objectContaining({ siteId: 'site-1', from: 'public', to: 'private' }), + }) + ); + }); + it('advertises no human upload choices while private readiness is disabled', async () => { + process.env.SITES_PRIVATE_ENABLED = 'false'; + await expect(service.getCapabilities(principal)).resolves.toMatchObject({ + defaultVisibility: 'private', + canCreate: false, + allowedVisibilities: [], + }); + }); + it('resolves valid missing host locators without a database Site for the anonymous bootstrap', async () => { + await expect(service.getGatewayLocator('site-missing--g-abcdef012345.sites.example.com')).resolves.toEqual({ + siteId: 'missing', + servingGeneration: 'abcdef012345', + }); + await expect(service.getGatewayLocator('site-missing--g-abcdef012345.sites.example.com:443')).resolves.toEqual({ + siteId: 'missing', + servingGeneration: 'abcdef012345', + }); + await expect(service.getGatewayLocator('site-missing.sites.example.com:9443')).rejects.toMatchObject({ + statusCode: 404, + }); + await expect(service.getGatewayLocator('site-missing.other.example.com')).rejects.toMatchObject({ + statusCode: 404, + }); + }); + it.each([undefined, 'public'] as const)( + 'refuses human creation (%s) before storage if the secure rollout gate is closed', + async (visibility) => { + process.env.SITES_PRIVATE_ENABLED = 'false'; + await expect( + service.createSite({ principal, visibility, fileName: 'index.html', content: Buffer.from('site') }) + ).rejects.toMatchObject({ code: 'private_sites_unavailable' }); + expect(mockPutFiles).not.toHaveBeenCalled(); + } + ); + it('returns the same missing/private deletion error contract to a nonowner', async () => { + addSite(state, { visibility: 'private', ownerSubject: 'other' }); + const errors = []; + for (const id of ['site-1', 'missing']) { + try { + await service.deleteSite(id, principal, 1); + } catch (error) { + errors.push({ + message: (error as Error).message, + code: (error as { code?: string }).code, + status: (error as { httpStatus?: number }).httpStatus, + }); + } + } + expect(errors).toHaveLength(2); + expect(errors[0]).toEqual(errors[1]); + expect(errors[0]).toEqual({ message: 'Site not found.', code: 'site_not_found', status: 404 }); + }); + it('filters unauthorized private rows before page totals and redacts public attribution', async () => { + addSite(state, { siteId: 'secret', visibility: 'private', ownerSubject: 'other' }); + addSite(state, { siteId: 'public', ownerSubject: 'other', createdBy: 'private-email@example.com' }); + addSite(state, { siteId: 'mine', visibility: 'private' }); + const result = await service.listSites({}, principal); + expect(result.pagination.items).toBe(2); + expect(result.sites.map((site) => site.id).sort()).toEqual(['mine', 'public']); + expect(result.sites.find((site) => site.id === 'public')).toMatchObject({ + createdBy: null, + updatedBy: null, + currentRole: null, + permissions: { canEdit: false }, + }); + const mine = await service.listSites({ view: 'mine' }, principal); + expect(mine.sites.map((site) => site.id)).toEqual(['mine']); + }); + it('denies all nonowner mutations including realm admins before storage', async () => { + addSite(state, { visibility: 'private' }); + await expect(service.getSite('site-1', stranger)).rejects.toMatchObject({ statusCode: 404 }); + await expect( + service.replaceSiteContent('site-1', { + principal: stranger, + fileName: 'index.html', + content: Buffer.from('bad'), + }) + ).rejects.toMatchObject({ httpStatus: 404 }); + await expect(service.extendSite('site-1', stranger)).rejects.toMatchObject({ httpStatus: 404 }); + await expect(service.setVisibility('site-1', 'public', stranger, 1)).rejects.toMatchObject({ httpStatus: 404 }); + await expect(service.deleteSite('site-1', stranger)).rejects.toMatchObject({ httpStatus: 404 }); + expect(mockPutFiles).not.toHaveBeenCalled(); + expect(mockDeletePrefix).not.toHaveBeenCalled(); + }); + it('requires a gateway authorizer for every private object before any storage call', async () => { + addSite(state, { visibility: 'private' }); + addVersion(state); + await expect(service.getGatewayObject('site-site-1.sites.example.com', '/')).rejects.toMatchObject({ + statusCode: 404, + }); + const authorize = jest.fn(async () => { + throw new Error('denied'); + }); + await expect(service.getGatewayObject('site-site-1.sites.example.com', '/asset.css', authorize)).rejects.toThrow( + 'denied' + ); + expect(authorize).toHaveBeenCalledTimes(1); + expect(mockGetObject).not.toHaveBeenCalled(); + }); + it('retires the old public host on privatization and never restores it when republishing', async () => { + addSite(state); + addVersion(state); + const hidden = await service.setVisibility('site-1', 'private', principal, 1); + expect(hidden.url).toBe('https://site-site-1--g-version00001.sites.example.com'); + await expect(service.getGatewaySite('site-site-1.sites.example.com')).rejects.toMatchObject({ statusCode: 404 }); + const published = await service.setVisibility('site-1', 'public', principal, 2); + expect(published.url).toBe(hidden.url); + await expect(service.getGatewaySite('site-site-1.sites.example.com')).rejects.toMatchObject({ statusCode: 404 }); + }); + it('rejects a stale visibility revision without modifying the site', async () => { + const row = addSite(state); + await expect(service.setVisibility('site-1', 'private', principal, 2)).rejects.toMatchObject({ + code: 'site_changed', + }); + expect(state.sites[0].visibility).toBe(row.visibility); + }); + it('does not resurrect content deleted while its replacement uploads', async () => { + addSite(state); + addVersion(state); + mockPutFiles.mockImplementationOnce(async () => { + await service.deleteSite('site-1', principal); + }); + await expect( + service.replaceSiteContent('site-1', { principal, fileName: 'index.html', content: Buffer.from('replacement') }) + ).rejects.toMatchObject({ statusCode: 404 }); + expect(state.sites[0].status).toBe('deleted'); + expect(mockDeletePrefix).toHaveBeenCalledWith('sites/site-1/versions/version00001'); + }); + it('rejects publishing or deleting content replaced since the owner prepared the action', async () => { + addSite(state, { visibility: 'private', servingGeneration: 'generation-1' }); + addVersion(state); + const before = await service.getSite('site-1', principal); + const replaced = await service.replaceSiteContent('site-1', { + principal, + fileName: 'index.html', + content: Buffer.from('new private content'), + expectedAccessRevision: before.accessRevision, + expectedContentRevision: before.contentRevision, + }); + expect(replaced).toMatchObject({ visibility: 'private', accessRevision: 2, contentRevision: 2 }); + expect(replaced.url).toBe(before.url); + await expect(service.setVisibility('site-1', 'public', principal, before.accessRevision)).rejects.toMatchObject({ + code: 'site_changed', + }); + await expect(service.deleteSite('site-1', principal, before.accessRevision)).rejects.toMatchObject({ + code: 'site_changed', + }); + expect(state.sites[0]).toMatchObject({ visibility: 'private', status: 'active', deletedAt: null }); + const published = await service.setVisibility('site-1', 'public', principal, replaced.accessRevision); + expect(published).toMatchObject({ visibility: 'public', accessRevision: 3, contentRevision: 2 }); + }); + it('rejects replacement when a private site is published during the upload', async () => { + addSite(state, { visibility: 'private', servingGeneration: 'generation-1' }); + addVersion(state); + mockPutFiles.mockImplementationOnce(async () => { + await service.setVisibility('site-1', 'public', principal, 1); + }); + await expect( + service.replaceSiteContent('site-1', { + principal, + fileName: 'index.html', + content: Buffer.from('private draft'), + }) + ).rejects.toMatchObject({ code: 'site_changed' }); + expect(state.sites[0]).toMatchObject({ activeVersionId: 'version-1', visibility: 'public', contentRevision: 1 }); + }); + it('rejects replacement after visibility changed during upload', async () => { + addSite(state); + addVersion(state); + mockPutFiles.mockImplementationOnce(async () => { + await service.setVisibility('site-1', 'private', principal, 1); + }); + await expect( + service.replaceSiteContent('site-1', { principal, fileName: 'index.html', content: Buffer.from('replacement') }) + ).rejects.toMatchObject({ code: 'site_changed' }); + expect(state.sites[0].activeVersionId).toBe('version-1'); + expect(state.sites[0].visibility).toBe('private'); + }); + }); + describe('cleanup queue wiring', () => { it('processes cleanup jobs and logs the result', async () => { const result = { expired: 3, cleaned: 2, errors: 1 }; @@ -997,7 +1423,6 @@ describe('SitesService behavior', () => { it.each([ ['Sites is disabled', { enabled: false }], - ['TTL is disabled', { enabled: true, ttl: { enabled: false } }], ['cleanup is disabled', { enabled: true, ttl: { enabled: true }, cleanup: { enabled: false } }], ])('does not schedule when %s', async (_case, sites) => { mockGetAllConfigs.mockResolvedValue({ sites }); @@ -1008,6 +1433,12 @@ describe('SitesService behavior', () => { expect(mockDebug).toHaveBeenCalledWith('Sites: cleanup disabled'); }); + it('schedules storage retries even when new sites have no expiry', async () => { + enabledConfig({ ttl: { enabled: false } }); + await service.setupSitesCleanupJob(); + expect(queueAdd).toHaveBeenCalledWith('sites-cleanup', {}, expect.objectContaining({ jobId: 'sites-cleanup' })); + }); + it('schedules one stable repeating cleanup job at the configured interval', async () => { enabledConfig({ cleanup: { enabled: true, intervalMinutes: 12 } }); diff --git a/src/server/services/__tests__/sites.test.ts b/src/server/services/__tests__/sites.test.ts index 291cdb4d..968a07bb 100644 --- a/src/server/services/__tests__/sites.test.ts +++ b/src/server/services/__tests__/sites.test.ts @@ -19,6 +19,10 @@ import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; mockRedisClient(); const mockGetAllConfigs = jest.fn(); +const mockAudit = jest.fn, unknown[]>(async () => undefined); +jest.mock('server/services/authAudit', () => ({ + recordAuthAuditEventInTransaction: (...args: unknown[]) => mockAudit(...args), +})); jest.mock('shared/config', () => ({ QUEUE_NAMES: { @@ -62,9 +66,54 @@ jest.mock('server/services/globalConfig', () => ({ }, })); +import type { Principal } from 'server/lib/principal'; +const principal: Principal = { + kind: 'user', + authMethod: 'session', + userId: 'owner', + issuer: 'https://identity.example/realms/lifecycle', + actor: 'owner', + roles: ['user'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + oauth: { sessionId: 'session', tokenId: 'jwt', clientId: 'cli', expiresAt: 1e12 }, +}; +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: jest.fn(async () => 'active'), + getUserSessionStatus: jest.fn(async () => 'active'), + getOAuthTokenStatus: jest.fn(async () => 'active'), +})); +jest.mock('server/lib/verifiedOAuthBearer', () => ({ getVerifiedOAuthBearer: jest.fn(() => 'fixture-bearer') })); +jest.mock('server/lib/sites/browserAuth', () => ({ + ...jest.requireActual('server/lib/sites/browserAuth'), + getSitesBrowserAuth: () => ({ assertOAuthLogin: async () => undefined }), +})); +const originalSitesEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = principal.issuer!; + process.env.SITES_PRIVATE_ENABLED = 'true'; + process.env.SITES_UI_ORIGIN = 'https://lifecycle.example.net'; + process.env.SITES_BROWSER_BRIDGE_SECRET = 'fixture-bridge-secret-at-least-thirty-two-bytes'; +}); +afterEach(() => { + process.env = { ...originalSitesEnv }; +}); + import SitesService from 'server/services/sites'; type SiteRow = { + visibility: 'private' | 'public'; + ownerKind: 'user' | 'service_key' | 'unresolved'; + ownerIssuer: string | null; + ownerSubject: string | null; + creatorTokenId: number | null; + servingGeneration: string | null; + accessRevision: number; + contentRevision: number; siteId: string; name: string; status: string; @@ -85,6 +134,27 @@ class SiteQuery { constructor(private readonly rows: SiteRow[]) {} + forUpdate() { + return this; + } + orWhere(scope: Partial) { + const existing = [...this.filters]; + this.filters = [ + (row) => + existing.every((f) => f(row)) || + Object.entries(scope).every(([key, value]) => row[key as keyof SiteRow] === value), + ]; + return this; + } + where(scope: Partial | keyof SiteRow | ((query: SiteQuery) => unknown), value?: unknown) { + if (typeof scope === 'function') { + const group = new SiteQuery(this.rows); + scope(group); + this.filters.push((row) => group.filters.every((f) => f(row))); + } else if (typeof scope === 'string') this.filters.push((row) => row[scope] === value); + else this.filters.push((row) => Object.entries(scope).every(([key, value]) => row[key as keyof SiteRow] === value)); + return this; + } whereNull(field: keyof SiteRow) { this.filters.push((row) => row[field] == null); return this; @@ -105,7 +175,7 @@ class SiteQuery { } orderBy(field: keyof SiteRow, direction: string) { - this.sortBy = { field, direction }; + if (!this.sortBy) this.sortBy = { field, direction }; return this; } @@ -137,6 +207,14 @@ class SiteQuery { function createSiteRow(overrides: Partial = {}): SiteRow { return { + visibility: 'public', + ownerKind: 'user', + ownerIssuer: principal.issuer!, + ownerSubject: principal.userId, + creatorTokenId: null, + servingGeneration: null, + accessRevision: 1, + contentRevision: 1, siteId: 'site-1', name: 'site', status: 'active', @@ -196,7 +274,7 @@ describe('SitesService', () => { createSiteRow({ siteId: 'new', updatedAt: '2026-05-02T00:00:00.000Z' }) ); - await expect(service.listSites()).resolves.toMatchObject({ + await expect(service.listSites({}, principal)).resolves.toMatchObject({ sites: [{ id: 'new' }, { id: 'old' }], pagination: { current: 1, @@ -207,36 +285,9 @@ describe('SitesService', () => { }); }); - it('filters to sites created or last updated by the supplied user email', async () => { - rows.push( - createSiteRow({ - siteId: 'created-by-user', - createdBy: 'ALICE@example.com', - updatedBy: 'other@example.com', - updatedAt: '2026-05-01T00:00:00.000Z', - }), - createSiteRow({ - siteId: 'updated-by-user', - createdBy: 'other@example.com', - updatedBy: 'alice@example.com', - updatedAt: '2026-05-03T00:00:00.000Z', - }), - createSiteRow({ - siteId: 'not-touched-by-user', - createdBy: 'other@example.com', - updatedBy: 'other@example.com', - updatedAt: '2026-05-04T00:00:00.000Z', - }) - ); - - await expect(service.listSites({ user: ' Alice@Example.com ' })).resolves.toMatchObject({ - sites: [{ id: 'updated-by-user' }, { id: 'created-by-user' }], - pagination: { - current: 1, - total: 1, - items: 2, - limit: 25, - }, + it('rejects legacy email filters instead of treating mutable attribution as ownership', async () => { + await expect(service.listSites({ user: 'Alice@Example.com' }, principal)).rejects.toMatchObject({ + statusCode: 400, }); }); @@ -247,7 +298,7 @@ describe('SitesService', () => { createSiteRow({ siteId: 'newest', updatedAt: '2026-05-03T00:00:00.000Z' }) ); - await expect(service.listSites({ page: 2, limit: 1 })).resolves.toMatchObject({ + await expect(service.listSites({ page: 2, limit: 1 }, principal)).resolves.toMatchObject({ sites: [{ id: 'middle' }], pagination: { current: 2, @@ -263,10 +314,10 @@ describe('SitesService', () => { const now = jest.spyOn(Date, 'now').mockReturnValue(new Date(expiresAt).getTime()); rows.push(createSiteRow({ expiresAt })); - await expect(service.listSites()).resolves.toMatchObject({ + await expect(service.listSites({}, principal)).resolves.toMatchObject({ sites: [{ id: 'site-1', status: 'expired', expiresAt }], }); - await expect(service.getSite('site-1')).resolves.toMatchObject({ + await expect(service.getSite('site-1', principal)).resolves.toMatchObject({ id: 'site-1', status: 'expired', expiresAt, @@ -274,7 +325,7 @@ describe('SitesService', () => { now.mockRestore(); }); - it('keeps an elapsed expiresAt active when site TTL is disabled', async () => { + it('does not resurrect elapsed sites when TTL defaults are disabled', async () => { mockGetAllConfigs.mockResolvedValue({ sites: { enabled: true, @@ -285,12 +336,12 @@ describe('SitesService', () => { }); rows.push(createSiteRow({ expiresAt: '2000-01-01T00:00:00.000Z' })); - await expect(service.listSites()).resolves.toMatchObject({ - sites: [{ id: 'site-1', status: 'active' }], + await expect(service.listSites({}, principal)).resolves.toMatchObject({ + sites: [{ id: 'site-1', status: 'expired' }], }); - await expect(service.getSite('site-1')).resolves.toMatchObject({ + await expect(service.getSite('site-1', principal)).resolves.toMatchObject({ id: 'site-1', - status: 'active', + status: 'expired', }); }); }); diff --git a/src/server/services/apiToken.ts b/src/server/services/apiToken.ts index 59c543b8..a1c5d162 100644 --- a/src/server/services/apiToken.ts +++ b/src/server/services/apiToken.ts @@ -27,15 +27,8 @@ import { getLogger } from 'server/lib/logger'; import { GITHUB_APP_INSTALLATION_ID } from 'shared/config'; import { paginate, type PaginationMetadata, type PaginationParams } from 'server/lib/paginate'; -export const API_TOKEN_SCOPES: ApiTokenScope[] = [ - 'env:read', - 'env:write', - 'env:admin', - 'sites:read', - 'sites:write', - 'repos:read', - 'repos:write', -]; +import { API_TOKEN_SCOPES } from 'server/lib/apiTokenScopes'; +export { API_TOKEN_SCOPES, scopeSatisfies } from 'server/lib/apiTokenScopes'; export { API_TOKEN_PATTERN }; const LAST_USED_WRITE_INTERVAL_MS = 60_000; @@ -86,6 +79,7 @@ export interface IssueTokenInput { } export interface UserTokenOwner { + issuer?: string | null; userId: string; githubUsername: string | null; email: string | null; @@ -105,17 +99,6 @@ export interface IssueUserTokenInput { export type OwnerSelectorField = 'ownerUserId' | 'ownerEmail' | 'ownerPreferredUsername'; -/** write ⊃ read within one resource; legacy env:admin covers env:* only; never cross-resource. */ -export function scopeSatisfies(granted: ApiTokenScope[], required: ApiTokenScope): boolean { - return granted.some((scope) => { - if (!API_TOKEN_SCOPES.includes(scope)) return false; - if (scope === required) return true; - if (scope === 'env:admin') return required === 'env:read' || required === 'env:write'; - const [resource, action] = scope.split(':'); - return action === 'write' && required === `${resource}:read`; - }); -} - /** SECURITY: only a null/undefined allowlist is unrestricted; an explicit empty one fails closed. */ export function isRepositoryAllowed(allowlist: string[] | null | undefined, fullName: string): boolean { if (!allowlist) return true; @@ -605,6 +588,7 @@ export default class ApiTokenService { expiresAt: input.expiresAt, createdBy: input.owner.userId, ownerUserId: input.owner.userId, + ownerIssuer: input.owner.issuer ?? null, ownerGithubUsername: input.owner.githubUsername, ownerEmail: input.owner.email ? input.owner.email.trim().toLowerCase() : null, ownerPreferredUsername: input.owner.preferredUsername diff --git a/src/server/services/keycloak/adminClient.test.ts b/src/server/services/keycloak/adminClient.test.ts index d11a6c37..fba652df 100644 --- a/src/server/services/keycloak/adminClient.test.ts +++ b/src/server/services/keycloak/adminClient.test.ts @@ -572,3 +572,33 @@ it('keeps its timeout active until the response body is consumed', async () => { expect.objectContaining({ kind: 'unavailable' } satisfies Partial) ); }); + +describe('OAuth access-token introspection', () => { + it('uses confidential client authentication directly with no admin token or client lookup', async () => { + const fetcher = jest.fn().mockResolvedValue(json({ active: true })); + await expect(client(fetcher).introspectAccessToken('user-access-token')).resolves.toBe(true); + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, request] = fetcher.mock.calls[0]; + expect(url).toBe('https://auth.example.com/realms/lifecycle/protocol/openid-connect/token/introspect'); + const body = new URLSearchParams(request.body); + expect(body.get('token')).toBe('user-access-token'); + expect(body.get('client_id')).toBe('management-client'); + expect(body.get('client_secret')).toBe('management-secret'); + expect(request.redirect).toBe('error'); + }); + it('accepts active=false as revoked, rather than transport failure', async () => { + await expect( + client(jest.fn().mockResolvedValue(json({ active: false }))).introspectAccessToken('token') + ).resolves.toBe(false); + }); + it.each([{}, { active: 'true' }, [], null])('fails closed for malformed status %j', async (value) => { + await expect(client(jest.fn().mockResolvedValue(json(value))).introspectAccessToken('token')).rejects.toMatchObject( + { kind: 'invalid_response' } + ); + }); + it('fails closed on forbidden introspection transport', async () => { + await expect( + client(jest.fn().mockResolvedValue(json({}, 403))).introspectAccessToken('token') + ).rejects.toMatchObject({ kind: 'forbidden' }); + }); +}); diff --git a/src/server/services/keycloak/adminClient.ts b/src/server/services/keycloak/adminClient.ts index 8755559a..a92ef39d 100644 --- a/src/server/services/keycloak/adminClient.ts +++ b/src/server/services/keycloak/adminClient.ts @@ -183,6 +183,30 @@ export class KeycloakAdminClient { await this.requestJson('DELETE', path, body); } + /** RFC 7662, authenticated as this confidential client; no Admin API role expansion. */ + async introspectAccessToken(token: string): Promise { + return this.fetchWithTimeout( + `${this.issuer}/protocol/openid-connect/token/introspect`, + { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: new URLSearchParams({ + token, + token_type_hint: 'access_token', + client_id: this.clientId, + client_secret: this.clientSecret, + }).toString(), + }, + async (response) => { + if (!response.ok) throw errorForStatus(response.status); + const result = await this.readJson(response); + if (!isRecord(result) || typeof result.active !== 'boolean') + throw new KeycloakAdminError('invalid_response', response.status, 'Keycloak returned invalid token status.'); + return result.active; + } + ); + } + private async accessToken(): Promise { const now = Date.now(); if (this.token && now < this.token.expiresAtMs - TOKEN_EXPIRY_MARGIN_MS) { diff --git a/src/server/services/keycloak/principalStatus.test.ts b/src/server/services/keycloak/principalStatus.test.ts index ec441a31..ca1d2b74 100644 --- a/src/server/services/keycloak/principalStatus.test.ts +++ b/src/server/services/keycloak/principalStatus.test.ts @@ -312,3 +312,18 @@ describe('principal status configuration', () => { afterAll(() => { restorePrincipalStatusConfig(); }); + +describe('standard OAuth token introspection', () => { + it.each([true, false])('maps live introspection active=%s without client-list privileges', async (active) => { + const introspectAccessToken = jest.fn().mockResolvedValue(active); + const status = new KeycloakPrincipalStatus({ introspectAccessToken } as unknown as KeycloakAdminClient); + await expect(status.getOAuthTokenStatus('test-bearer')).resolves.toBe(active ? 'active' : 'revoked'); + expect(introspectAccessToken).toHaveBeenCalledWith('test-bearer'); + }); + it('fails closed on unavailable/forbidden/malformed introspection without logging bearer data', async () => { + const introspectAccessToken = jest.fn().mockRejectedValue(new Error('unavailable')); + const status = new KeycloakPrincipalStatus({ introspectAccessToken } as unknown as KeycloakAdminClient); + await expect(status.getOAuthTokenStatus('secret-test-bearer')).resolves.toBe('unknown'); + expect(JSON.stringify(mockWarn.mock.calls)).not.toContain('secret-test-bearer'); + }); +}); diff --git a/src/server/services/keycloak/principalStatus.ts b/src/server/services/keycloak/principalStatus.ts index ee7cf671..e50d41d4 100644 --- a/src/server/services/keycloak/principalStatus.ts +++ b/src/server/services/keycloak/principalStatus.ts @@ -93,6 +93,19 @@ export class KeycloakPrincipalStatus { } } + async getOAuthTokenStatus(token: string): Promise<'active' | 'revoked' | 'unknown'> { + try { + return (await this.client.introspectAccessToken(token)) ? 'active' : 'revoked'; + } catch (error) { + // Never log the introspection request, response claims, or bearer. + getLogger().warn( + { error: error instanceof Error ? error.name : 'unknown' }, + 'Keycloak token status lookup failed' + ); + return 'unknown'; + } + } + private async resolveBaseRoleStatus(encodedSub: string): Promise<'active' | 'no_base_role' | 'unknown'> { // Composite endpoint: expands default-roles- and any other composite grants. const userRoles = await this.client.get(`/users/${encodedSub}/role-mappings/realm/composite`); @@ -139,3 +152,7 @@ function configuredService(): KeycloakPrincipalStatus | null { export async function getUserStatus(sub: string): Promise { return (await configuredService()?.getUserStatus(sub)) ?? 'unknown'; } + +export async function getOAuthTokenStatus(token: string): Promise<'active' | 'revoked' | 'unknown'> { + return (await configuredService()?.getOAuthTokenStatus(token)) ?? 'unknown'; +} diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index ab9dabb8..5f0a5859 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -22,7 +22,13 @@ import GlobalConfigService from './globalConfig'; import { QUEUE_NAMES } from 'shared/config'; import { redisClient } from 'server/lib/dependencies'; import { getLogger } from 'server/lib/logger'; -import { buildSiteUrl, parseSiteIdFromHost, resolveSitesConfig, ResolvedSitesConfig } from 'server/lib/sites/config'; +import { + buildSiteUrl, + parseSiteIdFromHost, + parseSiteHost, + resolveSitesConfig, + ResolvedSitesConfig, +} from 'server/lib/sites/config'; import { SitesObjectNotFoundError, SitesStorage } from 'server/lib/sites/storage'; import { normalizeGatewayPath, @@ -35,6 +41,11 @@ import type Site from 'server/models/Site'; import type SiteVersion from 'server/models/SiteVersion'; import type { PaginationMetadata } from 'server/lib/paginate'; import type { RequestUserIdentity } from 'server/lib/get-user'; +import type { Principal } from 'server/lib/principal'; +import { recordAuthAuditEventInTransaction } from 'server/services/authAudit'; +import { AppError } from 'server/lib/appError'; +import { assertPrivateSitesReady } from 'server/lib/sites/browserAuth'; +import { assertSitesPrincipal, assertSiteOwner, isSiteOwner, hasSitesScope } from 'server/lib/sites/policy'; const createSiteId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10); const createVersionId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 12); @@ -45,8 +56,15 @@ const MAX_LIST_LIMIT = 100; type SitesErrorStatusCode = 400 | 401 | 403 | 404 | 409 | 500 | 502 | 503; export class SitesServiceError extends Error { + readonly httpStatus?: number; + readonly code?: string; constructor(message: string, public statusCode: SitesErrorStatusCode = 500) { super(message); + // Match the ownership policy's not-found contract without changing other legacy errors. + if (statusCode === 404) { + this.httpStatus = 404; + this.code = 'site_not_found'; + } } } @@ -55,10 +73,16 @@ export type CreateOrReplaceSiteInput = { content: Buffer; name?: string | null; user?: RequestUserIdentity | null; + principal: Principal; + visibility?: 'private' | 'public'; + expectedAccessRevision?: number; + expectedContentRevision?: number; }; export type ListSitesFilters = { user?: string; + view?: 'mine' | 'public' | 'all'; + q?: string; page?: number; limit?: number; }; @@ -73,6 +97,13 @@ export type SiteResponse = { name: string; url: string; status: string; + visibility: 'private' | 'public'; + contentUrl: string; + openUrl: string; + accessRevision: number; + contentRevision: number; + currentRole: 'owner' | null; + permissions: { canView: boolean; canEdit: boolean; canDelete: boolean; canChangeVisibility: boolean }; createdAt: string | null; updatedAt: string | null; expiresAt: string | null; @@ -110,28 +141,40 @@ export default class SitesService extends Service { } } - private serialize(site: Site, config: ResolvedSitesConfig): SiteResponse { + private serialize(site: Site, config: ResolvedSitesConfig, principal?: Principal): SiteResponse { + const owner = isSiteOwner(site, principal); + const writable = owner && Boolean(principal && hasSitesScope(principal, 'write')); + const contentUrl = buildSiteUrl(site.siteId, config, site.servingGeneration); + const uiOrigin = process.env.SITES_UI_ORIGIN?.replace(/\/$/, ''); const expiresAt = site.expiresAt ? new Date(site.expiresAt).getTime() : null; const status = - site.status === 'active' && - config.ttl.enabled && - expiresAt !== null && - Number.isFinite(expiresAt) && - expiresAt <= Date.now() + site.status === 'active' && expiresAt !== null && Number.isFinite(expiresAt) && expiresAt <= Date.now() ? 'expired' : site.status; return { id: site.siteId, name: site.name, - url: buildSiteUrl(site.siteId, config), + url: contentUrl, + contentUrl, + openUrl: uiOrigin ? `${uiOrigin}/sites/open/${site.siteId}` : contentUrl, + visibility: site.visibility, + accessRevision: site.accessRevision, + contentRevision: site.contentRevision, + currentRole: owner ? 'owner' : null, + permissions: { + canView: true, + canEdit: writable && status === 'active', + canDelete: writable && !site.deletedAt && status !== 'deleted', + canChangeVisibility: writable && status === 'active' && site.ownerKind === 'user', + }, status, createdAt: site.createdAt || null, updatedAt: site.updatedAt || null, expiresAt: site.expiresAt || null, fileCount: Number(site.fileCount || 0), sizeBytes: Number(site.sizeBytes || 0), - createdBy: site.createdBy || null, - updatedBy: site.updatedBy || null, + createdBy: owner ? site.createdBy || null : null, + updatedBy: owner ? site.updatedBy || null : null, }; } @@ -146,21 +189,18 @@ export default class SitesService extends Service { return new Date(Date.now() + config.ttl.defaultDays * DAY_MS).toISOString(); } - private async createVersion( + private async stageVersion( siteId: string, upload: ValidatedSiteUpload, config: ResolvedSitesConfig, - uploadedStoragePrefixes: string[], - trx?: Transaction - ): Promise { + uploadedStoragePrefixes: string[] + ) { const versionId = createVersionId(); const storage = new SitesStorage(config); const storagePrefix = storage.versionPrefix(siteId, versionId); - uploadedStoragePrefixes.push(storagePrefix); await storage.putFiles(storagePrefix, upload.files); - - return this.db.models.SiteVersion.query(trx).insert({ + return { siteId, versionId, storagePrefix, @@ -168,7 +208,77 @@ export default class SitesService extends Service { fileCount: upload.fileCount, sizeBytes: upload.sizeBytes, manifest: upload.files.map(({ path, sizeBytes, contentType }) => ({ path, sizeBytes, contentType })), - }) as unknown as Promise; + }; + } + + private assertPrivateReady(config: ResolvedSitesConfig) { + try { + assertPrivateSitesReady(buildSiteUrl('readiness', config)); + } catch { + throw new AppError({ + httpStatus: 503, + code: 'private_sites_unavailable', + message: 'Private Sites are not enabled or securely configured on this installation.', + }); + } + } + + private async audit( + trx: Transaction, + event: string, + siteId: string, + principal: Principal, + meta: Record = {} + ) { + await recordAuthAuditEventInTransaction(trx, { + event: `sites.${event}`, + principalKind: principal.kind, + principalId: principal.userId, + actorId: principal.actor, + tokenId: principal.tokenId, + outcome: 'success', + meta: { siteId, issuer: principal.issuer ?? null, ...meta }, + }); + } + + private checkRevision(site: Site, access?: number, content?: number) { + if ( + (access !== undefined && access !== site.accessRevision) || + (content !== undefined && content !== site.contentRevision) + ) { + throw new AppError({ + httpStatus: 409, + code: 'site_changed', + message: 'The site changed. Refresh it before trying again.', + }); + } + } + + async getCapabilities(principal: Principal) { + await assertSitesPrincipal(principal); + const config = await this.getConfig(); + const machine = principal.kind === 'service_key'; + let privateReady = false; + try { + this.assertPrivateReady(config); + privateReady = true; + } catch { + /* Report unavailable, never silently publish. */ + } + return { + enabled: config.enabled, + upload: config.upload, + defaultVisibility: machine ? ('public' as const) : ('private' as const), + allowedVisibilities: + !config.enabled || !hasSitesScope(principal, 'write') + ? [] + : machine + ? ['public'] + : privateReady + ? ['private', 'public'] + : [], + canCreate: config.enabled && hasSitesScope(principal, 'write') && (machine || privateReady), + }; } private validateUpload(input: CreateOrReplaceSiteInput, config: ResolvedSitesConfig): ValidatedSiteUpload { @@ -247,173 +357,289 @@ export default class SitesService extends Service { } async createSite(input: CreateOrReplaceSiteInput): Promise { + await assertSitesPrincipal(input.principal, 'write'); const config = await this.getConfig(); this.assertEnabled(config); + const principal = input.principal; + const machine = principal.kind === 'service_key'; + const visibility = input.visibility ?? (machine ? 'public' : 'private'); + if (!['private', 'public'].includes(visibility) || (machine && visibility !== 'public')) { + throw new SitesServiceError('Service-key sites must be public; visibility must be private or public.', 400); + } + if (principal.kind !== 'service_key' || visibility === 'private') this.assertPrivateReady(config); const upload = this.validateUpload(input, config); const siteId = createSiteId(); - const siteName = input.name?.trim() || this.defaultSiteName(siteId, config); - const expiresAt = this.expirationForNewSite(config); - - const site = await this.withUploadRollback(config, (uploadedStoragePrefixes) => - this.db.models.Site.transact(async (trx) => { + const site = await this.withUploadRollback(config, async (prefixes) => { + const version = await this.stageVersion(siteId, upload, config, prefixes); + await assertSitesPrincipal(principal, 'write'); + return this.db.models.Site.transact(async (trx) => { + await assertSitesPrincipal(principal, 'write', trx); const created = (await this.db.models.Site.query(trx).insert({ siteId, - name: siteName, + name: input.name?.trim() || this.defaultSiteName(siteId, config), status: 'active', activeVersionId: null, - fileCount: 0, - sizeBytes: 0, - expiresAt, - createdBy: input.user?.email || null, - updatedBy: input.user?.email || null, - })) as Site; - - const version = await this.createVersion(siteId, upload, config, uploadedStoragePrefixes, trx); - return created.$query(trx).patchAndFetch({ - activeVersionId: version.versionId, fileCount: upload.fileCount, sizeBytes: upload.sizeBytes, - }) as unknown as Promise; - }) - ); - - return this.serialize(site, config); + expiresAt: this.expirationForNewSite(config), + visibility, + ownerKind: machine ? 'service_key' : 'user', + ownerIssuer: machine ? null : principal.issuer, + ownerSubject: machine ? null : principal.userId, + creatorTokenId: machine ? principal.tokenId : null, + servingGeneration: visibility === 'private' ? createVersionId() : null, + accessRevision: 1, + contentRevision: 1, + createdBy: principal.identity?.email || null, + updatedBy: principal.identity?.email || null, + })) as Site; + await this.db.models.SiteVersion.query(trx).insert(version); + await this.audit(trx, 'created', siteId, principal, { visibility }); + return created.$query(trx).patchAndFetch({ activeVersionId: version.versionId }) as unknown as Promise; + }); + }); + return this.serialize(site, config, principal); } - async listSites(filters: ListSitesFilters = {}): Promise { + async listSites(filters: ListSitesFilters = {}, principal: Principal): Promise { + await assertSitesPrincipal(principal); const config = await this.getConfig(); this.assertEnabled(config); - const user = filters.user?.trim().toLowerCase(); const { page, limit } = this.normalizePagination(filters); - + const view = filters.view ?? 'all'; + if (!['all', 'mine', 'public'].includes(view) || filters.user) { + throw new SitesServiceError('Use view=mine, public, or all; email filters are not supported.', 400); + } + const owner = + principal.kind === 'service_key' + ? { ownerKind: 'service_key', creatorTokenId: principal.tokenId } + : { ownerKind: 'user', ownerIssuer: principal.issuer, ownerSubject: principal.userId }; const query = this.db.models.Site.query().whereNull('deletedAt'); - if (user) { - query.whereRaw('(lower("createdBy") = ? or lower("updatedBy") = ?)', [user, user]); + if (view === 'mine') query.where(owner); + else if (view === 'public') query.where('visibility', 'public'); + else query.where((q) => q.where('visibility', 'public').orWhere(owner)); + const search = filters.q?.trim(); + if (search) { + if (search.length > 200) throw new SitesServiceError('Search is too long.', 400); + // Literal substring matching: user input never controls SQL wildcards. + query.whereRaw('(strpos(lower("name"), lower(?)) > 0 or strpos(lower("siteId"), lower(?)) > 0)', [ + search, + search, + ]); } - - const result = await query.orderBy('updatedAt', 'desc').page(page - 1, limit); + const result = await query + .orderBy('updatedAt', 'desc') + .orderBy('siteId', 'asc') + .page(page - 1, limit); return { - sites: (result.results as Site[]).map((site) => this.serialize(site, config)), - pagination: { - current: page, - total: Math.max(Math.ceil(result.total / limit), 1), - items: result.total, - limit, - }, + sites: (result.results as Site[]).map((site) => this.serialize(site, config, principal)), + pagination: { current: page, total: Math.max(Math.ceil(result.total / limit), 1), items: result.total, limit }, }; } - async getSite(siteId: string): Promise { + async getSite(siteId: string, principal: Principal): Promise { + await assertSitesPrincipal(principal); const config = await this.getConfig(); this.assertEnabled(config); - - const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as unknown as - | Site - | undefined; - if (!site) { + const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as Site | undefined; + if (!site || (site.visibility !== 'public' && !isSiteOwner(site, principal))) throw new SitesServiceError('Site not found.', 404); - } - - return this.serialize(site, config); + return this.serialize(site, config, principal); } - private async getActiveSite(siteId: string): Promise<{ site: Site; config: ResolvedSitesConfig }> { + private async getActiveSite(siteId: string, trx?: Transaction): Promise<{ site: Site; config: ResolvedSitesConfig }> { const config = await this.getConfig(); this.assertEnabled(config); - - const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as unknown as - | Site - | undefined; - if (!site || site.status !== 'active' || !site.activeVersionId) { - throw new SitesServiceError('Site not found.', 404); - } - - if (config.ttl.enabled && site.expiresAt && new Date(site.expiresAt).getTime() <= Date.now()) { + const query = this.db.models.Site.query(trx).findOne({ siteId }).whereNull('deletedAt'); + if (trx) query.forUpdate(); + const site = (await query) as Site | undefined; + if ( + !site || + site.status !== 'active' || + !site.activeVersionId || + (site.expiresAt && new Date(site.expiresAt).getTime() <= Date.now()) + ) { throw new SitesServiceError('Site not found.', 404); } - return { site, config }; } async replaceSiteContent(siteId: string, input: CreateOrReplaceSiteInput): Promise { + await assertSitesPrincipal(input.principal, 'write'); const { site, config } = await this.getActiveSite(siteId); + assertSiteOwner(site, input.principal); + this.checkRevision(site, input.expectedAccessRevision, input.expectedContentRevision); + const expectedAccess = input.expectedAccessRevision ?? site.accessRevision; + const expectedContent = input.expectedContentRevision ?? site.contentRevision; const upload = this.validateUpload(input, config); - let previousVersions: SiteVersion[] = []; - const updated = await this.withUploadRollback(config, (uploadedStoragePrefixes) => - this.db.models.Site.transact(async (trx) => { + const updated = await this.withUploadRollback(config, async (prefixes) => { + const version = await this.stageVersion(siteId, upload, config, prefixes); + await assertSitesPrincipal(input.principal, 'write'); + return this.db.models.Site.transact(async (trx) => { + const { site: current } = await this.getActiveSite(siteId, trx); + await assertSitesPrincipal(input.principal, 'write', trx); + assertSiteOwner(current, input.principal); + // Even old clients cannot overwrite a concurrent mutation after a long upload. + this.checkRevision(current, expectedAccess, expectedContent); previousVersions = (await this.db.models.SiteVersion.query(trx) .where({ siteId }) - .whereNull('deletedAt')) as unknown as SiteVersion[]; - const version = await this.createVersion(siteId, upload, config, uploadedStoragePrefixes, trx); - const patched = (await site.$query(trx).patchAndFetch({ + .whereNull('deletedAt')) as SiteVersion[]; + await this.db.models.SiteVersion.query(trx).insert(version); + await this.audit(trx, 'content_replaced', siteId, input.principal, { + accessRevision: current.accessRevision + 1, + contentRevision: current.contentRevision + 1, + }); + return current.$query(trx).patchAndFetch({ activeVersionId: version.versionId, fileCount: upload.fileCount, sizeBytes: upload.sizeBytes, - updatedBy: input.user?.email || site.updatedBy || null, - })) as Site; - - return patched; - }) - ); - + // Publishing/deleting must conflict if the content changed after confirmation was prepared. + accessRevision: current.accessRevision + 1, + contentRevision: current.contentRevision + 1, + updatedBy: input.principal.identity?.email || null, + }) as unknown as Promise; + }); + }); await this.cleanupSupersededVersions(config, siteId, previousVersions); + return this.serialize(updated, config, input.principal); + } - return this.serialize(updated, config); + async extendSite(siteId: string, principal: Principal, expectedAccessRevision?: number): Promise { + await assertSitesPrincipal(principal, 'write'); + const result = await this.db.models.Site.transact(async (trx) => { + const { site, config } = await this.getActiveSite(siteId, trx); + await assertSitesPrincipal(principal, 'write', trx); + assertSiteOwner(site, principal); + this.checkRevision(site, expectedAccessRevision); + if (!config.ttl.enabled) throw new SitesServiceError('TTL is disabled for hosted sites.', 400); + const base = site.expiresAt ? Math.max(new Date(site.expiresAt).getTime(), Date.now()) : Date.now(); + await this.audit(trx, 'extended', siteId, principal, { accessRevision: site.accessRevision + 1 }); + const updated = await site.$query(trx).patchAndFetch({ + expiresAt: new Date(base + config.ttl.extensionDays * DAY_MS).toISOString(), + accessRevision: site.accessRevision + 1, + }); + return this.serialize(updated as Site, config, principal); + }); + return result; } - async extendSite(siteId: string): Promise { - const { site, config } = await this.getActiveSite(siteId); - if (!config.ttl.enabled) { - throw new SitesServiceError('TTL is disabled for hosted sites.', 400); + async setVisibility( + siteId: string, + visibility: 'private' | 'public', + principal: Principal, + expectedAccessRevision: number + ): Promise { + await assertSitesPrincipal(principal, 'write'); + if ( + !['private', 'public'].includes(visibility) || + !Number.isSafeInteger(expectedAccessRevision) || + expectedAccessRevision < 1 + ) { + throw new SitesServiceError('Visibility and expectedAccessRevision are required.', 400); } - - const base = site.expiresAt ? Math.max(new Date(site.expiresAt).getTime(), Date.now()) : Date.now(); - const expiresAt = new Date(base + config.ttl.extensionDays * DAY_MS).toISOString(); - const updated = (await site.$query().patchAndFetch({ expiresAt })) as Site; - return this.serialize(updated, config); + if (visibility === 'private') this.assertPrivateReady(await this.getConfig()); + return this.db.models.Site.transact(async (trx) => { + const { site, config } = await this.getActiveSite(siteId, trx); + await assertSitesPrincipal(principal, 'write', trx); + assertSiteOwner(site, principal); + if (site.ownerKind !== 'user') throw new SitesServiceError('Only human-owned sites can change visibility.', 403); + this.checkRevision(site, expectedAccessRevision); + if (site.visibility === visibility) return this.serialize(site, config, principal); + await this.audit(trx, 'visibility_changed', siteId, principal, { + from: site.visibility, + to: visibility, + accessRevision: site.accessRevision + 1, + }); + const updated = await site.$query(trx).patchAndFetch({ + visibility, + accessRevision: site.accessRevision + 1, + servingGeneration: visibility === 'private' ? createVersionId() : site.servingGeneration, + }); + return this.serialize(updated as Site, config, principal); + }); } - async deleteSite(siteId: string): Promise { + async deleteSite(siteId: string, principal: Principal, expectedAccessRevision?: number): Promise { + await assertSitesPrincipal(principal, 'write'); const config = await this.getConfig(); this.assertEnabled(config); - - const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as unknown as - | Site - | undefined; - if (!site) { - throw new SitesServiceError('Site not found.', 404); - } - - const versions = (await this.db.models.SiteVersion.query().where({ siteId })) as unknown as SiteVersion[]; - await Promise.all(versions.map((version) => new SitesStorage(config).deletePrefix(version.storagePrefix))); - - const deleted = (await this.db.models.Site.transact(async (trx) => { - const timestamp = new Date().toISOString(); - const patched = (await site.$query(trx).patchAndFetch({ + const deleted = await this.db.models.Site.transact(async (trx) => { + const site = (await this.db.models.Site.query(trx).findOne({ siteId }).whereNull('deletedAt').forUpdate()) as + | Site + | undefined; + if (!site) throw new SitesServiceError('Site not found.', 404); + await assertSitesPrincipal(principal, 'write', trx); + assertSiteOwner(site, principal); + this.checkRevision(site, expectedAccessRevision); + await this.audit(trx, 'deleted', siteId, principal, { accessRevision: site.accessRevision + 1 }); + return site.$query(trx).patchAndFetch({ status: 'deleted', - deletedAt: timestamp, - })) as Site; - await this.db.models.SiteVersion.query(trx) - .where({ siteId }) - .whereNull('deletedAt') - .patch({ deletedAt: timestamp }); - return patched; - })) as Site; + deletedAt: new Date().toISOString(), + accessRevision: site.accessRevision + 1, + }) as unknown as Promise; + }); + // Authorization is removed before storage. Failed cleanup is retried by the job. + const versions = (await this.db.models.SiteVersion.query() + .where({ siteId }) + .whereNull('deletedAt')) as SiteVersion[]; + await this.cleanupSupersededVersions(config, siteId, versions); + return this.serialize(deleted, config, principal); + } - return this.serialize(deleted, config); + /** Anonymous stable-link resolver exposes only a currently public content URL. */ + async resolvePublicSiteUrl(siteId: string): Promise { + try { + const { site, config } = await this.getActiveSite(siteId); + return site.visibility === 'public' ? buildSiteUrl(siteId, config, site.servingGeneration) : null; + } catch (error) { + if (error instanceof SitesServiceError && error.statusCode === 404) return null; + throw error; + } } - async getGatewayObject(hostHeader: string | undefined, pathname: string): Promise { + async getGatewayLocator(hostHeader: string | undefined): Promise> { const config = await this.getConfig(); - this.assertEnabled(config); + const locator = parseSiteHost(hostHeader, config); + if (!config.enabled || !locator) throw new SitesServiceError('Site not found.', 404); + const expected = new URL(buildSiteUrl(locator.siteId, config, locator.generation)); + const requested = new URL(`${expected.protocol}//${hostHeader}`); + if (requested.host.toLowerCase() !== expected.host.toLowerCase()) + throw new SitesServiceError('Site not found.', 404); + this.assertPrivateReady(config); + return { siteId: locator.siteId, servingGeneration: locator.generation }; + } + async getGatewaySite(hostHeader: string | undefined): Promise<{ site: Site; config: ResolvedSitesConfig }> { + const config = await this.getConfig(); const siteId = parseSiteIdFromHost(hostHeader, config); - if (!siteId) { + if (!siteId) throw new SitesServiceError('Site not found.', 404); + const result = await this.getActiveSite(siteId); + const expectedHost = new URL(buildSiteUrl(siteId, result.config, result.site.servingGeneration)).host.toLowerCase(); + let requestedHost: string; + try { + requestedHost = new URL( + `${new URL(buildSiteUrl(siteId, result.config, result.site.servingGeneration)).protocol}//${hostHeader}` + ).host.toLowerCase(); + } catch { throw new SitesServiceError('Site not found.', 404); } + if (requestedHost !== expectedHost) throw new SitesServiceError('Site not found.', 404); + if (result.site.visibility === 'private') this.assertPrivateReady(result.config); + return result; + } - const { site } = await this.getActiveSite(siteId); + async getGatewayObject( + hostHeader: string | undefined, + pathname: string, + authorize?: (site: Site) => Promise + ): Promise { + const { site, config } = await this.getGatewaySite(hostHeader); + const siteId = site.siteId; + if (site.visibility === 'private') { + if (!authorize) throw new SitesServiceError('Site not found.', 404); + await authorize(site); + } const version = (await this.db.models.SiteVersion.query().findOne({ siteId, versionId: site.activeVersionId, @@ -450,51 +676,43 @@ export default class SitesService extends Service { async matchesGatewayHost(hostHeader: string | undefined): Promise { const config = await this.getConfig(); - if (!config.enabled) { - return false; - } - return Boolean(parseSiteIdFromHost(hostHeader, config)); + const host = hostHeader?.split(':')[0].toLowerCase(); + return Boolean(host && host.endsWith(`.${config.domain.toLowerCase()}`)); } async cleanupExpiredSites(): Promise<{ expired: number; cleaned: number; errors: number }> { const config = await this.getConfig(); - if (!config.enabled || !config.ttl.enabled || !config.cleanup.enabled) { - return { expired: 0, cleaned: 0, errors: 0 }; - } - - const expiredSites = (await this.db.models.Site.query() + if (!config.enabled || !config.cleanup.enabled) return { expired: 0, cleaned: 0, errors: 0 }; + const now = new Date().toISOString(); + // Atomic predicate cannot expire an extension that won the row lock first. + const expired = await this.db.models.Site.query() .whereNull('deletedAt') .where('status', 'active') .whereNotNull('expiresAt') - .where('expiresAt', '<=', new Date().toISOString()) - .limit(100)) as unknown as Site[]; - + .where('expiresAt', '<=', now) + .patch({ status: 'expired', deletedAt: now }); + const pending = (await this.db.models.SiteVersion.query() + .whereNull('site_versions.deletedAt') + .join('sites', 'sites.siteId', 'site_versions.siteId') + .whereRaw('(sites.status IN (?, ?) OR sites."activeVersionId" IS DISTINCT FROM site_versions."versionId")', [ + 'deleted', + 'expired', + ]) + .select('site_versions.*') + .limit(100)) as SiteVersion[]; let cleaned = 0; let errors = 0; - - for (const site of expiredSites) { + for (const version of pending) { try { - const versions = (await this.db.models.SiteVersion.query().where({ - siteId: site.siteId, - })) as unknown as SiteVersion[]; - await Promise.all(versions.map((version) => new SitesStorage(config).deletePrefix(version.storagePrefix))); - - const timestamp = new Date().toISOString(); - await this.db.models.Site.transact(async (trx) => { - await site.$query(trx).patch({ status: 'expired', deletedAt: timestamp }); - await this.db.models.SiteVersion.query(trx) - .where({ siteId: site.siteId }) - .whereNull('deletedAt') - .patch({ deletedAt: timestamp }); - }); + await new SitesStorage(config).deletePrefix(version.storagePrefix); + await version.$query().patch({ deletedAt: now }); cleaned++; } catch (error) { errors++; - getLogger().error({ error, siteId: site.siteId }, 'Sites: cleanup failed'); + getLogger().warn({ error, siteId: version.siteId }, 'Sites: terminal storage cleanup deferred'); } } - - return { expired: expiredSites.length, cleaned, errors }; + return { expired, cleaned, errors }; } processSitesCleanupQueue = async (_job: Job) => { @@ -507,7 +725,7 @@ export default class SitesService extends Service { async setupSitesCleanupJob() { const config = await this.getConfig(); - if (!config.enabled || !config.ttl.enabled || !config.cleanup.enabled) { + if (!config.enabled || !config.cleanup.enabled) { getLogger().debug('Sites: cleanup disabled'); return; } diff --git a/src/shared/__tests__/openApiSecurity.test.ts b/src/shared/__tests__/openApiSecurity.test.ts index f4add521..01f2d861 100644 --- a/src/shared/__tests__/openApiSecurity.test.ts +++ b/src/shared/__tests__/openApiSecurity.test.ts @@ -61,21 +61,47 @@ function isBearerAuthOrLifecycleKey(security: unknown): boolean { ); } -function hasAndCombinedSchemes(security: unknown): boolean { - return requirementObjects(security).some( - (req) => ('KeycloakBearer' in req || 'BearerAuth' in req) && 'LifecycleApiKey' in req - ); +// A public wrapper removes platform authentication, not any authentication performed by the handler. +// Each listed bridge requirement is mandatory in the SAME object as any allowed principal scheme (AND). +const HANDLER_SECURITY: Record> = { + 'POST /api/v2/sites/browser/bind': { SitesBrowserBridge: [] }, + 'POST /api/v2/sites/browser/mint': { SitesBrowserBridge: [] }, + 'POST /api/v2/sites/browser/revoke': { SitesBrowserBridge: [] }, +}; + +function principalSecurity(entry: Extract) { + const kinds = entry.kinds ?? ['user', 'personal_key', 'service_key']; + const handlerSecurity = HANDLER_SECURITY[keyOf(entry)] ?? {}; + return [ + ...(kinds.includes('user') ? [{ BearerAuth: [], ...handlerSecurity }] : []), + ...(kinds.some((kind) => kind === 'personal_key' || kind === 'service_key') + ? [{ LifecycleApiKey: [], ...handlerSecurity }] + : []), + ]; } describe('OpenAPI v2 security contract', () => { it('preserves BearerAuth while declaring descriptive session and Lifecycle API-key schemes', () => { const schemes = spec.components.securitySchemes; - expect(Object.keys(schemes).sort()).toEqual(['BearerAuth', 'KeycloakBearer', 'LifecycleApiKey']); + expect(Object.keys(schemes).sort()).toEqual([ + 'BearerAuth', + 'KeycloakBearer', + 'LifecycleApiKey', + 'SitesBrowserBridge', + ]); expect(schemes.BearerAuth).toMatchObject({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }); expect(schemes.KeycloakBearer).toMatchObject({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }); expect(schemes.LifecycleApiKey).toMatchObject({ type: 'http', scheme: 'bearer' }); expect(schemes.LifecycleApiKey.description).toMatch(/lfc_pat_/); expect(schemes.LifecycleApiKey.description).toMatch(/lfc_svc_/); + expect(schemes.SitesBrowserBridge).toMatchObject({ + type: 'apiKey', + in: 'header', + name: 'x-lfc-sites-bridge', + }); + expect(schemes.SitesBrowserBridge.description).toContain('single-use'); + expect(schemes.SitesBrowserBridge.description).toContain('SITES_BROWSER_BRIDGE_SECRET'); + expect(schemes.SitesBrowserBridge.description).not.toContain('SITES_UI_BRIDGE_SECRET'); }); it('defaults globally to the Keycloak session only', () => { @@ -88,10 +114,9 @@ describe('OpenAPI v2 security contract', () => { expect(spec.components.schemas.ApiTokenGrantableScope.enum).not.toContain('env:admin'); }); - it('offers both schemes (OR, never AND) on every principal operation', () => { + it('matches allowed principal kinds and requires handler credentials on every principal operation', () => { const missing: string[] = []; - const notOr: string[] = []; - const andCombined: string[] = []; + const mismatched: string[] = []; for (const entry of V2_ROUTE_POLICY_MANIFEST) { if (entry.policy !== 'principal') continue; @@ -100,12 +125,17 @@ describe('OpenAPI v2 security contract', () => { missing.push(keyOf(entry)); continue; } - if (hasAndCombinedSchemes(op.security)) andCombined.push(keyOf(entry)); - if (!isBearerAuthOrLifecycleKey(op.security)) notOr.push(`${keyOf(entry)} -> ${JSON.stringify(op.security)}`); + // The array contains credential alternatives (OR); each object contains mandatory credentials (AND). + // Compare complete objects, including scopes, so an extra anonymous or signature-only alternative fails. + try { + expect(op.security).toEqual(principalSecurity(entry)); + } catch { + mismatched.push(`${keyOf(entry)} -> ${JSON.stringify(op.security)}`); + } } // Every principal route must be documented: a future one that isn't fails here by name. - expect({ missing, notOr, andCombined }).toEqual({ missing: [], notOr: [], andCombined: [] }); + expect({ missing, mismatched }).toEqual({ missing: [], mismatched: [] }); }); it('never offers LifecycleApiKey on a session operation', () => { @@ -119,10 +149,55 @@ describe('OpenAPI v2 security contract', () => { expect(leaked).toEqual([]); }); - it('exposes the OAuth callback with no platform security', () => { - const entry = V2_ROUTE_POLICY_MANIFEST.find((e) => e.policy === 'public'); - expect(entry).toBeDefined(); - expect(operationFor(entry as V2RoutePolicyEntry).security).toEqual([]); + it('covers every public operation, including signature-only logout and anonymous public-link resolution', () => { + for (const entry of V2_ROUTE_POLICY_MANIFEST) { + if (entry.policy !== 'public') continue; + const extra = HANDLER_SECURITY[keyOf(entry)]; + expect({ operation: keyOf(entry), security: operationFor(entry)?.security }).toEqual({ + operation: keyOf(entry), + security: extra ? [extra] : [], + }); + } + }); + + it('documents OAuth AND the bridge signature for bind/mint, but only the signature for revoke', () => { + for (const action of ['bind', 'mint']) { + const route = `/api/v2/sites/browser/${action}`; + expect(V2_ROUTE_POLICY_MANIFEST.find((entry) => entry.route === route)).toMatchObject({ + method: 'POST', + policy: 'principal', + kinds: ['user'], + }); + expect(spec.paths[route].post.security).toEqual([{ BearerAuth: [], SitesBrowserBridge: [] }]); + } + expect(spec.paths['/api/v2/sites/browser/revoke'].post.security).toEqual([{ SitesBrowserBridge: [] }]); + expect(spec.paths['/api/v2/sites/browser/open/{siteId}'].get.security).toEqual([]); + for (const key of Object.keys(HANDLER_SECURITY)) { + expect(V2_ROUTE_POLICY_MANIFEST.some((entry) => keyOf(entry) === key)).toBe(true); + } + }); + + it('documents concrete signed request bodies and success results without exposing OAuth credentials', () => { + const requests = { + bind: 'SitesBrowserBindRequest', + mint: 'SitesBrowserMintRequest', + revoke: 'SitesBrowserLoginRequest', + }; + const results = { bind: ['bound'], mint: ['consumeUrl', 'ticket'], revoke: ['revoked'] }; + for (const action of ['bind', 'mint', 'revoke'] as const) { + const op = spec.paths[`/api/v2/sites/browser/${action}`].post; + expect(op.requestBody.required).toBe(true); + expect(op.requestBody.content['application/json'].schema).toEqual({ + $ref: `#/components/schemas/${requests[action]}`, + }); + const response = op.responses['200'].content['application/json'].schema; + expect(response.required).toEqual(['data']); + expect(Object.keys(response.properties.data.properties).sort()).toEqual(results[action]); + expect([...response.properties.data.required].sort()).toEqual(results[action]); + } + expect(spec.components.schemas.SitesBrowserLoginRequest.required).toEqual(['loginId', 'loginExpiresAt']); + expect(spec.components.schemas.SitesBrowserBindRequest.allOf[1].properties.createLogin.default).toBe(false); + expect(spec.components.schemas.SitesBrowserMintRequest.allOf[1].required).toEqual(['state', 'siteId']); }); it('documents auth/context with the OR form under operationId getAuthContext', () => { diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index 05d2b78c..f923acc6 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -194,16 +194,8 @@ describe('OpenAPI v2 environment contract', () => { describe('OpenAPI v2 sites contract', () => { it('documents sites list filters and pagination', () => { expect(getOperation('/api/v2/sites', 'get')?.parameters).toEqual([ - { - name: 'user', - in: 'query', - required: false, - description: 'Filters to sites created or last updated by the supplied user email.', - schema: { - type: 'string', - }, - example: 'user@example.com', - }, + { name: 'view', in: 'query', schema: { type: 'string', enum: ['mine', 'public', 'all'] } }, + { name: 'q', in: 'query', schema: { type: 'string', maxLength: 200 } }, { name: 'page', in: 'query', diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index db16337c..6277aa10 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -149,8 +149,8 @@ export const openApiSpecificationForV2Api: OAS3Options = { description: 'JWT token issued by a Keycloak identity provider. ' + 'Pass it in the Authorization header as "Bearer ". ' + - 'Authentication is only enforced when the ENABLE_AUTH environment variable is set to "true". ' + - 'When disabled, all requests are allowed without a token.', + 'Operation-specific authorization still applies; Sites browser bridge operations require ' + + 'their documented credentials even when general authentication is disabled.', }, KeycloakBearer: { type: 'http', @@ -167,8 +167,70 @@ export const openApiSpecificationForV2Api: OAS3Options = { 'service keys "lfc_svc_", and legacy keys "lfc_". Authority is the owner authority ' + 'intersected with the key scopes and repository constraint. Accepted only on programmatic routes.', }, + SitesBrowserBridge: { + type: 'apiKey', + in: 'header', + name: 'x-lfc-sites-bridge', + description: + 'Internal UI-to-core request signature, not a Lifecycle API key or the raw shared secret. ' + + 'The header is .<43-character-base64url-nonce>.. ' + + 'Sign the newline-separated values sites-v1, action (bind, mint, or revoke), timestamp, nonce, ' + + 'and the hex SHA-256 of the exact request body using SITES_BROWSER_BRIDGE_SECRET. ' + + 'Timestamps allow 30 seconds of skew; nonces are single-use. Keep the signing secret server-side.', + }, }, schemas: { + SitesBrowserLoginRequest: { + type: 'object', + required: ['loginId', 'loginExpiresAt'], + properties: { + loginId: { + type: 'string', + pattern: '^[A-Za-z0-9_-]{43}$', + description: 'Server-generated application-login nonce, distinct from the identity provider SSO session.', + }, + loginExpiresAt: { + type: 'integer', + description: + 'Absolute application-login expiry in Unix seconds. Binding and minting require a future expiry; ' + + 'revocation also accepts an expired login. The server limits future expiry to its login lifetime.', + }, + }, + }, + SitesBrowserBindRequest: { + allOf: [ + { $ref: '#/components/schemas/SitesBrowserLoginRequest' }, + { + type: 'object', + properties: { + createLogin: { + type: 'boolean', + default: false, + description: + 'True only for an explicit OAuth sign-in with a newly generated login nonce. ' + + 'Refresh requests must omit this or send false and match an existing unrevoked login.', + }, + }, + }, + ], + }, + SitesBrowserMintRequest: { + allOf: [ + { $ref: '#/components/schemas/SitesBrowserLoginRequest' }, + { + type: 'object', + required: ['state', 'siteId'], + properties: { + state: { + type: 'string', + pattern: '^[A-Za-z0-9_-]{43}$', + description: 'Opaque, unexpired bootstrap challenge issued by the content gateway.', + }, + siteId: { type: 'string', description: 'Site identifier matching the bootstrap challenge.' }, + }, + }, + ], + }, // =================================================================== // Core Reusable Schemas // =================================================================== @@ -236,6 +298,27 @@ export const openApiSpecificationForV2Api: OAS3Options = { id: { type: 'string', example: 'abc123def4' }, name: { type: 'string', example: 'sample-site' }, url: { type: 'string', format: 'uri', example: 'http://site-abc123def4.localhost:5002' }, + visibility: { type: 'string', enum: ['private', 'public'] }, + contentUrl: { type: 'string', format: 'uri' }, + openUrl: { type: 'string', format: 'uri' }, + accessRevision: { + type: 'integer', + minimum: 1, + description: + 'Site mutation revision; advances on content replacement, visibility, expiry extension and deletion.', + }, + contentRevision: { type: 'integer', minimum: 1 }, + currentRole: { type: 'string', enum: ['owner'], nullable: true }, + permissions: { + type: 'object', + properties: { + canView: { type: 'boolean' }, + canEdit: { type: 'boolean' }, + canDelete: { type: 'boolean' }, + canChangeVisibility: { type: 'boolean' }, + }, + required: ['canView', 'canEdit', 'canDelete', 'canChangeVisibility'], + }, status: { type: 'string', enum: ['active', 'deleted', 'expired'] }, createdAt: { type: 'string', format: 'date-time', nullable: true }, updatedAt: { type: 'string', format: 'date-time', nullable: true }, @@ -254,6 +337,13 @@ export const openApiSpecificationForV2Api: OAS3Options = { }, }, required: [ + 'visibility', + 'contentUrl', + 'openUrl', + 'accessRevision', + 'contentRevision', + 'currentRole', + 'permissions', 'id', 'name', 'url', @@ -271,6 +361,14 @@ export const openApiSpecificationForV2Api: OAS3Options = { SiteUploadRequest: { type: 'object', properties: { + visibility: { + type: 'string', + enum: ['private', 'public'], + description: + 'Creation only; omitted defaults private for users. Service-key creation always remains public.', + }, + expectedAccessRevision: { type: 'integer', minimum: 1, maximum: 2147483647 }, + expectedContentRevision: { type: 'integer', minimum: 1, maximum: 2147483647 }, file: { type: 'string', format: 'binary', @@ -1003,6 +1101,37 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + SitesCapabilities: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + canCreate: { type: 'boolean' }, + defaultVisibility: { type: 'string', enum: ['private', 'public'] }, + allowedVisibilities: { type: 'array', items: { type: 'string', enum: ['private', 'public'] } }, + upload: { + type: 'object', + properties: { + maxUploadBytes: { type: 'integer', minimum: 1 }, + maxExtractedBytes: { type: 'integer', minimum: 1 }, + maxFiles: { type: 'integer', minimum: 1 }, + allowedExtensions: { type: 'array', items: { type: 'string' } }, + }, + required: ['maxUploadBytes', 'maxExtractedBytes', 'maxFiles', 'allowedExtensions'], + }, + }, + required: ['enabled', 'canCreate', 'defaultVisibility', 'allowedVisibilities', 'upload'], + }, + SitesCapabilitiesSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { data: { $ref: '#/components/schemas/SitesCapabilities' } }, + required: ['data'], + }, + ], + }, + SiteSuccessResponse: { allOf: [ { $ref: '#/components/schemas/SuccessApiResponse' }, diff --git a/ws-server.ts b/ws-server.ts index eb5a5cd1..7520c0e1 100644 --- a/ws-server.ts +++ b/ws-server.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +// Initialize Next's Node globals before server helpers import next/server or next/headers. +import 'next/dist/server/node-environment'; import 'module-alias/register'; import { join } from 'path'; import moduleAlias from 'module-alias'; @@ -40,6 +42,7 @@ import { handleMcpHttpRequest as mcpHttpRequestHandler } from './src/server/mcp/ import { createLifecycleMcpRegistry } from './src/server/mcp/tools'; import { streamK8sLogs, AbortHandle } from './src/server/lib/k8sStreamer'; import SitesService from './src/server/services/sites'; +import { handleSitesRequest } from './src/server/lib/sites/gateway'; import { serializeSocketHttpResponse, EDITOR_PROXY_TIMEOUT_MS, @@ -1230,7 +1233,7 @@ async function handleChatPreviewHttp( } } -async function handleSitesGatewayHttp(req: IncomingMessage, res: ServerResponse, pathname: string) { +async function handleSitesGatewayHttp(req: IncomingMessage, res: ServerResponse) { if (LIFECYCLE_MODE !== 'gateway' && LIFECYCLE_MODE !== 'all') { return false; } @@ -1240,45 +1243,8 @@ async function handleSitesGatewayHttp(req: IncomingMessage, res: ServerResponse, return false; } - if (!req.method || !['GET', 'HEAD'].includes(req.method.toUpperCase())) { - res.statusCode = 404; - res.end('not found'); - return true; - } - - try { - const object = await service.getGatewayObject(req.headers.host, pathname); - - res.statusCode = object.statusCode; - res.setHeader('Content-Type', object.contentType); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Cache-Control', 'private, max-age=60'); - if (object.contentLength !== undefined) { - res.setHeader('Content-Length', object.contentLength.toString()); - } - - if (req.method.toUpperCase() === 'HEAD') { - (object.body as NodeJS.ReadableStream & { destroy?: () => void }).destroy?.(); - res.end(); - return true; - } - - object.body.on('error', (error) => { - logger.error({ error, path: pathname }, 'SitesGateway: stream failed'); - if (!res.headersSent) { - res.statusCode = 502; - } - res.end(); - }); - object.body.pipe(res); - return true; - } catch (error: any) { - const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 500; - logger.warn({ error, path: pathname, statusCode }, 'SitesGateway: request failed'); - res.statusCode = statusCode === 404 ? 404 : 500; - res.end(statusCode === 404 ? 'not found' : 'internal server error'); - return true; - } + await handleSitesRequest(req, res, service); + return true; } app.prepare().then(() => { @@ -1306,7 +1272,7 @@ app.prepare().then(() => { res.end('Preview is unavailable'); return; } - if (parsedUrl.pathname && (await handleSitesGatewayHttp(req, res, parsedUrl.pathname))) { + if (parsedUrl.pathname && (await handleSitesGatewayHttp(req, res))) { return; } if (handleMcpHttpRequest && (await handleMcpHttpRequest(req, res, parsedUrl.pathname))) { From 932650d3b3158bdb806626b335dc6531a3094526 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Tue, 15 Sep 2026 17:15:51 -0700 Subject: [PATCH 02/12] refactor(sites): simplify private browser access --- src/app/api/v2/sites/browser/bind/route.ts | 60 --- .../api/v2/sites/browser/mint/route.test.ts | 139 +++++++ src/app/api/v2/sites/browser/mint/route.ts | 30 +- src/app/api/v2/sites/browser/revoke/route.ts | 56 --- .../__tests__/v2RoutePolicyManifest.test.ts | 1 - src/server/lib/principal.ts | 2 +- src/server/lib/sites/browserAuth.test.ts | 377 +++++------------- src/server/lib/sites/browserAuth.ts | 320 +++------------ src/server/lib/sites/gateway.test.ts | 33 +- src/server/lib/sites/gateway.ts | 26 +- src/server/lib/sites/oauthRevocation.test.ts | 106 ++--- src/server/lib/sites/policy.test.ts | 58 ++- src/server/lib/sites/policy.ts | 13 +- src/server/lib/v2RoutePolicyManifest.ts | 2 - src/server/lib/verifiedOAuthBearer.ts | 4 +- src/server/middlewares/auth.test.ts | 23 +- src/server/middlewares/auth.ts | 5 - src/server/middlewares/requestId.test.ts | 16 +- .../services/__tests__/sites.behavior.test.ts | 2 - src/server/services/__tests__/sites.test.ts | 2 - src/shared/__tests__/openApiSecurity.test.ts | 87 +--- src/shared/openApiSpec.ts | 65 +-- 22 files changed, 437 insertions(+), 990 deletions(-) delete mode 100644 src/app/api/v2/sites/browser/bind/route.ts create mode 100644 src/app/api/v2/sites/browser/mint/route.test.ts delete mode 100644 src/app/api/v2/sites/browser/revoke/route.ts diff --git a/src/app/api/v2/sites/browser/bind/route.ts b/src/app/api/v2/sites/browser/bind/route.ts deleted file mode 100644 index 17b2bb77..00000000 --- a/src/app/api/v2/sites/browser/bind/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; -import { getSitesBrowserAuth, readSitesBridgeBody, SitesBrowserError } from 'server/lib/sites/browserAuth'; - -// The normal middleware verifies this bearer. Binding requires the UI's signed -// server-to-server envelope as well, and cannot revive a revoked application login. -/** - * @openapi - * /api/v2/sites/browser/bind: - * post: - * operationId: bindSitesBrowserLogin - * summary: Bind a UI OAuth token to an application login - * description: Internal server-to-server operation for the Lifecycle UI. Requires a verified user OAuth bearer from the configured UI client AND the signed bind request header. Lifecycle API keys and browser cookies are not accepted. Explicit sign-in creates a fresh application login; refresh must match an existing unrevoked login. Binding does not revoke or create an identity-provider SSO session. - * tags: [Sites] - * security: - * - BearerAuth: [] - * SitesBrowserBridge: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/SitesBrowserBindRequest' - * responses: - * '200': - * description: Operation completed. Response must not be cached. - * content: - * application/json: - * schema: - * type: object - * required: [data] - * properties: - * data: - * type: object - * required: [bound] - * properties: - * bound: - * type: boolean - * enum: [true] - * '400': - * description: Invalid signed request body. - * '401': - * description: Missing, invalid, expired, or replayed credentials, or an invalid application login. - * '403': - * description: The authenticated principal is not permitted to use this operation. - * '413': - * description: Signed request body exceeds 4096 bytes. - * '500': - * description: Unexpected server or storage dependency failure; completion could not be confirmed. - * '503': - * description: Required authorization or storage dependency is unavailable; no access is granted. - */ -export const POST = createPrincipalApiHandler({ scope: 'sites:read', kinds: ['user'] }, async (request, principal) => { - if (!principal.issuer || !principal.userId || !principal.oauth) throw new SitesBrowserError(401); - const auth = getSitesBrowserAuth(); - const raw = await readSitesBridgeBody(request); - const body = await auth.verifyBridge('bind', raw, request.headers.get('x-lfc-sites-bridge')); - await auth.bind({ issuer: principal.issuer, subject: principal.userId, oauth: principal.oauth }, body); - return NextResponse.json({ data: { bound: true } }, { headers: { 'Cache-Control': 'no-store' } }); -}); diff --git a/src/app/api/v2/sites/browser/mint/route.test.ts b/src/app/api/v2/sites/browser/mint/route.test.ts new file mode 100644 index 00000000..3d777597 --- /dev/null +++ b/src/app/api/v2/sites/browser/mint/route.test.ts @@ -0,0 +1,139 @@ +import { NextRequest } from 'next/server'; +import type { Principal } from 'server/lib/principal'; +import { AppError } from 'server/lib/appError'; +import { SitesBrowserAuth, randomSiteToken } from 'server/lib/sites/browserAuth'; + +const mockResolvePrincipal = jest.fn(); +const mockStatus = jest.fn(); +const mockTokenStatus = jest.fn(); +const mockSite = jest.fn(); +const mockRedisRows = new Map(); +const mockRedis = { + get: async (key: string) => mockRedisRows.get(key) ?? null, + set: async (key: string, value: string) => { + mockRedisRows.set(key, value); + return 'OK'; + }, + eval: async () => 1, +}; +jest.mock('server/lib/principal', () => ({ resolvePrincipal: (...args: unknown[]) => mockResolvePrincipal(...args) })); +jest.mock('server/services/apiToken', () => ({ + scopeSatisfies: (scopes: string[], scope: string) => scopes.includes(scope), +})); +jest.mock('server/services/authRateLimit', () => ({ checkApiKeyRateLimit: async () => ({ allowed: true }) })); +jest.mock('server/services/authAudit', () => ({ recordAuthAuditEvent: jest.fn() })); +jest.mock('server/lib/logger', () => ({ getLogger: () => ({ error: jest.fn(), info: jest.fn() }) })); +jest.mock('server/models/ApiToken', () => ({ __esModule: true, default: {} })); +jest.mock('server/services/globalConfig', () => ({ __esModule: true, default: {} })); +jest.mock('server/lib/redisClient', () => ({ + __esModule: true, + default: { getInstance: () => ({ getRedis: () => mockRedis }) }, +})); +jest.mock('server/services/sites', () => ({ + __esModule: true, + default: jest.fn(() => ({ getGatewaySite: (...args: unknown[]) => mockSite(...args) })), +})); +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: (...args: unknown[]) => mockStatus(...args), + getOAuthTokenStatus: (...args: unknown[]) => mockTokenStatus(...args), +})); +import { rememberVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; +import { POST } from './route'; +const issuer = 'https://identity.example/realms/lifecycle'; +const host = 'site-abc123--g-abcdef012345.sites.example.net'; +const site = { + siteId: 'abc123', + ownerKind: 'user', + ownerIssuer: issuer, + ownerSubject: 'owner', + visibility: 'private', + servingGeneration: 'abcdef012345', + accessRevision: 1, +}; +let principal: Principal; +const originalEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = issuer; + process.env.SITES_PRIVATE_ENABLED = 'true'; + process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; + mockRedisRows.clear(); + principal = { + kind: 'user', + authMethod: 'session', + issuer, + userId: 'owner', + actor: 'owner', + roles: ['user'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + oauth: { + sessionId: 'sid', + tokenId: 'jti', + clientId: 'existing-ui', + expiresAt: Math.floor(Date.now() / 1000) + 300, + }, + }; + rememberVerifiedOAuthBearer(principal, 'verified-request-bearer'); + mockResolvePrincipal.mockReset().mockImplementation(async () => principal); + mockStatus.mockReset().mockResolvedValue('active'); + mockTokenStatus.mockReset().mockResolvedValue('active'); + mockSite.mockReset().mockResolvedValue({ site }); +}); +afterEach(() => { + process.env = { ...originalEnv }; +}); +async function mint(body?: Record) { + const challenge = await new SitesBrowserAuth(mockRedis as any).challenge(site, host, '/'); + return POST( + new NextRequest('https://api.example/api/v2/sites/browser/mint', { + method: 'POST', + headers: { authorization: 'Bearer verified-request-bearer', 'content-type': 'application/json' }, + body: JSON.stringify(body ?? { siteId: site.siteId, state: challenge.state }), + }) + ); +} +it('mints with existing JWT authentication and only siteId/state, without any bridge config', async () => { + const response = await mint(); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect((await response.json()).data).toEqual({ + ticket: expect.any(String), + consumeUrl: `https://${host}/_lfc-sites/consume`, + }); + expect(mockStatus).toHaveBeenCalledWith('owner'); + expect(mockTokenStatus).toHaveBeenCalledWith('verified-request-bearer'); +}); +it.each(['personal_key', 'service_key'] as const)('rejects %s at the real route wrapper', async (kind) => { + principal.kind = kind; + principal.authMethod = 'api_key'; + const response = await mint(); + expect(response.status).toBe(403); + expect(mockTokenStatus).not.toHaveBeenCalled(); + expect(mockSite).not.toHaveBeenCalled(); +}); +it('denies anonymous, unrelated owner, expired JWT and disabled account without issuing tickets', async () => { + mockResolvePrincipal.mockRejectedValueOnce( + new AppError({ httpStatus: 401, code: 'invalid_credential', message: 'Denied' }) + ); + expect((await mint()).status).toBe(401); + principal.userId = 'other'; + expect((await mint()).status).toBe(404); + principal.userId = 'owner'; + principal.oauth!.expiresAt = Math.floor(Date.now() / 1000); + expect((await mint()).status).toBe(401); + principal.oauth!.expiresAt += 300; + mockStatus.mockResolvedValue('disabled'); + expect((await mint()).status).toBe(403); + expect([...mockRedisRows.keys()].some((key) => key.includes(':ticket:'))).toBe(false); +}); +it('denies unknown or revoked incoming credentials and invented owner fields', async () => { + mockTokenStatus.mockResolvedValueOnce('unknown'); + expect((await mint()).status).toBe(503); + mockTokenStatus.mockResolvedValueOnce('revoked'); + expect((await mint()).status).toBe(401); + expect((await mint({ siteId: site.siteId, state: randomSiteToken(), ownerSubject: 'owner' })).status).toBe(400); +}); diff --git a/src/app/api/v2/sites/browser/mint/route.ts b/src/app/api/v2/sites/browser/mint/route.ts index a8934d26..23e420c9 100644 --- a/src/app/api/v2/sites/browser/mint/route.ts +++ b/src/app/api/v2/sites/browser/mint/route.ts @@ -1,10 +1,8 @@ import { NextResponse } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; -import { getUser } from 'server/lib/get-user'; -import { getVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; import { getSitesBrowserAuth, - readSitesBridgeBody, + readSitesMintBody, assertPrivateSitesReady, SitesBrowserError, } from 'server/lib/sites/browserAuth'; @@ -15,13 +13,12 @@ import SitesService from 'server/services/sites'; * @openapi * /api/v2/sites/browser/mint: * post: - * operationId: mintSitesBrowserLogin + * operationId: mintSitesBrowserTicket * summary: Mint a private-site browser bootstrap ticket - * description: Internal server-to-server operation for the Lifecycle UI. Requires a live owner OAuth bearer AND the signed mint request header, an existing bound application login, and a matching gateway challenge. Lifecycle API keys and browser cookies are not accepted. Returns a single-use ticket valid for at most 60 seconds for POST consumption at the supplied content-host URL; it is not an OAuth token. + * description: Requires a live owner user JWT and a matching gateway challenge. Lifecycle API keys and browser cookies are not accepted. Returns a single-use ticket valid for at most 60 seconds for POST consumption at the supplied content-host URL. The resulting Site-only grant expires at the earlier of JWT expiry and 300 seconds after mint. Renewal requires another authenticated mint. * tags: [Sites] * security: * - BearerAuth: [] - * SitesBrowserBridge: [] * requestBody: * required: true * content: @@ -47,15 +44,15 @@ import SitesService from 'server/services/sites'; * type: string * format: uri * '400': - * description: Invalid signed request body. + * description: Invalid request body. * '401': - * description: Missing, invalid, expired, or replayed credentials, or an invalid application login. + * description: Missing, invalid or expired credentials or gateway challenge. * '403': * description: The authenticated principal is not permitted to use this operation. * '404': * description: Site is unavailable to this owner. * '413': - * description: Signed request body exceeds 4096 bytes. + * description: Request body exceeds 4096 bytes. * '500': * description: Unexpected server or storage dependency failure; completion could not be confirmed. * '503': @@ -66,20 +63,17 @@ import SitesService from 'server/services/sites'; export const POST = createPrincipalApiHandler({ scope: 'sites:read', kinds: ['user'] }, async (request, principal) => { assertPrivateSitesReady(); await assertSitesPrincipal(principal); - const raw = await readSitesBridgeBody(request); + const body = await readSitesMintBody(request); const auth = getSitesBrowserAuth(); - const body = await auth.verifyBridge('mint', raw, request.headers.get('x-lfc-sites-bridge')); - const challenge = await auth.readChallenge(body.state || ''); - const { site } = await new SitesService().getGatewaySite(challenge.host); - const bearer = getVerifiedOAuthBearer(principal); - if (!principal.issuer || !principal.userId || !bearer) throw new SitesBrowserError(401); + if (!principal.issuer || !principal.userId || !principal.oauth) throw new SitesBrowserError(401); await auth.rateLimit(`mint:${principal.issuer}:${principal.userId}`); + const challenge = await auth.readChallenge(body.state); + const { site } = await new SitesService().getGatewaySite(challenge.host); const result = await auth.mint( site, - { issuer: principal.issuer, subject: principal.userId, oauth: principal.oauth }, + { issuer: principal.issuer, subject: principal.userId }, body, - Number(getUser(request)?.exp), - bearer + principal.oauth.expiresAt ); return NextResponse.json({ data: result }, { headers: { 'Cache-Control': 'no-store' } }); }); diff --git a/src/app/api/v2/sites/browser/revoke/route.ts b/src/app/api/v2/sites/browser/revoke/route.ts deleted file mode 100644 index f5bf9dd5..00000000 --- a/src/app/api/v2/sites/browser/revoke/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createPublicApiHandler } from 'server/lib/createApiHandler'; -import { getSitesBrowserAuth, readSitesBridgeBody } from 'server/lib/sites/browserAuth'; - -// This exact route uses the dedicated server-to-server signature, including when the -// user's OAuth token has expired. It never accepts arbitrary x-user or browser cookies. -/** - * @openapi - * /api/v2/sites/browser/revoke: - * post: - * operationId: revokeSitesBrowserLogin - * summary: Revoke a UI application login - * description: Internal server-to-server logout operation for the Lifecycle UI. Requires the signed revoke request header only; no OAuth bearer or browser cookie is required, so logout remains available after OAuth expiry. Revokes the application login and its Sites access without ending the separate identity-provider SSO session. A Lifecycle API key cannot authorize this operation. - * tags: [Sites] - * security: - * - SitesBrowserBridge: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/SitesBrowserLoginRequest' - * responses: - * '200': - * description: Operation completed. Response must not be cached. - * content: - * application/json: - * schema: - * type: object - * required: [data] - * properties: - * data: - * type: object - * required: [revoked] - * properties: - * revoked: - * type: boolean - * enum: [true] - * '400': - * description: Invalid signed request body. - * '401': - * description: Missing, invalid, expired, or replayed credentials, or an invalid application login. - * '413': - * description: Signed request body exceeds 4096 bytes. - * '500': - * description: Unexpected server or storage dependency failure; completion could not be confirmed. - * '503': - * description: Required authorization or storage dependency is unavailable; no access is granted. - */ -export const POST = createPublicApiHandler(async (request) => { - const raw = await readSitesBridgeBody(request); - const auth = getSitesBrowserAuth(); - const body = await auth.verifyBridge('revoke', raw, request.headers.get('x-lfc-sites-bridge')); - await auth.revoke(body); - return NextResponse.json({ data: { revoked: true } }, { headers: { 'Cache-Control': 'no-store' } }); -}); diff --git a/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts b/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts index 8ae0eb66..07cc252d 100644 --- a/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts +++ b/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts @@ -47,7 +47,6 @@ describe('v2 route policy manifest', () => { const publicKeys = V2_ROUTE_POLICY_MANIFEST.filter((e) => e.policy === 'public').map(keyOf); expect(publicKeys).toEqual([ 'GET /api/v2/ai/agent/mcp-connections/{slug}/oauth/callback', - 'POST /api/v2/sites/browser/revoke', 'GET /api/v2/sites/browser/open/{siteId}', ]); }); diff --git a/src/server/lib/principal.ts b/src/server/lib/principal.ts index 3b6cfa66..11f9f9cc 100644 --- a/src/server/lib/principal.ts +++ b/src/server/lib/principal.ts @@ -38,7 +38,7 @@ export interface Principal { /** Verified OAuth token/session identity; absent on API keys and legacy claims. */ oauth?: OAuthCredential; kind: PrincipalKind; - authMethod: 'session' | 'oauth' | 'api_key' | 'sites_viewer'; + authMethod: 'session' | 'oauth' | 'api_key'; /** OAuth subject; owner subject for personal keys; null for service keys. */ userId: string | null; /** Subject, or `token:` for service keys — audit/attribution string. */ diff --git a/src/server/lib/sites/browserAuth.test.ts b/src/server/lib/sites/browserAuth.test.ts index 39d5841e..52e95efb 100644 --- a/src/server/lib/sites/browserAuth.test.ts +++ b/src/server/lib/sites/browserAuth.test.ts @@ -1,4 +1,3 @@ -import { createHmac } from 'crypto'; import { SitesBrowserAuth, SitesBrowserError, @@ -12,11 +11,6 @@ import { type BrowserSite, } from './browserAuth'; -const mockTokenStatus = jest.fn(); -jest.mock('server/services/keycloak/principalStatus', () => ({ - getOAuthTokenStatus: (...args: unknown[]) => mockTokenStatus(...args), -})); - jest.mock('server/lib/redisClient', () => ({ __esModule: true, default: { getInstance: jest.fn() } })); // Atomic test store shared by two protocol instances; actual Redis/ingress acceptance @@ -33,7 +27,7 @@ class Store { this.rows.set(key, { value, until: Date.now() + ttl * 1000 }); return 'OK'; } - async eval(script: string, _count: number, key: string, value?: string, ttl?: number, mode?: string) { + async eval(script: string, _count: number, key: string) { // Read and mutate without yielding: emulate the atomic Redis execution boundary. const row = this.rows.get(key); const current = row && row.until > Date.now() ? row.value : null; @@ -41,12 +35,6 @@ class Store { this.rows.delete(key); return current; } - if (script.includes('sites-login')) { - if (mode === 'existing' && !current) return 0; - if (current && current !== value) return 0; - this.rows.set(key, { value: value!, until: Date.now() + ttl! * 1000 }); - return 1; - } const count = Number(current || 0) + 1; this.rows.set(key, { value: String(count), until: row?.until || Date.now() + 60_000 }); return count; @@ -78,12 +66,9 @@ const originalEnv = { ...process.env }; beforeEach(() => { process.env.ENABLE_AUTH = 'true'; process.env.KEYCLOAK_ISSUER = actor.issuer; - process.env.SITES_UI_OAUTH_CLIENT_ID = actor.oauth.clientId; actor.oauth.expiresAt = Math.floor(Date.now() / 1000) + 1200; - mockTokenStatus.mockReset().mockResolvedValue('active'); process.env.SITES_PRIVATE_ENABLED = 'true'; process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; - process.env.SITES_BROWSER_BRIDGE_SECRET = 'x'.repeat(40); store = new Store(); auth = new SitesBrowserAuth(store as any); }); @@ -94,23 +79,13 @@ afterEach(() => { async function setup() { const challenge = await auth.challenge(site, host, '/docs/page.html?example=1'); const login = { - loginId: randomSiteToken(), - loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, siteId: site.siteId, state: challenge.state, }; - await auth.bind(actor, { ...login, createLogin: true }); - const minted = await auth.mint(site, actor, login, Math.floor(Date.now() / 1000) + 1200, 'authorizing-bearer-secret'); + const minted = await auth.mint(site, actor, login, Math.floor(Date.now() / 1000) + 1200); const cookies = { [challengeCookieName(challenge.state)]: challenge.secret }; return { challenge, login, minted, cookies }; } -function signature(action: string, body: string, timestamp = Math.floor(Date.now() / 1000)) { - const nonce = randomSiteToken(); - const hash = createHmac('sha256', process.env.SITES_BROWSER_BRIDGE_SECRET!) - .update(`sites-v1\n${action}\n${timestamp}\n${nonce}\n${siteTokenHash(body)}`) - .digest('hex'); - return `${timestamp}.${nonce}.${hash}`; -} it('consumes only once across two gateway instances and creates an owner-scoped session', async () => { const { minted, cookies } = await setup(); const second = new SitesBrowserAuth(store as any); @@ -124,7 +99,7 @@ it('consumes only once across two gateway instances and creates an owner-scoped expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1); const value = (outcomes.find((result) => result.status === 'fulfilled') as PromiseFulfilledResult).value; expect(value.path).toBe('/docs/page.html?example=1'); - expect(await second.viewer(value.sessionId, host)).toMatchObject(actor); + expect(await second.viewer(value.sessionId, host)).toMatchObject({ issuer: actor.issuer, subject: actor.subject }); expect(authorize).toHaveBeenCalledTimes(1); }); it.each(['wrong-origin', 'wrong-host', 'wrong-cookie'])('rejects %s before authorizing', async (scenario) => { @@ -141,28 +116,6 @@ it.each(['wrong-origin', 'wrong-host', 'wrong-cookie'])('rejects %s before autho ).rejects.toBeInstanceOf(SitesBrowserError); expect(authorize).not.toHaveBeenCalled(); }); -it('revokes existing viewers and pending tickets on logout', async () => { - const { minted, cookies, login } = await setup(); - const result = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); - await auth.revoke(login); - await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 401 }); - await expect(auth.mint(site, actor, login, login.loginExpiresAt, 'authorizing-bearer-secret')).rejects.toMatchObject({ - statusCode: 401, - }); -}); -it('a revocation tombstone wins over a concurrent first-time login registration', async () => { - const challenge = await auth.challenge(site, host, '/'); - const login = { - loginId: randomSiteToken(), - loginExpiresAt: Math.floor(Date.now() / 1000) + 300, - siteId: site.siteId, - state: challenge.state, - }; - await auth.revoke(login); - await expect(auth.mint(site, actor, login, login.loginExpiresAt, 'authorizing-bearer-secret')).rejects.toMatchObject({ - statusCode: 401, - }); -}); it('expires one-use tickets after 60 seconds', async () => { jest.useFakeTimers(); const { minted, cookies } = await setup(); @@ -173,13 +126,11 @@ it('expires one-use tickets after 60 seconds', async () => { }); it('never lets a different actor or service-key owner mint a viewer', async () => { const { login } = await setup(); - await expect( - auth.mint(site, { ...actor, subject: 'other' }, login, login.loginExpiresAt, 'authorizing-bearer-secret') - ).rejects.toMatchObject({ + await expect(auth.mint(site, { ...actor, subject: 'other' }, login, actor.oauth.expiresAt)).rejects.toMatchObject({ statusCode: 404, }); await expect( - auth.mint({ ...site, ownerKind: 'service_key' }, actor, login, login.loginExpiresAt, 'authorizing-bearer-secret') + auth.mint({ ...site, ownerKind: 'service_key' }, actor, login, actor.oauth.expiresAt) ).rejects.toMatchObject({ statusCode: 404 }); }); it('runs current authorization on consume and returns no session when ownership/generation changes', async () => { @@ -191,12 +142,12 @@ it('runs current authorization on consume and returns no session when ownership/ ).rejects.toMatchObject({ statusCode: 404 }); expect(Array.from(store.rows.keys()).filter((key) => key.includes(':viewer:'))).toHaveLength(0); }); -it('auth-off denies already issued cookies; restored authentication does not undo logout', async () => { - const { minted, cookies, login } = await setup(); +it('auth-off denies already issued cookies; content logout removes its viewer', async () => { + const { minted, cookies } = await setup(); const result = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); process.env.ENABLE_AUTH = 'false'; await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 503 }); - await auth.revoke(login); + await auth.logoutViewer(result.sessionId); process.env.ENABLE_AUTH = 'true'; await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 401 }); }); @@ -207,22 +158,11 @@ it('fails closed on Redis failure', async () => { 'redis down' ); }); -it('verifies action-bound bridge signatures, rejects replay/tampering and accepts revoke without a bearer', async () => { - const body = JSON.stringify({ loginId: randomSiteToken(), loginExpiresAt: Math.floor(Date.now() / 1000) + 300 }); - const signed = signature('revoke', body); - expect(await auth.verifyBridge('revoke', body, signed)).toEqual(JSON.parse(body)); - await expect(auth.verifyBridge('revoke', body, signed)).rejects.toMatchObject({ statusCode: 401 }); - await expect(auth.verifyBridge('mint', body, signature('revoke', body))).rejects.toMatchObject({ statusCode: 401 }); - await expect(auth.verifyBridge('mint', `${body} `, signature('mint', body))).rejects.toMatchObject({ - statusCode: 401, - }); - await expect(auth.verifyBridge('mint', body, signature('mint', body, 1))).rejects.toMatchObject({ statusCode: 401 }); -}); -it('requires explicitly enabled HTTPS, separate registrable domain and a dedicated secret', () => { +it('requires explicitly enabled HTTPS, separate registrable domain and the rollout switch', () => { expect(() => assertPrivateSitesReady(`https://${host}`)).not.toThrow(); expect(() => assertPrivateSitesReady('https://sites.example.com')).toThrow(); expect(() => assertPrivateSitesReady('http://sites.example.net')).toThrow(); - process.env.SITES_BROWSER_BRIDGE_SECRET = 'short'; + process.env.SITES_PRIVATE_ENABLED = 'false'; expect(() => assertPrivateSitesReady()).toThrow(); }); it('rejects unsafe return paths and ambiguous cookies', () => { @@ -238,8 +178,8 @@ it('rejects unsafe return paths and ambiguous cookies', () => { expect(parseSitesCookies('a=first; a=shadow').a).toBe(''); }); -it('bounds chunked bridge request bodies before parsing without Content-Length', async () => { - const { readSitesBridgeBody } = await import('./browserAuth'); +it('bounds chunked mint request bodies before parsing without Content-Length', async () => { + const { readSitesMintBody } = await import('./browserAuth'); let cancelled = false; const stream = new ReadableStream({ start(controller) { @@ -249,214 +189,103 @@ it('bounds chunked bridge request bodies before parsing without Content-Length', cancelled = true; }, }); - await expect(readSitesBridgeBody({ headers: new Headers(), body: stream })).rejects.toMatchObject({ + await expect(readSitesMintBody({ headers: new Headers(), body: stream })).rejects.toMatchObject({ statusCode: 413, }); expect(cancelled).toBe(true); }); -describe('OAuth application-login binding', () => { - const oauthActor = (tokenId = 'initial') => ({ - ...actor, - oauth: { - tokenId, - sessionId: 'same-sso-session', - clientId: 'lifecycle-ui', - expiresAt: Math.floor(Date.now() / 1000) + 1200, - }, - }); - const login = () => ({ - loginId: randomSiteToken(), - loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, - createLogin: true, - }); - beforeEach(() => { - process.env.KEYCLOAK_ISSUER = actor.issuer; - process.env.SITES_UI_OAUTH_CLIENT_ID = 'lifecycle-ui'; - }); - it('denies replay of all still-unexpired same-login tokens after logout, across instances', async () => { - const first = oauthActor(); - const refreshed = oauthActor('refreshed'); - const body = login(); - await auth.bind(first, body); - await auth.bind(refreshed, { ...body, createLogin: false }); - const otherInstance = new SitesBrowserAuth(store as any); - await otherInstance.assertOAuthLogin(first); - await otherInstance.assertOAuthLogin(refreshed); - await auth.revoke(body); - await expect(otherInstance.assertOAuthLogin(first)).rejects.toMatchObject({ statusCode: 401 }); - await expect(otherInstance.assertOAuthLogin(refreshed)).rejects.toMatchObject({ statusCode: 401 }); - await expect(otherInstance.bind(oauthActor('late-refresh'), body)).rejects.toMatchObject({ statusCode: 401 }); - }); - it('allows a fresh independent login using the same SSO session, without reviving old tokens', async () => { - const old = login(); - await auth.bind(oauthActor(), old); - await auth.revoke(old); - const fresh = login(); - await auth.bind(oauthActor('fresh-login-token'), fresh); - await auth.assertOAuthLogin(oauthActor('fresh-login-token')); - await expect(auth.bind(oauthActor(), fresh)).rejects.toMatchObject({ statusCode: 401 }); - await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 401 }); - }); - it('denies legacy unbound UI tokens but allows separate CLI OAuth clients', async () => { - await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 401 }); - const cli = oauthActor(); - cli.oauth.clientId = 'lifecycle-cli'; - await auth.assertOAuthLogin(cli); - await expect(auth.bind(cli, login())).rejects.toMatchObject({ statusCode: 401 }); - }); - it('fails closed if binding configuration or Redis authorization state is unavailable', async () => { - delete process.env.SITES_UI_OAUTH_CLIENT_ID; - await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 503 }); - process.env.SITES_UI_OAUTH_CLIENT_ID = 'lifecycle-ui'; - jest.spyOn(store, 'get').mockRejectedValue(new Error('redis unavailable')); - await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 503 }); - }); - it('keeps an existing token binding authoritative after the configured UI client changes', async () => { - const body = login(); - await auth.bind(oauthActor(), body); - await auth.revoke(body); - process.env.SITES_UI_OAUTH_CLIENT_ID = 'new-ui-client'; - await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 401 }); - }); - it('missing login state or expired token never establishes access', async () => { - const body = login(); - await auth.bind(oauthActor(), body); - store.rows.delete(`sites:browser:v1:login:${siteTokenHash(body.loginId)}`); - await expect(auth.assertOAuthLogin(oauthActor())).rejects.toMatchObject({ statusCode: 401 }); - await expect( - auth.bind(oauthActor('refresh-after-state-loss'), { ...body, createLogin: false }) - ).rejects.toMatchObject({ statusCode: 401 }); - const expired = oauthActor(); - expired.oauth.expiresAt = Math.floor(Date.now() / 1000) - 1; - await expect(auth.bind(expired, body)).rejects.toMatchObject({ statusCode: 401 }); - }); - it('bind envelopes cannot be replayed or substituted for mint/revoke', async () => { - const body = JSON.stringify(login()); - const signed = signature('bind', body); - await expect(auth.verifyBridge('mint', body, signed)).rejects.toMatchObject({ statusCode: 401 }); - await expect(auth.verifyBridge('bind', body, signed)).resolves.toEqual(JSON.parse(body)); - await expect(auth.verifyBridge('bind', body, signed)).rejects.toMatchObject({ statusCode: 401 }); - }); +it('consumes at most one ticket from the same browser challenge', async () => { + const { minted, login, cookies } = await setup(); + const second = await auth.mint(site, actor, login, actor.oauth.expiresAt); + await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); + await expect( + auth.consume(second.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}) + ).rejects.toMatchObject({ statusCode: 401 }); }); - -describe('encrypted viewer authorizing-token retention', () => { - function vaultRows() { - return [...store.rows.entries()].filter(([key]) => key.startsWith('sites:browser:v1:viewer-bearer:')); - } - it('introspects the exact minting bearer without putting bearer or ciphertext in viewer metadata', async () => { - const { minted, cookies } = await setup(); - const consumed = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); - const viewer = await auth.viewer(consumed.sessionId, host); - await expect(auth.getViewerOAuthTokenStatus(viewer)).resolves.toBe('active'); - expect(mockTokenStatus).toHaveBeenLastCalledWith('authorizing-bearer-secret'); - expect(vaultRows()).toHaveLength(1); - expect(JSON.stringify([...store.rows.values()])).not.toContain('authorizing-bearer-secret'); - expect(JSON.stringify(viewer)).not.toContain('ciphertext'); - expect(JSON.stringify(minted)).not.toContain('ciphertext'); - mockTokenStatus.mockResolvedValue('revoked'); - await auth.assertLogin(viewer); // Same live app login; only the exact access token was revoked. - await expect(auth.getViewerOAuthTokenStatus(viewer)).resolves.toBe('revoked'); - }); - it('keeps same-token retry ciphertext/deadline stable, including concurrent mint calls', async () => { - jest.useFakeTimers(); - const { login } = await setup(); - const [key, original] = vaultRows()[0]; - jest.advanceTimersByTime(300_000); - const challenge = await auth.challenge(site, host, '/'); - const updated = { ...login, state: challenge.state }; - const results = await Promise.all([ - auth.mint(site, actor, updated, actor.oauth.expiresAt, 'authorizing-bearer-secret'), - auth.mint(site, actor, updated, actor.oauth.expiresAt, 'authorizing-bearer-secret'), - ]); - expect(vaultRows()).toHaveLength(1); - expect(store.rows.get(key)).toEqual(original); - for (const result of results) { - const ticket = JSON.parse((await store.get(`sites:browser:v1:ticket:${siteTokenHash(result.ticket)}`))!); - expect(ticket.expiresAt).toBe(JSON.parse(original.value).expiresAt); - } - }); - it('preserves previous token records on refresh and isolates exact-token revocation', async () => { - const { login } = await setup(); - const refreshed = { ...actor, oauth: { ...actor.oauth, tokenId: 'refreshed-token-id' } }; - await auth.bind(refreshed, { ...login, createLogin: false }); - await auth.mint(site, refreshed, login, refreshed.oauth.expiresAt, 'refreshed-bearer-secret'); - expect(vaultRows()).toHaveLength(2); - mockTokenStatus.mockImplementation(async (bearer) => - bearer === 'authorizing-bearer-secret' ? 'revoked' : 'active' - ); - await expect(auth.getViewerOAuthTokenStatus(actor)).resolves.toBe('revoked'); - await expect(auth.getViewerOAuthTokenStatus(refreshed)).resolves.toBe('active'); - await auth.revoke(login); - await expect(auth.getViewerOAuthTokenStatus(refreshed)).rejects.toMatchObject({ httpStatus: 401 }); - }); - it('keeps a separate login on the same SSO session independent', async () => { - const first = await setup(); - const secondActor = { ...actor, oauth: { ...actor.oauth, tokenId: 'separate-login-token' } }; - const challenge = await auth.challenge(site, host, '/'); - const secondLogin = { - loginId: randomSiteToken(), - loginExpiresAt: first.login.loginExpiresAt, - state: challenge.state, - siteId: site.siteId, - }; - await auth.bind(secondActor, { ...secondLogin, createLogin: true }); - await auth.mint(site, secondActor, secondLogin, secondActor.oauth.expiresAt, 'separate-login-bearer'); - await auth.revoke(first.login); - await expect(auth.getViewerOAuthTokenStatus(actor)).rejects.toMatchObject({ httpStatus: 401 }); - await expect(auth.getViewerOAuthTokenStatus(secondActor)).resolves.toBe('active'); - }); - it.each(['missing-vault', 'missing-binding', 'missing-key', 'rotated-key', 'corrupt-vault', 'changed-metadata'])( - 'fails closed for %s without a session-list fallback', - async (failure) => { - await setup(); - const [key, record] = vaultRows()[0]; - let presented = actor; - if (failure === 'missing-vault') store.rows.delete(key); - if (failure === 'missing-binding') - store.rows.delete(`sites:browser:v1:oauth:${siteTokenHash(`${actor.issuer}\0${actor.oauth.tokenId}`)}`); - if (failure === 'missing-key') delete process.env.SITES_BROWSER_BRIDGE_SECRET; - if (failure === 'rotated-key') process.env.SITES_BROWSER_BRIDGE_SECRET = 'y'.repeat(40); - if (failure === 'corrupt-vault') { - const value = JSON.parse(record.value); - value.tag = Buffer.alloc(16).toString('base64url'); - store.rows.set(key, { ...record, value: JSON.stringify(value) }); - } - if (failure === 'changed-metadata') - presented = { ...actor, oauth: { ...actor.oauth, sessionId: 'other-session' } }; - await expect(auth.getViewerOAuthTokenStatus(presented)).rejects.toBeInstanceOf(SitesBrowserError); - expect(mockTokenStatus).not.toHaveBeenCalled(); - } +it.each([30, 300, 1200])('caps fixed viewer lifetime to token expiry or 300 seconds: %s', async (tokenSeconds) => { + jest.useFakeTimers(); + const start = Math.floor(Date.now() / 1000); + actor.oauth.expiresAt = start + tokenSeconds; + const challenge = await auth.challenge(site, host, '/'); + const minted = await auth.mint(site, actor, { siteId: site.siteId, state: challenge.state }, actor.oauth.expiresAt); + const result = await auth.consume( + minted.ticket, + host, + { [challengeCookieName(challenge.state)]: challenge.secret }, + process.env.SITES_UI_ORIGIN, + async () => {} ); - it('caps retention at 15 minutes, token expiry and login expiry, and rejects expired records', async () => { - jest.useFakeTimers(); - await setup(); - expect(vaultRows()[0][1].until - Date.now()).toBeLessThanOrEqual(900_000); - jest.advanceTimersByTime(901_000); - await expect(auth.getViewerOAuthTokenStatus(actor)).rejects.toMatchObject({ httpStatus: 401 }); - const short = { - ...actor, - oauth: { ...actor.oauth, tokenId: 'short', expiresAt: Math.floor(Date.now() / 1000) + 40 }, - }; - const challenge = await auth.challenge(site, host, '/'); - const login = { - loginId: randomSiteToken(), - loginExpiresAt: Math.floor(Date.now() / 1000) + 20, - state: challenge.state, - siteId: site.siteId, - }; - await auth.bind(short, { ...login, createLogin: true }); - await auth.mint(site, short, login, short.oauth.expiresAt, 'short-bearer'); - const shortRow = vaultRows().find(([_, row]) => row.until > Date.now())![1]; - expect(shortRow.until - Date.now()).toBeLessThanOrEqual(20_000); - jest.advanceTimersByTime(21_000); - await expect(auth.getViewerOAuthTokenStatus(short)).rejects.toMatchObject({ httpStatus: 401 }); - }); - it('fails closed for unavailable Redis or unknown introspection', async () => { - await setup(); - mockTokenStatus.mockResolvedValue('unknown'); - await expect(auth.getViewerOAuthTokenStatus(actor)).resolves.toBe('unknown'); - jest.spyOn(store, 'get').mockRejectedValue(new Error('unavailable')); - await expect(auth.getViewerOAuthTokenStatus(actor)).rejects.toMatchObject({ httpStatus: 503 }); + const viewer = await auth.viewer(result.sessionId, host); + expect(viewer.expiresAt).toBe(start + Math.min(tokenSeconds, 300)); + expect(result.maxAge).toBe(Math.min(tokenSeconds, 300)); + const originalRows = JSON.stringify([...store.rows]); + jest.advanceTimersByTime(10_000); + expect(await auth.viewer(result.sessionId, host)).toEqual(viewer); + expect(JSON.stringify([...store.rows])).toBe(originalRows); + jest.advanceTimersByTime(Math.min(tokenSeconds, 300) * 1000); + await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 401 }); +}); +it('supports authenticated renewal with a fresh token but cannot renew with an expired token', async () => { + jest.useFakeTimers(); + const deadline = Math.floor(Date.now() / 1000) + 40; + const challenge = await auth.challenge(site, host, '/'); + const body = { siteId: site.siteId, state: challenge.state }; + await auth.mint(site, actor, body, deadline); + jest.advanceTimersByTime(41_000); + await expect(auth.mint(site, actor, body, deadline)).rejects.toMatchObject({ statusCode: 401 }); + const freshDeadline = Math.floor(Date.now() / 1000) + 300; + const renewed = await auth.mint(site, actor, body, freshDeadline); + const result = await auth.consume( + renewed.ticket, + host, + { [challengeCookieName(challenge.state)]: challenge.secret }, + process.env.SITES_UI_ORIGIN, + async () => {} + ); + expect((await auth.viewer(result.sessionId, host)).expiresAt).toBe(freshDeadline); +}); +it('retains only Site claims, no OAuth login, token metadata or bearer vault', async () => { + const { minted, cookies } = await setup(); + const result = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); + expect(Object.keys(await auth.viewer(result.sessionId, host)).sort()).toEqual([ + 'accessRevision', + 'expiresAt', + 'generation', + 'host', + 'issuer', + 'siteId', + 'subject', + ]); + expect([...store.rows.keys()].every((key) => /^sites:browser:v2:(challenge|ticket|viewer|rate):/.test(key))).toBe( + true + ); + expect(JSON.stringify([...store.rows.values()])).not.toContain(actor.oauth.tokenId); +}); +it('rejects previous-version viewers and tickets', async () => { + const token = randomSiteToken(); + for (const kind of ['viewer', 'ticket']) { + await store.set(`sites:browser:v1:${kind}:${siteTokenHash(token)}`, JSON.stringify({ ...actor, host }), 'EX', 300); + } + await expect(auth.viewer(token, host)).rejects.toMatchObject({ statusCode: 401 }); + await expect(auth.consume(token, host, {}, process.env.SITES_UI_ORIGIN, jest.fn())).rejects.toMatchObject({ + statusCode: 401, }); }); +it('validates only siteId and state, denying invented owner and legacy login claims', async () => { + const { readSitesMintBody } = await import('./browserAuth'); + const body = { siteId: site.siteId, state: randomSiteToken() }; + const request = (value: unknown) => + new Request('https://api.example/mint', { method: 'POST', body: JSON.stringify(value) }); + await expect(readSitesMintBody(request(body))).resolves.toEqual(body); + for (const value of [ + null, + [], + {}, + { ...body, ownerSubject: 'owner' }, + { ...body, loginId: randomSiteToken() }, + { ...body, state: 'bad' }, + ]) { + await expect(readSitesMintBody(request(value))).rejects.toMatchObject({ statusCode: 400 }); + } +}); diff --git a/src/server/lib/sites/browserAuth.ts b/src/server/lib/sites/browserAuth.ts index 3aba32b8..b87abedf 100644 --- a/src/server/lib/sites/browserAuth.ts +++ b/src/server/lib/sites/browserAuth.ts @@ -1,26 +1,19 @@ -import { createHash, createHmac, createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import * as psl from 'psl'; import type Redis from 'ioredis'; import RedisClient from 'server/lib/redisClient'; import { AppError } from 'server/lib/appError'; -import type { OAuthCredential } from 'server/lib/get-user'; -import { getOAuthTokenStatus } from 'server/services/keycloak/principalStatus'; export const SITES_AUTH_PATH = '/_lfc-sites/'; export const SITES_VIEWER_COOKIE = '__Host-lfc-sites-viewer'; -export const MAX_LOGIN_SECONDS = 30 * 24 * 60 * 60; +export const MAX_VIEWER_SECONDS = 300; const TOKEN = /^[A-Za-z0-9_-]{43}$/; -const PREFIX = 'sites:browser:v1:'; +// Invalidate pre-simplification login/vault-backed browser state. +const PREFIX = 'sites:browser:v2:'; const CONSUME = `-- sites-consume local value = redis.call('GET', KEYS[1]) if value then redis.call('DEL', KEYS[1]) end return value`; -const LOGIN = `-- sites-login -local existing = redis.call('GET', KEYS[1]) -if ARGV[3] == 'existing' and not existing then return 0 end -if existing and existing ~= ARGV[1] then return 0 end -redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) -return 1`; const LIMIT = `-- sites-limit local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('EXPIRE', KEYS[1], 60) end @@ -31,7 +24,7 @@ export class SitesBrowserError extends AppError { super({ httpStatus: statusCode, code: 'sites_browser_unavailable', message }); } } -export type BrowserActor = { issuer: string; subject: string; oauth?: OAuthCredential }; +export type BrowserActor = { issuer: string; subject: string }; export type BrowserSite = { siteId: string; ownerKind: string; @@ -47,23 +40,10 @@ export type Viewer = BrowserActor & { host: string; generation: string | null; accessRevision: number; - loginId: string; - loginExpiresAt: number; expiresAt: number; }; type Ticket = Viewer & { challengeId: string; challenge: Challenge }; -type OAuthLogin = { issuer: string; subject: string; loginId: string; loginExpiresAt: number }; -type ViewerBearerRecord = { version: 1; expiresAt: number; iv: string; tag: string; ciphertext: string }; -const VIEWER_BEARER_PURPOSE = 'sites-viewer-bearer:v1'; - -export type BridgeBody = { - loginId: string; - loginExpiresAt: number; - /** True only for an explicit OAuth sign-in; refresh must find its existing login. */ - createLogin?: boolean; - state?: string; - siteId?: string; -}; +export type SitesBrowserMintBody = { siteId: string; state: string }; export const randomSiteToken = () => randomBytes(32).toString('base64url'); export const siteTokenHash = (value: string) => createHash('sha256').update(value).digest('hex'); const nowSeconds = () => Math.floor(Date.now() / 1000); @@ -78,16 +58,10 @@ export function sitesUiOrigin(): string { throw new SitesBrowserError(503); } } -function bridgeSecret(): string { - const secret = process.env.SITES_BROWSER_BRIDGE_SECRET || ''; - if (Buffer.byteLength(secret) < 32) throw new SitesBrowserError(503); - return secret; -} export function assertPrivateSitesReady(contentUrl?: string): void { if (process.env.ENABLE_AUTH !== 'true' || process.env.SITES_PRIVATE_ENABLED !== 'true') throw new SitesBrowserError(503); const ui = new URL(sitesUiOrigin()); - bridgeSecret(); if (contentUrl) { const content = new URL(contentUrl); const uiDomain = psl.get(ui.hostname); @@ -138,10 +112,10 @@ export function assertViewerOwns(site: BrowserSite, actor: BrowserActor): void { throw new SitesBrowserError(404); } -export async function readSitesBridgeBody(request: { +export async function readSitesMintBody(request: { headers: Headers; body: ReadableStream | null; -}): Promise { +}): Promise { const limit = 4096; if (Number(request.headers.get('content-length') || 0) > limit) throw new SitesBrowserError(413); if (!request.body) throw new SitesBrowserError(400); @@ -160,7 +134,28 @@ export async function readSitesBridgeBody(request: { await reader.cancel().catch(() => {}); reader.releaseLock(); } - return Buffer.concat(chunks).toString('utf8'); + let body: unknown; + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new SitesBrowserError(400); + } + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new SitesBrowserError(400); + const value = body as Record; + if ( + Object.keys(value).some((key) => key !== 'siteId' && key !== 'state') || + typeof value.siteId !== 'string' || + !/^[a-z0-9-]{1,64}$/.test(value.siteId) || + typeof value.state !== 'string' || + !TOKEN.test(value.state) + ) + throw new SitesBrowserError(400); + return { siteId: value.siteId, state: value.state }; +} + +/** Fixed authorization deadline: reads never renew the grant. */ +export function assertViewerActive(viewer: Viewer): void { + if (!Number.isSafeInteger(viewer.expiresAt) || viewer.expiresAt <= nowSeconds()) throw new SitesBrowserError(401); } export class SitesBrowserAuth { @@ -171,207 +166,6 @@ export class SitesBrowserAuth { async rateLimit(identity: string, maximum = 60): Promise { if (Number(await this.redis.eval(LIMIT, 1, this.key('rate', identity))) > maximum) throw new SitesBrowserError(429); } - async verifyBridge(action: 'mint' | 'revoke' | 'bind', rawBody: string, header: string | null): Promise { - // Revocation must still work after private creation is disabled or a user's OAuth token expires. - const parts = (header || '').split('.'); - if ( - parts.length !== 3 || - !/^\d+$/.test(parts[0]) || - !TOKEN.test(parts[1]) || - !/^[a-f0-9]{64}$/.test(parts[2]) || - Math.abs(nowSeconds() - Number(parts[0])) > 30 - ) - throw new SitesBrowserError(401); - const expected = createHmac('sha256', bridgeSecret()) - .update(`sites-v1\n${action}\n${parts[0]}\n${parts[1]}\n${siteTokenHash(rawBody)}`) - .digest(); - if (!timingSafeEqual(expected, Buffer.from(parts[2], 'hex'))) throw new SitesBrowserError(401); - if ((await this.redis.set(this.key('bridge', parts[1]), 'used', 'EX', 65, 'NX')) !== 'OK') - throw new SitesBrowserError(401); - let body: BridgeBody; - try { - body = JSON.parse(rawBody); - } catch { - throw new SitesBrowserError(400); - } - if ( - !body || - !TOKEN.test(body.loginId) || - !Number.isSafeInteger(body.loginExpiresAt) || - body.loginExpiresAt > nowSeconds() + MAX_LOGIN_SECONDS + 60 || - (body.createLogin !== undefined && typeof body.createLogin !== 'boolean') - ) - throw new SitesBrowserError(400); - return body; - } - private oauthTokenKey(actor: BrowserActor): string { - return this.key('oauth', `${actor.issuer}\0${actor.oauth!.tokenId}`); - } - private loginValue(actor: BrowserActor, expiresAt: number): string { - return JSON.stringify({ issuer: actor.issuer, subject: actor.subject, expiresAt }); - } - /** Register only the exact verified access token; never revoke an entire SSO session. */ - async bind(actor: BrowserActor, body: BridgeBody): Promise { - const clientId = process.env.SITES_UI_OAUTH_CLIENT_ID?.trim(); - if (!clientId) throw new SitesBrowserError(503); - if ( - actor.issuer !== process.env.KEYCLOAK_ISSUER?.trim() || - !actor.subject || - !actor.oauth || - actor.oauth.clientId !== clientId || - actor.oauth.expiresAt <= nowSeconds() || - body.loginExpiresAt <= nowSeconds() - ) - throw new SitesBrowserError(401); - const loginValue = this.loginValue(actor, body.loginExpiresAt); - if ( - Number( - await this.redis.eval( - LOGIN, - 1, - this.key('login', body.loginId), - loginValue, - body.loginExpiresAt - nowSeconds(), - body.createLogin === true ? 'create' : 'existing' - ) - ) !== 1 - ) - throw new SitesBrowserError(401); - const value = JSON.stringify({ - issuer: actor.issuer, - subject: actor.subject, - loginId: body.loginId, - loginExpiresAt: body.loginExpiresAt, - }); - // An already-issued token cannot migrate into a fresh login and escape its logout. - const key = this.oauthTokenKey(actor); - if (Number(await this.redis.eval(LOGIN, 1, key, value, actor.oauth.expiresAt - nowSeconds() + 60)) !== 1) - throw new SitesBrowserError(401); - await this.assertOAuthLogin(actor); - } - /** All UI-client tokens require a registration, including pre-upgrade tokens. */ - async assertOAuthLogin(actor: BrowserActor): Promise { - const clientId = process.env.SITES_UI_OAUTH_CLIENT_ID?.trim(); - if (!clientId) throw new SitesBrowserError(503); - if (!actor.oauth || actor.oauth.expiresAt <= nowSeconds()) throw new SitesBrowserError(401); - try { - const raw = await this.redis.get(this.oauthTokenKey(actor)); - if (!raw) { - if (actor.oauth.clientId === clientId) throw new SitesBrowserError(401); - return; - } - const bound = JSON.parse(raw) as OAuthLogin; - if (bound.issuer !== actor.issuer || bound.subject !== actor.subject || bound.loginExpiresAt <= nowSeconds()) - throw new SitesBrowserError(401); - const expected = this.loginValue(actor, bound.loginExpiresAt); - if ((await this.redis.get(this.key('login', bound.loginId))) !== expected) throw new SitesBrowserError(401); - return bound; - } catch (error) { - if (error instanceof SitesBrowserError) throw error; - throw new SitesBrowserError(503); - } - } - private viewerBearerKey(actor: BrowserActor, login: OAuthLogin): string { - return this.key('viewer-bearer', JSON.stringify([actor.issuer, actor.oauth!.tokenId, login.loginId])); - } - private viewerBearerAad(actor: BrowserActor, login: OAuthLogin, expiresAt: number): Buffer { - const oauth = actor.oauth!; - return Buffer.from( - JSON.stringify([ - VIEWER_BEARER_PURPOSE, - actor.issuer, - actor.subject, - oauth.sessionId, - oauth.tokenId, - oauth.clientId, - oauth.expiresAt, - login.loginId, - login.loginExpiresAt, - expiresAt, - ]) - ); - } - private viewerBearerEncryptionKey(): Buffer { - return createHmac('sha256', bridgeSecret()).update(`${VIEWER_BEARER_PURPOSE}:encryption`).digest(); - } - private decryptViewerBearer( - raw: string, - actor: BrowserActor, - login: OAuthLogin - ): { bearer: string; expiresAt: number } { - if (raw.length > 24576) throw new SitesBrowserError(503); - const record = JSON.parse(raw) as ViewerBearerRecord; - if ( - record.version !== 1 || - !Number.isSafeInteger(record.expiresAt) || - record.expiresAt <= nowSeconds() || - record.expiresAt > Math.min(actor.oauth!.expiresAt, login.loginExpiresAt, nowSeconds() + 900) - ) - throw new SitesBrowserError(401); - const iv = Buffer.from(record.iv, 'base64url'); - const tag = Buffer.from(record.tag, 'base64url'); - if (iv.length !== 12 || tag.length !== 16) throw new SitesBrowserError(503); - const decipher = createDecipheriv('aes-256-gcm', this.viewerBearerEncryptionKey(), iv); - decipher.setAAD(this.viewerBearerAad(actor, login, record.expiresAt)); - decipher.setAuthTag(tag); - const bearer = Buffer.concat([ - decipher.update(Buffer.from(record.ciphertext, 'base64url')), - decipher.final(), - ]).toString('utf8'); - if (!bearer || Buffer.byteLength(bearer) > 16384) throw new SitesBrowserError(503); - return { bearer, expiresAt: record.expiresAt }; - } - /** Mint-only retention. Randomized ciphertext never enters immutable LOGIN metadata. */ - private async retainViewerBearer( - actor: BrowserActor, - login: OAuthLogin, - bearer: string, - expiresAt: number - ): Promise { - try { - if (!bearer || Buffer.byteLength(bearer) > 16384) throw new SitesBrowserError(401); - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', this.viewerBearerEncryptionKey(), iv); - cipher.setAAD(this.viewerBearerAad(actor, login, expiresAt)); - const ciphertext = Buffer.concat([cipher.update(bearer, 'utf8'), cipher.final()]); - const record: ViewerBearerRecord = { - version: 1, - expiresAt, - iv: iv.toString('base64url'), - tag: cipher.getAuthTag().toString('base64url'), - ciphertext: ciphertext.toString('base64url'), - }; - const key = this.viewerBearerKey(actor, login); - const serialized = JSON.stringify(record); - // Same-token retries retain the original ciphertext and deadline. A later - // viewer is capped to that deadline; a refreshed jti gets its own record. - const inserted = await this.redis.set(key, serialized, 'EX', expiresAt - nowSeconds(), 'NX'); - const saved = inserted === 'OK' ? serialized : await this.redis.get(key); - if (!saved) throw new SitesBrowserError(401); - const retained = this.decryptViewerBearer(saved, actor, login); - if (retained.bearer !== bearer) throw new SitesBrowserError(401); - return Math.min(expiresAt, retained.expiresAt); - } catch (error) { - if (error instanceof SitesBrowserError) throw error; - throw new SitesBrowserError(503); - } - } - /** Decrypt only inside the authorization operation; callers never receive a bearer. */ - async getViewerOAuthTokenStatus(actor: BrowserActor): Promise<'active' | 'revoked' | 'unknown'> { - try { - const login = await this.assertOAuthLogin(actor); - if (!login) throw new SitesBrowserError(401); - const raw = await this.redis.get(this.viewerBearerKey(actor, login)); - if (!raw) throw new SitesBrowserError(401); - const { bearer } = this.decryptViewerBearer(raw, actor, login); - return await getOAuthTokenStatus(bearer); - } catch (error) { - if (error instanceof SitesBrowserError) throw error; - // Corruption, a lost/rotated bridge key, or unavailable Redis never downgrade - // to a session-list check. No encryption error/cause or bearer is logged. - throw new SitesBrowserError(503); - } - } async challenge(site: Pick, host: string, path: string) { assertPrivateSitesReady(`https://${host}`); const state = randomSiteToken(); @@ -392,34 +186,29 @@ export class SitesBrowserAuth { if (!raw) throw new SitesBrowserError(401); return JSON.parse(raw) as Challenge; } + /** Called only after normal JWT, current credential and ownership authorization. */ async mint( site: BrowserSite, actor: BrowserActor, - body: BridgeBody, - tokenExpiresAt: number, - authorizingBearer: string + body: SitesBrowserMintBody, + tokenExpiresAt: number ): Promise<{ ticket: string; consumeUrl: string }> { assertViewerOwns(site, actor); - if (!body.state || body.siteId !== site.siteId || body.loginExpiresAt <= nowSeconds()) - throw new SitesBrowserError(401); + if (!body.state || body.siteId !== site.siteId) throw new SitesBrowserError(401); const challenge = await this.readChallenge(body.state); if (challenge.siteId !== site.siteId || challenge.generation !== (site.servingGeneration ?? null)) throw new SitesBrowserError(401); assertPrivateSitesReady(`https://${challenge.host}`); - const login = await this.assertOAuthLogin(actor); - if (!login || login.loginId !== body.loginId || login.loginExpiresAt !== body.loginExpiresAt) - throw new SitesBrowserError(401); - let expiresAt = Math.min(nowSeconds() + 900, tokenExpiresAt, actor.oauth!.expiresAt, body.loginExpiresAt); + if (!Number.isSafeInteger(tokenExpiresAt)) throw new SitesBrowserError(401); + const expiresAt = Math.min(nowSeconds() + MAX_VIEWER_SECONDS, tokenExpiresAt); if (!Number.isSafeInteger(expiresAt) || expiresAt <= nowSeconds()) throw new SitesBrowserError(401); - expiresAt = await this.retainViewerBearer(actor, login, authorizingBearer, expiresAt); const value: Ticket = { - ...actor, + issuer: actor.issuer, + subject: actor.subject, siteId: site.siteId, host: challenge.host, generation: site.servingGeneration ?? null, accessRevision: site.accessRevision, - loginId: body.loginId, - loginExpiresAt: body.loginExpiresAt, expiresAt, challengeId: body.state, challenge, @@ -433,24 +222,6 @@ export class SitesBrowserAuth { ); return { ticket, consumeUrl: `https://${challenge.host}${SITES_AUTH_PATH}consume` }; } - async revoke(body: BridgeBody): Promise { - // A tombstone also beats concurrent first-time minting; expiry never extends a login. - await this.redis.set( - this.key('login', body.loginId), - 'revoked', - 'EX', - Math.max(1, Math.min(MAX_LOGIN_SECONDS + 60, body.loginExpiresAt - nowSeconds() + 60)) - ); - } - async assertLogin(viewer: Viewer): Promise { - if (viewer.expiresAt <= nowSeconds() || viewer.loginExpiresAt <= nowSeconds()) throw new SitesBrowserError(401); - const expected = JSON.stringify({ - issuer: viewer.issuer, - subject: viewer.subject, - expiresAt: viewer.loginExpiresAt, - }); - if ((await this.redis.get(this.key('login', viewer.loginId))) !== expected) throw new SitesBrowserError(401); - } async consume( ticket: string, host: string, @@ -466,15 +237,24 @@ export class SitesBrowserAuth { const cookie = cookies[challengeCookieName(value.challengeId)]; if (value.host !== host || !cookie || siteTokenHash(cookie) !== value.challenge.secretHash) throw new SitesBrowserError(401); - await this.assertLogin(value); + assertViewerActive(value); await authorize(value); // Deleting challenge prevents a second separately minted ticket reusing this bootstrap. const challenge = await this.redis.eval(CONSUME, 1, this.key('challenge', value.challengeId)); if (typeof challenge !== 'string') throw new SitesBrowserError(401); + assertViewerActive(value); const sessionId = randomSiteToken(); await this.redis.set( this.key('viewer', sessionId), - JSON.stringify(value), + JSON.stringify({ + issuer: value.issuer, + subject: value.subject, + siteId: value.siteId, + host: value.host, + generation: value.generation, + accessRevision: value.accessRevision, + expiresAt: value.expiresAt, + }), 'EX', Math.max(1, value.expiresAt - nowSeconds()) ); @@ -492,7 +272,7 @@ export class SitesBrowserAuth { if (!raw) throw new SitesBrowserError(401); const value = JSON.parse(raw) as Viewer; if (value.host !== host) throw new SitesBrowserError(401); - await this.assertLogin(value); + assertViewerActive(value); return value; } async logoutViewer(sessionId: string | undefined): Promise { diff --git a/src/server/lib/sites/gateway.test.ts b/src/server/lib/sites/gateway.test.ts index 86e15170..90a148f8 100644 --- a/src/server/lib/sites/gateway.test.ts +++ b/src/server/lib/sites/gateway.test.ts @@ -5,7 +5,6 @@ import { assertSitesPrincipal } from './policy'; const browser = { viewer: jest.fn(), - assertLogin: jest.fn(), challenge: jest.fn(), rateLimit: jest.fn(), consume: jest.fn(), @@ -30,10 +29,7 @@ const viewer = { generation: site.servingGeneration, accessRevision: 2, host: 'site-abc123--g-abcdef012345.sites.example.net', - loginId: 'login', - loginExpiresAt: 1e12, expiresAt: 1e12, - oauth: { sessionId: 'session', tokenId: 'jwt', clientId: 'ui', expiresAt: 1e12 }, }; function request(method = 'GET', headers = {}, url = '/') { return { @@ -75,10 +71,8 @@ beforeEach(() => { process.env.ENABLE_AUTH = 'true'; process.env.SITES_PRIVATE_ENABLED = 'true'; process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; - process.env.SITES_BROWSER_BRIDGE_SECRET = 'x'.repeat(40); jest.clearAllMocks(); browser.viewer.mockResolvedValue(viewer); - browser.assertLogin.mockResolvedValue(undefined); (assertSitesPrincipal as jest.Mock).mockResolvedValue(undefined); }); afterEach(() => { @@ -104,7 +98,7 @@ it('denies another authenticated user and changed generation', async () => { statusCode: 401, }); }); -it('authorizes HEAD, including another current account/login check before storage', async () => { +it('authorizes HEAD with current Site checks and no incoming-principal or IdP policy calls', async () => { const api = service(); const res = response(); await handleSitesRequest(request('HEAD'), res, api); @@ -113,8 +107,7 @@ it('authorizes HEAD, including another current account/login check before storag expect(res.headers['Origin-Agent-Cluster']).toBe('?1'); expect(res.headers['Cross-Origin-Resource-Policy']).toBe('same-origin'); expect(api.getGatewayObject).toHaveBeenCalledTimes(1); - expect(assertSitesPrincipal).toHaveBeenCalledTimes(2); - expect(browser.assertLogin).toHaveBeenCalled(); + expect(assertSitesPrincipal).not.toHaveBeenCalled(); }); it('never returns a login page or redirect for an unauthenticated asset/HEAD', async () => { browser.viewer.mockRejectedValue(new SitesBrowserError(401)); @@ -145,8 +138,8 @@ it('blocks service workers and unsupported methods without storage', async () => expect(api.getGatewayObject).not.toHaveBeenCalled(); } }); -it('never falls back to public bytes when account lookup or Redis fails', async () => { - (assertSitesPrincipal as jest.Mock).mockRejectedValue(new Error('directory down')); +it('never falls back to public bytes when Redis fails', async () => { + browser.viewer.mockRejectedValue(new Error('Redis down')); const api = service(); const res = response(); await handleSitesRequest(request(), res, api); @@ -197,9 +190,21 @@ it.each([ expect(existing.getGatewayObject).not.toHaveBeenCalled(); expect(missing.getGatewayObject).not.toHaveBeenCalled(); }); -it('propagates the verified OAuth session to both private authorization checks', async () => { - await handleSitesRequest(request('HEAD'), response(), service()); - expect(assertSitesPrincipal).toHaveBeenCalledWith(expect.objectContaining({ oauth: viewer.oauth })); +it('rejects a concurrent Site change or grant expiry immediately before storage', async () => { + for (const changed of [{ accessRevision: 3 }, { ownerSubject: 'other' }, { servingGeneration: 'retired' }]) { + const api = service(); + api.getGatewayObject.mockImplementation(async (_host: string, _path: string, authorize: any) => { + await authorize({ ...site, ...changed }); + throw new Error('must not reach storage'); + }); + const res = response(); + await handleSitesRequest(request('HEAD'), res, api); + expect(res.statusCode).toBe(404); + } + await expect( + authorizeSitesViewer(site as any, { ...viewer, expiresAt: Math.floor(Date.now() / 1000) }) + ).rejects.toMatchObject({ statusCode: 401 }); + expect(assertSitesPrincipal).not.toHaveBeenCalled(); }); it.each([new SitesBrowserError(401), new Error('Redis unavailable'), new SitesBrowserError(503)])( diff --git a/src/server/lib/sites/gateway.ts b/src/server/lib/sites/gateway.ts index 9caf879b..1eede831 100644 --- a/src/server/lib/sites/gateway.ts +++ b/src/server/lib/sites/gateway.ts @@ -1,10 +1,9 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { isIP } from 'net'; import type Site from 'server/models/Site'; -import type { Principal } from 'server/lib/principal'; -import { assertSitesPrincipal } from './policy'; import { assertViewerOwns, + assertViewerActive, challengeCookieName, getSitesBrowserAuth, parseSitesCookies, @@ -35,25 +34,8 @@ export function sitesClientAddress(req: Pick): Principal { - // The caller supplies only a viewer recovered from a server-side session originally minted - // from a verified JWT. This is never built from browser headers or unsigned claim input. - return { - kind: 'user', - authMethod: 'sites_viewer', - userId: viewer.subject, - issuer: viewer.issuer, - actor: viewer.subject, - roles: ['user'], - scopes: null, - tokenId: null, - repositoryAllowlist: null, - repositoryAllowlistRepoIds: null, - identity: null, - oauth: viewer.oauth, - }; -} export async function authorizeSitesViewer(site: Site, viewer: Viewer): Promise { + assertViewerActive(viewer); assertViewerOwns(site, viewer); if ( site.siteId !== viewer.siteId || @@ -61,7 +43,6 @@ export async function authorizeSitesViewer(site: Site, viewer: Viewer): Promise< site.accessRevision !== viewer.accessRevision ) throw new SitesBrowserError(401); - await assertSitesPrincipal(sitesViewerPrincipal(viewer)); } function securityHeaders(res: ServerResponse) { res.setHeader('Cache-Control', 'no-store'); @@ -178,8 +159,7 @@ export async function handleSitesRequest( } const object = await service.getGatewayObject(host, url.pathname, async (currentSite) => { if (!viewer) throw new SitesBrowserError(401); - // Check login again immediately before storage, including a concurrent logout. - await auth!.assertLogin(viewer); + // Recheck the fixed deadline and current Site immediately before storage. await authorizeSitesViewer(currentSite, viewer); }); if (site.visibility === 'private') { diff --git a/src/server/lib/sites/oauthRevocation.test.ts b/src/server/lib/sites/oauthRevocation.test.ts index 82bbb1e9..db6374b7 100644 --- a/src/server/lib/sites/oauthRevocation.test.ts +++ b/src/server/lib/sites/oauthRevocation.test.ts @@ -6,8 +6,9 @@ import { verifyBearerToken } from 'server/lib/auth'; import { resolvePrincipal } from 'server/lib/principal'; import { getVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; import { authenticateMcpRequest } from 'server/mcp/auth'; -import { SitesBrowserAuth, randomSiteToken, challengeCookieName } from './browserAuth'; +import { SitesBrowserAuth, challengeCookieName } from './browserAuth'; import { assertSitesPrincipal } from './policy'; +import { authorizeSitesViewer } from './gateway'; jest.mock('jose', () => ({ ...jest.requireActual('jose'), createRemoteJWKSet: jest.fn() })); jest.mock('server/services/apiToken', () => ({ __esModule: true, default: {} })); @@ -17,7 +18,6 @@ jest.mock('server/lib/logger', () => ({ getLogger: () => ({ warn: jest.fn(), err const mockDirectorySession = jest.fn(); jest.mock('server/services/keycloak/principalStatus', () => ({ getUserStatus: async () => 'active', - getUserSessionStatus: (...args: unknown[]) => mockDirectorySession(...args), getOAuthTokenStatus: (...args: unknown[]) => mockDirectorySession(...args), })); const mockRows = new Map(); @@ -28,15 +28,12 @@ const mockRedis = { mockRows.set(key, value); return 'OK'; }, - eval: async (_script: string, _count: number, key: string, value: string, _ttl: number, mode?: string) => { - const current = mockRows.get(key); - if (_script.includes('sites-consume')) { + eval: async (script: string, _count: number, key: string) => { + if (script.includes('sites-consume')) { + const current = mockRows.get(key); mockRows.delete(key); return current ?? null; } - if (mode === 'existing' && !current) return 0; - if (current && current !== value) return 0; - mockRows.set(key, value); return 1; }, }; @@ -55,10 +52,8 @@ beforeAll(async () => { process.env.KEYCLOAK_ISSUER = issuer; process.env.KEYCLOAK_CLIENT_ID = 'lifecycle-api'; process.env.KEYCLOAK_JWKS_URL = 'https://identity.example/test-jwks'; - process.env.SITES_UI_OAUTH_CLIENT_ID = 'lifecycle-ui'; process.env.SITES_PRIVATE_ENABLED = 'true'; process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; - process.env.SITES_BROWSER_BRIDGE_SECRET = 'x'.repeat(40); process.env.APP_HOST = 'https://api.example'; }); beforeEach(() => { @@ -82,7 +77,7 @@ async function sign(tokenId: string, clientId = 'lifecycle-ui', offlineAccess = .setAudience(['lifecycle-api', 'https://api.example/mcp']) .setJti(tokenId) .setIssuedAt() - .setExpirationTime('10m') + .setExpirationTime('5m') .sign(privateKey); } async function principals(token: string) { @@ -105,35 +100,14 @@ async function principals(token: string) { return [rest, mcp.principal]; } -test('unexpired bearer replay after app logout fails Sites policy through REST and MCP', async () => { - const auth = new SitesBrowserAuth(mockRedis as any); - const login = { loginId: randomSiteToken(), loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, createLogin: true }; - const bearer = await sign('initial-token'); - const refreshed = await sign('refreshed-token'); - for (const token of [bearer, refreshed]) { - const [rest, mcp] = await principals(token); - await auth.bind({ issuer, subject: 'owner', oauth: rest.oauth }, login); - await assertSitesPrincipal(rest, 'write'); - await assertSitesPrincipal(mcp, 'read'); - } - await auth.revoke(login); - // JWT verification still succeeds; Sites authorization must independently deny it. - for (const token of [bearer, refreshed]) { - for (const principal of await principals(token)) - await expect(assertSitesPrincipal(principal, 'write')).rejects.toMatchObject({ httpStatus: 401 }); - } - const freshLogin = { ...login, loginId: randomSiteToken() }; - for (const principal of await principals(await sign('new-login-token'))) { - await auth.bind({ issuer, subject: 'owner', oauth: principal.oauth }, freshLogin); - await assertSitesPrincipal(principal, 'write'); +test('existing UI and CLI JWTs authorize REST/MCP without a separate application login', async () => { + for (const client of ['lifecycle-ui', 'lifecycle-cli']) { + for (const principal of await principals(await sign(`token-${client}`, client))) { + await assertSitesPrincipal(principal, 'write'); + expect(principal.oauth!.expiresAt - Math.floor(Date.now() / 1000)).toBeLessThanOrEqual(300); + } } -}); - -test('unbound old UI tokens fail closed while separate CLI OAuth remains supported', async () => { - for (const principal of await principals(await sign('legacy-unbound'))) - await expect(assertSitesPrincipal(principal)).rejects.toMatchObject({ httpStatus: 401 }); - for (const principal of await principals(await sign('cli-token', 'lifecycle-cli'))) - await assertSitesPrincipal(principal); + expect(mockRows.size).toBe(0); }); test('IdP session revocation denies signed CLI OAuth tokens with an otherwise enabled account', async () => { @@ -147,21 +121,13 @@ test('IdP session revocation denies signed CLI OAuth tokens with an otherwise en expect(mockDirectorySession).toHaveBeenCalledWith(token); }); -test('offline OAuth selects live offline validation without bypassing application logout', async () => { - const auth = new SitesBrowserAuth(mockRedis as any); - const login = { loginId: randomSiteToken(), loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, createLogin: true }; +test('offline OAuth retains incoming token status validation without login registration', async () => { const token = await sign('offline-ui', 'lifecycle-ui', true); - for (const principal of await principals(token)) { - await auth.bind({ issuer, subject: 'owner', oauth: principal.oauth }, login); - await assertSitesPrincipal(principal); - } + for (const principal of await principals(token)) await assertSitesPrincipal(principal); expect(mockDirectorySession).toHaveBeenCalledWith(token); - await auth.revoke(login); - for (const principal of await principals(token)) - await expect(assertSitesPrincipal(principal)).rejects.toMatchObject({ httpStatus: 401 }); }); -test('a token-level revoke with live login blocks the next protected viewer authorization', async () => { +test('issued viewers use only their fixed grant deadline; new authorization still checks token status', async () => { const bearer = await sign('viewer-token'); const [rest] = await principals(bearer); const auth = new SitesBrowserAuth(mockRedis as any); @@ -176,34 +142,38 @@ test('a token-level revoke with live login blocks the next protected viewer auth }; const host = 'site-test--g-abcdef012345.sites.example.net'; const challenge = await auth.challenge(site, host, '/'); - const login = { loginId: randomSiteToken(), loginExpiresAt: Math.floor(Date.now() / 1000) + 3600, createLogin: true }; - const actor = { issuer, subject: 'owner', oauth: rest.oauth }; - await auth.bind(actor, login); - const minted = await auth.mint( - site, - actor, - { ...login, state: challenge.state, siteId: site.siteId }, - rest.oauth!.expiresAt, - bearer - ); - const viewerPrincipal = { ...rest, authMethod: 'sites_viewer' as const }; + const actor = { issuer, subject: 'owner' }; + await assertSitesPrincipal(rest); + const minted = await auth.mint(site, actor, { state: challenge.state, siteId: site.siteId }, rest.oauth!.expiresAt); const consumed = await auth.consume( minted.ticket, host, { [challengeCookieName(challenge.state)]: challenge.secret }, process.env.SITES_UI_ORIGIN, - async () => { - await assertSitesPrincipal(viewerPrincipal); - } + async (viewer) => authorizeSitesViewer(site as any, viewer) ); const viewer = await auth.viewer(consumed.sessionId, host); expect(JSON.stringify(viewer)).not.toContain(bearer); expect(JSON.stringify(viewer)).not.toContain('ciphertext'); expect(Array.from(mockRows.values()).some((row) => row.includes(bearer))).toBe(false); - await assertSitesPrincipal(viewerPrincipal); - // Only this token's status changes: the user, browser login and sid remain live. + await authorizeSitesViewer(site as any, viewer); + // V1 accepts revocation freshness bounded by the already-issued grant deadline. mockDirectorySession.mockImplementation(async (token) => (token === bearer ? 'revoked' : 'active')); - await auth.assertLogin(viewer); + mockDirectorySession.mockClear(); + await authorizeSitesViewer(site as any, viewer); + expect(mockDirectorySession).not.toHaveBeenCalled(); await expect(assertSitesPrincipal(rest)).rejects.toMatchObject({ httpStatus: 401 }); - await expect(assertSitesPrincipal(viewerPrincipal)).rejects.toMatchObject({ httpStatus: 401 }); + await expect( + authorizeSitesViewer(site as any, { ...viewer, expiresAt: Math.floor(Date.now() / 1000) }) + ).rejects.toMatchObject({ httpStatus: 401 }); + for (const changed of [ + { ownerSubject: 'other' }, + { servingGeneration: 'new' }, + { accessRevision: 2 }, + { siteId: 'other' }, + ]) { + await expect(authorizeSitesViewer({ ...site, ...changed } as any, viewer)).rejects.toMatchObject({ + httpStatus: expect.any(Number), + }); + } }); diff --git a/src/server/lib/sites/policy.test.ts b/src/server/lib/sites/policy.test.ts index aee0baca..95d9138d 100644 --- a/src/server/lib/sites/policy.test.ts +++ b/src/server/lib/sites/policy.test.ts @@ -5,13 +5,6 @@ import { rememberVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; const mockStatus = jest.fn(); const mockToken = jest.fn(); const mockSession = jest.fn(); -const mockOAuthLogin = jest.fn(); -jest.mock('./browserAuth', () => ({ - getSitesBrowserAuth: () => ({ - assertOAuthLogin: (...args: unknown[]) => mockOAuthLogin(...args), - getViewerOAuthTokenStatus: (...args: unknown[]) => mockSession(...args), - }), -})); jest.mock('server/models/ApiToken', () => ({ __esModule: true, default: { query: () => ({ findById: (...args: unknown[]) => mockToken(...args) }) }, @@ -27,21 +20,25 @@ jest.mock('server/services/keycloak/principalStatus', () => ({ getOAuthTokenStatus: (...args: unknown[]) => mockSession(...args), })); const issuer = 'https://identity.example/realms/lifecycle'; -const human = (overrides: Partial = {}): Principal => ({ - kind: 'user', - authMethod: 'sites_viewer', - issuer, - oauth: { sessionId: 'sid', tokenId: 'jti', clientId: 'ui', expiresAt: Math.floor(Date.now() / 1000) + 300 }, - userId: 'alice', - actor: 'alice', - roles: ['admin'], - scopes: null, - tokenId: null, - repositoryAllowlist: null, - repositoryAllowlistRepoIds: null, - identity: null, - ...overrides, -}); +const human = (overrides: Partial = {}): Principal => { + const principal: Principal = { + kind: 'user', + authMethod: 'session', + issuer, + oauth: { sessionId: 'sid', tokenId: 'jti', clientId: 'ui', expiresAt: Math.floor(Date.now() / 1000) + 300 }, + userId: 'alice', + actor: 'alice', + roles: ['admin'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + ...overrides, + }; + rememberVerifiedOAuthBearer(principal, 'test-bearer'); + return principal; +}; const site = { ownerKind: 'user', ownerIssuer: issuer, ownerSubject: 'alice', creatorTokenId: null } as Site; const previousEnv = { ...process.env }; beforeEach(() => { @@ -49,7 +46,6 @@ beforeEach(() => { process.env.KEYCLOAK_ISSUER = issuer; mockStatus.mockReset().mockResolvedValue('active'); mockSession.mockReset().mockResolvedValue('active'); - mockOAuthLogin.mockReset().mockResolvedValue(undefined); mockToken .mockReset() .mockResolvedValue({ kind: 'personal', ownerUserId: 'alice', ownerIssuer: issuer, scopes: ['sites:write'] }); @@ -133,23 +129,15 @@ test.each(['revoked', 'unknown'])('a %s OAuth session fails closed despite an en test('a legacy OAuth token missing revocation claims cannot access Sites', async () => { await expect(assertSitesPrincipal(human({ oauth: undefined }))).rejects.toMatchObject({ httpStatus: 401 }); }); -test('application logout prevents REST and MCP write authorization even with a live IdP session', async () => { - mockOAuthLogin.mockRejectedValue(new Error('revoked application login')); - for (const authMethod of ['session', 'oauth'] as const) { - const principal = human({ authMethod }); - rememberVerifiedOAuthBearer(principal, 'test-bearer'); - await expect(assertSitesPrincipal(principal, 'write')).rejects.toThrow('revoked application login'); - } -}); test('personal API keys keep their independent lifetime after application or IdP logout', async () => { mockSession.mockResolvedValue('revoked'); - mockOAuthLogin.mockRejectedValue(new Error('revoked application login')); await expect(assertSitesPrincipal(human({ kind: 'personal_key', tokenId: 7 }))).resolves.toBeUndefined(); expect(mockSession).not.toHaveBeenCalled(); - expect(mockOAuthLogin).not.toHaveBeenCalled(); }); test('REST/MCP principals missing their request-only bearer association fail closed', async () => { - await expect(assertSitesPrincipal(human({ authMethod: 'oauth' }))).rejects.toMatchObject({ httpStatus: 401 }); - await expect(assertSitesPrincipal(human({ authMethod: 'session' }))).rejects.toMatchObject({ httpStatus: 401 }); + await expect(assertSitesPrincipal({ ...human({ authMethod: 'oauth' }) })).rejects.toMatchObject({ httpStatus: 401 }); + await expect(assertSitesPrincipal({ ...human({ authMethod: 'session' }) })).rejects.toMatchObject({ + httpStatus: 401, + }); }); diff --git a/src/server/lib/sites/policy.ts b/src/server/lib/sites/policy.ts index 3f148531..c8fa6704 100644 --- a/src/server/lib/sites/policy.ts +++ b/src/server/lib/sites/policy.ts @@ -7,7 +7,6 @@ import type { Principal } from 'server/lib/principal'; import { AppError } from 'server/lib/appError'; import { scopeSatisfies } from 'server/lib/apiTokenScopes'; import { getUserStatus, getOAuthTokenStatus } from 'server/services/keycloak/principalStatus'; -import { getSitesBrowserAuth } from './browserAuth'; import { getVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; export function hasSitesScope(principal: Principal, operation: 'read' | 'write'): boolean { @@ -77,16 +76,7 @@ export async function assertSitesPrincipal( throw new AppError({ httpStatus: 401, code: 'invalid_credential', message: 'A live OAuth session is required.' }); } const bearer = getVerifiedOAuthBearer(principal); - const session = - principal.authMethod === 'sites_viewer' - ? await getSitesBrowserAuth().getViewerOAuthTokenStatus({ - issuer: principal.issuer, - subject: principal.userId, - oauth, - }) - : bearer - ? await getOAuthTokenStatus(bearer) - : 'revoked'; + const session = bearer ? await getOAuthTokenStatus(bearer) : 'revoked'; if (session !== 'active') { throw new AppError({ httpStatus: session === 'unknown' ? 503 : 401, @@ -95,7 +85,6 @@ export async function assertSitesPrincipal( retryable: session === 'unknown', }); } - await getSitesBrowserAuth().assertOAuthLogin({ issuer: principal.issuer, subject: principal.userId, oauth }); } const status = await getUserStatus(principal.userId); if (status !== 'active') { diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index 50626bf3..a74f2bd9 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -257,8 +257,6 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ { method: 'DELETE', route: '/api/v2/repositories/{fullName+}', policy: 'principal', scope: 'repos:write' }, { method: 'GET', route: '/api/v2/schema/validate', policy: 'principal', scope: 'repos:read' }, { method: 'POST', route: '/api/v2/sites/browser/mint', policy: 'principal', scope: 'sites:read', kinds: ['user'] }, - { method: 'POST', route: '/api/v2/sites/browser/bind', policy: 'principal', scope: 'sites:read', kinds: ['user'] }, - { method: 'POST', route: '/api/v2/sites/browser/revoke', policy: 'public' }, { method: 'GET', route: '/api/v2/sites/browser/open/{siteId}', policy: 'public' }, { method: 'GET', route: '/api/v2/sites/capabilities', policy: 'principal', scope: 'sites:read' }, { method: 'PATCH', route: '/api/v2/sites/{siteId}/access', policy: 'principal', scope: 'sites:write' }, diff --git a/src/server/lib/verifiedOAuthBearer.ts b/src/server/lib/verifiedOAuthBearer.ts index 44048d62..06664d01 100644 --- a/src/server/lib/verifiedOAuthBearer.ts +++ b/src/server/lib/verifiedOAuthBearer.ts @@ -1,8 +1,8 @@ import type { Principal } from './principal'; // Request-lifetime only. Bearers must never become enumerable principal fields, -// audit data, or serialized browser Viewer records. Viewer mint may separately -// retain an encrypted, short-lived copy for exact-token introspection. +// audit data, or serialized browser Viewer records. Only incoming-request +// authorization uses this association; Site grants never retain OAuth tokens. const requestBearers = new WeakMap(); export function rememberVerifiedOAuthBearer(principal: Principal, bearer: string): void { diff --git a/src/server/middlewares/auth.test.ts b/src/server/middlewares/auth.test.ts index ac6b451e..70bf358d 100644 --- a/src/server/middlewares/auth.test.ts +++ b/src/server/middlewares/auth.test.ts @@ -23,7 +23,6 @@ jest.mock('server/lib/auth', () => ({ })); import { authMiddleware } from './auth'; -import { requestIdMiddleware } from './requestId'; const VALID_TOKEN = `lfc_${'a'.repeat(40)}`; const originalEnableAuth = process.env.ENABLE_AUTH; @@ -52,23 +51,17 @@ afterAll(() => { }); describe('authMiddleware x-user stripping', () => { - it('preserves a signed revoke POST through the real middleware chain and strips spoofed identity', async () => { - const body = '{"loginId":"signed-test-value"}'; + it('does not bypass normal JWT authentication for the retired revoke route', async () => { + mockVerifyAuth.mockResolvedValue({ success: false, error: { status: 401, message: 'Unauthorized' } }); const request = new NextRequest('https://example.test/api/v2/sites/browser/revoke', { method: 'POST', - body, - headers: { 'x-user': 'spoofed', 'x-lfc-sites-bridge': 'bridge-signature' }, + body: '{}', + headers: { 'x-user': 'spoofed' }, }); - const next = jest.fn().mockResolvedValue(NextResponse.next()); - await requestIdMiddleware(request, (forwarded) => authMiddleware(forwarded, next)); - expect(mockVerifyAuth).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - const forwarded = forwardedRequest(next); - expect(forwarded.method).toBe('POST'); - expect(await forwarded.text()).toBe(body); - expect(forwarded.headers.get('x-user')).toBeNull(); - expect(forwarded.headers.get('x-lfc-sites-bridge')).toBe('bridge-signature'); - expect(forwarded.headers.has('x-request-id')).toBe(true); + const { next, result } = await runMiddleware(request); + expect(mockVerifyAuth).toHaveBeenCalledTimes(1); + expect(next).not.toHaveBeenCalled(); + expect(result.status).toBe(401); }); it('strips a crafted x-user when ENABLE_AUTH is off', async () => { process.env.ENABLE_AUTH = 'false'; diff --git a/src/server/middlewares/auth.ts b/src/server/middlewares/auth.ts index b47370be..61561bb2 100644 --- a/src/server/middlewares/auth.ts +++ b/src/server/middlewares/auth.ts @@ -68,11 +68,6 @@ export const authMiddleware: Middleware = async (request, next) => { return forwardWithoutUser(request, next); } - // Sites logout uses a dedicated bridge signature and must work after OAuth expiry. - if (request.method === 'POST' && request.nextUrl.pathname === '/api/v2/sites/browser/revoke') { - return forwardWithoutUser(request, next); - } - if (MCP_OAUTH_CALLBACK_PATH.test(request.nextUrl.pathname)) { return forwardWithoutUser(request, next); } diff --git a/src/server/middlewares/requestId.test.ts b/src/server/middlewares/requestId.test.ts index 3408befc..9fc4bbf6 100644 --- a/src/server/middlewares/requestId.test.ts +++ b/src/server/middlewares/requestId.test.ts @@ -62,18 +62,18 @@ describe('requestIdMiddleware', () => { }); it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])('preserves native %s request semantics', async (method) => { - const body = ['GET', 'HEAD'].includes(method) ? undefined : '{"action":"signed-payload"}'; - const request = new NextRequest('https://example.test/api/v2/sites/browser/revoke', { + const body = ['GET', 'HEAD'].includes(method) ? undefined : '{"action":"payload"}'; + const request = new NextRequest('https://example.test/api/v2/sites/browser/mint', { method, body, - headers: { 'x-lfc-sites-bridge': 'signature-value' }, + headers: { 'content-type': 'application/json' }, }); const next = jest.fn().mockResolvedValue(NextResponse.next()); await requestIdMiddleware(request, next); const forwarded = next.mock.calls[0][0] as NextRequest; expect(forwarded.method).toBe(method); expect(await forwarded.text()).toBe(body || ''); - expect(forwarded.headers.get('x-lfc-sites-bridge')).toBe('signature-value'); + expect(forwarded.headers.get('content-type')).toBe('application/json'); }); it('preserves a POST arriving from the installed Edge Request constructor realm', async () => { @@ -81,11 +81,11 @@ describe('requestIdMiddleware', () => { // miss Edge instances that otherwise silently become GET without a body. const { EdgeRuntime } = require('next/dist/compiled/edge-runtime'); const runtime = new EdgeRuntime(); - const body = '{"action":"cross-realm-signed-payload"}'; - const foreignRequest = new runtime.context.Request('https://example.test/api/v2/sites/browser/revoke', { + const body = '{"action":"cross-realm-payload"}'; + const foreignRequest = new runtime.context.Request('https://example.test/api/v2/sites/browser/mint', { method: 'POST', body, - headers: { 'x-lfc-sites-bridge': 'signature-value' }, + headers: { 'content-type': 'application/json' }, }); expect(foreignRequest instanceof Request).toBe(false); const next = jest.fn().mockResolvedValue(NextResponse.next()); @@ -93,6 +93,6 @@ describe('requestIdMiddleware', () => { const forwarded = next.mock.calls[0][0] as NextRequest; expect(forwarded.method).toBe('POST'); expect(await forwarded.text()).toBe(body); - expect(forwarded.headers.get('x-lfc-sites-bridge')).toBe('signature-value'); + expect(forwarded.headers.get('content-type')).toBe('application/json'); }); }); diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index 20c5f500..a506a12d 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -141,7 +141,6 @@ jest.mock('server/services/keycloak/principalStatus', () => ({ jest.mock('server/lib/verifiedOAuthBearer', () => ({ getVerifiedOAuthBearer: jest.fn(() => 'fixture-bearer') })); jest.mock('server/lib/sites/browserAuth', () => ({ ...jest.requireActual('server/lib/sites/browserAuth'), - getSitesBrowserAuth: () => ({ assertOAuthLogin: async () => undefined }), })); const originalSitesEnv = { ...process.env }; beforeEach(() => { @@ -149,7 +148,6 @@ beforeEach(() => { process.env.KEYCLOAK_ISSUER = principal.issuer!; process.env.SITES_PRIVATE_ENABLED = 'true'; process.env.SITES_UI_ORIGIN = 'https://lifecycle.example.net'; - process.env.SITES_BROWSER_BRIDGE_SECRET = 'fixture-bridge-secret-at-least-thirty-two-bytes'; }); afterEach(() => { process.env = { ...originalSitesEnv }; diff --git a/src/server/services/__tests__/sites.test.ts b/src/server/services/__tests__/sites.test.ts index 968a07bb..a7e9ed5a 100644 --- a/src/server/services/__tests__/sites.test.ts +++ b/src/server/services/__tests__/sites.test.ts @@ -89,7 +89,6 @@ jest.mock('server/services/keycloak/principalStatus', () => ({ jest.mock('server/lib/verifiedOAuthBearer', () => ({ getVerifiedOAuthBearer: jest.fn(() => 'fixture-bearer') })); jest.mock('server/lib/sites/browserAuth', () => ({ ...jest.requireActual('server/lib/sites/browserAuth'), - getSitesBrowserAuth: () => ({ assertOAuthLogin: async () => undefined }), })); const originalSitesEnv = { ...process.env }; beforeEach(() => { @@ -97,7 +96,6 @@ beforeEach(() => { process.env.KEYCLOAK_ISSUER = principal.issuer!; process.env.SITES_PRIVATE_ENABLED = 'true'; process.env.SITES_UI_ORIGIN = 'https://lifecycle.example.net'; - process.env.SITES_BROWSER_BRIDGE_SECRET = 'fixture-bridge-secret-at-least-thirty-two-bytes'; }); afterEach(() => { process.env = { ...originalSitesEnv }; diff --git a/src/shared/__tests__/openApiSecurity.test.ts b/src/shared/__tests__/openApiSecurity.test.ts index 01f2d861..b4197400 100644 --- a/src/shared/__tests__/openApiSecurity.test.ts +++ b/src/shared/__tests__/openApiSecurity.test.ts @@ -61,49 +61,24 @@ function isBearerAuthOrLifecycleKey(security: unknown): boolean { ); } -// A public wrapper removes platform authentication, not any authentication performed by the handler. -// Each listed bridge requirement is mandatory in the SAME object as any allowed principal scheme (AND). -const HANDLER_SECURITY: Record> = { - 'POST /api/v2/sites/browser/bind': { SitesBrowserBridge: [] }, - 'POST /api/v2/sites/browser/mint': { SitesBrowserBridge: [] }, - 'POST /api/v2/sites/browser/revoke': { SitesBrowserBridge: [] }, -}; - function principalSecurity(entry: Extract) { const kinds = entry.kinds ?? ['user', 'personal_key', 'service_key']; - const handlerSecurity = HANDLER_SECURITY[keyOf(entry)] ?? {}; return [ - ...(kinds.includes('user') ? [{ BearerAuth: [], ...handlerSecurity }] : []), - ...(kinds.some((kind) => kind === 'personal_key' || kind === 'service_key') - ? [{ LifecycleApiKey: [], ...handlerSecurity }] - : []), + ...(kinds.includes('user') ? [{ BearerAuth: [] }] : []), + ...(kinds.some((kind) => kind === 'personal_key' || kind === 'service_key') ? [{ LifecycleApiKey: [] }] : []), ]; } describe('OpenAPI v2 security contract', () => { it('preserves BearerAuth while declaring descriptive session and Lifecycle API-key schemes', () => { const schemes = spec.components.securitySchemes; - expect(Object.keys(schemes).sort()).toEqual([ - 'BearerAuth', - 'KeycloakBearer', - 'LifecycleApiKey', - 'SitesBrowserBridge', - ]); + expect(Object.keys(schemes).sort()).toEqual(['BearerAuth', 'KeycloakBearer', 'LifecycleApiKey']); expect(schemes.BearerAuth).toMatchObject({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }); expect(schemes.KeycloakBearer).toMatchObject({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }); expect(schemes.LifecycleApiKey).toMatchObject({ type: 'http', scheme: 'bearer' }); expect(schemes.LifecycleApiKey.description).toMatch(/lfc_pat_/); expect(schemes.LifecycleApiKey.description).toMatch(/lfc_svc_/); - expect(schemes.SitesBrowserBridge).toMatchObject({ - type: 'apiKey', - in: 'header', - name: 'x-lfc-sites-bridge', - }); - expect(schemes.SitesBrowserBridge.description).toContain('single-use'); - expect(schemes.SitesBrowserBridge.description).toContain('SITES_BROWSER_BRIDGE_SECRET'); - expect(schemes.SitesBrowserBridge.description).not.toContain('SITES_UI_BRIDGE_SECRET'); }); - it('defaults globally to the Keycloak session only', () => { expect(spec.security).toEqual([{ BearerAuth: [] }]); }); @@ -149,55 +124,33 @@ describe('OpenAPI v2 security contract', () => { expect(leaked).toEqual([]); }); - it('covers every public operation, including signature-only logout and anonymous public-link resolution', () => { + it('covers every public operation, including anonymous public-link resolution', () => { for (const entry of V2_ROUTE_POLICY_MANIFEST) { if (entry.policy !== 'public') continue; - const extra = HANDLER_SECURITY[keyOf(entry)]; expect({ operation: keyOf(entry), security: operationFor(entry)?.security }).toEqual({ operation: keyOf(entry), - security: extra ? [extra] : [], + security: [], }); } }); - it('documents OAuth AND the bridge signature for bind/mint, but only the signature for revoke', () => { - for (const action of ['bind', 'mint']) { - const route = `/api/v2/sites/browser/${action}`; - expect(V2_ROUTE_POLICY_MANIFEST.find((entry) => entry.route === route)).toMatchObject({ - method: 'POST', - policy: 'principal', - kinds: ['user'], - }); - expect(spec.paths[route].post.security).toEqual([{ BearerAuth: [], SitesBrowserBridge: [] }]); - } - expect(spec.paths['/api/v2/sites/browser/revoke'].post.security).toEqual([{ SitesBrowserBridge: [] }]); + it('documents only JWT-authenticated mint and its minimal body', () => { + const op = spec.paths['/api/v2/sites/browser/mint'].post; + expect(op.security).toEqual([{ BearerAuth: [] }]); + expect(op.requestBody.content['application/json'].schema).toEqual({ + $ref: '#/components/schemas/SitesBrowserMintRequest', + }); + expect(spec.components.schemas.SitesBrowserMintRequest.required).toEqual(['siteId', 'state']); + expect(spec.components.schemas.SitesBrowserMintRequest.additionalProperties).toBe(false); + expect( + Object.keys(op.responses['200'].content['application/json'].schema.properties.data.properties).sort() + ).toEqual(['consumeUrl', 'ticket']); expect(spec.paths['/api/v2/sites/browser/open/{siteId}'].get.security).toEqual([]); - for (const key of Object.keys(HANDLER_SECURITY)) { - expect(V2_ROUTE_POLICY_MANIFEST.some((entry) => keyOf(entry) === key)).toBe(true); - } - }); - - it('documents concrete signed request bodies and success results without exposing OAuth credentials', () => { - const requests = { - bind: 'SitesBrowserBindRequest', - mint: 'SitesBrowserMintRequest', - revoke: 'SitesBrowserLoginRequest', - }; - const results = { bind: ['bound'], mint: ['consumeUrl', 'ticket'], revoke: ['revoked'] }; - for (const action of ['bind', 'mint', 'revoke'] as const) { - const op = spec.paths[`/api/v2/sites/browser/${action}`].post; - expect(op.requestBody.required).toBe(true); - expect(op.requestBody.content['application/json'].schema).toEqual({ - $ref: `#/components/schemas/${requests[action]}`, - }); - const response = op.responses['200'].content['application/json'].schema; - expect(response.required).toEqual(['data']); - expect(Object.keys(response.properties.data.properties).sort()).toEqual(results[action]); - expect([...response.properties.data.required].sort()).toEqual(results[action]); + for (const action of ['bind', 'revoke']) { + expect(spec.paths[`/api/v2/sites/browser/${action}`]).toBeUndefined(); } - expect(spec.components.schemas.SitesBrowserLoginRequest.required).toEqual(['loginId', 'loginExpiresAt']); - expect(spec.components.schemas.SitesBrowserBindRequest.allOf[1].properties.createLogin.default).toBe(false); - expect(spec.components.schemas.SitesBrowserMintRequest.allOf[1].required).toEqual(['state', 'siteId']); + expect(spec.components.schemas.SitesBrowserLoginRequest).toBeUndefined(); + expect(spec.components.schemas.SitesBrowserBindRequest).toBeUndefined(); }); it('documents auth/context with the OR form under operationId getAuthContext', () => { diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 6277aa10..24e648f1 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -167,70 +167,25 @@ export const openApiSpecificationForV2Api: OAS3Options = { 'service keys "lfc_svc_", and legacy keys "lfc_". Authority is the owner authority ' + 'intersected with the key scopes and repository constraint. Accepted only on programmatic routes.', }, - SitesBrowserBridge: { - type: 'apiKey', - in: 'header', - name: 'x-lfc-sites-bridge', - description: - 'Internal UI-to-core request signature, not a Lifecycle API key or the raw shared secret. ' + - 'The header is .<43-character-base64url-nonce>.. ' + - 'Sign the newline-separated values sites-v1, action (bind, mint, or revoke), timestamp, nonce, ' + - 'and the hex SHA-256 of the exact request body using SITES_BROWSER_BRIDGE_SECRET. ' + - 'Timestamps allow 30 seconds of skew; nonces are single-use. Keep the signing secret server-side.', - }, }, schemas: { - SitesBrowserLoginRequest: { + SitesBrowserMintRequest: { type: 'object', - required: ['loginId', 'loginExpiresAt'], + additionalProperties: false, + required: ['siteId', 'state'], properties: { - loginId: { + siteId: { type: 'string', - pattern: '^[A-Za-z0-9_-]{43}$', - description: 'Server-generated application-login nonce, distinct from the identity provider SSO session.', + pattern: '^[a-z0-9-]{1,64}$', + description: 'Site identifier matching the bootstrap challenge.', }, - loginExpiresAt: { - type: 'integer', - description: - 'Absolute application-login expiry in Unix seconds. Binding and minting require a future expiry; ' + - 'revocation also accepts an expired login. The server limits future expiry to its login lifetime.', + state: { + type: 'string', + pattern: '^[A-Za-z0-9_-]{43}$', + description: 'Opaque, unexpired bootstrap challenge issued by the content gateway.', }, }, }, - SitesBrowserBindRequest: { - allOf: [ - { $ref: '#/components/schemas/SitesBrowserLoginRequest' }, - { - type: 'object', - properties: { - createLogin: { - type: 'boolean', - default: false, - description: - 'True only for an explicit OAuth sign-in with a newly generated login nonce. ' + - 'Refresh requests must omit this or send false and match an existing unrevoked login.', - }, - }, - }, - ], - }, - SitesBrowserMintRequest: { - allOf: [ - { $ref: '#/components/schemas/SitesBrowserLoginRequest' }, - { - type: 'object', - required: ['state', 'siteId'], - properties: { - state: { - type: 'string', - pattern: '^[A-Za-z0-9_-]{43}$', - description: 'Opaque, unexpired bootstrap challenge issued by the content gateway.', - }, - siteId: { type: 'string', description: 'Site identifier matching the bootstrap challenge.' }, - }, - }, - ], - }, // =================================================================== // Core Reusable Schemas // =================================================================== From d2af3d1f651dcfb1e38b38619ebfd89ab2760012 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Wed, 16 Sep 2026 09:41:43 -0700 Subject: [PATCH 03/12] refactor(sites): reuse existing configuration Use the established UI URL, authentication setting and Sites configuration instead of private- site-specific settings. --- .../api/v2/sites/browser/mint/route.test.ts | 3 +- src/server/lib/sites/browserAuth.test.ts | 46 ++++++++++++------- src/server/lib/sites/browserAuth.ts | 7 ++- src/server/lib/sites/gateway.test.ts | 25 +++++----- src/server/lib/sites/gateway.ts | 24 +--------- src/server/lib/sites/oauthRevocation.test.ts | 5 +- .../services/__tests__/sites.behavior.test.ts | 27 ++++++++--- src/server/services/__tests__/sites.test.ts | 3 +- src/server/services/sites.ts | 6 +-- 9 files changed, 75 insertions(+), 71 deletions(-) diff --git a/src/app/api/v2/sites/browser/mint/route.test.ts b/src/app/api/v2/sites/browser/mint/route.test.ts index 3d777597..abeab0d1 100644 --- a/src/app/api/v2/sites/browser/mint/route.test.ts +++ b/src/app/api/v2/sites/browser/mint/route.test.ts @@ -55,8 +55,7 @@ const originalEnv = { ...process.env }; beforeEach(() => { process.env.ENABLE_AUTH = 'true'; process.env.KEYCLOAK_ISSUER = issuer; - process.env.SITES_PRIVATE_ENABLED = 'true'; - process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; + process.env.LIFECYCLE_UI_URL = 'https://ui.example.com'; mockRedisRows.clear(); principal = { kind: 'user', diff --git a/src/server/lib/sites/browserAuth.test.ts b/src/server/lib/sites/browserAuth.test.ts index 52e95efb..65a0053c 100644 --- a/src/server/lib/sites/browserAuth.test.ts +++ b/src/server/lib/sites/browserAuth.test.ts @@ -5,6 +5,7 @@ import { siteTokenHash, challengeCookieName, assertPrivateSitesReady, + sitesUiOrigin, assertViewerOwns, safeSiteReturnPath, parseSitesCookies, @@ -67,8 +68,7 @@ beforeEach(() => { process.env.ENABLE_AUTH = 'true'; process.env.KEYCLOAK_ISSUER = actor.issuer; actor.oauth.expiresAt = Math.floor(Date.now() / 1000) + 1200; - process.env.SITES_PRIVATE_ENABLED = 'true'; - process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; + process.env.LIFECYCLE_UI_URL = 'https://ui.example.com'; store = new Store(); auth = new SitesBrowserAuth(store as any); }); @@ -93,8 +93,8 @@ it('consumes only once across two gateway instances and creates an owner-scoped assertViewerOwns(site, viewer); }); const outcomes = await Promise.allSettled([ - auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, authorize), - second.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, authorize), + auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, authorize), + second.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, authorize), ]); expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1); const value = (outcomes.find((result) => result.status === 'fulfilled') as PromiseFulfilledResult).value; @@ -110,7 +110,7 @@ it.each(['wrong-origin', 'wrong-host', 'wrong-cookie'])('rejects %s before autho minted.ticket, scenario === 'wrong-host' ? 'another.sites.example.net' : host, scenario === 'wrong-cookie' ? {} : cookies, - scenario === 'wrong-origin' ? 'https://evil.example.org' : process.env.SITES_UI_ORIGIN, + scenario === 'wrong-origin' ? 'https://evil.example.org' : process.env.LIFECYCLE_UI_URL, authorize ) ).rejects.toBeInstanceOf(SitesBrowserError); @@ -121,7 +121,7 @@ it('expires one-use tickets after 60 seconds', async () => { const { minted, cookies } = await setup(); jest.advanceTimersByTime(61_000); await expect( - auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, jest.fn()) + auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, jest.fn()) ).rejects.toMatchObject({ statusCode: 401 }); }); it('never lets a different actor or service-key owner mint a viewer', async () => { @@ -136,7 +136,7 @@ it('never lets a different actor or service-key owner mint a viewer', async () = it('runs current authorization on consume and returns no session when ownership/generation changes', async () => { const { minted, cookies } = await setup(); await expect( - auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => { + auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => { throw new SitesBrowserError(404); }) ).rejects.toMatchObject({ statusCode: 404 }); @@ -144,7 +144,7 @@ it('runs current authorization on consume and returns no session when ownership/ }); it('auth-off denies already issued cookies; content logout removes its viewer', async () => { const { minted, cookies } = await setup(); - const result = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); + const result = await auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}); process.env.ENABLE_AUTH = 'false'; await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 503 }); await auth.logoutViewer(result.sessionId); @@ -154,17 +154,29 @@ it('auth-off denies already issued cookies; content logout removes its viewer', it('fails closed on Redis failure', async () => { const { minted, cookies } = await setup(); store.eval = jest.fn().mockRejectedValue(new Error('redis down')); - await expect(auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, jest.fn())).rejects.toThrow( + await expect(auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, jest.fn())).rejects.toThrow( 'redis down' ); }); -it('requires explicitly enabled HTTPS, separate registrable domain and the rollout switch', () => { +it('requires authentication, HTTPS and a separate registrable domain', () => { expect(() => assertPrivateSitesReady(`https://${host}`)).not.toThrow(); expect(() => assertPrivateSitesReady('https://sites.example.com')).toThrow(); expect(() => assertPrivateSitesReady('http://sites.example.net')).toThrow(); - process.env.SITES_PRIVATE_ENABLED = 'false'; + process.env.ENABLE_AUTH = 'false'; expect(() => assertPrivateSitesReady()).toThrow(); }); +it('derives the trusted origin from the existing UI URL', () => { + process.env.LIFECYCLE_UI_URL = 'https://UI.example.com:443/app/?q=test#section'; + expect(sitesUiOrigin()).toBe('https://ui.example.com'); + expect(() => assertPrivateSitesReady(`https://${host}`)).not.toThrow(); +}); +it.each(['', 'not-a-url', 'http://ui.example.com', 'https://user:password@ui.example.com'])( + 'rejects unsafe or missing private UI URL %s', + (url) => { + process.env.LIFECYCLE_UI_URL = url; + expect(() => assertPrivateSitesReady()).toThrow(); + } +); it('rejects unsafe return paths and ambiguous cookies', () => { for (const path of [ '//evil.test', @@ -198,9 +210,9 @@ it('bounds chunked mint request bodies before parsing without Content-Length', a it('consumes at most one ticket from the same browser challenge', async () => { const { minted, login, cookies } = await setup(); const second = await auth.mint(site, actor, login, actor.oauth.expiresAt); - await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); + await auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}); await expect( - auth.consume(second.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}) + auth.consume(second.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}) ).rejects.toMatchObject({ statusCode: 401 }); }); it.each([30, 300, 1200])('caps fixed viewer lifetime to token expiry or 300 seconds: %s', async (tokenSeconds) => { @@ -213,7 +225,7 @@ it.each([30, 300, 1200])('caps fixed viewer lifetime to token expiry or 300 seco minted.ticket, host, { [challengeCookieName(challenge.state)]: challenge.secret }, - process.env.SITES_UI_ORIGIN, + process.env.LIFECYCLE_UI_URL, async () => {} ); const viewer = await auth.viewer(result.sessionId, host); @@ -240,14 +252,14 @@ it('supports authenticated renewal with a fresh token but cannot renew with an e renewed.ticket, host, { [challengeCookieName(challenge.state)]: challenge.secret }, - process.env.SITES_UI_ORIGIN, + process.env.LIFECYCLE_UI_URL, async () => {} ); expect((await auth.viewer(result.sessionId, host)).expiresAt).toBe(freshDeadline); }); it('retains only Site claims, no OAuth login, token metadata or bearer vault', async () => { const { minted, cookies } = await setup(); - const result = await auth.consume(minted.ticket, host, cookies, process.env.SITES_UI_ORIGIN, async () => {}); + const result = await auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}); expect(Object.keys(await auth.viewer(result.sessionId, host)).sort()).toEqual([ 'accessRevision', 'expiresAt', @@ -268,7 +280,7 @@ it('rejects previous-version viewers and tickets', async () => { await store.set(`sites:browser:v1:${kind}:${siteTokenHash(token)}`, JSON.stringify({ ...actor, host }), 'EX', 300); } await expect(auth.viewer(token, host)).rejects.toMatchObject({ statusCode: 401 }); - await expect(auth.consume(token, host, {}, process.env.SITES_UI_ORIGIN, jest.fn())).rejects.toMatchObject({ + await expect(auth.consume(token, host, {}, process.env.LIFECYCLE_UI_URL, jest.fn())).rejects.toMatchObject({ statusCode: 401, }); }); diff --git a/src/server/lib/sites/browserAuth.ts b/src/server/lib/sites/browserAuth.ts index b87abedf..88ceee6f 100644 --- a/src/server/lib/sites/browserAuth.ts +++ b/src/server/lib/sites/browserAuth.ts @@ -49,18 +49,17 @@ export const siteTokenHash = (value: string) => createHash('sha256').update(valu const nowSeconds = () => Math.floor(Date.now() / 1000); export function sitesUiOrigin(): string { - const raw = process.env.SITES_UI_ORIGIN; + const raw = process.env.LIFECYCLE_UI_URL; try { const url = new URL(raw || ''); - if (url.protocol !== 'https:' || url.username || url.password || url.origin !== raw) throw new Error(); + if (url.protocol !== 'https:' || url.username || url.password) throw new Error(); return url.origin; } catch { throw new SitesBrowserError(503); } } export function assertPrivateSitesReady(contentUrl?: string): void { - if (process.env.ENABLE_AUTH !== 'true' || process.env.SITES_PRIVATE_ENABLED !== 'true') - throw new SitesBrowserError(503); + if (process.env.ENABLE_AUTH !== 'true') throw new SitesBrowserError(503); const ui = new URL(sitesUiOrigin()); if (contentUrl) { const content = new URL(contentUrl); diff --git a/src/server/lib/sites/gateway.test.ts b/src/server/lib/sites/gateway.test.ts index 90a148f8..8fa979c2 100644 --- a/src/server/lib/sites/gateway.test.ts +++ b/src/server/lib/sites/gateway.test.ts @@ -69,8 +69,7 @@ function service(current = site) { const originalEnv = { ...process.env }; beforeEach(() => { process.env.ENABLE_AUTH = 'true'; - process.env.SITES_PRIVATE_ENABLED = 'true'; - process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; + process.env.LIFECYCLE_UI_URL = 'https://ui.example.com'; jest.clearAllMocks(); browser.viewer.mockResolvedValue(viewer); (assertSitesPrincipal as jest.Mock).mockResolvedValue(undefined); @@ -155,15 +154,19 @@ it('public HEAD does not inspect viewer credentials', async () => { expect(assertSitesPrincipal).not.toHaveBeenCalled(); }); -it('ignores forged forwarding headers from an untrusted peer and follows only configured proxies', () => { - const { sitesClientAddress } = require('./gateway'); - process.env.SITES_TRUSTED_PROXY_ADDRESSES = '127.0.0.1'; - const direct = request('GET', { 'x-forwarded-for': '1.1.1.1' }); - direct.socket.remoteAddress = '203.0.113.5'; - expect(sitesClientAddress(direct)).toBe('203.0.113.5'); - direct.socket.remoteAddress = '::ffff:127.0.0.1'; - direct.headers['x-forwarded-for'] = '1.1.1.1, 203.0.113.5'; - expect(sitesClientAddress(direct)).toBe('203.0.113.5'); +it('ignores forwarded addresses when rate limiting bootstrap requests', async () => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + browser.challenge.mockResolvedValue({ state: 's'.repeat(43), secret: 'c'.repeat(43) }); + for (const forwarded of ['1.1.1.1', '2.2.2.2']) { + const req = request('GET', { + 'x-forwarded-for': forwarded, + 'sec-fetch-mode': 'navigate', + 'sec-fetch-dest': 'document', + }); + req.socket.remoteAddress = '203.0.113.5'; + await handleSitesRequest(req, response(), service()); + } + expect(browser.rateLimit.mock.calls).toEqual([['challenge:203.0.113.5'], ['challenge:203.0.113.5']]); }); it.each([ diff --git a/src/server/lib/sites/gateway.ts b/src/server/lib/sites/gateway.ts index 1eede831..1cfa0946 100644 --- a/src/server/lib/sites/gateway.ts +++ b/src/server/lib/sites/gateway.ts @@ -1,5 +1,4 @@ import type { IncomingMessage, ServerResponse } from 'http'; -import { isIP } from 'net'; import type Site from 'server/models/Site'; import { assertViewerOwns, @@ -15,25 +14,6 @@ import { } from './browserAuth'; import type SitesService from 'server/services/sites'; -export function sitesClientAddress(req: Pick): string { - const normalize = (value: string) => - value.startsWith('::ffff:') && isIP(value.slice(7)) === 4 ? value.slice(7) : value; - const peer = normalize(req.socket.remoteAddress || 'unknown'); - const trusted = new Set( - (process.env.SITES_TRUSTED_PROXY_ADDRESSES || '') - .split(',') - .map((value) => normalize(value.trim())) - .filter((value) => isIP(value)) - ); - if (!trusted.has(peer)) return peer; - const forwarded = req.headers['x-forwarded-for']; - if (typeof forwarded !== 'string' || forwarded.length > 2048) return peer; - const chain = forwarded.split(',').map((value) => normalize(value.trim())); - if (chain.some((value) => !isIP(value))) return peer; - for (const address of chain.reverse()) if (!trusted.has(address)) return address; - return peer; -} - export async function authorizeSitesViewer(site: Site, viewer: Viewer): Promise { assertViewerActive(viewer); assertViewerOwns(site, viewer); @@ -79,7 +59,7 @@ export async function handleSitesRequest( // private hosts take the same browser flow; mint still authorizes the real Site. const locator = await service.getGatewayLocator(host); const browser = getSitesBrowserAuth(); - await browser.rateLimit(`challenge:${sitesClientAddress(req)}`); + await browser.rateLimit(`challenge:${req.socket.remoteAddress || 'unknown'}`); const challenge = await browser.challenge(locator, host, `${url.pathname}${url.search}`); res.setHeader( 'Set-Cookie', @@ -98,7 +78,7 @@ export async function handleSitesRequest( if (url.pathname === `${SITES_AUTH_PATH}consume` && req.method === 'POST') { if (!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) throw new SitesBrowserError(415); - await auth!.rateLimit(`consume:${sitesClientAddress(req)}`, 240); + await auth!.rateLimit(`consume:${req.socket.remoteAddress || 'unknown'}`, 240); const values = new URLSearchParams(await readSmallBody(req)); if (values.getAll('ticket').length !== 1) throw new SitesBrowserError(400); const result = await auth!.consume( diff --git a/src/server/lib/sites/oauthRevocation.test.ts b/src/server/lib/sites/oauthRevocation.test.ts index db6374b7..7a87e80d 100644 --- a/src/server/lib/sites/oauthRevocation.test.ts +++ b/src/server/lib/sites/oauthRevocation.test.ts @@ -52,8 +52,7 @@ beforeAll(async () => { process.env.KEYCLOAK_ISSUER = issuer; process.env.KEYCLOAK_CLIENT_ID = 'lifecycle-api'; process.env.KEYCLOAK_JWKS_URL = 'https://identity.example/test-jwks'; - process.env.SITES_PRIVATE_ENABLED = 'true'; - process.env.SITES_UI_ORIGIN = 'https://ui.example.com'; + process.env.LIFECYCLE_UI_URL = 'https://ui.example.com'; process.env.APP_HOST = 'https://api.example'; }); beforeEach(() => { @@ -149,7 +148,7 @@ test('issued viewers use only their fixed grant deadline; new authorization stil minted.ticket, host, { [challengeCookieName(challenge.state)]: challenge.secret }, - process.env.SITES_UI_ORIGIN, + process.env.LIFECYCLE_UI_URL, async (viewer) => authorizeSitesViewer(site as any, viewer) ); const viewer = await auth.viewer(consumed.sessionId, host); diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index a506a12d..f2292de2 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -146,8 +146,7 @@ const originalSitesEnv = { ...process.env }; beforeEach(() => { process.env.ENABLE_AUTH = 'true'; process.env.KEYCLOAK_ISSUER = principal.issuer!; - process.env.SITES_PRIVATE_ENABLED = 'true'; - process.env.SITES_UI_ORIGIN = 'https://lifecycle.example.net'; + process.env.LIFECYCLE_UI_URL = 'https://lifecycle.example.net'; }); afterEach(() => { process.env = { ...originalSitesEnv }; @@ -1230,14 +1229,28 @@ describe('SitesService behavior', () => { }) ); }); - it('advertises no human upload choices while private readiness is disabled', async () => { - process.env.SITES_PRIVATE_ENABLED = 'false'; + it('advertises no uploads while the existing Sites setting is disabled', async () => { + mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); await expect(service.getCapabilities(principal)).resolves.toMatchObject({ defaultVisibility: 'private', canCreate: false, allowedVisibilities: [], }); }); + it('denies public and private gateway reads when the existing Sites setting is disabled', async () => { + addSite(state, { siteId: 'private', visibility: 'private' }); + addSite(state, { siteId: 'public', visibility: 'public' }); + mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false, domain: 'sites.example.com' } }); + for (const id of ['private', 'public']) { + const host = `site-${id}.sites.example.com`; + await expect(service.getGatewaySite(host)).rejects.toMatchObject({ statusCode: 404 }); + await expect(service.getGatewayLocator(host)).rejects.toMatchObject({ statusCode: 404 }); + await expect(service.getGatewayObject(host, '/index.html', async () => {})).rejects.toMatchObject({ + statusCode: 404, + }); + } + expect(mockGetObject).not.toHaveBeenCalled(); + }); it('resolves valid missing host locators without a database Site for the anonymous bootstrap', async () => { await expect(service.getGatewayLocator('site-missing--g-abcdef012345.sites.example.com')).resolves.toEqual({ siteId: 'missing', @@ -1255,12 +1268,12 @@ describe('SitesService behavior', () => { }); }); it.each([undefined, 'public'] as const)( - 'refuses human creation (%s) before storage if the secure rollout gate is closed', + 'refuses human creation (%s) before storage if the existing Sites setting is disabled', async (visibility) => { - process.env.SITES_PRIVATE_ENABLED = 'false'; + mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); await expect( service.createSite({ principal, visibility, fileName: 'index.html', content: Buffer.from('site') }) - ).rejects.toMatchObject({ code: 'private_sites_unavailable' }); + ).rejects.toMatchObject({ statusCode: 404 }); expect(mockPutFiles).not.toHaveBeenCalled(); } ); diff --git a/src/server/services/__tests__/sites.test.ts b/src/server/services/__tests__/sites.test.ts index a7e9ed5a..27b2962b 100644 --- a/src/server/services/__tests__/sites.test.ts +++ b/src/server/services/__tests__/sites.test.ts @@ -94,8 +94,7 @@ const originalSitesEnv = { ...process.env }; beforeEach(() => { process.env.ENABLE_AUTH = 'true'; process.env.KEYCLOAK_ISSUER = principal.issuer!; - process.env.SITES_PRIVATE_ENABLED = 'true'; - process.env.SITES_UI_ORIGIN = 'https://lifecycle.example.net'; + process.env.LIFECYCLE_UI_URL = 'https://lifecycle.example.net'; }); afterEach(() => { process.env = { ...originalSitesEnv }; diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index 5f0a5859..ea937c0f 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -145,7 +145,7 @@ export default class SitesService extends Service { const owner = isSiteOwner(site, principal); const writable = owner && Boolean(principal && hasSitesScope(principal, 'write')); const contentUrl = buildSiteUrl(site.siteId, config, site.servingGeneration); - const uiOrigin = process.env.SITES_UI_ORIGIN?.replace(/\/$/, ''); + const uiUrl = process.env.LIFECYCLE_UI_URL; const expiresAt = site.expiresAt ? new Date(site.expiresAt).getTime() : null; const status = site.status === 'active' && expiresAt !== null && Number.isFinite(expiresAt) && expiresAt <= Date.now() @@ -156,7 +156,7 @@ export default class SitesService extends Service { name: site.name, url: contentUrl, contentUrl, - openUrl: uiOrigin ? `${uiOrigin}/sites/open/${site.siteId}` : contentUrl, + openUrl: uiUrl ? new URL(`/sites/open/${site.siteId}`, uiUrl).toString() : contentUrl, visibility: site.visibility, accessRevision: site.accessRevision, contentRevision: site.contentRevision, @@ -218,7 +218,7 @@ export default class SitesService extends Service { throw new AppError({ httpStatus: 503, code: 'private_sites_unavailable', - message: 'Private Sites are not enabled or securely configured on this installation.', + message: 'Private Sites are not securely configured on this installation.', }); } } From 4343694b08c158832f6fd269b81d848bcdaf9077 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Mon, 21 Sep 2026 15:28:43 -0700 Subject: [PATCH 04/12] refactor(sites): preserve config read access and remove unused logout route --- src/app/api/v2/config/sites/route.test.ts | 11 ++- src/app/api/v2/config/sites/route.ts | 2 +- .../environments-api-acceptance.test.ts | 6 +- src/server/lib/sites/browserAuth.test.ts | 5 +- src/server/lib/sites/browserAuth.ts | 3 - src/server/lib/sites/gateway.test.ts | 1 - src/server/lib/sites/gateway.ts | 12 +-- src/server/lib/v2RoutePolicyManifest.ts | 2 +- ws-server.test.ts | 73 ++----------------- 9 files changed, 23 insertions(+), 92 deletions(-) diff --git a/src/app/api/v2/config/sites/route.test.ts b/src/app/api/v2/config/sites/route.test.ts index bf00d0a8..29c508d3 100644 --- a/src/app/api/v2/config/sites/route.test.ts +++ b/src/app/api/v2/config/sites/route.test.ts @@ -77,7 +77,7 @@ describe('/api/v2/config/sites', () => { } }); - it('requires admin to read full configuration', async () => { + it('allows any session to read config', async () => { mockGetUser.mockReturnValue({ sub: 'sample-user', realm_access: { @@ -88,9 +88,12 @@ describe('/api/v2/config/sites', () => { const response = await GET(makeRequest()); const body = await response.json(); - expect(response.status).toBe(403); - expect(body.error).toBeDefined(); - expect(mockGetSitesConfig).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(body.data.config).toMatchObject({ + enabled: false, + domain: 'localhost', + hostPrefix: 'site', + }); }); it('requires admin access before updating config', async () => { diff --git a/src/app/api/v2/config/sites/route.ts b/src/app/api/v2/config/sites/route.ts index fd7e9ff0..27c9c063 100644 --- a/src/app/api/v2/config/sites/route.ts +++ b/src/app/api/v2/config/sites/route.ts @@ -106,5 +106,5 @@ const putHandler = async (req: NextRequest) => { return successResponse({ config }, { status: 200 }, req); }; -export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); +export const GET = createApiHandler(getHandler, { auth: 'session' }); export const PUT = createApiHandler(putHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/environments/__tests__/environments-api-acceptance.test.ts b/src/app/api/v2/environments/__tests__/environments-api-acceptance.test.ts index be8b7c04..51b75e8b 100644 --- a/src/app/api/v2/environments/__tests__/environments-api-acceptance.test.ts +++ b/src/app/api/v2/environments/__tests__/environments-api-acceptance.test.ts @@ -66,7 +66,11 @@ jest.mock('server/services/apiToken', () => { const actual = jest.requireActual('server/services/apiToken'); return { __esModule: true, ...actual, default: { verifyToken: jest.fn(), touchLastUsed: jest.fn() } }; }); -jest.mock('server/lib/get-user', () => ({ getRequestUserIdentity: jest.fn() })); +jest.mock('server/lib/get-user', () => ({ + getRequestUserIdentity: jest.fn(), + getUser: jest.fn(), + getOAuthCredentialFromClaims: jest.fn(), +})); jest.mock('server/services/globalConfig', () => { const getAllConfigs = jest.fn(); const getConfig = jest.fn(); diff --git a/src/server/lib/sites/browserAuth.test.ts b/src/server/lib/sites/browserAuth.test.ts index 65a0053c..01eac922 100644 --- a/src/server/lib/sites/browserAuth.test.ts +++ b/src/server/lib/sites/browserAuth.test.ts @@ -142,14 +142,11 @@ it('runs current authorization on consume and returns no session when ownership/ ).rejects.toMatchObject({ statusCode: 404 }); expect(Array.from(store.rows.keys()).filter((key) => key.includes(':viewer:'))).toHaveLength(0); }); -it('auth-off denies already issued cookies; content logout removes its viewer', async () => { +it('auth-off denies already issued cookies', async () => { const { minted, cookies } = await setup(); const result = await auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}); process.env.ENABLE_AUTH = 'false'; await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 503 }); - await auth.logoutViewer(result.sessionId); - process.env.ENABLE_AUTH = 'true'; - await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 401 }); }); it('fails closed on Redis failure', async () => { const { minted, cookies } = await setup(); diff --git a/src/server/lib/sites/browserAuth.ts b/src/server/lib/sites/browserAuth.ts index 88ceee6f..01702e6b 100644 --- a/src/server/lib/sites/browserAuth.ts +++ b/src/server/lib/sites/browserAuth.ts @@ -274,9 +274,6 @@ export class SitesBrowserAuth { assertViewerActive(value); return value; } - async logoutViewer(sessionId: string | undefined): Promise { - if (sessionId && TOKEN.test(sessionId)) await this.redis.eval(CONSUME, 1, this.key('viewer', sessionId)); - } } let instance: SitesBrowserAuth | undefined; export function getSitesBrowserAuth(): SitesBrowserAuth { diff --git a/src/server/lib/sites/gateway.test.ts b/src/server/lib/sites/gateway.test.ts index 8fa979c2..1d18d154 100644 --- a/src/server/lib/sites/gateway.test.ts +++ b/src/server/lib/sites/gateway.test.ts @@ -8,7 +8,6 @@ const browser = { challenge: jest.fn(), rateLimit: jest.fn(), consume: jest.fn(), - logoutViewer: jest.fn(), }; jest.mock('./policy', () => ({ assertSitesPrincipal: jest.fn() })); jest.mock('./browserAuth', () => ({ ...jest.requireActual('./browserAuth'), getSitesBrowserAuth: () => browser })); diff --git a/src/server/lib/sites/gateway.ts b/src/server/lib/sites/gateway.ts index 1cfa0946..c51227dd 100644 --- a/src/server/lib/sites/gateway.ts +++ b/src/server/lib/sites/gateway.ts @@ -78,10 +78,10 @@ export async function handleSitesRequest( if (url.pathname === `${SITES_AUTH_PATH}consume` && req.method === 'POST') { if (!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) throw new SitesBrowserError(415); - await auth!.rateLimit(`consume:${req.socket.remoteAddress || 'unknown'}`, 240); + await auth.rateLimit(`consume:${req.socket.remoteAddress || 'unknown'}`, 240); const values = new URLSearchParams(await readSmallBody(req)); if (values.getAll('ticket').length !== 1) throw new SitesBrowserError(400); - const result = await auth!.consume( + const result = await auth.consume( values.get('ticket') || '', host, cookies, @@ -96,14 +96,6 @@ export async function handleSitesRequest( res.end(); return; } - if (url.pathname === `${SITES_AUTH_PATH}logout` && req.method === 'POST') { - if (req.headers.origin !== `https://${host}` && req.headers.origin !== sitesUiOrigin()) - throw new SitesBrowserError(403); - await auth!.logoutViewer(cookies[SITES_VIEWER_COOKIE]); - res.setHeader('Set-Cookie', `${SITES_VIEWER_COOKIE}=; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`); - res.writeHead(204).end(); - return; - } throw new SitesBrowserError(404); } if (req.method !== 'GET' && req.method !== 'HEAD') throw new SitesBrowserError(404); diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index a74f2bd9..0478c036 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -236,7 +236,7 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ policy: 'session', roles: ['admin'], }, - { method: 'GET', route: '/api/v2/config/sites', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/config/sites', policy: 'session' }, { method: 'PUT', route: '/api/v2/config/sites', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/environments', policy: 'principal', scope: 'env:read' }, { method: 'POST', route: '/api/v2/environments', policy: 'principal', scope: 'env:write' }, diff --git a/ws-server.test.ts b/ws-server.test.ts index 9561e45f..c1d30602 100644 --- a/ws-server.test.ts +++ b/ws-server.test.ts @@ -25,7 +25,7 @@ const mockStreamK8sLogs = jest.fn(); const mockParseChatPreviewHost = jest.fn(); const mockResolveChatPreviewSessionForHost = jest.fn(); const mockMatchesGatewayHost = jest.fn(); -const mockGetGatewayObject = jest.fn(); +const mockHandleSitesRequest = jest.fn(); const mockAgentSessionGetSession = jest.fn(); const mockVerifyBearerToken = jest.fn(); const mockAgentSessionFindOne = jest.fn(); @@ -128,10 +128,10 @@ jest.mock('./src/server/lib/k8sStreamer', () => ({ streamK8sLogs: mockStreamK8sL jest.mock('./src/server/services/sites', () => ({ __esModule: true, default: jest.fn(() => ({ - getGatewayObject: mockGetGatewayObject, matchesGatewayHost: mockMatchesGatewayHost, })), })); +jest.mock('./src/server/lib/sites/gateway', () => ({ handleSitesRequest: mockHandleSitesRequest })); jest.mock('./src/server/lib/agentSession/workspaceEditorProxy', () => ({ EDITOR_PROXY_PING_INTERVAL_MS: 10_000, EDITOR_PROXY_PONG_DEADLINE_MS: 5_000, @@ -390,7 +390,7 @@ describe('ws-server public dispatch', () => { mockParseChatPreviewHost.mockReturnValue(null); mockResolveChatPreviewSessionForHost.mockResolvedValue(null); mockMatchesGatewayHost.mockResolvedValue(false); - mockGetGatewayObject.mockReset(); + mockHandleSitesRequest.mockResolvedValue(undefined); mockHttpRequest.mockReset(); mockHttpsRequest.mockReset(); mockLifecycleMode = 'web'; @@ -528,76 +528,15 @@ describe('ws-server public dispatch', () => { expect(mockNextHandler).not.toHaveBeenCalled(); }); - it('serves a gateway HEAD request without forwarding its body or falling through', async () => { + it.each(['HEAD', 'GET'])('delegates gateway %s requests without falling through', async (method) => { mockLifecycleMode = 'gateway'; mockMatchesGatewayHost.mockResolvedValue(true); - const body = { destroy: jest.fn(), on: jest.fn(), pipe: jest.fn() }; - mockGetGatewayObject.mockResolvedValue({ - body, - contentLength: 42, - contentType: 'text/html; charset=utf-8', - statusCode: 200, - }); const handler = await bootServer(); - const req = { ...request('/docs/index.html'), method: 'HEAD' }; + const req = { ...request('/docs/index.html'), method }; const res = response(); - await handler(req, res); - expect(mockMatchesGatewayHost).toHaveBeenCalledWith('lifecycle.test'); - expect(mockGetGatewayObject).toHaveBeenCalledWith('lifecycle.test', '/docs/index.html'); - expect(res.statusCode).toBe(200); - expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); - expect(res.setHeader).toHaveBeenCalledWith('Content-Length', '42'); - expect(body.destroy).toHaveBeenCalledTimes(1); - expect(body.pipe).not.toHaveBeenCalled(); - expect(res.end).toHaveBeenCalledWith(); - expect(mockMcpHttpRequestHandler).not.toHaveBeenCalled(); - expect(mockNextHandler).not.toHaveBeenCalled(); - }); - - it('pipes a gateway GET response and converts a body-stream failure into 502', async () => { - mockLifecycleMode = 'gateway'; - mockMatchesGatewayHost.mockResolvedValue(true); - const bodyListeners: Record = {}; - const body = { - on: jest.fn((event: string, listener: Listener) => { - bodyListeners[event] = listener; - }), - pipe: jest.fn(), - }; - mockGetGatewayObject.mockResolvedValue({ - body, - contentType: 'application/javascript', - statusCode: 200, - }); - const handler = await bootServer(); - const res = response(); - - await handler(request('/assets/app.js'), res); - - expect(body.pipe).toHaveBeenCalledWith(res); - expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'private, max-age=60'); - expect(mockMcpHttpRequestHandler).not.toHaveBeenCalled(); - bodyListeners.error(new Error('object store disconnected')); - expect(res.statusCode).toBe(502); - expect(res.end).toHaveBeenCalledWith(); - }); - - it.each([ - ['not-found storage failures', Object.assign(new Error('missing'), { statusCode: 404 }), 404, 'not found'], - ['unexpected storage failures', new Error('object store unavailable'), 500, 'internal server error'], - ])('maps %s without falling through to another handler', async (_label, error, status, body) => { - mockLifecycleMode = 'gateway'; - mockMatchesGatewayHost.mockResolvedValue(true); - mockGetGatewayObject.mockRejectedValue(error); - const handler = await bootServer(); - const res = response(); - - await handler(request('/missing.html'), res); - - expect(res.statusCode).toBe(status); - expect(res.end).toHaveBeenCalledWith(body); + expect(mockHandleSitesRequest).toHaveBeenCalledWith(req, res, expect.any(Object)); expect(mockMcpHttpRequestHandler).not.toHaveBeenCalled(); expect(mockNextHandler).not.toHaveBeenCalled(); }); From 7e74d4508eacb4dd3732c921c576c1d15c202bd2 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Mon, 21 Sep 2026 16:52:07 -0700 Subject: [PATCH 05/12] Support Sites ACL migration downgrade --- .../db/migrations/035_sites_access_control.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/server/db/migrations/035_sites_access_control.ts b/src/server/db/migrations/035_sites_access_control.ts index b7cd2366..06dc5bc4 100644 --- a/src/server/db/migrations/035_sites_access_control.ts +++ b/src/server/db/migrations/035_sites_access_control.ts @@ -42,8 +42,27 @@ export async function up(knex: Knex): Promise { // Existing personal keys remain unbound. Reissue rather than guess their historical realm. } -export async function down(): Promise { - throw new Error( - 'Sites ACL rollback is unsafe: preserve enforcement and use the documented forward recovery procedure.' - ); +/** + * Operator-approved downgrade: the old gateway serves every Site publicly. + * Site rows, versions, and stored files remain; owner/visibility metadata does not. + * Stop all new core processes before running this migration so startup cannot reapply it. + */ +export async function down(knex: Knex): Promise { + await knex.raw('LOCK TABLE sites IN ACCESS EXCLUSIVE MODE'); + await knex.raw('DROP TRIGGER sites_preserve_owner ON sites'); + await knex.raw('DROP FUNCTION sites_preserve_owner()'); + await knex.raw('ALTER TABLE sites DROP CONSTRAINT sites_access_consistency'); + await knex.raw('DROP INDEX sites_owner_identity_idx'); + await knex.raw('DROP INDEX sites_creator_token_idx'); + await knex.raw('DROP INDEX sites_visibility_idx'); + await knex.raw(`ALTER TABLE sites + DROP COLUMN visibility, + DROP COLUMN "ownerKind", + DROP COLUMN "ownerIssuer", + DROP COLUMN "ownerSubject", + DROP COLUMN "creatorTokenId", + DROP COLUMN "servingGeneration", + DROP COLUMN "accessRevision", + DROP COLUMN "contentRevision"`); + await knex.raw('ALTER TABLE api_tokens DROP COLUMN "ownerIssuer"'); } From 5a95f69995272b9dec92c8a3c067458be46a65d4 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Mon, 21 Sep 2026 16:55:45 -0700 Subject: [PATCH 06/12] docs(sites): clarify downgrade exposure --- src/server/db/migrations/035_sites_access_control.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/db/migrations/035_sites_access_control.ts b/src/server/db/migrations/035_sites_access_control.ts index 06dc5bc4..1aaf94ed 100644 --- a/src/server/db/migrations/035_sites_access_control.ts +++ b/src/server/db/migrations/035_sites_access_control.ts @@ -43,7 +43,7 @@ export async function up(knex: Knex): Promise { } /** - * Operator-approved downgrade: the old gateway serves every Site publicly. + * Operator-approved downgrade: the old gateway serves active, unexpired Sites publicly. * Site rows, versions, and stored files remain; owner/visibility metadata does not. * Stop all new core processes before running this migration so startup cannot reapply it. */ From edf5f19edaa9e6ffc2e3acaa009607502d50c612 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Mon, 21 Sep 2026 21:50:43 -0700 Subject: [PATCH 07/12] feat(sites): allow opt-in shared-apex gateway domain Some deployments can't put Sites content on a separate registrable domain from the UI. Add SITES_ALLOW_SHARED_APEX to bypass that check per-deployment, paired with a tighter gateway CSP (connect-src/form-action 'self') so hosted content can't reach elsewhere on the shared apex. --- src/server/lib/sites/browserAuth.test.ts | 6 ++++++ src/server/lib/sites/browserAuth.ts | 11 ++++++++++- src/server/lib/sites/gateway.test.ts | 10 ++++++++++ src/server/lib/sites/gateway.ts | 6 +++++- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/server/lib/sites/browserAuth.test.ts b/src/server/lib/sites/browserAuth.test.ts index 01eac922..bdf17053 100644 --- a/src/server/lib/sites/browserAuth.test.ts +++ b/src/server/lib/sites/browserAuth.test.ts @@ -162,6 +162,12 @@ it('requires authentication, HTTPS and a separate registrable domain', () => { process.env.ENABLE_AUTH = 'false'; expect(() => assertPrivateSitesReady()).toThrow(); }); +it('the shared-apex opt-in only bypasses the same-domain check, never HTTPS', () => { + expect(() => assertPrivateSitesReady('https://sites.example.com')).toThrow(); + process.env.SITES_ALLOW_SHARED_APEX = 'true'; + expect(() => assertPrivateSitesReady('https://sites.example.com')).not.toThrow(); + expect(() => assertPrivateSitesReady('http://sites.example.com')).toThrow(); +}); it('derives the trusted origin from the existing UI URL', () => { process.env.LIFECYCLE_UI_URL = 'https://UI.example.com:443/app/?q=test#section'; expect(sitesUiOrigin()).toBe('https://ui.example.com'); diff --git a/src/server/lib/sites/browserAuth.ts b/src/server/lib/sites/browserAuth.ts index 01702e6b..79eaf7ca 100644 --- a/src/server/lib/sites/browserAuth.ts +++ b/src/server/lib/sites/browserAuth.ts @@ -65,7 +65,16 @@ export function assertPrivateSitesReady(contentUrl?: string): void { const content = new URL(contentUrl); const uiDomain = psl.get(ui.hostname); const contentDomain = psl.get(content.hostname); - if (content.protocol !== 'https:' || !uiDomain || !contentDomain || uiDomain === contentDomain) + // A shared registrable domain lets SameSite treat the UI and hosted content as one site. + // Only bypass once the deployment's gateway CSP restricts hosted-content egress and its + // auth cookies are confirmed host-only. + const sharedApexAllowed = process.env.SITES_ALLOW_SHARED_APEX === 'true'; + if ( + content.protocol !== 'https:' || + !uiDomain || + !contentDomain || + (uiDomain === contentDomain && !sharedApexAllowed) + ) throw new SitesBrowserError(503); } } diff --git a/src/server/lib/sites/gateway.test.ts b/src/server/lib/sites/gateway.test.ts index 1d18d154..3d66708e 100644 --- a/src/server/lib/sites/gateway.test.ts +++ b/src/server/lib/sites/gateway.test.ts @@ -104,9 +104,19 @@ it('authorizes HEAD with current Site checks and no incoming-principal or IdP po expect(res.headers['Cross-Origin-Opener-Policy']).toBe('same-origin'); expect(res.headers['Origin-Agent-Cluster']).toBe('?1'); expect(res.headers['Cross-Origin-Resource-Policy']).toBe('same-origin'); + expect(res.headers['Content-Security-Policy']).toBe("worker-src 'none'; frame-ancestors 'none'"); expect(api.getGatewayObject).toHaveBeenCalledTimes(1); expect(assertSitesPrincipal).not.toHaveBeenCalled(); }); +it('restricts hosted-content network egress when the shared-apex opt-in is on', async () => { + process.env.SITES_ALLOW_SHARED_APEX = 'true'; + const api = service(); + const res = response(); + await handleSitesRequest(request('HEAD'), res, api); + expect(res.headers['Content-Security-Policy']).toBe( + "worker-src 'none'; frame-ancestors 'none'; connect-src 'self'; form-action 'self'" + ); +}); it('never returns a login page or redirect for an unauthenticated asset/HEAD', async () => { browser.viewer.mockRejectedValue(new SitesBrowserError(401)); const api = service(); diff --git a/src/server/lib/sites/gateway.ts b/src/server/lib/sites/gateway.ts index c51227dd..1308a264 100644 --- a/src/server/lib/sites/gateway.ts +++ b/src/server/lib/sites/gateway.ts @@ -140,7 +140,11 @@ export async function handleSitesRequest( res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Origin-Agent-Cluster', '?1'); res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); - res.setHeader('Content-Security-Policy', "worker-src 'none'; frame-ancestors 'none'"); + const csp = + process.env.SITES_ALLOW_SHARED_APEX === 'true' + ? "worker-src 'none'; frame-ancestors 'none'; connect-src 'self'; form-action 'self'" + : "worker-src 'none'; frame-ancestors 'none'"; + res.setHeader('Content-Security-Policy', csp); } res.statusCode = object.statusCode; res.setHeader('Content-Type', object.contentType); From 92de61c50a2ed94a3a729d528cd7f06caa99a5de Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Mon, 21 Sep 2026 22:07:11 -0700 Subject: [PATCH 08/12] Show creator on public Sites --- src/server/services/__tests__/sites.behavior.test.ts | 4 ++-- src/server/services/sites.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index f2292de2..2943c6a6 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -1295,7 +1295,7 @@ describe('SitesService behavior', () => { expect(errors[0]).toEqual(errors[1]); expect(errors[0]).toEqual({ message: 'Site not found.', code: 'site_not_found', status: 404 }); }); - it('filters unauthorized private rows before page totals and redacts public attribution', async () => { + it('filters unauthorized private rows before page totals and shows public creator attribution', async () => { addSite(state, { siteId: 'secret', visibility: 'private', ownerSubject: 'other' }); addSite(state, { siteId: 'public', ownerSubject: 'other', createdBy: 'private-email@example.com' }); addSite(state, { siteId: 'mine', visibility: 'private' }); @@ -1303,7 +1303,7 @@ describe('SitesService behavior', () => { expect(result.pagination.items).toBe(2); expect(result.sites.map((site) => site.id).sort()).toEqual(['mine', 'public']); expect(result.sites.find((site) => site.id === 'public')).toMatchObject({ - createdBy: null, + createdBy: 'private-email@example.com', updatedBy: null, currentRole: null, permissions: { canEdit: false }, diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index ea937c0f..6ccf2873 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -173,7 +173,7 @@ export default class SitesService extends Service { expiresAt: site.expiresAt || null, fileCount: Number(site.fileCount || 0), sizeBytes: Number(site.sizeBytes || 0), - createdBy: owner ? site.createdBy || null : null, + createdBy: owner || site.visibility === 'public' ? site.createdBy || null : null, updatedBy: owner ? site.updatedBy || null : null, }; } From 0af126b06788110e5db4762b9b7fe4d7491de8e1 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Tue, 22 Sep 2026 08:55:56 -0700 Subject: [PATCH 09/12] Simplify Sites visibility to keep stable content URLs --- src/app/api/v2/sites/[siteId]/access/route.ts | 2 +- .../api/v2/sites/browser/mint/route.test.ts | 3 +- .../db/migrations/035_sites_access_control.ts | 4 +-- src/server/lib/sites/browserAuth.test.ts | 6 ++-- src/server/lib/sites/browserAuth.ts | 12 ++------ src/server/lib/sites/config.test.ts | 10 +++---- src/server/lib/sites/config.ts | 20 ++++--------- src/server/lib/sites/gateway.test.ts | 12 ++++---- src/server/lib/sites/gateway.ts | 7 +---- src/server/lib/sites/oauthRevocation.test.ts | 10 ++----- src/server/models/Site.ts | 1 - .../services/__tests__/sites.behavior.test.ts | 28 +++++++++---------- src/server/services/__tests__/sites.test.ts | 2 -- src/server/services/sites.ts | 28 +++++++------------ 14 files changed, 50 insertions(+), 95 deletions(-) diff --git a/src/app/api/v2/sites/[siteId]/access/route.ts b/src/app/api/v2/sites/[siteId]/access/route.ts index c11fcc28..5a9c348f 100644 --- a/src/app/api/v2/sites/[siteId]/access/route.ts +++ b/src/app/api/v2/sites/[siteId]/access/route.ts @@ -33,7 +33,7 @@ import SitesService, { SitesServiceError } from 'server/services/sites'; * required: [visibility, expectedAccessRevision] * responses: * '200': - * description: Visibility changed; private transition retires the previous content URL. + * description: Visibility changed; the Site ID and content URL stay the same. * content: * application/json: * schema: diff --git a/src/app/api/v2/sites/browser/mint/route.test.ts b/src/app/api/v2/sites/browser/mint/route.test.ts index abeab0d1..bd530ab2 100644 --- a/src/app/api/v2/sites/browser/mint/route.test.ts +++ b/src/app/api/v2/sites/browser/mint/route.test.ts @@ -40,14 +40,13 @@ jest.mock('server/services/keycloak/principalStatus', () => ({ import { rememberVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; import { POST } from './route'; const issuer = 'https://identity.example/realms/lifecycle'; -const host = 'site-abc123--g-abcdef012345.sites.example.net'; +const host = 'site-abc123.sites.example.net'; const site = { siteId: 'abc123', ownerKind: 'user', ownerIssuer: issuer, ownerSubject: 'owner', visibility: 'private', - servingGeneration: 'abcdef012345', accessRevision: 1, }; let principal: Principal; diff --git a/src/server/db/migrations/035_sites_access_control.ts b/src/server/db/migrations/035_sites_access_control.ts index 1aaf94ed..112ad0d9 100644 --- a/src/server/db/migrations/035_sites_access_control.ts +++ b/src/server/db/migrations/035_sites_access_control.ts @@ -12,7 +12,6 @@ export async function up(knex: Knex): Promise { table.string('ownerSubject', 255).nullable(); // No FK: deleting or rotating a key must not delete or transfer the original owner's Sites. table.integer('creatorTokenId').nullable(); - table.string('servingGeneration', 32).nullable(); table.integer('accessRevision').notNullable().defaultTo(1); table.integer('contentRevision').notNullable().defaultTo(1); table.index(['ownerIssuer', 'ownerSubject'], 'sites_owner_identity_idx'); @@ -25,7 +24,7 @@ export async function up(knex: Knex): Promise { ("ownerKind" = 'user' AND "ownerIssuer" IS NOT NULL AND length("ownerIssuer") > 0 AND "ownerSubject" IS NOT NULL AND length("ownerSubject") > 0 AND "creatorTokenId" IS NULL) OR ("ownerKind" = 'service_key' AND "ownerIssuer" IS NULL AND "ownerSubject" IS NULL AND "creatorTokenId" IS NOT NULL AND "creatorTokenId" > 0) OR ("ownerKind" = 'unresolved' AND "ownerIssuer" IS NULL AND "ownerSubject" IS NULL AND "creatorTokenId" IS NULL) - ) AND (visibility = 'public' OR ("ownerKind" = 'user' AND "servingGeneration" IS NOT NULL AND length("servingGeneration") > 0)) + ) AND (visibility = 'public' OR "ownerKind" = 'user') )`); await knex.raw(`CREATE FUNCTION sites_preserve_owner() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN @@ -61,7 +60,6 @@ export async function down(knex: Knex): Promise { DROP COLUMN "ownerIssuer", DROP COLUMN "ownerSubject", DROP COLUMN "creatorTokenId", - DROP COLUMN "servingGeneration", DROP COLUMN "accessRevision", DROP COLUMN "contentRevision"`); await knex.raw('ALTER TABLE api_tokens DROP COLUMN "ownerIssuer"'); diff --git a/src/server/lib/sites/browserAuth.test.ts b/src/server/lib/sites/browserAuth.test.ts index bdf17053..9b0f22f8 100644 --- a/src/server/lib/sites/browserAuth.test.ts +++ b/src/server/lib/sites/browserAuth.test.ts @@ -57,10 +57,9 @@ const site: BrowserSite = { ownerIssuer: actor.issuer, ownerSubject: actor.subject, visibility: 'private', - servingGeneration: 'abcdef012345', accessRevision: 1, }; -const host = 'site-abc123--g-abcdef012345.sites.example.net'; +const host = 'site-abc123.sites.example.net'; let store: Store; let auth: SitesBrowserAuth; const originalEnv = { ...process.env }; @@ -133,7 +132,7 @@ it('never lets a different actor or service-key owner mint a viewer', async () = auth.mint({ ...site, ownerKind: 'service_key' }, actor, login, actor.oauth.expiresAt) ).rejects.toMatchObject({ statusCode: 404 }); }); -it('runs current authorization on consume and returns no session when ownership/generation changes', async () => { +it('runs current authorization on consume and returns no session when ownership changes', async () => { const { minted, cookies } = await setup(); await expect( auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => { @@ -266,7 +265,6 @@ it('retains only Site claims, no OAuth login, token metadata or bearer vault', a expect(Object.keys(await auth.viewer(result.sessionId, host)).sort()).toEqual([ 'accessRevision', 'expiresAt', - 'generation', 'host', 'issuer', 'siteId', diff --git a/src/server/lib/sites/browserAuth.ts b/src/server/lib/sites/browserAuth.ts index 79eaf7ca..b49c240b 100644 --- a/src/server/lib/sites/browserAuth.ts +++ b/src/server/lib/sites/browserAuth.ts @@ -31,14 +31,12 @@ export type BrowserSite = { ownerIssuer?: string | null; ownerSubject?: string | null; visibility: string; - servingGeneration?: string | null; accessRevision: number; }; -type Challenge = { siteId: string; host: string; generation: string | null; secretHash: string; path: string }; +type Challenge = { siteId: string; host: string; secretHash: string; path: string }; export type Viewer = BrowserActor & { siteId: string; host: string; - generation: string | null; accessRevision: number; expiresAt: number; }; @@ -174,14 +172,13 @@ export class SitesBrowserAuth { async rateLimit(identity: string, maximum = 60): Promise { if (Number(await this.redis.eval(LIMIT, 1, this.key('rate', identity))) > maximum) throw new SitesBrowserError(429); } - async challenge(site: Pick, host: string, path: string) { + async challenge(site: Pick, host: string, path: string) { assertPrivateSitesReady(`https://${host}`); const state = randomSiteToken(); const secret = randomSiteToken(); const challenge: Challenge = { siteId: site.siteId, host, - generation: site.servingGeneration ?? null, secretHash: siteTokenHash(secret), path: safeSiteReturnPath(path), }; @@ -204,8 +201,7 @@ export class SitesBrowserAuth { assertViewerOwns(site, actor); if (!body.state || body.siteId !== site.siteId) throw new SitesBrowserError(401); const challenge = await this.readChallenge(body.state); - if (challenge.siteId !== site.siteId || challenge.generation !== (site.servingGeneration ?? null)) - throw new SitesBrowserError(401); + if (challenge.siteId !== site.siteId) throw new SitesBrowserError(401); assertPrivateSitesReady(`https://${challenge.host}`); if (!Number.isSafeInteger(tokenExpiresAt)) throw new SitesBrowserError(401); const expiresAt = Math.min(nowSeconds() + MAX_VIEWER_SECONDS, tokenExpiresAt); @@ -215,7 +211,6 @@ export class SitesBrowserAuth { subject: actor.subject, siteId: site.siteId, host: challenge.host, - generation: site.servingGeneration ?? null, accessRevision: site.accessRevision, expiresAt, challengeId: body.state, @@ -259,7 +254,6 @@ export class SitesBrowserAuth { subject: value.subject, siteId: value.siteId, host: value.host, - generation: value.generation, accessRevision: value.accessRevision, expiresAt: value.expiresAt, }), diff --git a/src/server/lib/sites/config.test.ts b/src/server/lib/sites/config.test.ts index 6c71dd34..8e624c22 100644 --- a/src/server/lib/sites/config.test.ts +++ b/src/server/lib/sites/config.test.ts @@ -159,14 +159,14 @@ describe('sites storage configuration', () => { }); }); -describe('serving generations', () => { - it('preserves legacy URLs and extracts a generation without changing the site id', () => { +describe('stable site hosts', () => { + it('keeps the original host format and rejects retired generation hosts', () => { const config = resolveSitesConfig({ domain: 'sites.example.net' }); - const generated = buildSiteUrl('abc123', config, 'abcdef012345'); - expect(generated).toBe('https://site-abc123--g-abcdef012345.sites.example.net'); + const generated = buildSiteUrl('abc123', config); + expect(generated).toBe('https://site-abc123.sites.example.net'); expect(parseSiteIdFromHost(new URL(generated).host, config)).toBe('abc123'); expect(parseSiteIdFromHost('site-abc123--g-x.sites.example.net', config)).toBeNull(); - expect(parseSiteIdFromHost('site-abc123--g-abcdef012345--g-abcdef012345.sites.example.net', config)).toBeNull(); + expect(parseSiteIdFromHost('site-abc123--g-abcdef012345.sites.example.net', config)).toBeNull(); expect(parseSiteIdFromHost('site-abc123.sites.example.net@evil.test', config)).toBeNull(); }); }); diff --git a/src/server/lib/sites/config.ts b/src/server/lib/sites/config.ts index 8a01d08c..4a8c1430 100644 --- a/src/server/lib/sites/config.ts +++ b/src/server/lib/sites/config.ts @@ -180,7 +180,7 @@ export function resolveSitesConfig(config?: SitesConfig | null): ResolvedSitesCo }; } -export function buildSiteUrl(siteId: string, config: ResolvedSitesConfig, generation?: string | null): string { +export function buildSiteUrl(siteId: string, config: ResolvedSitesConfig): string { const protocol = process.env.SITES_GATEWAY_HTTPS === 'true' ? 'https' @@ -188,13 +188,10 @@ export function buildSiteUrl(siteId: string, config: ResolvedSitesConfig, genera ? 'http' : 'https'; const port = config.port ? `:${config.port}` : ''; - return `${protocol}://${config.hostPrefix}-${siteId}${generation ? `--g-${generation}` : ''}.${config.domain}${port}`; + return `${protocol}://${config.hostPrefix}-${siteId}.${config.domain}${port}`; } -export function parseSiteHost( - hostHeader: string | undefined, - config: ResolvedSitesConfig -): { siteId: string; generation: string | null } | null { +export function parseSiteIdFromHost(hostHeader: string | undefined, config: ResolvedSitesConfig): string | null { if (!hostHeader || !/^[a-z0-9.-]+(?::[0-9]+)?$/i.test(hostHeader)) return null; const host = hostHeader.split(':')[0].toLowerCase(); const suffix = `.${config.domain.toLowerCase()}`; @@ -202,13 +199,6 @@ export function parseSiteHost( const label = host.slice(0, -suffix.length); const prefix = `${config.hostPrefix}-`; if (!label.startsWith(prefix)) return null; - const locator = label.slice(prefix.length); - const parts = locator.split('--g-'); - if (parts.length > 2 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(parts[0])) return null; - if (parts.length === 2 && !/^[a-z0-9]{12,40}$/.test(parts[1])) return null; - return { siteId: parts[0], generation: parts[1] || null }; -} - -export function parseSiteIdFromHost(hostHeader: string | undefined, config: ResolvedSitesConfig): string | null { - return parseSiteHost(hostHeader, config)?.siteId ?? null; + const siteId = label.slice(prefix.length); + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(siteId) ? siteId : null; } diff --git a/src/server/lib/sites/gateway.test.ts b/src/server/lib/sites/gateway.test.ts index 3d66708e..32bf6dc2 100644 --- a/src/server/lib/sites/gateway.test.ts +++ b/src/server/lib/sites/gateway.test.ts @@ -17,7 +17,6 @@ const site = { ownerKind: 'user', ownerIssuer: 'https://id.example.com', ownerSubject: 'owner', - servingGeneration: 'abcdef012345', accessRevision: 2, visibility: 'private', }; @@ -25,9 +24,8 @@ const viewer = { issuer: site.ownerIssuer, subject: site.ownerSubject, siteId: site.siteId, - generation: site.servingGeneration, accessRevision: 2, - host: 'site-abc123--g-abcdef012345.sites.example.net', + host: 'site-abc123.sites.example.net', expiresAt: 1e12, }; function request(method = 'GET', headers = {}, url = '/') { @@ -57,7 +55,7 @@ function response() { } function service(current = site) { return { - getGatewayLocator: jest.fn(async () => ({ siteId: current.siteId, servingGeneration: current.servingGeneration })), + getGatewayLocator: jest.fn(async () => ({ siteId: current.siteId })), getGatewaySite: jest.fn(async () => ({ site: current, config: {} })), getGatewayObject: jest.fn(async (_host, _path, authorize) => { if (current.visibility === 'private') await authorize(current); @@ -88,11 +86,11 @@ it.each(['/', '/style.css', '/app.js', '/private.json'])( expect(res.headers['Cache-Control']).toBe('no-store'); } ); -it('denies another authenticated user and changed generation', async () => { +it('denies another authenticated user and a stale access revision', async () => { await expect(authorizeSitesViewer(site as any, { ...viewer, subject: 'other' })).rejects.toMatchObject({ statusCode: 404, }); - await expect(authorizeSitesViewer(site as any, { ...viewer, generation: 'old' })).rejects.toMatchObject({ + await expect(authorizeSitesViewer(site as any, { ...viewer, accessRevision: 1 })).rejects.toMatchObject({ statusCode: 401, }); }); @@ -203,7 +201,7 @@ it.each([ expect(missing.getGatewayObject).not.toHaveBeenCalled(); }); it('rejects a concurrent Site change or grant expiry immediately before storage', async () => { - for (const changed of [{ accessRevision: 3 }, { ownerSubject: 'other' }, { servingGeneration: 'retired' }]) { + for (const changed of [{ accessRevision: 3 }, { ownerSubject: 'other' }, { siteId: 'other' }]) { const api = service(); api.getGatewayObject.mockImplementation(async (_host: string, _path: string, authorize: any) => { await authorize({ ...site, ...changed }); diff --git a/src/server/lib/sites/gateway.ts b/src/server/lib/sites/gateway.ts index 1308a264..0ccfa75b 100644 --- a/src/server/lib/sites/gateway.ts +++ b/src/server/lib/sites/gateway.ts @@ -17,12 +17,7 @@ import type SitesService from 'server/services/sites'; export async function authorizeSitesViewer(site: Site, viewer: Viewer): Promise { assertViewerActive(viewer); assertViewerOwns(site, viewer); - if ( - site.siteId !== viewer.siteId || - (site.servingGeneration ?? null) !== viewer.generation || - site.accessRevision !== viewer.accessRevision - ) - throw new SitesBrowserError(401); + if (site.siteId !== viewer.siteId || site.accessRevision !== viewer.accessRevision) throw new SitesBrowserError(401); } function securityHeaders(res: ServerResponse) { res.setHeader('Cache-Control', 'no-store'); diff --git a/src/server/lib/sites/oauthRevocation.test.ts b/src/server/lib/sites/oauthRevocation.test.ts index 7a87e80d..fcf06d67 100644 --- a/src/server/lib/sites/oauthRevocation.test.ts +++ b/src/server/lib/sites/oauthRevocation.test.ts @@ -136,10 +136,9 @@ test('issued viewers use only their fixed grant deadline; new authorization stil ownerIssuer: issuer, ownerSubject: 'owner', visibility: 'private', - servingGeneration: 'abcdef012345', accessRevision: 1, }; - const host = 'site-test--g-abcdef012345.sites.example.net'; + const host = 'site-test.sites.example.net'; const challenge = await auth.challenge(site, host, '/'); const actor = { issuer, subject: 'owner' }; await assertSitesPrincipal(rest); @@ -165,12 +164,7 @@ test('issued viewers use only their fixed grant deadline; new authorization stil await expect( authorizeSitesViewer(site as any, { ...viewer, expiresAt: Math.floor(Date.now() / 1000) }) ).rejects.toMatchObject({ httpStatus: 401 }); - for (const changed of [ - { ownerSubject: 'other' }, - { servingGeneration: 'new' }, - { accessRevision: 2 }, - { siteId: 'other' }, - ]) { + for (const changed of [{ ownerSubject: 'other' }, { accessRevision: 2 }, { siteId: 'other' }]) { await expect(authorizeSitesViewer({ ...site, ...changed } as any, viewer)).rejects.toMatchObject({ httpStatus: expect.any(Number), }); diff --git a/src/server/models/Site.ts b/src/server/models/Site.ts index 8b5ee688..afa45ae5 100644 --- a/src/server/models/Site.ts +++ b/src/server/models/Site.ts @@ -30,7 +30,6 @@ export default class Site extends Model { ownerIssuer!: string | null; ownerSubject!: string | null; creatorTokenId!: number | null; - servingGeneration!: string | null; accessRevision!: number; contentRevision!: number; activeVersionId?: string | null; diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index 2943c6a6..952eb2ba 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -162,7 +162,6 @@ type SiteData = { ownerIssuer: string | null; ownerSubject: string | null; creatorTokenId: number | null; - servingGeneration: string | null; accessRevision: number; contentRevision: number; siteId: string; @@ -237,7 +236,6 @@ function addSite(state: FakeState, overrides: Partial = {}): SiteRow { ownerIssuer: principal.issuer!, ownerSubject: principal.userId, creatorTokenId: null, - servingGeneration: null, accessRevision: 1, contentRevision: 1, siteId: 'site-1', @@ -1158,7 +1156,7 @@ describe('SitesService behavior', () => { contentRevision: 1, permissions: { canEdit: true }, }); - expect(result.url).toContain('--g-version00001'); + expect(result.url).toBe('https://site-site000001.sites.example.com'); expect(result.openUrl).toBe('https://lifecycle.example.net/sites/open/site000001'); expect(state.sites[0]).toMatchObject({ ownerKind: 'user', ownerSubject: 'owner', ownerIssuer: principal.issuer }); }); @@ -1252,13 +1250,12 @@ describe('SitesService behavior', () => { expect(mockGetObject).not.toHaveBeenCalled(); }); it('resolves valid missing host locators without a database Site for the anonymous bootstrap', async () => { - await expect(service.getGatewayLocator('site-missing--g-abcdef012345.sites.example.com')).resolves.toEqual({ + await expect(service.getGatewayLocator('site-missing.sites.example.com')).resolves.toEqual({ siteId: 'missing' }); + await expect(service.getGatewayLocator('site-missing.sites.example.com:443')).resolves.toEqual({ siteId: 'missing', - servingGeneration: 'abcdef012345', }); - await expect(service.getGatewayLocator('site-missing--g-abcdef012345.sites.example.com:443')).resolves.toEqual({ - siteId: 'missing', - servingGeneration: 'abcdef012345', + await expect(service.getGatewayLocator('site-missing--g-abcdef012345.sites.example.com')).rejects.toMatchObject({ + statusCode: 404, }); await expect(service.getGatewayLocator('site-missing.sites.example.com:9443')).rejects.toMatchObject({ statusCode: 404, @@ -1342,15 +1339,18 @@ describe('SitesService behavior', () => { expect(authorize).toHaveBeenCalledTimes(1); expect(mockGetObject).not.toHaveBeenCalled(); }); - it('retires the old public host on privatization and never restores it when republishing', async () => { + it('keeps the content URL and Site ID while changing visibility', async () => { addSite(state); addVersion(state); const hidden = await service.setVisibility('site-1', 'private', principal, 1); - expect(hidden.url).toBe('https://site-site-1--g-version00001.sites.example.com'); - await expect(service.getGatewaySite('site-site-1.sites.example.com')).rejects.toMatchObject({ statusCode: 404 }); + expect(hidden.url).toBe('https://site-site-1.sites.example.com'); + expect(hidden.openUrl).toBe('https://lifecycle.example.net/sites/open/site-1'); + await expect(service.getGatewaySite('site-site-1.sites.example.com')).resolves.toMatchObject({ + site: expect.objectContaining({ visibility: 'private' }), + }); const published = await service.setVisibility('site-1', 'public', principal, 2); expect(published.url).toBe(hidden.url); - await expect(service.getGatewaySite('site-site-1.sites.example.com')).rejects.toMatchObject({ statusCode: 404 }); + expect(published.openUrl).toBe(hidden.openUrl); }); it('rejects a stale visibility revision without modifying the site', async () => { const row = addSite(state); @@ -1372,7 +1372,7 @@ describe('SitesService behavior', () => { expect(mockDeletePrefix).toHaveBeenCalledWith('sites/site-1/versions/version00001'); }); it('rejects publishing or deleting content replaced since the owner prepared the action', async () => { - addSite(state, { visibility: 'private', servingGeneration: 'generation-1' }); + addSite(state, { visibility: 'private' }); addVersion(state); const before = await service.getSite('site-1', principal); const replaced = await service.replaceSiteContent('site-1', { @@ -1395,7 +1395,7 @@ describe('SitesService behavior', () => { expect(published).toMatchObject({ visibility: 'public', accessRevision: 3, contentRevision: 2 }); }); it('rejects replacement when a private site is published during the upload', async () => { - addSite(state, { visibility: 'private', servingGeneration: 'generation-1' }); + addSite(state, { visibility: 'private' }); addVersion(state); mockPutFiles.mockImplementationOnce(async () => { await service.setVisibility('site-1', 'public', principal, 1); diff --git a/src/server/services/__tests__/sites.test.ts b/src/server/services/__tests__/sites.test.ts index 27b2962b..d9553050 100644 --- a/src/server/services/__tests__/sites.test.ts +++ b/src/server/services/__tests__/sites.test.ts @@ -108,7 +108,6 @@ type SiteRow = { ownerIssuer: string | null; ownerSubject: string | null; creatorTokenId: number | null; - servingGeneration: string | null; accessRevision: number; contentRevision: number; siteId: string; @@ -209,7 +208,6 @@ function createSiteRow(overrides: Partial = {}): SiteRow { ownerIssuer: principal.issuer!, ownerSubject: principal.userId, creatorTokenId: null, - servingGeneration: null, accessRevision: 1, contentRevision: 1, siteId: 'site-1', diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index 6ccf2873..efcb192b 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -22,13 +22,7 @@ import GlobalConfigService from './globalConfig'; import { QUEUE_NAMES } from 'shared/config'; import { redisClient } from 'server/lib/dependencies'; import { getLogger } from 'server/lib/logger'; -import { - buildSiteUrl, - parseSiteIdFromHost, - parseSiteHost, - resolveSitesConfig, - ResolvedSitesConfig, -} from 'server/lib/sites/config'; +import { buildSiteUrl, parseSiteIdFromHost, resolveSitesConfig, ResolvedSitesConfig } from 'server/lib/sites/config'; import { SitesObjectNotFoundError, SitesStorage } from 'server/lib/sites/storage'; import { normalizeGatewayPath, @@ -144,7 +138,7 @@ export default class SitesService extends Service { private serialize(site: Site, config: ResolvedSitesConfig, principal?: Principal): SiteResponse { const owner = isSiteOwner(site, principal); const writable = owner && Boolean(principal && hasSitesScope(principal, 'write')); - const contentUrl = buildSiteUrl(site.siteId, config, site.servingGeneration); + const contentUrl = buildSiteUrl(site.siteId, config); const uiUrl = process.env.LIFECYCLE_UI_URL; const expiresAt = site.expiresAt ? new Date(site.expiresAt).getTime() : null; const status = @@ -387,7 +381,6 @@ export default class SitesService extends Service { ownerIssuer: machine ? null : principal.issuer, ownerSubject: machine ? null : principal.userId, creatorTokenId: machine ? principal.tokenId : null, - servingGeneration: visibility === 'private' ? createVersionId() : null, accessRevision: 1, contentRevision: 1, createdBy: principal.identity?.email || null, @@ -554,7 +547,6 @@ export default class SitesService extends Service { const updated = await site.$query(trx).patchAndFetch({ visibility, accessRevision: site.accessRevision + 1, - servingGeneration: visibility === 'private' ? createVersionId() : site.servingGeneration, }); return this.serialize(updated as Site, config, principal); }); @@ -591,23 +583,23 @@ export default class SitesService extends Service { async resolvePublicSiteUrl(siteId: string): Promise { try { const { site, config } = await this.getActiveSite(siteId); - return site.visibility === 'public' ? buildSiteUrl(siteId, config, site.servingGeneration) : null; + return site.visibility === 'public' ? buildSiteUrl(siteId, config) : null; } catch (error) { if (error instanceof SitesServiceError && error.statusCode === 404) return null; throw error; } } - async getGatewayLocator(hostHeader: string | undefined): Promise> { + async getGatewayLocator(hostHeader: string | undefined): Promise> { const config = await this.getConfig(); - const locator = parseSiteHost(hostHeader, config); - if (!config.enabled || !locator) throw new SitesServiceError('Site not found.', 404); - const expected = new URL(buildSiteUrl(locator.siteId, config, locator.generation)); + const siteId = parseSiteIdFromHost(hostHeader, config); + if (!config.enabled || !siteId) throw new SitesServiceError('Site not found.', 404); + const expected = new URL(buildSiteUrl(siteId, config)); const requested = new URL(`${expected.protocol}//${hostHeader}`); if (requested.host.toLowerCase() !== expected.host.toLowerCase()) throw new SitesServiceError('Site not found.', 404); this.assertPrivateReady(config); - return { siteId: locator.siteId, servingGeneration: locator.generation }; + return { siteId }; } async getGatewaySite(hostHeader: string | undefined): Promise<{ site: Site; config: ResolvedSitesConfig }> { @@ -615,11 +607,11 @@ export default class SitesService extends Service { const siteId = parseSiteIdFromHost(hostHeader, config); if (!siteId) throw new SitesServiceError('Site not found.', 404); const result = await this.getActiveSite(siteId); - const expectedHost = new URL(buildSiteUrl(siteId, result.config, result.site.servingGeneration)).host.toLowerCase(); + const expectedHost = new URL(buildSiteUrl(siteId, result.config)).host.toLowerCase(); let requestedHost: string; try { requestedHost = new URL( - `${new URL(buildSiteUrl(siteId, result.config, result.site.servingGeneration)).protocol}//${hostHeader}` + `${new URL(buildSiteUrl(siteId, result.config)).protocol}//${hostHeader}` ).host.toLowerCase(); } catch { throw new SitesServiceError('Site not found.', 404); From 735f498932f89377c4227ee827ef7845b8b0c5b4 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Tue, 22 Sep 2026 10:44:32 -0700 Subject: [PATCH 10/12] fix(sites): return 403 instead of 404 for private-site access denial Denied-owner responses now return a distinct 403 with a clear message instead of collapsing into the same 404 used for missing/deleted Sites. This is an internal tool, so anti-enumeration protection is traded for clarity: a denied teammate now knows to ask the owner for access rather than assuming the link is broken. --- src/app/api/v2/sites/browser/mint/route.test.ts | 2 +- src/server/lib/sites/browserAuth.test.ts | 4 ++-- src/server/lib/sites/browserAuth.ts | 3 ++- src/server/lib/sites/gateway.test.ts | 2 +- src/server/lib/sites/policy.test.ts | 2 +- src/server/lib/sites/policy.ts | 8 ++++++-- src/server/services/__tests__/sites.behavior.test.ts | 10 +++++----- src/server/services/sites.ts | 6 ++++-- 8 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/app/api/v2/sites/browser/mint/route.test.ts b/src/app/api/v2/sites/browser/mint/route.test.ts index bd530ab2..9e411bbf 100644 --- a/src/app/api/v2/sites/browser/mint/route.test.ts +++ b/src/app/api/v2/sites/browser/mint/route.test.ts @@ -119,7 +119,7 @@ it('denies anonymous, unrelated owner, expired JWT and disabled account without ); expect((await mint()).status).toBe(401); principal.userId = 'other'; - expect((await mint()).status).toBe(404); + expect((await mint()).status).toBe(403); principal.userId = 'owner'; principal.oauth!.expiresAt = Math.floor(Date.now() / 1000); expect((await mint()).status).toBe(401); diff --git a/src/server/lib/sites/browserAuth.test.ts b/src/server/lib/sites/browserAuth.test.ts index 9b0f22f8..217ba8c0 100644 --- a/src/server/lib/sites/browserAuth.test.ts +++ b/src/server/lib/sites/browserAuth.test.ts @@ -126,11 +126,11 @@ it('expires one-use tickets after 60 seconds', async () => { it('never lets a different actor or service-key owner mint a viewer', async () => { const { login } = await setup(); await expect(auth.mint(site, { ...actor, subject: 'other' }, login, actor.oauth.expiresAt)).rejects.toMatchObject({ - statusCode: 404, + statusCode: 403, }); await expect( auth.mint({ ...site, ownerKind: 'service_key' }, actor, login, actor.oauth.expiresAt) - ).rejects.toMatchObject({ statusCode: 404 }); + ).rejects.toMatchObject({ statusCode: 403 }); }); it('runs current authorization on consume and returns no session when ownership changes', async () => { const { minted, cookies } = await setup(); diff --git a/src/server/lib/sites/browserAuth.ts b/src/server/lib/sites/browserAuth.ts index b49c240b..60f2cd6a 100644 --- a/src/server/lib/sites/browserAuth.ts +++ b/src/server/lib/sites/browserAuth.ts @@ -114,8 +114,9 @@ export function parseSitesCookies(raw?: string): Record { } export function assertViewerOwns(site: BrowserSite, actor: BrowserActor): void { assertPrivateSitesReady(); + // Internal tool: 403 here (not 404) so a denied teammate knows to ask the owner. if (site.ownerKind !== 'user' || site.ownerIssuer !== actor.issuer || site.ownerSubject !== actor.subject) - throw new SitesBrowserError(404); + throw new SitesBrowserError(403, 'You do not have access to this Site.'); } export async function readSitesMintBody(request: { diff --git a/src/server/lib/sites/gateway.test.ts b/src/server/lib/sites/gateway.test.ts index 32bf6dc2..63544322 100644 --- a/src/server/lib/sites/gateway.test.ts +++ b/src/server/lib/sites/gateway.test.ts @@ -88,7 +88,7 @@ it.each(['/', '/style.css', '/app.js', '/private.json'])( ); it('denies another authenticated user and a stale access revision', async () => { await expect(authorizeSitesViewer(site as any, { ...viewer, subject: 'other' })).rejects.toMatchObject({ - statusCode: 404, + statusCode: 403, }); await expect(authorizeSitesViewer(site as any, { ...viewer, accessRevision: 1 })).rejects.toMatchObject({ statusCode: 401, diff --git a/src/server/lib/sites/policy.test.ts b/src/server/lib/sites/policy.test.ts index 95d9138d..f83d5c62 100644 --- a/src/server/lib/sites/policy.test.ts +++ b/src/server/lib/sites/policy.test.ts @@ -58,7 +58,7 @@ test('admin status, matching display email, or a different issuer never grant ow expect(isSiteOwner(site, human())).toBe(true); expect(isSiteOwner(site, human({ userId: 'bob' }))).toBe(false); expect(isSiteOwner(site, human({ issuer: 'https://other.example/realm' }))).toBe(false); - expect(() => assertSiteOwner(site, human({ userId: 'bob' }))).toThrow('Site not found'); + expect(() => assertSiteOwner(site, human({ userId: 'bob' }))).toThrow('You do not have access to this Site.'); expect(isSiteOwner({ ...site, ownerKind: 'unresolved' } as Site, human())).toBe(false); }); test('machine ownership is the immutable token id and cannot impersonate a human', async () => { diff --git a/src/server/lib/sites/policy.ts b/src/server/lib/sites/policy.ts index c8fa6704..880fefba 100644 --- a/src/server/lib/sites/policy.ts +++ b/src/server/lib/sites/policy.ts @@ -113,7 +113,11 @@ export function isSiteOwner(site: Site, principal: Principal | null | undefined) export function assertSiteOwner(site: Site, principal: Principal): void { if (!isSiteOwner(site, principal)) { - // Same response for missing and inaccessible private resources. - throw new AppError({ httpStatus: 404, code: 'site_not_found', message: 'Site not found.' }); + // Internal tool: 403 here (not 404) so a denied teammate knows to ask the owner. + throw new AppError({ + httpStatus: 403, + code: 'site_access_denied', + message: 'You do not have access to this Site.', + }); } } diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index 952eb2ba..afea5d4c 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -1310,17 +1310,17 @@ describe('SitesService behavior', () => { }); it('denies all nonowner mutations including realm admins before storage', async () => { addSite(state, { visibility: 'private' }); - await expect(service.getSite('site-1', stranger)).rejects.toMatchObject({ statusCode: 404 }); + await expect(service.getSite('site-1', stranger)).rejects.toMatchObject({ statusCode: 403 }); await expect( service.replaceSiteContent('site-1', { principal: stranger, fileName: 'index.html', content: Buffer.from('bad'), }) - ).rejects.toMatchObject({ httpStatus: 404 }); - await expect(service.extendSite('site-1', stranger)).rejects.toMatchObject({ httpStatus: 404 }); - await expect(service.setVisibility('site-1', 'public', stranger, 1)).rejects.toMatchObject({ httpStatus: 404 }); - await expect(service.deleteSite('site-1', stranger)).rejects.toMatchObject({ httpStatus: 404 }); + ).rejects.toMatchObject({ httpStatus: 403 }); + await expect(service.extendSite('site-1', stranger)).rejects.toMatchObject({ httpStatus: 403 }); + await expect(service.setVisibility('site-1', 'public', stranger, 1)).rejects.toMatchObject({ httpStatus: 403 }); + await expect(service.deleteSite('site-1', stranger)).rejects.toMatchObject({ httpStatus: 403 }); expect(mockPutFiles).not.toHaveBeenCalled(); expect(mockDeletePrefix).not.toHaveBeenCalled(); }); diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index efcb192b..3470b82d 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -435,8 +435,10 @@ export default class SitesService extends Service { const config = await this.getConfig(); this.assertEnabled(config); const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as Site | undefined; - if (!site || (site.visibility !== 'public' && !isSiteOwner(site, principal))) - throw new SitesServiceError('Site not found.', 404); + if (!site) throw new SitesServiceError('Site not found.', 404); + // Internal tool: 403 here (not 404) so a denied teammate knows to ask the owner. + if (site.visibility !== 'public' && !isSiteOwner(site, principal)) + throw new SitesServiceError('You do not have access to this Site.', 403); return this.serialize(site, config, principal); } From 39c2cdddb7c67f483fdf3278cbe3afbec7e82695 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Tue, 22 Sep 2026 11:11:10 -0700 Subject: [PATCH 11/12] test(sites): update two more nonowner-delete assertions for the 403 change Missed in the earlier 404->403 pass: a service-key-owned site's nonowner delete, and the test that asserted missing/private deletion errors were identical (now intentionally distinct: 404 for missing, 403 for denied). --- src/server/services/__tests__/sites.behavior.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index afea5d4c..dc5d14f0 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -1185,7 +1185,7 @@ describe('SitesService behavior', () => { ownerSubject: null, ownerIssuer: null, }); - await expect(service.deleteSite(result.id, principal)).rejects.toMatchObject({ httpStatus: 404 }); + await expect(service.deleteSite(result.id, principal)).rejects.toMatchObject({ httpStatus: 403 }); await expect( service.createSite({ principal: machine, @@ -1274,7 +1274,7 @@ describe('SitesService behavior', () => { expect(mockPutFiles).not.toHaveBeenCalled(); } ); - it('returns the same missing/private deletion error contract to a nonowner', async () => { + it('gives a nonowner a distinct deletion error for a private Site versus a missing one', async () => { addSite(state, { visibility: 'private', ownerSubject: 'other' }); const errors = []; for (const id of ['site-1', 'missing']) { @@ -1288,9 +1288,10 @@ describe('SitesService behavior', () => { }); } } - expect(errors).toHaveLength(2); - expect(errors[0]).toEqual(errors[1]); - expect(errors[0]).toEqual({ message: 'Site not found.', code: 'site_not_found', status: 404 }); + expect(errors).toEqual([ + { message: 'You do not have access to this Site.', code: 'site_access_denied', status: 403 }, + { message: 'Site not found.', code: 'site_not_found', status: 404 }, + ]); }); it('filters unauthorized private rows before page totals and shows public creator attribution', async () => { addSite(state, { siteId: 'secret', visibility: 'private', ownerSubject: 'other' }); From 656683df296cb5124147c0c7c0fc836df835d451 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Tue, 22 Sep 2026 12:37:39 -0700 Subject: [PATCH 12/12] fix(sites): order the sites list by creation date instead of last update Replacing content or toggling visibility no longer bumps a site to the top of the list; the list now reflects when a site was first created. --- src/server/services/__tests__/sites.test.ts | 12 ++++++------ src/server/services/sites.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/server/services/__tests__/sites.test.ts b/src/server/services/__tests__/sites.test.ts index d9553050..3c752133 100644 --- a/src/server/services/__tests__/sites.test.ts +++ b/src/server/services/__tests__/sites.test.ts @@ -260,13 +260,13 @@ describe('SitesService', () => { describe('listSites', () => { it('returns all non-deleted sites without a user filter', async () => { rows.push( - createSiteRow({ siteId: 'old', updatedAt: '2026-05-01T00:00:00.000Z' }), + createSiteRow({ siteId: 'old', createdAt: '2026-05-01T00:00:00.000Z' }), createSiteRow({ siteId: 'deleted', - updatedAt: '2026-05-03T00:00:00.000Z', + createdAt: '2026-05-03T00:00:00.000Z', deletedAt: '2026-05-04T00:00:00.000Z', }), - createSiteRow({ siteId: 'new', updatedAt: '2026-05-02T00:00:00.000Z' }) + createSiteRow({ siteId: 'new', createdAt: '2026-05-02T00:00:00.000Z' }) ); await expect(service.listSites({}, principal)).resolves.toMatchObject({ @@ -288,9 +288,9 @@ describe('SitesService', () => { it('paginates sites after sorting and filtering', async () => { rows.push( - createSiteRow({ siteId: 'oldest', updatedAt: '2026-05-01T00:00:00.000Z' }), - createSiteRow({ siteId: 'middle', updatedAt: '2026-05-02T00:00:00.000Z' }), - createSiteRow({ siteId: 'newest', updatedAt: '2026-05-03T00:00:00.000Z' }) + createSiteRow({ siteId: 'oldest', createdAt: '2026-05-01T00:00:00.000Z' }), + createSiteRow({ siteId: 'middle', createdAt: '2026-05-02T00:00:00.000Z' }), + createSiteRow({ siteId: 'newest', createdAt: '2026-05-03T00:00:00.000Z' }) ); await expect(service.listSites({ page: 2, limit: 1 }, principal)).resolves.toMatchObject({ diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index 3470b82d..cf039a8b 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -421,7 +421,7 @@ export default class SitesService extends Service { ]); } const result = await query - .orderBy('updatedAt', 'desc') + .orderBy('createdAt', 'desc') .orderBy('siteId', 'asc') .page(page - 1, limit); return {