3 Commits

Author SHA1 Message Date
Scott Idem
fdee7c16ca fix(auth): harden magic-link root_url and clean up stale array-response code
- Defensive fallback for root_url: $ae_loc.base_url || window.location.origin
  so the backend email builder always gets a valid URL (guide warns that a null
  root_url produces a broken magic link "None?user_id=...")
- handle_lookup_user_email: drop stale array-response branch; use user_id (V3
  primary field) instead of user_id_random (legacy alias, same value)
- handle_change_password: same cleanup — user_id preferred over user_id_random,
  dead array-response else-if removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:40:59 -04:00
Scott Idem
4d08994e79 docs: sync updated frontend API guide for user auth endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:34:55 -04:00
Scott Idem
bbdfe75866 fix(auth): migrate sign-in from legacy /user/* to V3 action endpoints
Legacy GET /user/authenticate and GET /user/lookup_email were returning 404
because the backend has removed those routes. Updated all 5 auth functions in
ae_core__user.ts to use V3 equivalents:

- auth_ae_obj__username_password: GET /user/authenticate → POST /v3/action/user/authenticate (body)
- auth_ae_obj__user_id_user_auth_key: GET /user/authenticate → POST /v3/action/user/authenticate (body)
- send_email_auth_ae_obj__user_id: GET /user/{id}/email_auth_key_url → GET /v3/action/user/{id}/email_auth_key_url
- qry_ae_obj_li__user_email: GET /user/lookup_email → POST /v3/crud/user/search
- auth_ae_obj__user_id_change_password: PATCH /user/{id}/change_password → POST /v3/action/user/{id}/change_password

Credentials are now in the POST body (not query params) for authenticate calls.
Updated two call sites in e_app_sign_in_out.svelte to drop removed null_account_id param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:12:52 -04:00
3 changed files with 91 additions and 202 deletions

View File

@@ -401,7 +401,7 @@ or:
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`). **Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
**Errors:** `400` missing credentials, `403` wrong password or account disabled, `404` user not found. **Errors:** `400` missing credentials, `403` wrong password / account disabled / account not yet enabled / account expired, `404` user not found.
> **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`. > **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`.
@@ -421,7 +421,7 @@ Check a user's current password without changing it.
``` ```
or use `"username"` instead of `"user_id"` to look up by username within the account. or use `"username"` instead of `"user_id"` to look up by username within the account.
**Response:** `data: true` on match. `403` on mismatch, `404` if user not found. **Response:** `data: true` on match. `400` if the user has no password set, `403` on mismatch, `404` if user not found.
--- ---
@@ -474,10 +474,19 @@ Generate a new auth key and email a one-time login link to the user's email addr
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `root_url` | `string` | `null` | Base URL the login link is built from. | | `root_url` | `string` | *(required)* | Base URL the login link is built from. Must be provided — if omitted the link in the email will be malformed (`None?...`). |
| `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. | | `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. |
**Response:** `data: true` on success (email sent). `500` if delivery failed (check account email config and that the user account is enabled with `allow_auth_key = true`). > [!IMPORTANT]
> `root_url` is **required in practice**. The FastAPI query param accepts `null` but the email builder does not guard against it — omitting it produces a broken link in the email.
**Magic link URL format (default `key_param_name`):**
```
{root_url}?user_id={user_id_random}&auth_key={auth_key}&valid_email=True
```
The frontend at `root_url` should read these query params and call `POST /v3/action/user/authenticate` with `{ "user_id": "...", "auth_key": "..." }`. Note that `valid_email=True` is **always** injected — authenticating via a magic link automatically marks the user's email as verified.
**Response:** `data: true` on success (email sent). `404` if user not found. `500` if delivery failed — common causes: account email not configured, user `enable = false`, or `allow_auth_key = false`.
--- ---

View File

@@ -328,117 +328,76 @@ export async function delete_ae_obj_id__user({
} }
/* /*
* *** LEGACY AUTHENTICATION HEADER LOGIC *** * *** V3 AUTHENTICATION FUNCTIONS ***
* *
* The functions in this section interact with legacy Aether API authentication endpoints * All functions below use the V3 action endpoints:
* (e.g., /user/authenticate, /user/lookup_email). * POST /v3/action/user/authenticate (was: GET /user/authenticate)
* GET /v3/action/user/{id}/email_auth_key_url (was: GET /user/{id}/email_auth_key_url)
* POST /v3/crud/user/search (was: GET /user/lookup_email)
* POST /v3/action/user/{id}/change_password (was: PATCH /user/{id}/change_password)
* *
* Unlike V3 endpoints which handle context automatically or via standard headers, * Key differences from the legacy routes:
* these legacy endpoints have specific requirements: * - Credentials are in the POST body, not query params (safer — not logged in URLs)
* * - x-account-id header still required to scope username/email lookups to the account
* 1. They often require the `x-account-id` header to be explicitly set to the target * - x-no-account-id must still be removed when we have a real account context
* account ID to find the user within that specific account context.
* 2. The standard API wrapper logic might strip `x-account-id` if `x-no-account-id`
* is present (Bootstrap Paradox logic). We must explicitly remove `x-no-account-id`
* and set `x-account-id` to ensure the request is routed correctly.
* 3. Some endpoints accept `account_id` as a query parameter, while others (like email sending)
* may crash (500 Error) if unexpected parameters are passed.
*/ */
// Updated 2025-04-04 // Updated 2026-04-25 — migrated from GET /user/authenticate to POST /v3/action/user/authenticate
// This function handles username/password authentication.
// It explicitly sets the x-account-id header to ensure the user is looked up in the correct account.
export async function auth_ae_obj__username_password({ export async function auth_ae_obj__username_password({
api_cfg, api_cfg,
account_id, account_id,
null_account_id = false,
username, username,
password, password,
params = {},
try_cache = true,
log_lvl = 0 log_lvl = 0
}: { }: {
api_cfg: any; api_cfg: any;
account_id: string; account_id: string;
null_account_id?: boolean;
username: string; username: string;
password: string; password: string;
params?: key_val;
try_cache?: boolean;
log_lvl?: number; log_lvl?: number;
}) { }) {
if (log_lvl) { if (log_lvl) {
console.log( console.log(
`*** auth_ae_obj__username_password() *** account_id=${account_id} username=${username} password=${password}` `*** auth_ae_obj__username_password() *** account_id=${account_id} username=${username}`
); );
} }
const endpoint = '/user/authenticate'; // WHY: Must set x-account-id explicitly so the backend scopes the username lookup
// to the correct account. Remove x-no-account-id which is only used during bootstrap.
// Prepare API config with correct headers to override global guest settings
const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } }; const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } };
if (account_id) { use_api_cfg.headers['x-account-id'] = account_id;
use_api_cfg.headers['x-account-id'] = account_id; delete use_api_cfg.headers['x-no-account-id'];
delete use_api_cfg.headers['x-no-account-id'];
params['account_id'] = account_id;
}
if (null_account_id) {
params['null_account_id'] = true;
}
params['username'] = username; // Required
params['password'] = password; // Required
params['inc_jwt'] = true; // Request a JWT in the response
if (log_lvl > 1) {
console.log(`auth_ae_obj__username_password() - params:`, params);
}
ae_promises.auth__username_password = await api ae_promises.auth__username_password = await api
.get_object({ .post_object({
api_cfg: use_api_cfg, api_cfg: use_api_cfg,
endpoint: endpoint, endpoint: '/v3/action/user/authenticate',
params: params, data: { username, password },
// data: {}, log_lvl
log_lvl: log_lvl
}) })
.then(async function (user_obj_get_result) { .then(function (result: any) {
if (user_obj_get_result) { return result ?? null;
return user_obj_get_result;
} else {
console.log('No results returned.');
return null;
}
}) })
.catch(function (error: any) { .catch(function (error: any) {
console.log('No results returned or failed.', error); console.log('auth_ae_obj__username_password failed:', error);
return null;
}); });
if (log_lvl) {
console.log(
'ae_promises.auth__username_password:',
ae_promises.auth__username_password
);
}
return ae_promises.auth__username_password; return ae_promises.auth__username_password;
} }
// Updated 2025-04-04 // Updated 2026-04-25 — migrated from GET /user/authenticate to POST /v3/action/user/authenticate
// This function handles authentication using a User ID and a one-time auth key.
export async function auth_ae_obj__user_id_user_auth_key({ export async function auth_ae_obj__user_id_user_auth_key({
api_cfg, api_cfg,
account_id, account_id,
user_id, user_id,
user_auth_key, user_auth_key,
params = {},
try_cache = true,
log_lvl = 0 log_lvl = 0
}: { }: {
api_cfg: any; api_cfg: any;
account_id: string; account_id: string;
user_id: string; user_id: string;
user_auth_key: string; user_auth_key: string;
params?: key_val;
try_cache?: boolean;
log_lvl?: number; log_lvl?: number;
}) { }) {
if (log_lvl) { if (log_lvl) {
@@ -447,61 +406,36 @@ export async function auth_ae_obj__user_id_user_auth_key({
); );
} }
const endpoint = '/user/authenticate';
// Prepare API config with correct headers to override global guest settings
const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } }; const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } };
if (account_id) { use_api_cfg.headers['x-account-id'] = account_id;
use_api_cfg.headers['x-account-id'] = account_id; delete use_api_cfg.headers['x-no-account-id'];
delete use_api_cfg.headers['x-no-account-id'];
params['account_id'] = account_id;
}
params['user_id'] = user_id; // Required
params['auth_key'] = user_auth_key; // Required
params['inc_jwt'] = true; // Request a JWT in the response
if (log_lvl > 1) {
console.log(`auth_ae_obj__user_id_user_auth_key() - params:`, params);
}
ae_promises.auth__user_id_user_key = await api ae_promises.auth__user_id_user_key = await api
.get_object({ .post_object({
api_cfg: use_api_cfg, api_cfg: use_api_cfg,
endpoint: endpoint, endpoint: '/v3/action/user/authenticate',
params: params, // WHY: valid_email=true marks the user's email as verified on successful magic-link auth
log_lvl: log_lvl data: { user_id, auth_key: user_auth_key, valid_email: true },
log_lvl
}) })
.then(async function (user_obj_get_result) { .then(function (result: any) {
if (user_obj_get_result) { return result ?? null;
return user_obj_get_result;
} else {
console.log('No results returned.');
return null;
}
}) })
.catch(function (error: any) { .catch(function (error: any) {
console.log('No results returned or failed.', error); console.log('auth_ae_obj__user_id_user_auth_key failed:', error);
return null;
}); });
if (log_lvl) {
console.log(
'ae_promises.auth__user_id_user_key:',
ae_promises.auth__user_id_user_key
);
}
return ae_promises.auth__user_id_user_key; return ae_promises.auth__user_id_user_key;
} }
// Send an email to the user with a new one time use authentication key. // Updated 2026-04-25 — migrated from GET /user/{id}/email_auth_key_url to V3 action path
// Updated 2025-04-08
// NOTE: This legacy endpoint is sensitive to extra query parameters and will 500 if account_id is passed in the URL.
export async function send_email_auth_ae_obj__user_id({ export async function send_email_auth_ae_obj__user_id({
api_cfg, api_cfg,
account_id, account_id,
user_id, user_id,
base_url, base_url,
key_param_name = 'user_key', // API defaults to 'auth_key' key_param_name = 'user_key',
params = {},
log_lvl = 0 log_lvl = 0
}: { }: {
api_cfg: any; api_cfg: any;
@@ -509,7 +443,6 @@ export async function send_email_auth_ae_obj__user_id({
user_id: string; user_id: string;
base_url?: string; base_url?: string;
key_param_name?: string; key_param_name?: string;
params?: key_val;
log_lvl?: number; log_lvl?: number;
}) { }) {
if (log_lvl) { if (log_lvl) {
@@ -518,47 +451,30 @@ export async function send_email_auth_ae_obj__user_id({
); );
} }
const email_auth_key_endpoint = `/user/${user_id}/email_auth_key_url`;
params = {
root_url: base_url,
key_param_name: key_param_name
};
// Prepare API config with correct headers
const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } }; const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } };
if (account_id) { use_api_cfg.headers['x-account-id'] = account_id;
use_api_cfg.headers['x-account-id'] = account_id; delete use_api_cfg.headers['x-no-account-id'];
delete use_api_cfg.headers['x-no-account-id'];
// WARNING: Do NOT add account_id to params here, as it causes a 500 error on the legacy backend.
}
ae_promises.auth_key__send_email = await api.get_object({ ae_promises.auth_key__send_email = await api.get_object({
api_cfg: use_api_cfg, api_cfg: use_api_cfg,
endpoint: email_auth_key_endpoint, endpoint: `/v3/action/user/${user_id}/email_auth_key_url`,
params: params, params: { root_url: base_url, key_param_name },
log_lvl: log_lvl log_lvl
}); });
return ae_promises.auth_key__send_email; return ae_promises.auth_key__send_email;
} }
// Look up user based on email address provided // Updated 2026-04-25 — migrated from GET /user/lookup_email to POST /v3/crud/user/search
// Updated 2025-04-08
export async function qry_ae_obj_li__user_email({ export async function qry_ae_obj_li__user_email({
api_cfg, api_cfg,
account_id, account_id,
null_account_id = false,
email, email,
params = {},
try_cache = true,
log_lvl = 0 log_lvl = 0
}: { }: {
api_cfg: any; api_cfg: any;
account_id: string; account_id: string;
null_account_id?: boolean;
email: string; email: string;
params?: key_val;
try_cache?: boolean;
log_lvl?: number; log_lvl?: number;
}) { }) {
if (log_lvl) { if (log_lvl) {
@@ -567,56 +483,39 @@ export async function qry_ae_obj_li__user_email({
); );
} }
const endpoint = '/user/lookup_email';
// Prepare API config with correct headers
const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } }; const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } };
if (account_id) { use_api_cfg.headers['x-account-id'] = account_id;
use_api_cfg.headers['x-account-id'] = account_id; delete use_api_cfg.headers['x-no-account-id'];
delete use_api_cfg.headers['x-no-account-id'];
params['account_id'] = account_id;
}
params['email'] = email; // Required const results = await api
params['null_account_id'] = null_account_id || false; .search_ae_obj({
ae_promises.qry__user_email = await api
.get_object({
api_cfg: use_api_cfg, api_cfg: use_api_cfg,
endpoint: endpoint, obj_type: 'user',
params: params, search_query: { and: [{ field: 'email', op: 'eq', value: email }] },
log_lvl: log_lvl log_lvl
})
.then(async function (user_obj_get_result) {
if (user_obj_get_result) {
return user_obj_get_result;
} else {
console.log('No results returned.');
return null;
}
}) })
.catch(function (error: any) { .catch(function (error: any) {
console.log('No results returned or failed.', error); console.log('qry_ae_obj_li__user_email failed:', error);
return null;
}); });
return ae_promises.qry__user_email; // Return the first match to preserve the same interface callers expect
// (the old /user/lookup_email endpoint returned a single user object)
return results?.[0] ?? null;
} }
// Change user password // Updated 2026-04-25 — migrated from PATCH /user/{id}/change_password to POST /v3/action
// Updated 2025-04-11
export async function auth_ae_obj__user_id_change_password({ export async function auth_ae_obj__user_id_change_password({
api_cfg, api_cfg,
account_id, account_id,
user_id, user_id,
password, password,
params = {},
log_lvl = 0 log_lvl = 0
}: { }: {
api_cfg: any; api_cfg: any;
account_id: string; account_id: string;
user_id: string; user_id: string;
password: string; password: string;
params?: key_val;
log_lvl?: number; log_lvl?: number;
}) { }) {
if (log_lvl) { if (log_lvl) {
@@ -625,27 +524,19 @@ export async function auth_ae_obj__user_id_change_password({
); );
} }
const endpoint = `/user/${user_id}/change_password`;
params['user_id'] = user_id; // Required
ae_promises.change_password__user_id = await api ae_promises.change_password__user_id = await api
.patch_object({ .post_object({
api_cfg: api_cfg, api_cfg,
endpoint: endpoint, endpoint: `/v3/action/user/${user_id}/change_password`,
params: params, data: { new_password: password },
data: { password: password }, log_lvl
log_lvl: log_lvl
}) })
.then(async function (change_password_result) { .then(function (result: any) {
if (change_password_result) { return result ?? null;
return change_password_result;
} else {
console.log('No results returned.');
return null;
}
}) })
.catch(function (error: any) { .catch(function (error: any) {
console.log('No results returned or failed.', error); console.log('auth_ae_obj__user_id_change_password failed:', error);
return null;
}); });
return ae_promises.change_password__user_id; return ae_promises.change_password__user_id;

View File

@@ -233,12 +233,18 @@ function handle_send_auth_email({ user_id }: { user_id: string }) {
console.log($ae_loc.hostname); // URL hostname console.log($ae_loc.hostname); // URL hostname
// This function creates a new auth_key and then sends an email to the user with the new auth key. // This function creates a new auth_key and then sends an email to the user with the new auth key.
// WHY: root_url is required by the backend email builder — if null/undefined it
// produces a broken link ("None?user_id=..."). Fall back to window.location.origin
// in case $ae_loc.base_url is not yet set when this fires.
const magic_link_root_url =
$ae_loc.base_url || (browser ? window.location.origin : '');
ae_promises.send_email_auth_ae_obj__user_id = ae_promises.send_email_auth_ae_obj__user_id =
core_func.send_email_auth_ae_obj__user_id({ core_func.send_email_auth_ae_obj__user_id({
api_cfg: $ae_api, api_cfg: $ae_api,
account_id: $slct.account_id, account_id: $slct.account_id,
user_id: user_id, user_id: user_id,
base_url: $ae_loc.base_url, base_url: magic_link_root_url,
log_lvl: 0 log_lvl: 0
}); });
} }
@@ -252,22 +258,13 @@ function handle_lookup_user_email({ email }: { email: string }) {
.qry_ae_obj_li__user_email({ .qry_ae_obj_li__user_email({
api_cfg: $ae_api, api_cfg: $ae_api,
account_id: $slct.account_id, account_id: $slct.account_id,
null_account_id: false,
email: email, email: email,
log_lvl: 0 log_lvl: 0
}) })
.then((user_response) => { .then((user_response) => {
if (user_response?.user_id_random) { if (user_response?.user_id) {
console.log(`User found for email:`, user_response); console.log(`User found for email:`, user_response);
handle_send_auth_email({ handle_send_auth_email({ user_id: user_response.user_id });
user_id: user_response.user_id_random
});
email_send_status = 'sent';
} else if (user_response && user_response.length > 0) {
console.log(`Multiple users found for email:`, user_response);
handle_send_auth_email({
user_id: user_response[0].user_id_random
});
email_send_status = 'sent'; email_send_status = 'sent';
} else { } else {
console.warn('No user found for email:', email); console.warn('No user found for email:', email);
@@ -321,24 +318,16 @@ async function handle_change_password() {
await core_func.qry_ae_obj_li__user_email({ await core_func.qry_ae_obj_li__user_email({
api_cfg: $ae_api, api_cfg: $ae_api,
account_id: $slct.account_id, account_id: $slct.account_id,
null_account_id: false,
email: user_email, email: user_email,
log_lvl: 0 log_lvl: 0
}); });
if (!ae_promises.load__user_obj_li) { if (!ae_promises.load__user_obj_li?.user_id) {
// This means a 404 was returned
alert('No user found with that email address.'); alert('No user found with that email address.');
return; return;
} else if (ae_promises.load__user_obj_li?.user_id_random) { } else {
console.log(`User found for email:`, ae_promises.load__user_obj_li); console.log(`User found for email:`, ae_promises.load__user_obj_li);
use_user_id = ae_promises.load__user_obj_li.user_id_random; use_user_id = ae_promises.load__user_obj_li.user_id;
} else if (ae_promises.load__user_obj_li.length > 0) {
console.log(
`Multiple users found for email:`,
ae_promises.load__user_obj_li
);
use_user_id = ae_promises.load__user_obj_li[0].user_id_random;
} }
} else { } else {
wait_for_lookup = false; wait_for_lookup = false;