Skip to content

feat(0186): sign in with Discord — identity only, scope identify, session cookie - #220

Merged
adamkoot merged 12 commits into
developfrom
feat/0186_discord-sign-in-identity-only
Aug 18, 2026
Merged

feat(0186): sign in with Discord — identity only, scope identify, session cookie#220
adamkoot merged 12 commits into
developfrom
feat/0186_discord-sign-in-identity-only

Conversation

@adamkoot

Copy link
Copy Markdown
Collaborator

Third slice of the self-service onboarding epic. Adds Discord sign-in to the
existing prices-api axum router — identity only, scope identify, a session
cookie. No key is issued, no eligibility is checked, nothing is stored.

Closes the code half of task 0186. Ships behind [0183]'s PORTAL_ENABLED
flag, which stays false in production.

What this adds

Four routes under /api-tokens/api/, so [0183]'s prefix gate covers them
without knowing they exist:

route does
GET /auth/login mints state + PKCE, redirects to Discord
GET /auth/callback verifies state, exchanges the code, issues a session
GET /auth/me reports who the caller is, or that they are nobody
POST /auth/logout clears the session

New module tree packages/prices-api/src/portal/auth/crypto,
state_token, session, cookies, discord, secret. No new crate, no
second gateway integration, no second build (ADR 0008, 2026-08-07 meeting).

Frontend is a link and a line of text. Signed-out and cancelled are plain
text, not screens. Styling is [0193]'s.

The security-relevant decisions

state is bound to the browser, not just signed. /auth/login mints two
values from one nonce: the state parameter that travels to Discord, and a
signed HttpOnly cookie holding the same nonce plus the PKCE verifier. A
single signed state would not catch the interesting attack — an attacker who
calls /auth/login themselves holds a genuine, correctly-signed state, and
pasting it into a victim's browser is the whole login-CSRF. Their nonce does
not match the victim's cookie.

Replay is closed by clearing the pending cookie before the outcome is
known
, on every path out of the callback. A second presentation of the same
code+state has nothing left to match against, so nothing here needs a
used-nonce table. Discord's codes are single-use too, but that is Discord's
guarantee to keep, not ours to rely on.

One key signs three token kinds, which is only safe with domain
separation.
The MAC covers context ‖ 0x00 ‖ payload. Without it a state
which the holder reads out of their own address bar — would verify as a
session cookie, and /auth/login would be a session vending machine.

No Discord token is persisted. current_user takes the token by value,
so after that line the handler cannot reach it. The session carries exactly
three fields and a test asserts on the serialized form, so adding a fourth is
what fails rather than a reviewer having to notice.

Scope is verified, not just requested. Scopes are declared in the Developer
Portal and in the authorize URL, so the two can disagree and only the token
response shows the real grant. Compared as a whole string, so identify guilds
is refused (ADR 0010).

The action slot is carried and checked now, though only sign-in exists.
[0189] binds issuance and rework to a round-trip, and adding the field then
would mean re-deriving the signing format while a deployed portal holds live
cookies in the old one.

Secrets

The client secret lives in Secrets Manager and reaches the Lambda through the
Parameters & Secrets extension it already loads for its ClickHouse
certificate. The environment carries only the name, computed by
portalOauthSecretName() beside mtlsSecretName (ADR 0007, Tranche 3 AC 6).
The IAM grant is scoped to that one secret, on the api-handler role only — no
worker signs a cookie.

One secret with four fields, three of which are not secret. They are one
Discord application registration, owned by one person, and the redirect_uri
is re-pointed by hand at the domain cutover — a CloudFormation-managed value
would be restored by the next cdk deploy, breaking sign-in silently some
time after the cutover appeared to work. SecretsStack publishes the name and
nothing else, the same rule it already states for the mTLS material.

The read is skipped entirely while the portal is closed. Reading it
unconditionally would fail Lambda init on a deployment where nobody has
created the secret yet — and one router serves every route group, so that
would take /v1 down to protect four routes that answer an empty 404 either
way. With the portal open the stance inverts: a missing or malformed secret is
fatal, so it surfaces as Init Errors at deploy rather than as a 503 under a
sign-in button.

Two things verified rather than built

