Skip to content

plugin-auth: a correct-password sign-in refused by an organization IP list counts toward the account lockout #31251

Closed

Description

@objectstack-fleet

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

Out-of-scope finding (class a) from the #30924 PR 2 dev (report 6712181248 on #30924), filed by domain:server#6. ⛔ Not a claim. Suggested-grade: p2 (a person working from outside their organization's network locks their own account with the right password; an administrator then has to unlock it).

Observed (measured by the #30924 dev on main): an organization with allowed_ip_ranges, deployment lockout threshold 3. Three sign-ins with the CORRECT password from outside the organization's list each answer 403 IP_NOT_ALLOWED (SESSION_SCOPING_REFUSAL, refused when the session is created). The fourth sign-in, from INSIDE the list with the correct password, answers 403 ACCOUNT_LOCKED; failed_login_count is 3.

Cause: packages/plugins/plugin-auth/src/auth-manager-after-hook.ts (the ADR-0069 D2 counter, SIGN_IN_EMAIL_PATH branch) treats ANY APIError on ctx.context.returned as a failed credential and calls recordSignInOutcome(email, false). The organization IP refusal is thrown after the password was verified, so it is counted as if the password were wrong.

Expected: only a refused CREDENTIAL counts toward lockout. A policy refusal after the credential passed (organization IP list; check whether any other post-credential refusal shares the path) neither increments nor resets the counter. Its audit row stays as #30924 writes it.

Reach: public door POST /api/v1/auth/sign-in/email. Not a disclosure: a wrong password already counts, so this adds no new way for a stranger to lock an account; it locks out the account holder who did nothing wrong.

Pins owed: the measured sequence above, red on main, green after; a wrong-password control still counts; a correct sign-in from inside the list still resets.

Dedupe words: lockout IP_NOT_ALLOWED, organization allowed_ip_ranges lockout, recordSignInOutcome scoping refusal.

Activity

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Nov 7, 2026
  2. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

    Claim: PM loop round 1 · 2026-11-07T19:50Z · seat 6 refill; filed by this seat from the #30924 PR 2 dev's out-of-scope finding (report 6712181248), graded p2 under 「定级你可以自己决定」
    Session: session_01FEkQ5p3T5YWhLjurajudnQ
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-t94vm3
    Worktree: the card thread's own checkout of claude/project-thread-t94vm3
    Domain: domain:server
    Seat: domain:server#6
    File surface: packages/plugins/plugin-auth/src/auth-manager-after-hook.ts (the ADR-0069 D2 counter's SIGN_IN_EMAIL_PATH branch) + its tests; root .changeset/. ⛔ packages/spec/**, ⛔ the regions PR #31249 holds (auth-session-audit.ts, auth-manager-database-hooks.ts, ip-allow-list-refusal-audit.ts, auth-plugin.ts IP gate, plugin-audit) (stop on breach; explain in the report)
    Container & model: S–M, mode:thread, model: default, effort: default
    Clause-②: no (an internal counter; no published answer changes; the dev re-declares if one does)
    Metadata-protocol: none
    Responsibility: the account lockout counter in plugin-auth counts a correct-password sign-in that an organization IP list refused after the credential passed; no other path covers it; reached by any member signing in from outside their organization's allowed network
    Thread-read: none
    Premise: measured by the #30924 dev on main (report 6712181248); the dev re-measures red-first on origin/main 0d0c377b
    Serial constraints cleared: #30924 PR 2 (PR #31249) is in review and touches disjoint files; second to land merges origin/main once

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 7, 2026
  4. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#6 · os-project-manager · session_01FEkQ5mSU6a4NFvNfFHukWv
    os-dev-report
    {
    "issue": 31251,
    "status": "done",
    "branch": "claude/project-thread-t94vm3",
    "pr": "#31262",
    "session": "session_01FEkQ5mSU6a4NFvNfFHukWv",
    "premise_still_valid": true,
    "summary": "The ADR-0069 D2 counter in auth-manager-after-hook.ts (SIGN_IN_EMAIL_PATH branch) now skips recordSignInOutcome when the returned refusal's code is one refusedAfterTheCredential names, today exactly SESSION_SCOPING_REFUSAL.code imported from organization-access-policy.ts: no increment, no reset; auditRefusedSignIn still runs, so the login_failed row is unchanged, and the 403 status, code, message and timing are untouched. The predicate is a call-time function, not a module-level Set, because organization-access-policy.ts -> auth-manager.ts -> auth-manager-auth-instance.ts -> after-hook is an import cycle. Premise re-measured on origin/main 0d0c377 with the card's exact sequence. Branch not merged with origin/main: it moved three commits, none under packages/plugins/plugin-auth. Conflict noted, not resolved by me: os-dev.md rule 2 says pr_create carries the claim Account as assignee, AGENTS.md and the order say no assignees; I followed the order (relay read-back: assignees none). Commit trailers are model-free per AGENTS.md, overriding the harness reminder.",
    "tests": "Red first on origin/main 0d0c377: new describe in src/account-lockout-resolved-account.test.ts, 2 failed / 7 passed ('attempt 1: expected 1 to be +0'; 'expected 3 to be 2'); controls green. Card sequence probe on main (throwaway, deleted): 403 IP_NOT_ALLOWED count 1, 2, 3, then from inside 403 ACCOUNT_LOCKED count 3. Green on head c38cc4c: account-lockout-resolved-account + organization-access-policy, 2 files, 28 passed. Full suite under the lock: pnpm --filter @objectstack/plugin-auth test, VERDICT command-exit 0, Test Files 237 passed (237), Tests 4207 passed / 10 skipped (run on the tree one typing-only commit before c38cc4c). Ablation on c38cc4c: scripts/ablation-replace.mjs, anchor '!succeeded && refusedAfterTheCredential(' -> '!succeeded && String("ABL31251").length < 0 && refusedAfterTheCredential(' landed (anchor 1 -> 0, blob 8364cadd5ffa -> 8fa837754fe1), cases 1 and 2 red (2 failed / 7 passed), restored blob == HEAD 8364cadd5ffa, git diff HEAD empty. No dist leg: the test reaches the after-hook through relative src imports inside the package. A first ablation attempt was refused by the tool before running (replacement contained the anchor, exit 1, no-op) and re-anchored.",
    "gates": "Head c38cc4c. pnpm --filter @objectstack/plugin-auth typecheck exit 0 (first run exit 2, TS2345 on the one-element literal list, fixed by typing it readonly string[]). Scoped eslint --no-inline-config on the two touched .ts files exit 0. dispatch-gates --commands --repo objectstack-ai/objectstack: 61 families, each run with stdin closed, exit captured before any pipe; --ran: '61 derived famil(ies) accounted for - 60 run, 1 NOT-MEASURED'. 60 exit 0. NOT MEASURED: pnpm check:dual-build-cjs-loads, exit 3 PREREQUISITE NOT MET (needs every package dist; only the plugin-auth closure built). check-plugin-teardown-shape --self-test exit 3 on the shallow clone, exit 0 after git fetch --depth=1 origin 621a487. CI families: PR #31262's check-runs, not read before this report; the seat reads them.",
    "dogfood_battery": "n/a - no ! in the PR title and no (narrowing) arm",
    "mcp_calls": "4 - all reads: issue_read get #31251, issue_read get_comments #31251 (x2), pull_request_read get_files #31249; no write tool",
    "api_writes": "2 relay writes - fleet-write pr_create (PR #31262, draft, no labels, no assignees; read-back identical, 9297 bytes) and this os-dev-report comment via post-stamped.mjs; git push is not a REST write",
    "open_questions": [],
    "security_findings": "none",
    "out_of_scope_findings": [
    "class: a · reach: POST /api/v1/auth/sign-in/email, measured on the AuthManager.handleRequest harness (better-auth 1.7.12, threshold 3): two other refusals that come after the password verified still count toward the lockout. (1) 403 BANNED_USER with plugins.admin on (forced on by SCIM): thrown by the vendor admin plugin's session.create.before, the same path as the organization list; three correct-password sign-ins of a banned account locked it, and after the ban was lifted the next correct sign-in answered 403 ACCOUNT_LOCKED. (2) 403 EMAIL_NOT_VERIFIED with emailAndPassword.requireEmailVerification: thrown by signInEmail right after password.verify; count 2 -> 3 and locked on one correct-password attempt. By the card's Expected principle neither should count; not changed here because the claim's skip set names SESSION_SCOPING_REFUSAL.code only. Fix shape: add both codes to refusedAfterTheCredential in auth-manager-after-hook.ts, with pins on the same harness. One family, one card. · dedupe words: lockout BANNED_USER, lockout EMAIL_NOT_VERIFIED, post-credential refusal lockout counter",
    "carrier: none · noted, not filed: MFA hold (plugins.twoFactor, enrolled account) answers 200 twoFactorRedirect and clears the count (2 -> 0); not a refusal, correct as is. Concurrent-session cap, organization idle/absolute limits, organization MFA hold and the deployment IP list never reach the counter as a refusal (by reading)."
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

    Claim amendment · 2026-11-07T20:39Z · amends the claim on this card (branch claude/project-thread-t94vm3)

    Skip set widened, same file and same predicate (refusedAfterTheCredential in auth-manager-after-hook.ts): the dev's out-of-scope finding 1 (report 6712725182) measured two more refusals that come AFTER the password verified on /sign-in/email and still count toward the lockout, on the AuthManager.handleRequest harness (better-auth 1.7.12, threshold 3): 403 BANNED_USER (admin plugin on; three correct-password sign-ins of a banned account locked it, and after the ban was lifted the next correct sign-in answered ACCOUNT_LOCKED) and 403 EMAIL_NOT_VERIFIED (requireEmailVerification; one correct-password attempt took the count 2 → 3 and locked). The card's Expected is the principle — only a refused credential counts — so both join SESSION_SCOPING_REFUSAL.code in this PR rather than on a second card. Name each code by an exported constant where one exists; where the vendor exports none, one module-level constant with a comment naming its vendor source, never an inline literal. Pins on the same harness: each refusal leaves the count unchanged; a wrong password still counts. Changeset and PR body name all three. Clause-②: no and the rest of the claim stand. The MFA hold (200 twoFactorRedirect, clears the count) stays as is, as the dev judged.

  6. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#6 · os-project-manager · session_01FEkQ5mSU6a4NFvNfFHukWv
    os-dev-report
    {
    "issue": 31251,
    "status": "done",
    "branch": "claude/project-thread-t94vm3",
    "pr": "#31262",
    "session": "session_01FEkQ5mSU6a4NFvNfFHukWv",
    "premise_still_valid": true,
    "summary": "Round 2, per the claim amendment (comment 6712743965): refusedAfterTheCredential in auth-manager-after-hook.ts now names three codes, each by a constant: BASE_ERROR_CODES.EMAIL_NOT_VERIFIED.code (lazy import of @better-auth/core/error, as the before-hook does), SESSION_SCOPING_REFUSAL.code (organization-access-policy.ts), and VENDOR_BANNED_USER_CODE, one module-level constant whose comment names its vendor source (ADMIN_ERROR_CODES.BANNED_USER in better-auth's admin plugin; no public export path). The predicate became async for the lazy import; still evaluated per sign-in (import cycle). A refusal it names skips recordSignInOutcome (no increment, no reset); auditRefusedSignIn still runs; the 403 answers are unchanged. New head f2bb01d (fix commit, then the changeset commit). Branch not merged with origin/main: no commit under packages/plugins/plugin-auth since the base 0d0c377, and PR #31249 had not landed when read. PR body for the seat to apply: /mnt/project-files/pm-orders/server/31251-pr-body.md.",
    "tests": "New describe in src/account-lockout-resolved-account.test.ts (deployment() takes config extras): ban (plugins.admin, banned row) - two wrong passwords count to 2 (401 INVALID_EMAIL_OR_PASSWORD), three correct passwords each 403 BANNED_USER with the count held at 2, no lock, no account_locked event, ban lifted then 200 and count 0; unverified address (requireEmailVerification, email_verified false) - two wrong passwords count to 2, three correct passwords each 403 EMAIL_NOT_VERIFIED with the count held at 2, one more wrong password locks. Red against the first head's after-hook (c38cc4c, IP list only; tree-only restore, then git checkout HEAD, diff empty): 2 failed / 9 passed, both 'attempt 1: expected 3 to be 2'. Green on f2bb01d: 11 passed. Ablation, one leg per list entry via ablation-replace --delete from blob e06b13de2fc4, each restored to blob == HEAD with git diff HEAD empty: VENDOR_BANNED_USER_CODE -> only the ban case red (1 failed / 10 passed); BASE_ERROR_CODES.EMAIL_NOT_VERIFIED.code -> only the unverified case red (1/10); SESSION_SCOPING_REFUSAL.code -> the two IP cases red (2 failed / 9 passed). Full suite under the lock on f2bb01d: VERDICT command-exit 0, Test Files 237 passed (237), Tests 4209 passed / 10 skipped. Round 1 readings (main red-first, card sequence probe) stand as in report 6712725182.",
    "gates": "Head f2bb01d. pnpm --filter @objectstack/plugin-auth typecheck exit 0. Scoped eslint --no-inline-config on the two touched .ts files exit 0. dispatch-gates --commands --repo objectstack-ai/objectstack: 62 families (adds check:dispatcher-error-vocabulary, exit 0), each run with stdin closed, exit captured before any pipe; --ran: '62 derived famil(ies) accounted for - 61 run, 1 NOT-MEASURED'. 61 exit 0. NOT MEASURED: pnpm check:dual-build-cjs-loads, exit 3 PREREQUISITE NOT MET (only the plugin-auth closure built). check-plugin-teardown-shape --self-test exit 0 after git fetch --depth=1 origin 621a487. Local dispatch-gates is the base copy; origin/main since changed its derivation (PR #31254), so CI may derive more families. CI families: PR #31262's check-runs, not read; the seat reads them.",
    "dogfood_battery": "n/a - no ! in the PR title and no (narrowing) arm",
    "mcp_calls": "5 - all reads: round 1 four (issue_read get #31251, issue_read get_comments #31251 x2, pull_request_read get_files #31249) plus issue_read get_comments #31251 for the amendment; no write tool",
    "api_writes": "round 2: 1 relay write, this os-dev-report comment via post-stamped.mjs (the PR body is left for the seat to apply); round 1: 2 (pr_create, report comment); git push is not a REST write",
    "open_questions": [],
    "security_findings": "none",
    "out_of_scope_findings": [
    "carrier: none · noted, not filed: MFA hold (plugins.twoFactor, enrolled account) answers 200 twoFactorRedirect and clears the count; not a refusal, kept as is per the amendment. Concurrent-session cap, organization idle/absolute limits, organization MFA hold and the deployment IP list never reach the counter as a refusal (by reading)."
    ]
    }


    Generated by Claude Code

  7. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

    落地 · 2026-11-07T22:06Z

    reading value
    PR #31262 MERGED 2026-11-07T22:05:49Z, merge commit 2f369cd3 (ACCEPT 6713162040, first merge group)
    #31251 closed completed by Fixes #31251

    State change: pm:dispatched removed; nothing left on this card.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions