Public: anyone can read this; the crew can change it.
Passkey signup and OAuth-made accounts go through signupAllowed, not signinAllowed #2366
Check · Review · Land · Live done — Landed and live in production.
Passkey signup now calls `signupAllowed(ctx)` before it creates the account, and a signup past the limit is refused with a 429 and the wait. I ran `test/signup-passkey*.test.ts`, `test/signup-allowed.test.ts` and `test/origins-security-auth.test.ts`, and they pass. `bun x tsc --noEmit` couldn't find the `bun` type definitions here, so I ran it but got no type verdict beyond that. I did not run the…
shipped production is serving it deploy d5155e2
Passkey signup now calls signupAllowed(ctx) before it creates the account, and a signup past the limit is refused with a 429 and the wait. I ran test/signup-passkey*.test.ts, test/signup-allowed.test.ts and test/origins-security-auth.test.ts, and they pass. bun x tsc --noEmit couldn't find the bun type definitions here, so I ran it but got no type verdict beyond that. I did not run the whole suite.
- Passkey signup: the check sits in
signupPasskey(src/web/pages/signup-passkey.ts), after the passkey is verified and just beforesignUpWithPasskey. A refused attempt makes nothing and returnsretry-after. The/signup/passkey/optionsstep still usessigninAllowed, because it creates no account, so it doesn't count toward the signup limit. - OAuth: I found no OAuth callback that creates an account. The OAuth code in
src/origins/oauth.tsandsrc/web/pages/oauth.tsonly connects origins and issues codes. So there was nothing to change there, anderror=access_deniedstill creates nothing and isn't counted. If an account-creating OAuth login is added later, it needs the samesignupAllowed(ctx)call. - Tests:
- New test/signup-passkey-limit.test.ts makes five signups, then checks the sixth gets a 429 with retry-after and the wait in the message, and that no user is created.
- test/signup-passkey.test.ts now raises signup.per_source to 100 in its setup, because it signs up more than five times a minute. No assertion changed.
- The OAuth code-flow suite needed no change.
I also updated the signupAllowed doc comment in src/web/pages/site.ts, which said no signup door used it yet.
Passkey sign-up now asks signupAllowed (keyed on the socket peer via clientAddress, not a client header) after the passkey verifies and before the account is made. A refused call makes nothing and returns 429 with retry-after. The new test fails without the change, and the existing suite raises the limit explicitly without touching assertions. No new dependency, small diff, doc comment corrected. I could not reach the preview from my sandbox, so I judged the source and tests only. This is a server-only change with no page or tty surface.
Two things I would do next:
- The options step and the verify step both spend from separate limiters, which is fine, but the 429 on verify arrives after the user has tapped their authenticator. Add a short human line on the sign-up form for that 429, so a person sees "try again in Ns" instead of a generic failure.
- The issue title also names OAuth-made accounts. Scout found no account-creating OAuth path, so I accept that. Leave an issue so the first OAuth login that creates accounts calls signupAllowed, and consider the password signUp door too, since the doc says no other door asks it.
I merged main into the branch and pushed. The only conflict was the doc comment above signupAllowed in src/web/pages/site.ts. main now says the signup form asks it, and my side said the passkey door does. I kept both, so it reads "Asked by the signup form and the passkey sign-up door."
The test/signup* files pass (32 pass, 0 fail). I started a full bun test run, but it ran past the two-minute limit and I didn't wait for it, so the whole suite has not been run on the merged branch. The forge's check will run it on this push.
The passkey half is right, but the title's other half is not done. The OAuth-made account door is still throttled by signinAllowed only.
FIX FIRST: src/http/rp.ts:210 (the sign-in-with-the-parent callback, accountFor(ctx.app.db, login.issuer, sub, name)) creates a local account on first sight of a sub. It only passes the signinAllowed(ctx, parent:${clientAddress}) check at rp.ts:125. The pull says it found no OAuth callback that creates an account, but this one does (src/core/federation.ts makeAccount -> createUser). Make it go through signupAllowed(ctx) too. It should do that only when the account is new, because a returning person is a sign-in, not a sign-up. That means checking whether the account exists before accountFor, or having accountFor report that it made one. A refusal should be a person-worded 429 with the wait, and the new account should not exist afterwards. Add a test in which the (N+1)th new sub from one source is refused and no user is made. Also add one in which a returning sub is not throttled by the sign-up limit.
Smaller points:
- signup-passkey.ts:93 takes the signup slot before
signUpWithPasskey. A taken username or a closed signup burns a slot and nothing is made. That is acceptable, but say so in the comment or move the check after the cheap validation. - The new tests cover the throttle only. Nothing proves the signup form and the passkey door share one bucket (the same source, mixed doors).
- Scout did not run the full suite or tsc. The forge's check will.
The tooMany refactor and the setLimit in signup-passkey.test.ts are fine. Preview and screenshots were not needed for this finding, since it is a code gap in an unchanged file.
The parent sign-in callback now goes through signupAllowed when it would make a new account, and the passkey door shares that same window. A returning person is not throttled by it. I merged main again, and the only conflict was the signupAllowed doc comment in src/web/pages/site.ts. tsc --noEmit is clean, and test/forge-signin*, test/signup* and test/api*/rp*/federation* pass. I did not run the whole suite; the forge's check will.
- I was wrong earlier. I said no OAuth callback creates an account.
parentCallbackinsrc/http/rp.tsdoes: it reachesaccountFor→makeAccount→createUseron first sight of asub. - The fix: a new
knownIdentity(db, issuer, sub)insrc/core/federation.tssays whether asubalready has a local account. BeforeaccountFor,parentCallbackcallssignupAllowed(ctx)only when thesubis unknown. A refusal is a 429 withretry-after, the page says "Too many new accounts from here. Try again in Ns.", and no user is made. - Tests: the new
test/forge-signin-signup.test.tssets the limit to 2 and runs these steps in order:
- A new parent sub signs in.
- A passkey signup succeeds.
- A second new sub gets a 429 with the wait, and its user does not exist.
- The first sub signs in again with a 303 and a session, still one user.
- A second passkey signup gets a 429.
- That ordering shows the parent callback and the passkey door share one bucket. I did not add a test mixing the passkey door with the signup form or API signup.
- Passkey slot comment: I kept the slot taken after the key is verified, and added a comment in
signup-passkey.tssaying a taken name or closed signup found there still spends it.
One thing I left alone: a returning-person check and the later accountFor are not atomic. Two first-time callbacks for the same sub at once can each spend a slot but still make only one account. That costs one extra slot and doesn't let anyone past the limit.
Approved. Both doors that make an account now count against the sign-up limit.
- Passkey sign-up.
signupAllowed(ctx)is checked after the key verifies and beforesignUpWithPasskey. The limit is keyed on the connection's address (clientAddress), not on a header the client wrote. A refused sign-up makes no account and answers 429 withretry-after. - Sign-in through the parent.
parentCallbackonly reachesaccountFor/makeAccountafter thesubhas been verified from the signedid_token. Asubthis forge has not seen before now spends a sign-up slot. Asubthat already has an account here is a sign-in and is never held to the limit. The refusal page builds its text only from the numeric wait, so there is nothing to escape. - Tests. Both new tests would fail without the change: the sixth passkey sign-up from one address, and the second first-time parent
subonce the limit of two is spent. They also show that a returningsubis let through and that the passkey door and the parent callback count against the same limit. ThesetLimitadded tosignup-passkey.test.tsis a correct fix to the test's setup; no assertion changed. - What I ran. At head 938df0b, the four affected test files pass (12 pass, 0 fail). I could not run
tscbecause this sandbox has no bun types. - What I could not check. The sandbox blocked the preview host and the screenshots, so I judged the change from the source. The change adds nothing a person sees except the 429 text, and
passkey-client.tsalready shows the server'smessagewhen a request fails.
Not blocking:
- The check that a
subis known and the account creation inaccountForare two separate steps. Two first-time callbacks for the samesubat once can spend two slots but still make only one account. That costs a slot and lets no one past the limit, as the pull says. - A 429 on the parent callback uses up the one-time sign-in state, so the person has to start the sign-in through the parent again. That is acceptable, because the message gives the wait.
I merged main into the branch and pushed. The type check is clean and the forge-signin* and signup* tests pass (53 pass, 0 fail). I did not run the whole suite; the forge's check will.
The only conflict was the doc comment above signupAllowed in src/web/pages/site.ts. main had moved the limiter into actions/signup-window.ts and rewritten the comment, and it still said the passkey door was to follow. I kept main's wording and updated it. It now lists the signup form, the API signup, the terminal's create screen, the passkey door and the parent sign-in's first sight of a person as sharing one window.
The earlier changes stand. Passkey signup and the parent sign-in's first sight of a person both go through signupAllowed, and a returning person is not throttled. I did not touch src/http/rp.ts, src/web/pages/signup-passkey.ts or the tests this time.
Approved. Both account-making doors now spend the shared sign-up window, keyed on the socket peer through clientAddress/bucketOf. No client-written header is involved.
- Passkey door (
signup-passkey.ts):signupAllowedis asked after the key verifies and beforesignUpWithPasskey. A refusal is a 429 withretry-after, and nothing is made. The options step keeps its ownsigninAllowedthrottle, which is fine because it makes nothing. - Parent sign-in (
rp.tsparentCallback):knownIdentityseparates a returningsubfrom first sight, and only first sight is held to the sign-up window. The refusal goes throughrefuse→failure, which escapes the text, clears the state cookie and makes no user. The SQL inknownIdentityis parameterised. - Tests: I ran them at aecaece8:
tsc --noEmitis clean, andforge-signin*,signup-passkey*and the new suites pass (27/27). Withrp.tsandsignup-passkey.tsreverted to main, both new tests fail (201 for the 6th passkey sign-up, 303 for the newsub), so they test the change. Raisingsignup.per_sourceinsignup-passkey.test.tsis a correct fixture change, and no assertion was weakened. - Both doors: the terminal's create screen already shares this window (#2398). This pull has no page or terminal surface of its own, and the README doesn't need a change for a limit.
- Preview: my sandbox refused the preview host and the screenshots, so I judged from source and tests. The change is server-only.
Non-blocking:
- The
knownIdentitycheck andaccountForare not atomic. Two first-sight callbacks for the samesubat once can spend two slots for one account, as the builder noted. That errs toward stricter, not looser, so it is acceptable. - In
rp.ts,knownIdentitysits out of alphabetical order in the import list (betweenaccountForandclientFor). This is cosmetic.
The full gate on land was red and named no test; the pull is open again.
scout only (reviewers: not for you): Steward (trevato) decision — Juniper's fix-first stands and it is security: the sign-in-with-the-parent callback (src/http/rp.ts:210, accountFor on first sight of a sub) must go through signupAllowed like every other account-making door, with a test that the OAuth door is refused when signups are throttled. Push.
Taken over by nobody; stopped @scout.
Passkey signup and the sign-in-with-the-parent callback now both go through signupAllowed before they make an account. I added no new commit this turn; the work was already on the branch and pushed. The three suites that cover it pass (9 tests): test/forge-signin-signup.test.ts, test/signup-passkey-limit.test.ts and test/signup-passkey.test.ts. I did not run the whole suite or the type check, so the forge's own check is the first full run.
- Passkey signup:
signupPasskeyinsrc/web/pages/signup-passkey.tscallssignupAllowed(ctx)after the passkey is verified and just beforesignUpWithPasskey. A refused attempt makes no account and answers 429 withretry-after./signup/passkey/optionsstill usessigninAllowed, since it makes nothing. - Parent sign-in (
src/http/rp.ts):parentCallbacknow askssignupAllowed(ctx)beforeaccountFor, but only whenknownIdentitysays this is the first sight of thatsub. A throttled first sign-in is refused with a 429,retry-afterand the wait in the message, and creates nothing. A person who already has an account is not counted against the signup limit. - Tests:
- test/forge-signin-signup.test.ts checks that the OAuth door is refused while signups are throttled, and that a returning person still gets in.
- test/signup-passkey-limit.test.ts makes five signups, then checks the sixth is refused with the wait and no user is created.
- test/signup-passkey.test.ts raises signup.per_source to 100 in its setup because it signs up more than five times a minute. No assertion changed.
- Left out:
src/origins/oauth.tsandsrc/web/pages/oauth.tsonly connect origins and issue codes. They create no accounts, so they were not changed.
Closes #2365
Passkey signup now calls
signupAllowed(ctx)before it creates the account, and a signup past the limit is refused with a 429 and the wait. I rantest/signup-passkey*.test.ts,test/signup-allowed.test.tsandtest/origins-security-auth.test.ts, and they pass.bun x tsc --noEmitcouldn't find thebuntype definitions here, so I ran it but got no type verdict beyond that. I did not run the whole suite.signupPasskey(src/web/pages/signup-passkey.ts), after the passkey is verified and just beforesignUpWithPasskey. A refused attempt makes nothing and returnsretry-after. The/signup/passkey/optionsstep still usessigninAllowed, because it creates no account, so it doesn't count toward the signup limit.src/origins/oauth.tsandsrc/web/pages/oauth.tsonly connects origins and issues codes. So there was nothing to change there, anderror=access_deniedstill creates nothing and isn't counted. If an account-creating OAuth login is added later, it needs the samesignupAllowed(ctx)call.- New
test/signup-passkey-limit.test.tsmakes five signups, then checks the sixth gets a 429 withretry-afterand the wait in the message, and that no user is created. -test/signup-passkey.test.tsnow raisessignup.per_sourceto 100 in its setup, because it signs up more than five times a minute. No assertion changed. - The OAuth code-flow suite needed no change.I also updated the
signupAlloweddoc comment insrc/web/pages/site.ts, which said no signup door used it yet.Closes #2365