diff --git a/CHANGELOG.md b/CHANGELOG.md index a140882c3..6ca611e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Cross-origin auth tokens stripped in `@objectstack/hono` adapter (follow-up to PR #1178)** — `createHonoApp()` was not exposing `set-auth-token` via `Access-Control-Expose-Headers`, diverging from `plugin-hono-server`'s CORS wiring. On Vercel deployments (where all traffic flows through `createHonoApp()`), the browser stripped the header from every response, preventing the better-auth `bearer()` plugin from delivering rotated session tokens to cross-origin clients. Cross-origin sessions silently broke even after the wildcard fixes in #1177/#1178. The adapter now always includes `set-auth-token` in `exposeHeaders`, merged with any user-supplied values, mirroring the invariant established in commit `151dd19c`. (`packages/adapters/hono/src/index.ts`) - **CORS wildcard patterns in `@objectstack/hono` adapter (follow-up to PR #1177)** — `createHonoApp()` was the third CORS code path that still treated wildcard origins (e.g. `https://*.objectui.org`) as literal strings when passing them to Hono's `cors()` middleware. Because `apps/server` routes all non-OPTIONS requests through this adapter on Vercel, the browser would see a successful preflight (handled by the Vercel short-circuit) followed by a POST/GET response with no `Access-Control-Allow-Origin` header, blocking every real request. The adapter now imports `hasWildcardPattern` / `createOriginMatcher` from `@objectstack/plugin-hono-server` and uses the same matcher-function branch as `plugin-hono-server`, so all three Hono-based CORS paths share a single source of truth. (`packages/adapters/hono/src/index.ts`) - **CORS wildcard patterns on Vercel deployments** — `CORS_ORIGIN` values containing wildcard patterns (e.g. `https://*.objectui.org,https://*.objectstack.ai,http://localhost:*`) no longer cause browser CORS errors when `apps/server` is deployed to Vercel. The Vercel entrypoint's OPTIONS preflight short-circuit previously matched origins with a literal `Array.includes()`, treating `*` as a plain character and rejecting legitimate subdomains. It now shares the same pattern-matching logic as the Hono plugin's `cors()` middleware via new exports `createOriginMatcher` / `hasWildcardPattern` / `matchOriginPattern` / `normalizeOriginPatterns` from `@objectstack/plugin-hono-server`. (`apps/server/server/index.ts`, `packages/plugins/plugin-hono-server/src/pattern-matcher.ts`) diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index c3626aec4..f37e7b9e5 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -1025,4 +1025,65 @@ describe('createHonoApp', () => { expect(res.headers.get('access-control-allow-origin')).toBe('https://app.objectui.org'); }); }); + + describe('CORS expose-headers defaults', () => { + // `set-auth-token` must always be exposed so the better-auth bearer() + // plugin can deliver rotated session tokens to cross-origin clients. + // This mirrors plugin-hono-server's CORS wiring — all three Hono-based + // CORS sites must stay in lockstep on this default. + const ORIG_CORS_ORIGIN = process.env.CORS_ORIGIN; + + beforeEach(() => { + delete process.env.CORS_ORIGIN; + }); + + afterAll(() => { + if (ORIG_CORS_ORIGIN === undefined) delete process.env.CORS_ORIGIN; + else process.env.CORS_ORIGIN = ORIG_CORS_ORIGIN; + }); + + it('always exposes set-auth-token by default', async () => { + const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'https://app.example.com' }, + }); + const exposed = res.headers.get('access-control-expose-headers') || ''; + expect(exposed.toLowerCase()).toContain('set-auth-token'); + }); + + it('merges user-supplied exposeHeaders with set-auth-token (does not replace)', async () => { + const app = createHonoApp({ + kernel: mockKernel, + prefix: '/api/v1', + cors: { exposeHeaders: ['X-Custom-Header'] }, + }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'https://app.example.com' }, + }); + const exposed = (res.headers.get('access-control-expose-headers') || '').toLowerCase(); + expect(exposed).toContain('set-auth-token'); + expect(exposed).toContain('x-custom-header'); + }); + + it('does not duplicate set-auth-token when user also supplies it', async () => { + const app = createHonoApp({ + kernel: mockKernel, + prefix: '/api/v1', + cors: { exposeHeaders: ['set-auth-token', 'X-Other'] }, + }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'https://app.example.com' }, + }); + const exposed = (res.headers.get('access-control-expose-headers') || '').toLowerCase(); + const occurrences = exposed.split(',').map(s => s.trim()).filter(s => s === 'set-auth-token'); + expect(occurrences.length).toBe(1); + expect(exposed).toContain('x-other'); + }); + }); }); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index ea2ac4bb0..6a90b52ec 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -118,11 +118,26 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { origin = configuredOrigin; } + // Always include `set-auth-token` in exposed headers so that the + // better-auth `bearer()` plugin (registered by plugin-auth) can + // deliver rotated session tokens to cross-origin clients. Without + // this, browsers strip the header from every response, the client + // never sees the new token, and cross-origin sessions silently + // break even when preflight and the actual request both succeed. + // + // This mirrors `plugin-hono-server`'s CORS wiring — all three + // Hono-based CORS sites must stay in lockstep on this default. + const defaultExposeHeaders = ['set-auth-token']; + const exposeHeaders = Array.from(new Set([ + ...defaultExposeHeaders, + ...(corsOpts.exposeHeaders ?? []), + ])); + app.use('*', cors({ origin: origin as any, allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], allowHeaders: corsOpts.allowHeaders || ['Content-Type', 'Authorization', 'X-Requested-With'], - exposeHeaders: corsOpts.exposeHeaders || [], + exposeHeaders, credentials, maxAge, }));