* fix(authProviders): instrument OIDC callback + redirect on fail (not 500)
Production-blocking: every OIDC callback (bogus or real code) returned
500 Internal Server Error, even with valid sessions. No log was emitted
between `try auth via openid` and the 500 — passport strategy threw
silently before `handleProviderAuth` could log.
Per Codex plan-review amendments:
- Switch to explicit-callback passport.authenticate variant so strategy
errors surface as err/info/status args instead of being swallowed.
- Privacy-safe diagnostic shape (no raw code/state/tokens/user). Fields:
stage, errMessage/Name/Stack, hasUser, infoSummary, statusCode,
hasSession + sessionKeys (names only), cookie + state + code
PRESENCE (lengths only), host, forwardedProto.
- Permanent invalid-callback guard: ALL failures → 302 to /login with
?error=oidc_callback_failed, never 500. Same wrap covers
handleProviderAuth throws.
Image-tag: hardcoreeng/account:integration-wac-2026-06-21-codex-r7.
Next step (P0-T2): user attempts login, we capture the actual error
from the diagnostic, then ship a surgical fix in r8.
Signed-off-by: Michael Uray <michael.uray@gmail.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* fix(account): admin.ts trim+lowercase on both env-set AND lookup
Codex plan-review Critical: server/account/src/admin.ts:15-18 built the
ADMIN_EMAILS set without normalizing, AND isAdminEmail() didn't normalize
the input. Today value is clean so isAdmin works, but a future env-value
like ADMIN_EMAILS=" Michael@Uray.io " would silently break admin
detection (Set lookup would miss the lowercased input).
Mongo backend already has this hardening (collections/mongo.ts:1141-1147).
Postgres + this helper didn't.
Now BOTH sides normalize: env split -> trim -> toLowerCase -> filter
non-empty + warn on no-@ entries (Codex Optional: warn not reject --
too sharp for a latent fix). Lookup also trim().toLowerCase().
Standalone upstream-PR-able per Codex Q-I3.
Tests cover whitespace, case-insensitive env, case-insensitive lookup,
invalid-shape warn, empty/unset env, multi-entry.
Signed-off-by: Michael Uray <michael.uray@gmail.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* chore(authProviders): drop cookieHeaderLen from OIDC diagnostics (E4 amendment)
Codex E4 amendment (optional, low-priority): hasCookieHeader is
sufficient to diagnose missing session-cookie issues; the
cookieHeaderLen byte count adds no operational value and is one more
PII-adjacent surface to keep an eye on. Drop it from both diagnostic
paths in pods/authProviders/src/openid.ts.
Signed-off-by: Michael Uray <michael.uray@gmail.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* auth(oidc): gate verbose callback diagnostics behind OIDC_DEBUG (L-AUTH-2)
Bei jedem fehlgeschlagenen OIDC-Callback wurde ein umfangreicher Diag-Block
(errStack/sessionKeys/host/…) auf error-Level geloggt -> anonymes Log-
Flooding via wiederholter invalider Callbacks. Normalbetrieb loggt jetzt nur
eine knappe Fehlerzeile; der volle Diag-Block nur mit OIDC_DEBUG=true.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* account(admin): drop non-email ADMIN_EMAILS entries unless explicitly allowed (L-AUTH-3)
Ein Tippfehler wie ADMIN_EMAILS=admin,michel@... legte bislang einen
unbeabsichtigten Admin-Identifier an (Eintrag ohne @ wurde still behalten).
Jetzt werden @-lose Eintraege per Default verworfen (fail-closed) und nur
bei ADMIN_EMAILS_ALLOW_LOGIN_ID=true als Login-ID akzeptiert; Warnung stets.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* auth(oidc): redirect to /login when provider auth yields no account (L-AUTH-4)
handleProviderAuth liefert '' bei einem nicht aufloesbaren Login (z.B. kein
Account + Signup disabled). Bisher erfolgte dann kein Redirect und next()
lief ins Leere -> haengende/leere Antwort. Jetzt fail-closed: explizites
302 auf /login?error=oidc_no_account.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* test(account): use example.com fixtures in adminEmails tests
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* auth(oidc): retry strategy registration with capped backoff (L-AUTH-5)
The account service registered the OIDC passport strategy only once at
startup via a single fire-and-forget Issuer.discover(). A transient issuer
outage in that one boot moment left the 'oidc' strategy permanently
unregistered, producing 500 "Unknown authentication strategy" on every
OpenID login until a manual container restart (observed: ~10 days).
Make registration self-healing:
- Extract registerOidcStrategyWithRetry(), which retries the ENTIRE
registration chain (discover -> new Client -> new Strategy -> passport.use)
with capped exponential backoff (5s, x2, cap 60s), unbounded. The loop only
returns after passport.use succeeds, so a failure at client or strategy
construction (e.g. temporarily incomplete issuer metadata during an IdP
upgrade) is retried too instead of permanently disabling OIDC. discover and
sleep are injectable for tests; passport is a structural { use } interface.
It never rejects: the success log runs failure-safe AFTER passport.use and
returns immediately, so a throwing logger can never re-enter the loop and
register the strategy a second time; the failure warn is likewise guarded so
a throwing sink cannot break the never-rejects contract.
- Gate log flooding via shouldWarnOnAttempt(): warn on the first 5 attempts
and every 60th thereafter (~24 warn lines/day at the 60s cap instead of
~1440), following the branch's L-AUTH-2 rationale; error stacks only under
OIDC_DEBUG. Success logs once with the attempt count.
- Wire it fire-and-forget in registerOpenid() and flip an oidcReady flag on
success. The .then body is a plain boolean flip that cannot throw; a trailing
.catch(() => {}) is belt-and-suspenders so this fire-and-forget chain can
never surface an unhandled rejection. Routes stay registered synchronously.
- During the pending window, /auth/openid responds 503 with Retry-After: 5
and a short text body instead of a 500 or a redirect (the login app ignores
an error query param, and L-AUTH-4 only covers the callback path).
Add the package's first tests: the retry helper in isolation (schedule, 60s
cap, whole-attempt retry incl. client-construction failure, warn gating,
OIDC_DEBUG both ways, a throwing success log that must not re-register, and a
throwing failure warn that must not reject) and the registerOpenid wiring
(synchronous route registration, 503 pending window, exactly-once strategy
registration).
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* account(admin): keep non-email ADMIN_EMAILS entries by default; strict mode opt-in
Review feedback on #10931: dropping entries without "@" by default would
silently revoke admin rights on upgrade for deployments that use login-id
style values. The repo's own dev/tests/ws-tests compose files ship
ADMIN_EMAILS=admin,${PLATFORM_ADMIN_EMAILS} and tests/prepare-pg.sh creates
that `admin` account.
Restore the original semantics as the default: every non-empty entry is
kept (trimmed + lowercased), and entries without "@" are logged once at
startup so a typo stays visible. The fail-closed behaviour becomes an
explicit opt-in via ADMIN_EMAILS_STRICT=true. ADMIN_EMAILS_ALLOW_LOGIN_ID,
introduced earlier on this branch and never released, is removed so there
is a single switch.
Empty entries stay dropped. This is a deliberate tightening compared to
the original `new Set(ADMIN_EMAILS.split(','))`, which put '' into the set
for any empty entry (ADMIN_EMAILS='', ',admin', 'admin,', 'admin,,ops@...')
and so made isAdminEmail('') true. loginOrSignUpWithProvider passes '' when
the provider returns no email, so such a login received an admin token.
isAdminEmail('') is now always false.
Tests: default keeps `admin` and warns, strict drops, only the exact value
"true" enables strict mode, no warning for clean values, empty entries in
every position never make an empty email an admin, trim/lowercase coverage
unchanged. Each test saves, clears and restores ADMIN_EMAILS and
ADMIN_EMAILS_STRICT so none depends on the outer environment.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* auth(oidc): settle the passport callback wrapper on rejection; address review
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* auth(providers): pass only provider-verified emails; abort OIDC retry on shutdown; more callback tests
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
---------
Signed-off-by: Michael Uray <michael.uray@gmail.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Co-authored-by: Michael Uray <michaeluray@users.noreply.github.com>
* feat: API token management in workspace settings
Add UI and backend support for creating, listing, and revoking
API tokens scoped to workspaces. Includes owner-level workspace
token visibility, OpenAPI documentation, Mongo/Postgres persistence,
and i18n translations.
Signed-off-by: Don Kendall <kendall@donkendall.com>
* feat: enforce API token revocation at transactor level
Embed apiTokenId in JWT extra field and add a per-token revocation
cache (60s TTL) in the transactor REST handler. Revoked tokens are
now rejected within ~60 seconds instead of remaining valid until
JWT expiry.
Adds checkApiTokenRevoked account service method for the transactor
to query individual token revocation status.
Signed-off-by: Don Kendall <kendall@donkendall.com>
* feat: implement Phase 1 API token scopes (read/write/delete)
Add coarse-grained scope enforcement for API tokens. Tokens can now
be created with scopes ['read:*'], ['read:*','write:*'], or
['read:*','write:*','delete:*']. Existing tokens without scopes
retain full access (backward compatible).
- DB: v26 migration adds scopes TEXT[] column to api_tokens
- Types: add scopes field to ApiToken and ApiTokenInfo
- Operations: createApiToken accepts/validates/persists scopes,
embeds in JWT via extra.scopes
- Enforcement: withSession checks scopes against method; tx handler
additionally requires delete:* for TxRemoveDoc
- Client: createApiToken signature accepts optional scopes param
- UI: scope preset dropdown in create popup (default: Read Only),
permissions column in token list with i18n labels
- Also fixes 3 pre-existing TS2322/TS2345 errors in operations.ts
Signed-off-by: Don Kendall <kendall@donkendall.com>
* test: add unit tests for API token scope enforcement
- scopes.test.ts: 8 tests for hasScope() and getRequiredScope() logic
- apiTokenScopes.test.ts: 7 tests for createApiToken scope validation
(valid scopes, multiple scopes, no scopes backward compat, invalid
format rejection, empty array rejection, domain-scope rejection)
and listApiTokens scopes inclusion
- Export hasScope/getRequiredScope from rpc.ts for testability
Signed-off-by: Don Kendall <kendall@donkendall.com>
* fix: address review feedback — role restriction, locale parity, formatting
- Restrict API token creation/revocation to AccountRole.User or higher
(guests cannot use API tokens), per reviewer suggestion
- Add 5 missing translation keys (ApiTokenPermissions, ApiTokenScopePreset,
ApiTokenScopeReadOnly, ApiTokenScopeReadWrite, ApiTokenScopeFullAccess)
to all non-en locale files to fix locale parity CI test
- Fix prettier formatting in apiTokenScopes.test.ts
- Rename local `extra` to `tokenExtra` in createApiToken to avoid
shadowing the decoded token's `extra` field
Signed-off-by: Don Kendall <kendall@donkendall.com>
* fix: address aonnikov architectural review feedback
- rpc.ts: use system service token for checkApiTokenRevoked so the
revocation check is not coupled to the user's potentially-revoked
bearer token; systemAccountUuid + service:'server' ensures account
service always accepts the call
- ApiDocsSection.svelte: derive transactor base URL from
login.metadata.LoginEndpoint (set on auth) instead of
window.location.origin, which is not necessarily the transactor host
- ApiTokenCreatePopup.svelte: replace manual translate() calls and
themeStore language watch with DropdownLabelsIntl + DropdownIntlItem[],
which handle i18n automatically; error state is now IntlString
- General.svelte: remove legacy GenerateApiToken button, handler, and
ApiTokenPopup import in favour of the new ApiTokens settings panel
Signed-off-by: Don Kendall <kendall@donkendall.com>
* fix: formatting in ApiTokenPopup, apiTokenScopes test, and operations
Signed-off-by: Don Kendall <kendall@donkendall.com>
* fix: apply rushx fmt to pass CI formatting check
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* fix: restore (s as any) cast in server_http.ts removed by ESLint autofix
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* refactor(token): centralize API token revocation/expiry in verifyToken
Address @aonnikov's review: the bespoke checkApiTokenRevoked RPC and the
ad-hoc revocation cache in the transactor are replaced by a reusable
verifyToken in server-token that checks signature, expiry, and (for
revokable API tokens) revocation via a pluggable checker.
- server-token: add verifyToken + isTokenExpired + setApiTokenRevocationChecker
(the 'method to verify' metadata the plugin needs, without depending on the
account client). Revocation cache (60s TTL) now lives here, reusable by any
service (transactor, blob access, etc.).
- account: the account is now authoritative — wrap() rejects revoked/expired
API tokens, so any account method (selectWorkspace/getWorkspaceInfo/...)
naturally 401s. Removed the redundant checkApiTokenRevoked method.
- account-client: drop checkApiTokenRevoked.
- transactor: withSession uses verifyToken; the registered checker reuses the
existing getLoginInfoByToken instead of a dedicated boolean RPC. Scopes are
parsed once and threaded through (no re-decode in the tx handler).
- tests: verifyToken/isTokenExpired unit coverage.
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* fix(setting): derive REST API base from account-provided transactor endpoint
Address @aonnikov: the REST API host must come from the transactor endpoint
returned by the account service (the login endpoint, a ws(s):// URL), not be
constructed from window.location. Convert ws->http and append /api/v1, matching
the existing ServerManagerGeneral pattern.
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* i18n(setting): translate API token strings across all locales
Address @ArtyomSavchenko: the new API token UI strings were left in English
in non-en locales. Provide translations for ru/de/es/fr/it/pt/pt-br/zh/ja/cs/tr.
The en/ru locale-parity test passes (the prior failure was an en/ru key
mismatch, resolved by the develop merge).
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* style: wrap long lines to satisfy prettier (account utils import, ApiDocsSection)
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* fix(api-token): drop unenforceable scopes, harden the token lifecycle
The scopes were only ever consulted in the transactor's REST handler, so
they promised a boundary the platform did not keep:
- the WebSocket transport decodes the token and never looks at scopes, so
a read-only token could open a socket and issue any transaction;
- even on the REST path, delete:* only matched a top-level TxRemoveDoc and
was bypassed by nesting the removal in a TxApplyIf;
- nothing stopped a scoped token from calling createApiToken and minting
an unscoped one.
Narrowing a token's rights has to happen in the pipeline, where it covers
every transport, and that is a larger design than this feature. Until then
a token carries its account's rights and says so, rather than displaying a
restriction that does not hold. Scopes are removed end to end.
What is kept is made to work:
- API tokens can no longer create, list or revoke API tokens. Otherwise a
leaked token renews itself and revokes the tokens meant to stop it.
- Revocation fails closed. The account is the only authority on it, so an
unreachable account now rejects the token instead of admitting a revoked
one to whoever can keep the account busy. Verdicts stay trusted for the
cache TTL so brief outages do not cut off healthy tokens.
- The revocation cache is bounded and re-checks negative verdicts.
- Revoking your own token no longer requires a role in its workspace, so
leaving a workspace cannot strand a credential you can never revoke.
- The per-account limit counts only usable tokens; revoked and expired
ones are kept for the audit trail and used to lock out anyone rotating.
- Rejected REST tokens are logged with a reason, since expired, revoked
and unverifiable are one opaque 401 from outside.
The unused listWorkspaceApiTokens/revokeWorkspaceApiToken pair is removed;
it had no caller and no test.
Tests cover the role restriction, the API-token guard, ownership on
revoke, the limit accounting and fail-closed revocation. Removing any of
those checks fails them.
Claude-Session: https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* fix(setting): correct the API token settings page
- Moves the page from workspace settings to account settings. Tokens
belong to the account: the page already lists them across every
workspace, and creating one only needs the User role the account
service checks, not Owner as the category required.
- Surfaces failures. A failed revoke was logged to the console and the
row re-rendered unchanged; loading workspaces could reject unhandled
and leave an empty dropdown with no explanation.
- Outside a secure context there is no clipboard API, so the OK button
did nothing and the dialog had no way out but Cancel. It now closes,
leaving the token selectable.
- Replaces hardcoded English labels, adds aria-expanded on the docs
disclosure and labels on the copy targets.
- Fills in the Korean and Polish translations, which were the only two
locales missing these keys, and drops the API access strings orphaned
when this PR replaced the old Generate API token button.
Claude-Session: https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* chore: drop docs/openapi.yaml from the API token PR
Unrelated to this feature and wrong for this repo: it documents a
/_tokens minting service and a tools/mint-token CLI that do not exist
here, uses reverse-proxy prefixes from a self-hosted deployment rather
than the transactor's /api/v1 routes, and states that revocation is a
future enhancement, which is what this PR implements. Nothing references
it. REST API docs belong in their own change, generated against the
routes that exist.
Claude-Session: https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
---------
Signed-off-by: Don Kendall <kendall@donkendall.com>
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
* Fix cross-workspace blob/thumbnail and metadata disclosure
The preview pod's thumbnail and metadata routes did not validate the
caller's token against the workspace taken from the URL.
Reported-by: Ugur Ozer, Aeon AI Risk Management
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
* Update copyright year
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
* Use secure mode
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
* Fix formatting
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
* Remove uuid check
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
---------
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
When a user's name has no spaces (common for CJK names like "西门吹雪"),
name.split(' ') returns a single-element array so the last name
destructures as undefined, causing a PostgreSQL NOT NULL constraint
violation on the last_name column.
- openid: prefer standard OIDC given_name/family_name claims when
available, fall back to splitting name with safe slice(1).join()
- github: same split fix using displayName ?? username
- loginOrSignUpWithProvider: add ?? '' defensive fallback at insertOne
to guard against any undefined last name reaching the DB
Fixes#10628
Signed-off-by: SaiVaraprasad Medapati <varaprasadreddy9676@gmail.com>
Co-authored-by: SaiVaraprasad Medapati <varaprasadreddy9676@gmail.com>
* Feat: Allow to disable any plugin and few extra stuff
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
* Remove redundant logging
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
* Update desktop to also filter features
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
---------
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>