Skip to content

Commit 7b129b8

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(auth): serve set-initial-password on per-environment runtimes (#1544) (#1577)
* fix(auth): serve set-initial-password on per-environment runtimes (#1544) 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e15c845 commit 7b129b8

5 files changed

Lines changed: 208 additions & 53 deletions

File tree

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

Lines changed: 7 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@objectstack/platform-objects/apps';
1313
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
1414
import { AuthManager } from './auth-manager.js';
15+
import { runSetInitialPassword } from './set-initial-password.js';
1516
import {
1617
authIdentityObjects,
1718
authPluginManifestHeader,
@@ -480,60 +481,15 @@ export class AuthPlugin implements Plugin {
480481
// which user is asking) and refuses if a credential already exists
481482
// (the user should use better-auth's /change-password endpoint in
482483
// that case so the current password is verified).
484+
//
485+
// The body is `runSetInitialPassword` (shared with the cloud
486+
// AuthProxyPlugin) so both mount points wrap better-auth's server-only
487+
// `auth.api.setPassword` identically — see set-initial-password.ts.
483488
rawApp.post(`${basePath}/set-initial-password`, async (c: any) => {
484489
try {
485-
let body: any = {};
486-
try { body = await c.req.json(); } catch { body = {}; }
487-
const newPassword: unknown = body?.newPassword;
488-
if (typeof newPassword !== 'string' || newPassword.length === 0) {
489-
return c.json({ success: false, error: { code: 'invalid_request', message: 'newPassword is required' } }, 400);
490-
}
491-
492490
const authApi = await this.authManager!.getApi();
493-
const session = await authApi.getSession({ headers: c.req.raw.headers });
494-
if (!session?.user?.id) {
495-
return c.json({ success: false, error: { code: 'unauthorized', message: 'Sign in first' } }, 401);
496-
}
497-
const userId = session.user.id;
498-
499-
const authCtx: any = await this.authManager!.getAuthContext();
500-
if (!authCtx?.internalAdapter || !authCtx?.password) {
501-
return c.json({ success: false, error: { code: 'unavailable', message: 'Auth context unavailable' } }, 503);
502-
}
503-
504-
// Length checks mirror better-auth's emailAndPassword.{min,max}PasswordLength
505-
// so the validation surface is consistent across set / change / reset.
506-
const minLen = authCtx.password?.config?.minPasswordLength ?? 8;
507-
const maxLen = authCtx.password?.config?.maxPasswordLength ?? 128;
508-
if (newPassword.length < minLen) {
509-
return c.json({ success: false, error: { code: 'password_too_short', message: `Password must be at least ${minLen} characters` } }, 400);
510-
}
511-
if (newPassword.length > maxLen) {
512-
return c.json({ success: false, error: { code: 'password_too_long', message: `Password must be at most ${maxLen} characters` } }, 400);
513-
}
514-
515-
const accounts = await authCtx.internalAdapter.findAccounts(userId);
516-
const existingCredential = accounts?.find?.((a: any) => a.providerId === 'credential' && a.password);
517-
if (existingCredential) {
518-
// Use /change-password (requires currentPassword) instead.
519-
return c.json({
520-
success: false,
521-
error: {
522-
code: 'credential_account_exists',
523-
message: 'A local password is already set for this account. Use change-password instead.',
524-
},
525-
}, 409);
526-
}
527-
528-
const passwordHash = await authCtx.password.hash(newPassword);
529-
await authCtx.internalAdapter.createAccount({
530-
userId,
531-
providerId: 'credential',
532-
accountId: userId,
533-
password: passwordHash,
534-
});
535-
536-
return c.json({ success: true });
491+
const { status, body } = await runSetInitialPassword(authApi as any, c.req.raw);
492+
return c.json(body, status);
537493
} catch (error) {
538494
const err = error instanceof Error ? error : new Error(String(error));
539495
ctx.logger.error('[AuthPlugin] set-initial-password failed', err);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
export * from './auth-plugin.js';
1212
export * from './auth-manager.js';
13+
export * from './set-initial-password.js';
1314
export * from './objectql-adapter.js';
1415
export * from './auth-schema-config.js';
1516
export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system';
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { runSetInitialPassword, type SetPasswordCapableApi } from './set-initial-password.js';
5+
6+
function makeRequest(body: unknown): Request {
7+
return new Request('https://example.test/api/v1/auth/set-initial-password', {
8+
method: 'POST',
9+
headers: { 'content-type': 'application/json', cookie: 'better-auth.session_token=abc' },
10+
body: typeof body === 'string' ? body : JSON.stringify(body),
11+
});
12+
}
13+
14+
/** Mimic a better-call APIError (`{ statusCode, status, body: { code, message } }`). */
15+
function apiError(statusCode: number, code: string, message: string) {
16+
return Object.assign(new Error(message), { statusCode, status: code, body: { code, message } });
17+
}
18+
19+
describe('runSetInitialPassword', () => {
20+
it('rejects a missing newPassword with 400 invalid_request (no API call)', async () => {
21+
const api: SetPasswordCapableApi = { setPassword: vi.fn() };
22+
const res = await runSetInitialPassword(api, makeRequest({}));
23+
expect(res.status).toBe(400);
24+
expect(res.body).toEqual({ success: false, error: { code: 'invalid_request', message: 'newPassword is required' } });
25+
expect(api.setPassword).not.toHaveBeenCalled();
26+
});
27+
28+
it('rejects a non-JSON body with 400', async () => {
29+
const api: SetPasswordCapableApi = { setPassword: vi.fn() };
30+
const res = await runSetInitialPassword(api, makeRequest('not-json{'));
31+
expect(res.status).toBe(400);
32+
expect(res.body.error?.code).toBe('invalid_request');
33+
});
34+
35+
it('forwards newPassword + session headers to better-auth and returns 200 on success', async () => {
36+
const setPassword = vi.fn().mockResolvedValue({ status: true });
37+
const req = makeRequest({ newPassword: 'super-secret-pw' });
38+
const res = await runSetInitialPassword({ setPassword }, req);
39+
40+
expect(res).toEqual({ status: 200, body: { success: true } });
41+
expect(setPassword).toHaveBeenCalledTimes(1);
42+
const arg = setPassword.mock.calls[0][0];
43+
expect(arg.body).toEqual({ newPassword: 'super-secret-pw' });
44+
// The session cookie must ride along so better-auth's session middleware
45+
// can identify the caller.
46+
expect(arg.headers.get('cookie')).toContain('better-auth.session_token');
47+
});
48+
49+
it('maps PASSWORD_ALREADY_SET to 409 (use change-password)', async () => {
50+
const setPassword = vi.fn().mockRejectedValue(
51+
apiError(400, 'PASSWORD_ALREADY_SET', 'A local password is already set'),
52+
);
53+
const res = await runSetInitialPassword({ setPassword }, makeRequest({ newPassword: 'x'.repeat(12) }));
54+
expect(res.status).toBe(409);
55+
expect(res.body.error).toEqual({ code: 'PASSWORD_ALREADY_SET', message: 'A local password is already set' });
56+
});
57+
58+
it('preserves length-validation errors (400 PASSWORD_TOO_SHORT)', async () => {
59+
const setPassword = vi.fn().mockRejectedValue(apiError(400, 'PASSWORD_TOO_SHORT', 'Password is too short'));
60+
const res = await runSetInitialPassword({ setPassword }, makeRequest({ newPassword: 'short' }));
61+
expect(res.status).toBe(400);
62+
expect(res.body.error?.code).toBe('PASSWORD_TOO_SHORT');
63+
});
64+
65+
it('passes through a 401 when no session is present', async () => {
66+
const setPassword = vi.fn().mockRejectedValue(apiError(401, 'UNAUTHORIZED', 'Sign in first'));
67+
const res = await runSetInitialPassword({ setPassword }, makeRequest({ newPassword: 'x'.repeat(12) }));
68+
expect(res.status).toBe(401);
69+
expect(res.body.error?.code).toBe('UNAUTHORIZED');
70+
});
71+
72+
it('falls back to 500 internal for a plain (non-APIError) throw', async () => {
73+
const setPassword = vi.fn().mockRejectedValue(new Error('adapter exploded'));
74+
const res = await runSetInitialPassword({ setPassword }, makeRequest({ newPassword: 'x'.repeat(12) }));
75+
expect(res.status).toBe(500);
76+
expect(res.body.error).toEqual({ code: 'internal', message: 'adapter exploded' });
77+
});
78+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Shared `set-initial-password` handler.
5+
*
6+
* better-auth ships a `setPassword` operation that does EXACTLY what we want
7+
* (require a session, enforce min/max length, link a `credential` account if
8+
* none exists, refuse if one already does). But it is registered with
9+
* `createAuthEndpoint({ ... })` — note: NO leading path string — which means
10+
* better-auth deliberately exposes it as a **server-only** `auth.api.setPassword`
11+
* call and gives it no HTTP route. Setting a password without proving the old
12+
* one is privilege-sensitive, so it must not be reachable over the wire by
13+
* default.
14+
*
15+
* To let an SSO-onboarded user set an *initial* local password from the
16+
* browser, we wrap that server API in our own authenticated HTTP route. This
17+
* helper is the single source of truth for that route body so the two mount
18+
* points — the full `AuthPlugin` (host kernel) and the cloud `AuthProxyPlugin`
19+
* (per-environment runtime) — stay in lockstep instead of hand-copying ~50
20+
* lines of hash/createAccount logic (the original drift that let #1544 ship a
21+
* route on one path but not the other).
22+
*/
23+
24+
/** Minimal shape of the better-auth server API we depend on. */
25+
export interface SetPasswordCapableApi {
26+
setPassword(opts: { body: { newPassword: string }; headers: Headers }): Promise<unknown>;
27+
}
28+
29+
export interface SetInitialPasswordResult {
30+
/** HTTP status to return to the caller. */
31+
status: number;
32+
/** JSON body; mirrors the `{ success, error: { code, message } }` envelope the client parses. */
33+
body: { success: boolean; error?: { code: string; message: string } };
34+
}
35+
36+
/**
37+
* Run set-initial-password against the environment's better-auth API.
38+
*
39+
* @param authApi the better-auth server api (`auth.api`, via `AuthManager.getApi()`)
40+
* @param request the raw Web `Request` — its `headers` carry the session
41+
* cookie that better-auth's session middleware reads, and its
42+
* body carries `{ newPassword }`.
43+
*/
44+
export async function runSetInitialPassword(
45+
authApi: SetPasswordCapableApi,
46+
request: Request,
47+
): Promise<SetInitialPasswordResult> {
48+
let parsed: unknown;
49+
try {
50+
parsed = await request.json();
51+
} catch {
52+
parsed = {};
53+
}
54+
const newPassword: unknown = (parsed as { newPassword?: unknown } | null)?.newPassword;
55+
if (typeof newPassword !== 'string' || newPassword.length === 0) {
56+
return {
57+
status: 400,
58+
body: { success: false, error: { code: 'invalid_request', message: 'newPassword is required' } },
59+
};
60+
}
61+
62+
try {
63+
// better-auth's session middleware reads the session from `headers`;
64+
// length checks + the "already set" guard happen inside setPassword.
65+
await authApi.setPassword({ body: { newPassword }, headers: request.headers });
66+
return { status: 200, body: { success: true } };
67+
} catch (error) {
68+
return mapSetPasswordError(error);
69+
}
70+
}
71+
72+
/**
73+
* Map a better-auth `APIError` (better-call: `{ statusCode, status, body: { code, message } }`)
74+
* onto our response envelope. The client only surfaces `error.message`, but we
75+
* preserve the status code and code string for parity with the change/reset
76+
* flows. `PASSWORD_ALREADY_SET` is normalised to 409 so callers can tell
77+
* "already has a password → use change-password" apart from validation errors.
78+
*/
79+
function mapSetPasswordError(error: unknown): SetInitialPasswordResult {
80+
const e = error as {
81+
statusCode?: number;
82+
status?: number | string;
83+
body?: { code?: string; message?: string };
84+
message?: string;
85+
} | null;
86+
87+
const code = e?.body?.code ?? 'internal';
88+
const message = e?.body?.message ?? e?.message ?? 'set-initial-password failed';
89+
const rawStatus =
90+
typeof e?.statusCode === 'number' ? e.statusCode : typeof e?.status === 'number' ? e.status : 500;
91+
const status = code === 'PASSWORD_ALREADY_SET' ? 409 : rawStatus;
92+
93+
return { status, body: { success: false, error: { code, message } } };
94+
}

packages/runtime/src/cloud/auth-proxy-plugin.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import type { Plugin, PluginContext } from '@objectstack/core';
3030
import { createHmac, randomUUID } from 'node:crypto';
31+
import { runSetInitialPassword } from '@objectstack/plugin-auth';
3132
import type { KernelManager } from './kernel-manager.js';
3233
import type { EnvironmentDriverRegistry } from './environment-registry.js';
3334

@@ -279,7 +280,7 @@ export class AuthProxyPlugin implements Plugin {
279280
// the user in the env by email, mints a fresh better-auth
280281
// session, sets the signed session cookie and 302s to
281282
// `next`. If the user has no credential account yet, we
282-
// redirect to /_console/system/profile?recovery_needed=true
283+
// redirect to the standalone /_console/set-password page
283284
// so they can configure a disaster-recovery local password.
284285
if (c.req.method === 'GET' && subPath === 'sso-exchange') {
285286
try {
@@ -337,7 +338,7 @@ export class AuthProxyPlugin implements Plugin {
337338

338339
const finalNext = hasCredentialAccount
339340
? next
340-
: `/_console/system/profile?recovery_needed=true&next=${encodeURIComponent(next)}`;
341+
: `/_console/set-password?next=${encodeURIComponent(next)}`;
341342
const headers = new Headers();
342343
headers.set('Set-Cookie', setCookie);
343344
headers.set('Location', finalNext);
@@ -410,6 +411,31 @@ export class AuthProxyPlugin implements Plugin {
410411
}
411412
}
412413

414+
// ── set-initial-password ──────────────────────────
415+
// POST /api/v1/auth/set-initial-password
416+
//
417+
// better-auth's `setPassword` is a server-only API (no
418+
// HTTP route), and the full AuthPlugin — which exposes it
419+
// as a custom route — is SKIPPED on a per-environment
420+
// runtime. So without this short-circuit the request falls
421+
// through to better-auth and 404s, dead-ending the
422+
// `sso-exchange` → "Set local password" recovery flow
423+
// (see #1544). Reuse the exact same wrapper the AuthPlugin
424+
// uses so the two paths can never drift again.
425+
if (c.req.method === 'POST' && subPath === 'set-initial-password') {
426+
try {
427+
if (typeof authSvc?.getApi !== 'function') {
428+
return c.json({ success: false, error: { code: 'unavailable', message: 'Auth API unavailable' } }, 503);
429+
}
430+
const authApi = await authSvc.getApi();
431+
const { status, body } = await runSetInitialPassword(authApi, c.req.raw);
432+
return c.json(body, status);
433+
} catch (err: any) {
434+
ctx.logger?.error?.('[AuthProxyPlugin] set-initial-password failed', err instanceof Error ? err : new Error(String(err)));
435+
return c.json({ success: false, error: { code: 'internal', message: err?.message ?? String(err) } }, 500);
436+
}
437+
}
438+
413439
const fn = await resolveAuthHandler(authSvc);
414440
if (!fn) {
415441
return c.json({ error: 'auth_service_unavailable', environmentId }, 503);

0 commit comments

Comments
 (0)