From cc6f7251047978608603206bf01a5b4a12db80b9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:43:25 +0800 Subject: [PATCH 1/2] fix(auth): serve set-initial-password on per-environment runtimes (#1544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit better-auth's `setPassword` is a server-only API (registered with no HTTP path on purpose — setting a password without proving the old one is privilege-sensitive). The full AuthPlugin wrapped it in a custom `/api/v1/auth/set-initial-password` route, but that plugin is SKIPPED on a per-environment (cloud) runtime, where auth is served by AuthProxyPlugin. There the route was absent, so the SSO-onboarded "Set local password" recovery flow POSTed it and 404'd, dead-ending first-time owner setup. - Extract the route body into a shared `runSetInitialPassword` helper that wraps `auth.api.setPassword` + normalises better-call APIErrors onto our `{ success, error }` envelope. - AuthPlugin now calls the helper (drops ~50 lines of hand-rolled getSession/hash/createAccount that had drifted from better-auth's own validation surface). - AuthProxyPlugin short-circuits `set-initial-password` before forwarding to better-auth, using the same helper so the two mount points can't drift. - Unit tests for the helper (missing arg, success header forwarding, already-set→409, length errors, 401, 500 fallback). Redirect target (profile → standalone /set-password page) is a follow-up paired with the objectui page; not flipped here to avoid a broken interim. Co-Authored-By: Claude Opus 4.8 --- .../plugins/plugin-auth/src/auth-plugin.ts | 58 ++---------- packages/plugins/plugin-auth/src/index.ts | 1 + .../src/set-initial-password.test.ts | 78 +++++++++++++++ .../plugin-auth/src/set-initial-password.ts | 94 +++++++++++++++++++ .../runtime/src/cloud/auth-proxy-plugin.ts | 26 +++++ 5 files changed, 206 insertions(+), 51 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/set-initial-password.test.ts create mode 100644 packages/plugins/plugin-auth/src/set-initial-password.ts diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 83c8a5128..e753f1d5b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -12,6 +12,7 @@ import { } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; import { AuthManager } from './auth-manager.js'; +import { runSetInitialPassword } from './set-initial-password.js'; import { authIdentityObjects, authPluginManifestHeader, @@ -480,60 +481,15 @@ export class AuthPlugin implements Plugin { // which user is asking) and refuses if a credential already exists // (the user should use better-auth's /change-password endpoint in // that case so the current password is verified). + // + // The body is `runSetInitialPassword` (shared with the cloud + // AuthProxyPlugin) so both mount points wrap better-auth's server-only + // `auth.api.setPassword` identically — see set-initial-password.ts. rawApp.post(`${basePath}/set-initial-password`, async (c: any) => { try { - let body: any = {}; - try { body = await c.req.json(); } catch { body = {}; } - const newPassword: unknown = body?.newPassword; - if (typeof newPassword !== 'string' || newPassword.length === 0) { - return c.json({ success: false, error: { code: 'invalid_request', message: 'newPassword is required' } }, 400); - } - const authApi = await this.authManager!.getApi(); - const session = await authApi.getSession({ headers: c.req.raw.headers }); - if (!session?.user?.id) { - return c.json({ success: false, error: { code: 'unauthorized', message: 'Sign in first' } }, 401); - } - const userId = session.user.id; - - const authCtx: any = await this.authManager!.getAuthContext(); - if (!authCtx?.internalAdapter || !authCtx?.password) { - return c.json({ success: false, error: { code: 'unavailable', message: 'Auth context unavailable' } }, 503); - } - - // Length checks mirror better-auth's emailAndPassword.{min,max}PasswordLength - // so the validation surface is consistent across set / change / reset. - const minLen = authCtx.password?.config?.minPasswordLength ?? 8; - const maxLen = authCtx.password?.config?.maxPasswordLength ?? 128; - if (newPassword.length < minLen) { - return c.json({ success: false, error: { code: 'password_too_short', message: `Password must be at least ${minLen} characters` } }, 400); - } - if (newPassword.length > maxLen) { - return c.json({ success: false, error: { code: 'password_too_long', message: `Password must be at most ${maxLen} characters` } }, 400); - } - - const accounts = await authCtx.internalAdapter.findAccounts(userId); - const existingCredential = accounts?.find?.((a: any) => a.providerId === 'credential' && a.password); - if (existingCredential) { - // Use /change-password (requires currentPassword) instead. - return c.json({ - success: false, - error: { - code: 'credential_account_exists', - message: 'A local password is already set for this account. Use change-password instead.', - }, - }, 409); - } - - const passwordHash = await authCtx.password.hash(newPassword); - await authCtx.internalAdapter.createAccount({ - userId, - providerId: 'credential', - accountId: userId, - password: passwordHash, - }); - - return c.json({ success: true }); + const { status, body } = await runSetInitialPassword(authApi as any, c.req.raw); + return c.json(body, status); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); ctx.logger.error('[AuthPlugin] set-initial-password failed', err); diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index 974dfbc1b..d6e37b4e6 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -10,6 +10,7 @@ export * from './auth-plugin.js'; export * from './auth-manager.js'; +export * from './set-initial-password.js'; export * from './objectql-adapter.js'; export * from './auth-schema-config.js'; export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system'; diff --git a/packages/plugins/plugin-auth/src/set-initial-password.test.ts b/packages/plugins/plugin-auth/src/set-initial-password.test.ts new file mode 100644 index 000000000..83b160390 --- /dev/null +++ b/packages/plugins/plugin-auth/src/set-initial-password.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { runSetInitialPassword, type SetPasswordCapableApi } from './set-initial-password.js'; + +function makeRequest(body: unknown): Request { + return new Request('https://example.test/api/v1/auth/set-initial-password', { + method: 'POST', + headers: { 'content-type': 'application/json', cookie: 'better-auth.session_token=abc' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +/** Mimic a better-call APIError (`{ statusCode, status, body: { code, message } }`). */ +function apiError(statusCode: number, code: string, message: string) { + return Object.assign(new Error(message), { statusCode, status: code, body: { code, message } }); +} + +describe('runSetInitialPassword', () => { + it('rejects a missing newPassword with 400 invalid_request (no API call)', async () => { + const api: SetPasswordCapableApi = { setPassword: vi.fn() }; + const res = await runSetInitialPassword(api, makeRequest({})); + expect(res.status).toBe(400); + expect(res.body).toEqual({ success: false, error: { code: 'invalid_request', message: 'newPassword is required' } }); + expect(api.setPassword).not.toHaveBeenCalled(); + }); + + it('rejects a non-JSON body with 400', async () => { + const api: SetPasswordCapableApi = { setPassword: vi.fn() }; + const res = await runSetInitialPassword(api, makeRequest('not-json{')); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('invalid_request'); + }); + + it('forwards newPassword + session headers to better-auth and returns 200 on success', async () => { + const setPassword = vi.fn().mockResolvedValue({ status: true }); + const req = makeRequest({ newPassword: 'super-secret-pw' }); + const res = await runSetInitialPassword({ setPassword }, req); + + expect(res).toEqual({ status: 200, body: { success: true } }); + expect(setPassword).toHaveBeenCalledTimes(1); + const arg = setPassword.mock.calls[0][0]; + expect(arg.body).toEqual({ newPassword: 'super-secret-pw' }); + // The session cookie must ride along so better-auth's session middleware + // can identify the caller. + expect(arg.headers.get('cookie')).toContain('better-auth.session_token'); + }); + + it('maps PASSWORD_ALREADY_SET to 409 (use change-password)', async () => { + const setPassword = vi.fn().mockRejectedValue( + apiError(400, 'PASSWORD_ALREADY_SET', 'A local password is already set'), + ); + const res = await runSetInitialPassword({ setPassword }, makeRequest({ newPassword: 'x'.repeat(12) })); + expect(res.status).toBe(409); + expect(res.body.error).toEqual({ code: 'PASSWORD_ALREADY_SET', message: 'A local password is already set' }); + }); + + it('preserves length-validation errors (400 PASSWORD_TOO_SHORT)', async () => { + const setPassword = vi.fn().mockRejectedValue(apiError(400, 'PASSWORD_TOO_SHORT', 'Password is too short')); + const res = await runSetInitialPassword({ setPassword }, makeRequest({ newPassword: 'short' })); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('PASSWORD_TOO_SHORT'); + }); + + it('passes through a 401 when no session is present', async () => { + const setPassword = vi.fn().mockRejectedValue(apiError(401, 'UNAUTHORIZED', 'Sign in first')); + const res = await runSetInitialPassword({ setPassword }, makeRequest({ newPassword: 'x'.repeat(12) })); + expect(res.status).toBe(401); + expect(res.body.error?.code).toBe('UNAUTHORIZED'); + }); + + it('falls back to 500 internal for a plain (non-APIError) throw', async () => { + const setPassword = vi.fn().mockRejectedValue(new Error('adapter exploded')); + const res = await runSetInitialPassword({ setPassword }, makeRequest({ newPassword: 'x'.repeat(12) })); + expect(res.status).toBe(500); + expect(res.body.error).toEqual({ code: 'internal', message: 'adapter exploded' }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/set-initial-password.ts b/packages/plugins/plugin-auth/src/set-initial-password.ts new file mode 100644 index 000000000..4c02a1433 --- /dev/null +++ b/packages/plugins/plugin-auth/src/set-initial-password.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared `set-initial-password` handler. + * + * better-auth ships a `setPassword` operation that does EXACTLY what we want + * (require a session, enforce min/max length, link a `credential` account if + * none exists, refuse if one already does). But it is registered with + * `createAuthEndpoint({ ... })` — note: NO leading path string — which means + * better-auth deliberately exposes it as a **server-only** `auth.api.setPassword` + * call and gives it no HTTP route. Setting a password without proving the old + * one is privilege-sensitive, so it must not be reachable over the wire by + * default. + * + * To let an SSO-onboarded user set an *initial* local password from the + * browser, we wrap that server API in our own authenticated HTTP route. This + * helper is the single source of truth for that route body so the two mount + * points — the full `AuthPlugin` (host kernel) and the cloud `AuthProxyPlugin` + * (per-environment runtime) — stay in lockstep instead of hand-copying ~50 + * lines of hash/createAccount logic (the original drift that let #1544 ship a + * route on one path but not the other). + */ + +/** Minimal shape of the better-auth server API we depend on. */ +export interface SetPasswordCapableApi { + setPassword(opts: { body: { newPassword: string }; headers: Headers }): Promise; +} + +export interface SetInitialPasswordResult { + /** HTTP status to return to the caller. */ + status: number; + /** JSON body; mirrors the `{ success, error: { code, message } }` envelope the client parses. */ + body: { success: boolean; error?: { code: string; message: string } }; +} + +/** + * Run set-initial-password against the environment's better-auth API. + * + * @param authApi the better-auth server api (`auth.api`, via `AuthManager.getApi()`) + * @param request the raw Web `Request` — its `headers` carry the session + * cookie that better-auth's session middleware reads, and its + * body carries `{ newPassword }`. + */ +export async function runSetInitialPassword( + authApi: SetPasswordCapableApi, + request: Request, +): Promise { + let parsed: unknown; + try { + parsed = await request.json(); + } catch { + parsed = {}; + } + const newPassword: unknown = (parsed as { newPassword?: unknown } | null)?.newPassword; + if (typeof newPassword !== 'string' || newPassword.length === 0) { + return { + status: 400, + body: { success: false, error: { code: 'invalid_request', message: 'newPassword is required' } }, + }; + } + + try { + // better-auth's session middleware reads the session from `headers`; + // length checks + the "already set" guard happen inside setPassword. + await authApi.setPassword({ body: { newPassword }, headers: request.headers }); + return { status: 200, body: { success: true } }; + } catch (error) { + return mapSetPasswordError(error); + } +} + +/** + * Map a better-auth `APIError` (better-call: `{ statusCode, status, body: { code, message } }`) + * onto our response envelope. The client only surfaces `error.message`, but we + * preserve the status code and code string for parity with the change/reset + * flows. `PASSWORD_ALREADY_SET` is normalised to 409 so callers can tell + * "already has a password → use change-password" apart from validation errors. + */ +function mapSetPasswordError(error: unknown): SetInitialPasswordResult { + const e = error as { + statusCode?: number; + status?: number | string; + body?: { code?: string; message?: string }; + message?: string; + } | null; + + const code = e?.body?.code ?? 'internal'; + const message = e?.body?.message ?? e?.message ?? 'set-initial-password failed'; + const rawStatus = + typeof e?.statusCode === 'number' ? e.statusCode : typeof e?.status === 'number' ? e.status : 500; + const status = code === 'PASSWORD_ALREADY_SET' ? 409 : rawStatus; + + return { status, body: { success: false, error: { code, message } } }; +} diff --git a/packages/runtime/src/cloud/auth-proxy-plugin.ts b/packages/runtime/src/cloud/auth-proxy-plugin.ts index 3d0b6c323..c915eada8 100644 --- a/packages/runtime/src/cloud/auth-proxy-plugin.ts +++ b/packages/runtime/src/cloud/auth-proxy-plugin.ts @@ -28,6 +28,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { createHmac, randomUUID } from 'node:crypto'; +import { runSetInitialPassword } from '@objectstack/plugin-auth'; import type { KernelManager } from './kernel-manager.js'; import type { EnvironmentDriverRegistry } from './environment-registry.js'; @@ -410,6 +411,31 @@ export class AuthProxyPlugin implements Plugin { } } + // ── set-initial-password ────────────────────────── + // POST /api/v1/auth/set-initial-password + // + // better-auth's `setPassword` is a server-only API (no + // HTTP route), and the full AuthPlugin — which exposes it + // as a custom route — is SKIPPED on a per-environment + // runtime. So without this short-circuit the request falls + // through to better-auth and 404s, dead-ending the + // `sso-exchange` → "Set local password" recovery flow + // (see #1544). Reuse the exact same wrapper the AuthPlugin + // uses so the two paths can never drift again. + if (c.req.method === 'POST' && subPath === 'set-initial-password') { + try { + if (typeof authSvc?.getApi !== 'function') { + return c.json({ success: false, error: { code: 'unavailable', message: 'Auth API unavailable' } }, 503); + } + const authApi = await authSvc.getApi(); + const { status, body } = await runSetInitialPassword(authApi, c.req.raw); + return c.json(body, status); + } catch (err: any) { + ctx.logger?.error?.('[AuthProxyPlugin] set-initial-password failed', err instanceof Error ? err : new Error(String(err))); + return c.json({ success: false, error: { code: 'internal', message: err?.message ?? String(err) } }, 500); + } + } + const fn = await resolveAuthHandler(authSvc); if (!fn) { return c.json({ error: 'auth_service_unavailable', environmentId }, 503); From b4d13541875cf41a7fc55276688847b95542f138 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:56:11 +0800 Subject: [PATCH 2/2] fix(auth): redirect SSO recovery to standalone /set-password page (#1544) Now that objectui ships a shell-less `/set-password` auth surface (sibling of login/reset-password), point the sso-exchange "no credential yet" redirect at it instead of the full-shell profile page + `?recovery_needed=true` banner. Conventional shape for an auth screen; the profile page drops the recovery special-casing. Co-Authored-By: Claude Opus 4.8 --- packages/runtime/src/cloud/auth-proxy-plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/cloud/auth-proxy-plugin.ts b/packages/runtime/src/cloud/auth-proxy-plugin.ts index c915eada8..6b93bec3c 100644 --- a/packages/runtime/src/cloud/auth-proxy-plugin.ts +++ b/packages/runtime/src/cloud/auth-proxy-plugin.ts @@ -280,7 +280,7 @@ export class AuthProxyPlugin implements Plugin { // the user in the env by email, mints a fresh better-auth // session, sets the signed session cookie and 302s to // `next`. If the user has no credential account yet, we - // redirect to /_console/system/profile?recovery_needed=true + // redirect to the standalone /_console/set-password page // so they can configure a disaster-recovery local password. if (c.req.method === 'GET' && subPath === 'sso-exchange') { try { @@ -338,7 +338,7 @@ export class AuthProxyPlugin implements Plugin { const finalNext = hasCredentialAccount ? next - : `/_console/system/profile?recovery_needed=true&next=${encodeURIComponent(next)}`; + : `/_console/set-password?next=${encodeURIComponent(next)}`; const headers = new Headers(); headers.set('Set-Cookie', setCookie); headers.set('Location', finalNext);