The gateway throttle and the CloudFront cookie forwarding this task requires
were already correct from [0184], which wrote them citing this task. Both
were checked by measurement rather than taken on trust — and both are now
guarded, because in each case the correct setting was chosen for a different
reason and is load-bearing here by coincidence, which is what a later tidy-up
breaks.

  • apiGatewayCacheEnabled flipped to false in a throwaway synth and the
    arrays diffed: the three portal entries survive, rate=10, burst=40, cachingEnabled=false.
  • ALL_VIEWER_EXCEPT_HOST_HEADER was chosen for Host and happens to forward
    cookies; CACHING_DISABLED was chosen for x-api-key and happens to satisfy
    "do not cache auth paths".

tools/scripts/verify-openapi-routes.mjs gains four assertions:
the two managed-policy IDs, IncludeCookies: false on the access log, and a
source scan pinning ...portalSettings / apiDocsSettings to both arms of
if (cacheEnabled). That last one cannot be a template check — production runs
the cache on, so a synth only ever exercises one arm and the regression
produces a byte-identical template. Measured, not assumed.

Testing

87 automated tests, 65 new: 46 unit across the six new modules, 36 integration
(23 new in tests/portal_auth.rs), 27 frontend (9 new). The integration suite
drives all four routes over HTTP against a mock Discord bound to loopback,
using the same reqwest client production uses — a trait-based fake would have
skipped the HTTP layer, which is where three of the requirements actually live
(secret in the body not the URL, the verifier matching its challenge, the scope
check).

Green: cargo fmt --check, cargo check --workspace, cargo clippy -p prices-api --all-targets -D warnings, cargo test --workspace, cargo build --features lambda, nx run-many -t lint typecheck build test, nx format:check --all, make -C infra synth-production, openapi:lint,
openapi:verify-routes, openapi:verify-servers, verify-lambda-assets.sh.

An adversarial review pass found no High or Medium defect. Its top hypothesis —
that API Gateway REST v1 would drop the second of the callback's two
Set-Cookie headers and silently lose the session — was checked against
lambda_http 1.2.1 and does not hold: it sends everything through
multiValueHeaders and leaves headers deliberately empty. Secrets were
probed with canary values at RUST_LOG=info, debug and trace, including
reqwest/hyper internals: zero hits. Eight Low findings are recorded in the
task file.

Manual verification

The live Discord round-trip has been verified by Adam against a Discord
application he registered, running locally with the flag on: sign-in completes
and the page renders his Discord username and ID. That also settles the
question [0156] left open — redirect-URI matching behaved as the runbook
assumes.

docs/runbooks/portal-oauth-deploy-prep.md is new: registration, secret
provisioning, local verification, and a five-step ordering for the
custom-domain cutover with the failure mode of inverting it spelled out
(Discord refuses on its own error page, so nothing reaches our logs).

Not in scope

Everything that turns an identity into an entitlement is [0189]:
guilds.members.read, the guild membership call, pending, the snowflake
account-age minimum. Key issuance is [0187]. This PR adds no store — [0190]
decides whether a registry is needed at all.

Ordering note for whoever merges

Safe to merge on its own: production stays PORTAL_ENABLED=false and these
routes answer an empty 404.

Do not flip the flag before [0205] deploys. The four sign-in routes sit at
depth 3 (auth/login), and the mapping currently in production is [0184]'s
intermediate {proxy} + {proxy}/{sub} pair, which answers 403 Missing Authentication Token at that depth. This branch carries the committed
{proxy+}; [0205] is what ships it. The session-through-CloudFront criterion
is the one thing that cannot be closed until then.

Four routes on the existing axum router under task 0183's gated prefix —
/auth/login, /auth/callback, /auth/me, /auth/logout — so the whole slice is
an empty 404 until PORTAL_ENABLED is flipped. No new crate, no second gateway
integration (ADR 0008, 2026-08-07 meeting).

Authorization Code with PKCE (S256) and scope exactly `identify`, requested in
the authorize URL and verified again on the token response so a Developer
Portal registration that drifts wider fails closed rather than quietly
collecting more (ADR 0010).

