Skip to content

Commit f27779c

Browse files
Merge pull request #1173 from objectstack-ai/copilot/fix-issue-1172
plugin-auth: always register better-auth `bearer()` for cross-origin / mobile token auth
2 parents bc21aad + 151dd19 commit f27779c

File tree

9 files changed

+168
-6
lines changed

9 files changed

+168
-6
lines changed

packages/plugins/plugin-auth/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Minor Changes
6+
7+
- Always register better-auth's `bearer()` plugin so cross-origin browsers
8+
(where third-party cookies are blocked) and native mobile clients can
9+
authenticate via `Authorization: Bearer <token>` headers and pick up
10+
rotated tokens from the `set-auth-token` response header (fixes #1172).
11+
312
## 4.0.4
413

514
### Patch Changes

packages/plugins/plugin-auth/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ Authentication & Identity Plugin for ObjectStack.
2828
-**Passkeys** - WebAuthn/Passkey support (when enabled)
2929
-**Magic Links** - Passwordless authentication (when enabled)
3030
-**Organizations** - Multi-tenant support (when enabled)
31+
-**Bearer-token Auth** - Cross-origin and mobile clients can authenticate
32+
via `Authorization: Bearer <token>` and receive rotated tokens on the
33+
`set-auth-token` response header (always enabled, no config required).
3134

3235
### ObjectQL-Based Database Architecture
3336
-**Native ObjectQL Data Persistence** - Uses ObjectQL's IDataEngine interface

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ describe('AuthManager', () => {
299299
});
300300

301301
describe('plugin registration', () => {
302-
it('should not include any plugins when no plugin config is provided', () => {
302+
it('should always register the bearer plugin even with no plugin config', () => {
303303
let capturedConfig: any;
304304
(betterAuth as any).mockImplementation((config: any) => {
305305
capturedConfig = config;
@@ -314,7 +314,7 @@ describe('AuthManager', () => {
314314
manager.getAuthInstance();
315315
warnSpy.mockRestore();
316316

317-
expect(capturedConfig.plugins).toEqual([]);
317+
expect(capturedConfig.plugins.map((p: any) => p.id)).toEqual(['bearer']);
318318
});
319319

320320
it('should register organization plugin with schema mapping when enabled', () => {
@@ -404,13 +404,35 @@ describe('AuthManager', () => {
404404
manager.getAuthInstance();
405405
warnSpy.mockRestore();
406406

407-
expect(capturedConfig.plugins).toHaveLength(3);
407+
expect(capturedConfig.plugins).toHaveLength(4);
408408
expect(capturedConfig.plugins.map((p: any) => p.id).sort()).toEqual(
409-
['magic-link', 'organization', 'two-factor'],
409+
['bearer', 'magic-link', 'organization', 'two-factor'],
410410
);
411411
});
412412
});
413413

414+
describe('bearer plugin (cross-origin / mobile token auth)', () => {
415+
it('should always register the bearer plugin regardless of other flags', () => {
416+
let capturedConfig: any;
417+
(betterAuth as any).mockImplementation((config: any) => {
418+
capturedConfig = config;
419+
return { handler: vi.fn(), api: {} };
420+
});
421+
422+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
423+
const manager = new AuthManager({
424+
secret: 'test-secret-at-least-32-chars-long',
425+
baseUrl: 'http://localhost:3000',
426+
plugins: { organization: true },
427+
});
428+
manager.getAuthInstance();
429+
warnSpy.mockRestore();
430+
431+
const bearerPlugin = capturedConfig.plugins.find((p: any) => p.id === 'bearer');
432+
expect(bearerPlugin).toBeDefined();
433+
});
434+
});
435+
414436
describe('trustedOrigins passthrough', () => {
415437
it('should forward trustedOrigins to betterAuth when provided', () => {
416438
let capturedConfig: any;

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Auth, BetterAuthOptions } from 'better-auth';
55
import { organization } from 'better-auth/plugins/organization';
66
import { twoFactor } from 'better-auth/plugins/two-factor';
77
import { magicLink } from 'better-auth/plugins/magic-link';
8+
import { bearer } from 'better-auth/plugins/bearer';
89
import type {
910
AuthConfig,
1011
EmailAndPasswordConfig,
@@ -204,6 +205,22 @@ export class AuthManager {
204205
const pluginConfig = this.config.plugins;
205206
const plugins: any[] = [];
206207

208+
// bearer() — ALWAYS enabled.
209+
//
210+
// Enables token-based authentication for cross-origin and mobile clients
211+
// where third-party cookies are blocked (e.g. Safari ITP, Chrome CHIPS,
212+
// native apps). The plugin:
213+
// • Accepts `Authorization: Bearer <token>` on incoming requests and
214+
// transparently resolves the session as if a cookie had been sent.
215+
// • Emits a `set-auth-token` response header on sign-in / session-refresh
216+
// that the client can store (e.g. in `localStorage`) and replay on
217+
// subsequent requests.
218+
//
219+
// This mirrors how Salesforce, Notion, Supabase and first-party mobile
220+
// SDKs handle auth. Cookie-based auth remains available for same-origin
221+
// browser deployments; bearer is additive, not a replacement.
222+
plugins.push(bearer());
223+
207224
if (pluginConfig?.organization) {
208225
plugins.push(organization({
209226
schema: buildOrganizationPluginSchema(),

packages/plugins/plugin-hono-server/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# @objectstack/plugin-hono-server
22

3+
## Unreleased
4+
5+
### Minor Changes
6+
7+
- CORS middleware now exposes `set-auth-token` by default so clients can
8+
capture rotated bearer tokens emitted by `@objectstack/plugin-auth`.
9+
- `HonoCorsOptions` accepts `allowHeaders` and `exposeHeaders`. User-supplied
10+
`exposeHeaders` are merged with the `set-auth-token` default.
11+
312
## 4.0.4
413

514
### Patch Changes

packages/plugins/plugin-hono-server/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,28 @@ kernel.use(new HonoServerPlugin({
100100
}));
101101
```
102102

103+
### Bearer token auth (`set-auth-token`)
104+
105+
The CORS middleware always includes `set-auth-token` in `Access-Control-Expose-Headers`
106+
so that `@objectstack/plugin-auth`'s `bearer()` plugin can deliver rotated session
107+
tokens to cross-origin and mobile clients. `Authorization` is included in
108+
`Access-Control-Allow-Headers` by default so `Authorization: Bearer <token>`
109+
requests succeed preflight.
110+
111+
You can contribute additional exposed / allowed headers — `exposeHeaders`
112+
entries you supply are **merged** with the `set-auth-token` default:
113+
114+
```typescript
115+
kernel.use(new HonoServerPlugin({
116+
cors: {
117+
origins: ['https://app.example.com'],
118+
credentials: true,
119+
exposeHeaders: ['X-Request-Id'], // in addition to set-auth-token
120+
allowHeaders: ['Content-Type', 'Authorization', 'X-Tenant-Id'],
121+
},
122+
}));
123+
```
124+
103125
## Architecture
104126

105127
This plugin wraps `@objectstack/hono` to provide a turnkey HTTP server solution for the Runtime. It binds the standard `HttpDispatcher` to a Hono application and starts listening on the configured port.

packages/plugins/plugin-hono-server/src/adapter.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ export interface HonoCorsOptions {
1616
enabled?: boolean;
1717
origins?: string | string[];
1818
methods?: string[];
19+
/**
20+
* Request headers allowed on preflight (`Access-Control-Allow-Headers`).
21+
*
22+
* Defaults to `['Content-Type', 'Authorization', 'X-Requested-With']`,
23+
* which is sufficient for cookie and bearer-token auth.
24+
*/
25+
allowHeaders?: string[];
26+
/**
27+
* Response headers exposed to JS (`Access-Control-Expose-Headers`).
28+
*
29+
* Defaults to `['set-auth-token']` so that better-auth's `bearer()` plugin
30+
* can hand rotated session tokens to cross-origin clients. User-supplied
31+
* values are merged with this default — `set-auth-token` is always
32+
* exposed unless CORS is disabled entirely.
33+
*/
34+
exposeHeaders?: string[];
1935
credentials?: boolean;
2036
maxAge?: number;
2137
}

packages/plugins/plugin-hono-server/src/hono-plugin.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ vi.mock('./adapter', () => ({
3333
})
3434
}));
3535

36+
// Capture the config passed to hono/cors so we can assert allowHeaders / exposeHeaders.
37+
const corsConfigCapture: { last?: any } = {};
38+
vi.mock('hono/cors', () => ({
39+
cors: vi.fn((config: any) => {
40+
corsConfigCapture.last = config;
41+
// Return a no-op middleware
42+
return async (_c: any, next: any) => next();
43+
}),
44+
}));
45+
3646
describe('HonoServerPlugin', () => {
3747
let context: any;
3848
let logger: any;
@@ -232,5 +242,47 @@ describe('HonoServerPlugin', () => {
232242
delete process.env.CORS_ENABLED;
233243
}
234244
});
245+
246+
it('should always expose set-auth-token header (for better-auth bearer plugin)', async () => {
247+
corsConfigCapture.last = undefined;
248+
249+
const plugin = new HonoServerPlugin();
250+
await plugin.init(context as PluginContext);
251+
252+
expect(corsConfigCapture.last).toBeDefined();
253+
expect(corsConfigCapture.last.exposeHeaders).toContain('set-auth-token');
254+
// Default allowHeaders should include Authorization so Bearer tokens work
255+
expect(corsConfigCapture.last.allowHeaders).toContain('Authorization');
256+
});
257+
258+
it('should merge user-supplied exposeHeaders with set-auth-token default', async () => {
259+
corsConfigCapture.last = undefined;
260+
261+
const plugin = new HonoServerPlugin({
262+
cors: {
263+
exposeHeaders: ['X-Request-Id', 'X-Rate-Limit'],
264+
},
265+
});
266+
await plugin.init(context as PluginContext);
267+
268+
expect(corsConfigCapture.last.exposeHeaders).toEqual(
269+
expect.arrayContaining(['set-auth-token', 'X-Request-Id', 'X-Rate-Limit']),
270+
);
271+
});
272+
273+
it('should honor custom allowHeaders while still allowing bearer auth header when explicitly provided', async () => {
274+
corsConfigCapture.last = undefined;
275+
276+
const plugin = new HonoServerPlugin({
277+
cors: {
278+
allowHeaders: ['Content-Type', 'Authorization', 'X-Tenant-Id'],
279+
},
280+
});
281+
await plugin.init(context as PluginContext);
282+
283+
expect(corsConfigCapture.last.allowHeaders).toEqual(
284+
['Content-Type', 'Authorization', 'X-Tenant-Id'],
285+
);
286+
});
235287
});
236288
});

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,23 @@ export class HonoServerPlugin implements Plugin {
209209
}
210210

211211
const rawApp = this.server.getRawApp();
212+
// Always include `set-auth-token` in exposed headers so that
213+
// the better-auth `bearer()` plugin can deliver rotated
214+
// session tokens to cross-origin clients (see plugin-auth).
215+
// User-supplied exposeHeaders are merged with this default.
216+
const defaultAllowHeaders = ['Content-Type', 'Authorization', 'X-Requested-With'];
217+
const defaultExposeHeaders = ['set-auth-token'];
218+
const allowHeaders = corsOpts.allowHeaders ?? defaultAllowHeaders;
219+
const exposeHeaders = Array.from(new Set([
220+
...defaultExposeHeaders,
221+
...(corsOpts.exposeHeaders ?? []),
222+
]));
223+
212224
rawApp.use('*', cors({
213225
origin: origin as any,
214226
allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
215-
allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
216-
exposeHeaders: [],
227+
allowHeaders,
228+
exposeHeaders,
217229
credentials,
218230
maxAge,
219231
}));

0 commit comments

Comments
 (0)