Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 7 additions & 51 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
78 changes: 78 additions & 0 deletions packages/plugins/plugin-auth/src/set-initial-password.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
94 changes: 94 additions & 0 deletions packages/plugins/plugin-auth/src/set-initial-password.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
}

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<SetInitialPasswordResult> {
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 } } };
}
30 changes: 28 additions & 2 deletions packages/runtime/src/cloud/auth-proxy-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -279,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 {
Expand Down Expand Up @@ -337,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);
Expand Down Expand Up @@ -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);
Expand Down