`state` is signed and bound to the browser by a second signed cookie carrying
the same nonce plus the PKCE verifier. That pair is what refuses a callback
with no cookie, a mismatched nonce (login-CSRF), a forged signature, a crossed
action or an expired window. Replay is closed by clearing the pending cookie
on every path out of the handler, before the outcome is known, so a second
presentation of the same code+state has nothing left to match against. The
`state` carries an action slot even though only sign-in exists — task 0189
needs it and adding it later would mean re-deriving the signing format while a
deployed portal holds live cookies in the old one.

The session is a signed HttpOnly + Secure + SameSite=Lax cookie scoped to
/api-tokens/, carrying the Discord user ID, a display username and an expiry
enforced server-side. No Discord access or refresh token is persisted:
`current_user` consumes the token by value, so the handler cannot reach it
afterwards.

One key signs all three token kinds, which is safe only because the MAC is
domain-separated — without it a `state`, which the holder reads out of their
own address bar, would verify as a session cookie.

The client secret lives in Secrets Manager and reaches the Lambda through the
Parameters & Secrets extension it already loads for its ClickHouse
certificate; the environment carries only the name, computed by a helper
beside `mtlsSecretName` (ADR 0007, Tranche 3 AC 6). The read is skipped
entirely while the portal is closed — reading it unconditionally would fail
Lambda init on a deployment where the secret does not exist yet, and one
router serves every route group, so that would take /v1 down to protect four
routes that answer 404 either way.

The gateway throttle and the CloudFront cookie forwarding this task requires
turned out to be already correct from task 0184. Both were verified by
measurement rather than re-implemented, and both are now guarded: three new
assertions in verify-openapi-routes.mjs pin the CloudFront policies and the
access-log cookie setting, and a source scan pins the methodSettings entries
to BOTH arms of `if (cacheEnabled)` — a synth cannot see that one, because
production runs the cache on and only ever exercises one arm.

Frontend is a link and a line of text. Signed-out and cancelled are plain
text, not screens; styling is task 0193's.

Verified: the live Discord round-trip by Adam against his own registered
application, locally with the flag on. 87 automated tests, 65 new. Everything
else — the session through CloudFront — needs task 0205's gateway deploy,
since these four routes sit at depth 3 and the deployed mapping covers 1-2.
A mutation sweep over the fifteen security properties this slice claims found
two the suite did not detect. This closes the one that is closable.

Deleting the `pending.action != state.action` comparison in
`state_token::accept` left the whole suite green. With a single `Action`
variant the mismatch arm is unreachable, and the test that looked like it
covered it — signing a payload with an unknown action string — fails earlier,
at deserialization, and reports BadSignature. So the check was never
exercised.

That defeats the reason the slot is carried now rather than later. Task 0189
adds the second real action, and the point of paying for the field early is
that the comparison is already there AND already tested when it does.

`Action` therefore gains a `#[cfg(test)]` variant. It is not 0189's `issue`
arriving early: `Action::parse` never yields it, so `/auth/login` cannot mint a
round-trip for it, and it is compiled out of every shipped binary. It exists
only to make the mismatch arm reachable. Re-running the mutation now fails
exactly one test.

The second miss — swapping `ct_eq` for `==` on the MAC comparison — is left
uncovered and recorded instead. Both are functionally identical; only timing
separates them, and a statistical timing assertion is too flaky to be worth its
false failures. Code review is the control there.
Two findings from the review pass, both in the "reports an error and leaves
the user worse off" family.

**A stranger could cancel someone else's sign-in.** The callback cleared the
pending-login cookie before it verified anything, so every unverifiable shape
took an in-flight login down with it: `?error=x`, `?state=<garbage>`,
`?code=x`, or no parameters at all. `SameSite=Lax` sends that cookie on a
top-level GET navigation, which is exactly what any third-party page can
cause, so a victim part-way through signing in could be knocked back to
`invalid_state` by a page they merely visited. Nothing was disclosed and no
session could be issued — a denial of login rather than a break — but it cost
an attacker one link and the victim saw only that signing in "did not work".

The fix is an ordering one. `state` is now verified first, on every path
including the cancellation, and only then is the cookie dropped. RFC 6749
§4.1.2.1 has the authorization server echo `state` on the error response too,
so an error callback carrying none did not come from Discord finishing our
round-trip and has no business acting on this browser's state. `refuse_state`
no longer emits a cookie header at all.

Replay is unaffected: every path from a verified `state` onwards still clears,
so a second presentation of the same code+state still finds nothing to match
against. That half now has a test of its own rather than riding on the first.

**The portal offered no way out of a failed session check.** When `/auth/me`
answered anything but a session, the page reported the error and removed the
sign-in control, leaving a dead end the visitor could escape only by guessing
at a reload. Signing in is a fresh top-level navigation and does not depend on
the request that just failed, so the control stays.

+3 integration tests, +1 frontend test. The suite that asserted a refusal
DOES clear the cookie now asserts the opposite, which is the behaviour change:
that assertion was pinning the defect.
…nment

Two findings from code review.

**A data race in the integration suite.** `Endpoints::from_env` ran inside
`app()`, so `std::env::var` executed on every router construction — fifteen of
them in `tests/portal_auth.rs` — while thirteen others called
`std::env::set_var` to point a router at the mock Discord. libtest runs those
on parallel threads, so a reader could be walking `environ` while `setenv`
reallocated it. That is the undefined behaviour `set_var` was made `unsafe`
for in edition 2024, and it does not surface as a failing test: it is an
intermittent segfault that aborts the whole binary.

Serialising the readers behind the same lock would have worked. Threading the
value through `AppConfig` removes the race by construction instead, and takes
the `unsafe` block out of the test file entirely. It also closes the quieter
half of the same problem, which no lock would have: a router built by
`signed_in_app` captured whatever base URL another test had last written, and
was harmless only because none of those tests reached the token exchange.

Production is unaffected — `AppConfig::from_env` still reads the same two
overrides, and `compute-stack.ts` still sets neither, so the deployed handler
takes Discord's real endpoints as before.

**The sign-out re-read bypassed its own staleness guard.** `load()` returns a
canceller and the mount effect wired it up, but `onSignOut` called `load()` and
dropped the return value, leaving that request's `live` flag with nothing to
flip it. The canceller now lives in a ref: one mechanism covers both the
supersede case and the unmount case, rather than depending on which caller
remembered. Cosmetic under React 18, which made the stray `setState` a silent
no-op — but it defeated the guard the comment above the effect claims is there.
The recorded section claimed "no High or Medium defect in the code", which was
true when it was written and wrong twice over afterwards: the action-slot
comparison was dead code, and the callback cleared the pending-login cookie
before verifying anything. It also still listed the missing sign-in control as
open after it had been fixed.

Replaced with a ledger that separates fixed from open and names the commit for
each fix, so a future session reads what the code actually does rather than
what the first pass believed. Adds the two findings that arrived later — the
CloudFront Set-Cookie response direction, and the one mutation no test can
catch — and the mutation-coverage numbers, which are the evidence that the
suite detects defects rather than merely passing.
The runbook told an operator how to provision the secret but only gestured at
the local round-trip, and nothing said which port the redirect URI belongs to —
which is the single thing most likely to be got wrong, because it is the Vite
port and not the API's.

`packages/prices-api/README.md` gains the full procedure: the Discord
registration, the shape of `.portal-oauth.json` with placeholder values, the two
commands, what a working run looks like both in the browser and over curl, and a
symptom table. Every command in it was run as written.

Two things worth stating that were not written down anywhere:

- **No ClickHouse is required.** `serve` builds a lazy plaintext client that
  never connects unless a `/v1` route is hit, and it ignores `CH_ENABLED`
  entirely — that variable is a Lambda-only knob. Earlier notes passed
  `CH_ENABLED=false` as though it mattered.
- **The proxy is what makes the browser see one origin**, which is the property
  `SameSite=Lax` depends on. A separate backend port breaks the session in a way
  no test in this repo can see, so it is not an arbitrary bit of dev ergonomics.

The runbook's section 4 now points at this rather than keeping a second, thinner
copy, and holds only what is genuinely its own: adding the second redirect URI to
the registration, and deciding whether to remove it afterwards.

The env table gains the five variables task 0186 introduced, including which of
them CDK sets and which exist only as a local or test seam.
@karczuRF

Copy link
Copy Markdown
Collaborator

Review notes — Discord sign-in

Reviewed origin/develop...origin/feat/0186_discord-sign-in-identity-only (41 files, +5420/−191): the Rust portal module, the CDK stacks, the CI verify script and the frontend.

The security-critical parts hold up. PKCE S256 matches the RFC vector, ct_eq reuse and domain separation are correct, the cookie attribute set and the SameSite=Lax reasoning are right for a Discord return navigation, redirect_uri is identical between authorize and token exchange, and the scope is compared whole-string. CDK IAM grant argument order and apiHandlerRole ordering are fine, portalSettings genuinely appears in both arms of if (cacheEnabled), and the new source-scan regex in tools/scripts/verify-openapi-routes.mjs matches exactly the two assignments it asserts.

Two things I checked by measurement rather than by reading, both clean:

  • Crypto-provider unification is safe. Adding reqwest with rustls-tls-webpki-roots turns on rustls's ring feature alongside prices-clickhouse's aws-lc-rs (confirmed with cargo tree -e features -i rustls@0.23.40 --features lambda; develop has only aws-lc-rs). That's the classic "no process-level CryptoProvider" panic setup, but it does not fire here: reqwest 0.12.28 uses builder_with_provider with an explicit ring::default_provider() fallback (async_impl/client.rs:763-776), and mtls.rs:193 installs aws_lc_rs before its own ClientConfig::builder(). Worth knowing if anyone ever switches to the -no-provider reqwest feature.
  • No test reaches the real discord.com. Every test that gets as far as the token exchange uses app_against(&mock); signed_in_app(true) is only used on paths that reject before the exchange.

Three findings below.


1. Every Discord OAuth error is reported to the visitor as "cancelled", and nothing is logged — packages/prices-api/src/portal/auth/mod.rs:270

The branch is if query.error.is_some(), with no check of the value and no tracing call. So server_error, temporarily_unavailable, invalid_request and — most importantly — invalid_scope all redirect to /api-tokens/?signin=cancelled.

Scenario: the Developer Portal registration drifts (exactly the drift discord.rs's scope check exists to catch), Discord returns ?error=invalid_scope, and every visitor sees "Sign-in cancelled." forever while clicking the button again — with nothing in CloudWatch to explain it. That's the same silent-remote-failure mode docs/runbooks/portal-oauth-deploy-prep.md is written to prevent, arriving through the one door the handler doesn't watch.

The integration test only exercises error=access_denied. Minimum fix: treat access_denied as cancelled, and everything else as a logged failure.

2. Endpoints::from_env() is live in the deployed Lambda, contradicting its own doc comment — packages/prices-api/src/portal/auth/discord.rs:113

AppConfig::from_env() calls it unconditionally in every build including --features lambda, so DISCORD_API_BASE and DISCORD_AUTHORIZE_URL are honoured in production. The comment above it claims "there is no deployed configuration in which these are attacker- or operator-reachable", but nothing enforces that — a Lambda env var is settable by anyone holding lambda:UpdateFunctionConfiguration, and by any future compute-stack.ts edit.

Setting DISCORD_API_BASE points exchange_code's POST — which carries client_secret and the authorization code in the form body — at an arbitrary host. That exfiltrates the client secret without a single GetSecretValue call to show up in CloudTrail.

Smaller second consequence: authorize_url is the only non-literal input to Location on the login path, and redirect()'s location.parse().expect(...) (mod.rs:436) panics into a 500 if that value contains a byte invalid in a header.

Suggest gating the overrides behind #[cfg(not(feature = "lambda"))], or refusing a non-https://discord.com base when the lambda feature is on.

3. A malformed callback answers 502 discord_unavailable, letting an anonymous caller manufacture 5xx — packages/prices-api/src/portal/auth/mod.rs:277

A callback that verifies its state but carries neither code nor error is a client-side malformation, yet it is routed through refuse_discord and returns 502 with code: "discord_unavailable" and a warn reading stage="callback shape".

The routes are keyless by design and throttled at 10 req/s, so anyone can call /auth/login and then request /auth/callback?state=<their own> to drive the api-handler's 5xx rate — polluting exactly the alarm surface task 0204 is building right now, and making a real Discord outage indistinguishable from a script.

A 400 with the invalid_state/invalid_query envelope would be the honest answer; the pending cookie can still be dropped alongside it.

**1. Every Discord OAuth error was reported as "cancelled", and none was
logged.** The branch was `if query.error.is_some()`, so `invalid_scope` — what
Discord returns when the Developer Portal registration has drifted from
`discord::SCOPE` — landed every visitor on "Sign-in cancelled." with nothing in
CloudWatch. That is the same drift the token-response scope check exists to
catch, arriving by the one door that check never sees, and it presents as every
visitor changing their mind forever.

`access_denied` alone is now a cancellation: silent, unchanged page. Everything
else, including values Discord may invent later, is a logged failure landing on
`?signin=failed`, which the portal renders as its own state. The `error` value
is attacker-controlled, so it reaches the log truncated and stripped of
anything outside printable ASCII, and it never reaches the URL at all — both
landing states are literals.

**2. `Endpoints::from_env()` was live in the deployed Lambda.** Setting
`DISCORD_API_BASE` moved the endpoint in a `--features lambda` build, proved by
running one. `exchange_code` then posts `client_secret` and the authorization
`code` to a host of the setter's choosing, which exfiltrates the secret with no
`GetSecretValue` of their own in CloudTrail — the Lambda does its usual read and
posts the result out. `lambda:UpdateFunctionConfiguration` is enough, and that
is a permission distinct from `UpdateFunctionCode`.

The overrides are now compiled out under the `lambda` feature. The comment that
claimed "no deployed configuration in which these are attacker- or
operator-reachable" is replaced with what the code enforces; `compute-stack.ts`
setting neither is now a second line rather than the only one.

`PORTAL_OAUTH_SECRET_FILE` is gated the same way, and it deserves it more than
it first appears: the same permission attaches LAYERS, whose contents unpack to
`/opt`. One permission therefore places a chosen file and points this at it,
handing over `session_signing_key` and with it the ability to mint a session for
any Discord ID — worse than the endpoint override, not milder.

`redirect` no longer `expect`s its `Location`. The authorize URL is the one
target not built from literals alone, and a value carrying a newline panicked
the task: no response written, connection dropped, `curl` reporting `000`, and
on Lambda an invocation error for what is a configuration fault.

**3. A malformed callback answered `502 discord_unavailable`.** These routes are
keyless and throttled at 10 req/s, so anyone could call `/auth/login`, take the
`state` and replay it with no `code` to manufacture 5xx — polluting the alarms
0204 is building and making a real Discord outage indistinguishable from a
script. Now a `400` with `invalid_query`; the pending cookie still goes, because
`state` has verified by then. The `cfg(test)` `Action::TestOther` arm follows.

Every new behaviour is declared as a property a mutation can break — the gap
that let all three ship. Eight mutations, eight caught. The logging one needed
the decision extracted into `refuse_oauth_error` so a test can drive it with a
capturing subscriber: "there is a `warn!` here" is exactly the claim that
survives its own deletion.
…lper

CI failed on `cargo test --workspace` with `missing fields portal_endpoints and
portal_oauth in initializer of AppConfig` at `tests/common/mod.rs:18` — a file
that does not exist on this branch. Task 0119 merged into `develop` twelve
commits ago and added it, and a `pull_request` build tests the MERGE of the
branch with its target rather than the branch alone. Green locally, red in CI,
and correctly so: the merge result is what would land.

`portal_enabled` is false in that helper, so the portal routes answer an empty
404 whatever the credentials say; `None` and the default endpoints are the shape
every non-portal test wants.

Third time an `AppConfig` field has broken test literals — 0119 hit it with
`portal_enabled` in c7bf795, this branch hit it twice. `tests/common` is the
structural answer for the suites 0119 owns; the portal suites still spell the
struct out, which is a cost worth noticing rather than one worth a refactor
mid-review.

Verified on the merge result rather than on either side: the exact four CI Rust
steps (fmt, check, the clippy set 0119 extended to include prices-api, and
`test --workspace` — 89 suites), the Nx targets, the Lambda build, synth, all
three OpenAPI guards, and the 61-assertion adversarial suite. The three review
fixes still hold.
@adamkoot
adamkoot force-pushed the feat/0186_discord-sign-in-identity-only branch from add38a3 to 0866f9e Compare August 18, 2026 12:46
Task 0119 merged into `develop` with `ValidatedQuery`/`ValidatedPath`/
`ValidatedJson` — opt-in wrappers so every rejection answers in the
`ErrorEnvelope` voice rather than axum's `text/plain`. The data handlers adopted
them; the portal routes, written on a branch cut before that landed, did not. So
this is drift the merge created rather than anything either side got wrong.

It matters more here than the "consistency" framing suggests. A `text/plain`
rejection on these routes is read by task 0185's bundle, whose `getJson` reports
a non-JSON body as "could not reach the portal backend" — the failure mode that
file is explicit about, because it is also what a broken CloudFront behaviour
ordering looks like. A caller's own duplicated query key would therefore present
to them as an outage, and to anyone reading the page as a routing regression.

Measured before and after on `?code=a&code=b`:

    before  400  text/plain          Failed to deserialize query string: …
    after   400  application/json    {"code":"invalid_query", …}

Both handlers, both directions pinned: reverting either to plain `Query` fails
the new test. Valid queries are unaffected — `/auth/login` still redirects,
`?action=signin` still redirects, `?action=issue` still refuses at 400.

Every field on both query types stays `Option`, so an unknown key is still
ignored rather than rejected. Discord may add parameters to a callback, and a
sign-in that broke because of one would be self-inflicted.
…edger

The ledger was written after my own review passes and before karczuRF's, so it
claimed five fixes where there are now nine, and said nothing about the finding
that mattered most: the endpoint overrides being live in the deployed Lambda.

Adds F6-F9 and a section the previous version had no reason to contain — what
the mutation sweeps did NOT find. None of the three external findings would have
been caught by any of them, because each concerned behaviour never declared as a
property: 'only a cancellation is silent', 'the Lambda ignores the overrides',
'a client error is not a 5xx'. A sweep proves the properties you wrote are
enforced and says nothing about the ones you did not think to write. Recording
that bounds the method for whoever reads these numbers next and might otherwise
read 24-of-25 as coverage.
…w states

Two gaps the last two changes opened and nothing covered.

**`ValidatedQuery` must not out-rank task 0183's gate.** The extractor rejects
before the handler but after routing; the gate is a layer, so it runs first.
That ordering is the only reason a malformed query on a CLOSED portal still
answers an empty 404 rather than a 400 naming `invalid_query` — which would say
"this route exists and parses input", the disclosure the gate is built to
prevent. Nothing about `ValidatedQuery`'s own contract guarantees it; it is a
property of how the two compose in `portal::apply`, and it now has a test.
Moving the gate behind the merge fails three tests.

**The docs did not know about `?signin=failed`.** Worth more than tidiness on
the runbook side: scope drift is now caught in two places that fire at different
moments. A registration asking for LESS than the code requests is refused by
Discord at the authorize step and never returns a code — `invalid_scope`, the
new landing state, a log line, and no token exchange. One asking for MORE gets
as far as the granted-scope check. An operator debugging drift needs to know
which log line points at which, and the runbook exists for exactly that failure.

The README symptom table gains the states the review fixes introduced:
`invalid_scope`, the cancelled/failed split, `invalid_query`, and
`sign_in_misconfigured`.
@adamkoot
adamkoot merged commit 6da4a30 into develop Aug 18, 2026
3 checks passed
@adamkoot
adamkoot deleted the feat/0186_discord-sign-in-identity-only branch August 18, 2026 13:34
adamkoot added a commit that referenced this pull request Aug 18, 2026
0185 (#218) and 0186 (#220) are both merged to develop, so archive them and
promote 0187, whose prerequisite is the session cookie 0186 issues.

0185 closes with six of seven criteria; the deploy-on-merge one was withdrawn
with Adam on 2026-08-14. 0186 closes with nine of ten: the session surviving
CloudFront is archived open on 0205's deploy, since the four sign-in routes sit
at depth 3 and the deployed gateway maps only depth 1-2. Neither spawns a
backlog task — every follow-up already has one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants