Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/components/backend/authenticator/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@ Implements the API declared in [PRD section 7.1](./PRD.md#71-public-api-surface)

Exchange response contract (the load-bearing part): `200` + `X-Gateway-Jwt: Bearer <jwt>` + `Cache-Control: max-age = min(authz_cache_max_age, jwt_exp - now - 60 s)`; `401` (no/expired session) + `Cache-Control: no-store`; any other status is treated by the gateway as "authenticator unavailable" and fails closed.

Callback failure contract (issue #2032): `GET /auth/callback` is a browser-facing IdP redirect target, so its failures redirect (`302`) to `default_return_to` with a fixed `auth_error=<reason>` query parameter instead of answering problem+json the user cannot act on. Reasons: `state_expired` (unknown, expired -- the 300 s login-state TTL -- or already-consumed state), `idp_error` (the IdP redirected back with `error=`), `invalid_callback` (missing `code`/`state`), `exchange_failed` (code exchange / id_token validation), `access_denied` (no tenant resolved, no matching person in Identity, or a view-as override naming an unknown person -- still a denial, never a fallback to the caller). The reason strings are a fixed vocabulary -- nothing IdP- or caller-supplied reaches the `Location` header. The SPA consumes `auth_error`, restarts the login for the retryable reasons behind a retry loop guard, and shows an error screen for `access_denied` or repeated failures. Rate-limit (`429`) and internal (`5xx`) responses stay problem+json.

### 3.4 Internal Dependencies

| Dependency Module | Interface Used | Purpose |
Expand Down
4 changes: 3 additions & 1 deletion docs/components/backend/authenticator/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ Defined in the [parent backend PRD](../specs/PRD.md) as `cpt-insightspec-actor-o

The system **MUST** implement OIDC authorization code flow with PKCE as a confidential client. The authenticator **MUST** generate `state`, `nonce`, and PKCE verifier per login attempt and validate them on callback. The browser **MUST NOT** receive or transmit the IdP code, ID token, access token, or refresh token at any point.

A failed callback **MUST NOT** dead-end the browser on an error document: `/auth/callback` is an IdP redirect target with no page loaded, so every browser-facing failure (expired, unknown, or replayed `state`; IdP-reported error; missing parameters; code-exchange failure; a denied person) **MUST** redirect (302) back to the SPA with a fixed `auth_error=<reason>` query parameter, allowing the SPA to restart the login from scratch (issue #2032). Rate-limit (429) and internal (5xx) responses stay problem+json.

The new session token issued at the end of a successful callback **MUST** be generated server-side from a CSPRNG and **MUST NOT** be derived from, or equal to, any value present in the incoming request (cookies, headers, query). Any `__Host-sid` cookie present on the `/auth/callback` request **MUST** be ignored; if its value maps to a live session in Redis, that session **MUST** be revoked before the new session is created. This prevents session fixation where an attacker plants a known token before the victim logs in.

At login the system **MUST** resolve the authenticated person via Identity Service (`sub` to `person_id` plus tenant memberships) and **MUST** fetch access-control claims once, from the permissions service when it exists; until then the configured `authenticator.default_roles` apply.
Expand Down Expand Up @@ -552,7 +554,7 @@ If Redis is unreachable, `/internal/authz` and `/auth/*` mutations **MUST** fail
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/auth/login` | Start OIDC flow; 302 to IdP. Optional `__override=<email>` view-as target, honored only when `override_enabled` (5.16). |
| GET | `/auth/callback` | OIDC callback; creates session + linked JWT; sets cookie; 302 to SPA. |
| GET | `/auth/callback` | OIDC callback; creates session + linked JWT; sets cookie; 302 to SPA. Failures also 302 to the SPA, with `auth_error=<reason>` (5.1). |
| POST | `/auth/refresh` | Rotate cookie, extend session TTL; return `{expires_at, refresh_at}`. |
| POST | `/auth/logout` | Revoke current session; clear cookie; return RP-logout URL. |
| GET | `/auth/me` | Current user, tenants, plus `{expires_at, refresh_at}`; `impersonator_email` on view-as sessions (5.16). |
Expand Down
2 changes: 1 addition & 1 deletion docs/components/backend/authenticator/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"operationId": "authenticator.callback",
"responses": {
"302": {
"description": "Redirect to the SPA with the session cookie set"
"description": "Redirect to the SPA: with the session cookie set on success, or with `auth_error=<reason>` (state_expired, idp_error, invalid_callback, exchange_failed, access_denied) on a failed login so the SPA can restart the flow"
}
},
"summary": "Complete login: exchange the code and set the session cookie",
Expand Down
2 changes: 1 addition & 1 deletion src/backend/clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ doc-valid-idents = [
# ─── Clippy default list (preserved verbatim) ────────────────────
"..",
# ─── Auth & identity protocols ───────────────────────────────────
"OAuth", "OAuth2", "OIDC", "OpenID", "PKCE",
"IdP", "OAuth", "OAuth2", "OIDC", "OpenID", "PKCE",
"JWT", "JWTs", "JWS", "JWE",
# ─── Wire protocols & RPC ────────────────────────────────────────
"gRPC", "HTTPS", "TLS", "mTLS", "WebSocket", "WebSockets",
Expand Down
4 changes: 0 additions & 4 deletions src/backend/services/authenticator/src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@

use toolkit_canonical_errors::resource_error;

/// Failures resolving / creating the request's internal person.
#[resource_error("gts.cf.insight.authenticator.person.v1~")]
pub struct PersonError;

/// OIDC handshake failures (state/nonce/exchange/id_token validation).
#[resource_error("gts.cf.insight.authenticator.oidc.v1~")]
pub struct OidcError;
Expand Down
104 changes: 73 additions & 31 deletions src/backend/services/authenticator/src/api/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use serde::Deserialize;
use uuid::Uuid;

use crate::api::AppState;
use crate::api::error::{OidcError, PersonError, SessionError};
use crate::api::error::{OidcError, SessionError};
use crate::audit::AuditEvent;
use crate::cookie;
use crate::identity::PersonResolution;
Expand Down Expand Up @@ -140,6 +140,33 @@ pub struct CallbackParams {
state: Option<String>,
#[serde(default)]
error: Option<String>,
/// The IdP's human-readable detail (e.g. Entra's `AADSTS…` codes) — the
/// only place the failure cause survives now that the browser gets a
/// redirect instead of a problem body.
#[serde(default)]
error_description: Option<String>,
}

/// Bounce a failed callback back into the SPA (#2032). The browser arrives
/// here on an IdP redirect with no page loaded, so problem+json would dead-end
/// the login on raw JSON. Redirect to `default_return_to` with a fixed
/// `auth_error=<reason>` instead — the SPA restarts the login (loop-guarded)
/// or shows an error screen. `reason` must be one of the fixed codes; nothing
/// IdP- or caller-supplied may reach the Location header.
fn login_error_redirect(default_return_to: &str, reason: &str) -> Response {
let sep = if default_return_to.contains('?') {
'&'
} else {
'?'
};
build_response(
StatusCode::FOUND,
vec![(
LOCATION.clone(),
format!("{default_return_to}{sep}auth_error={reason}"),
)],
Body::empty(),
)
}

/// Complete login: validate state, exchange the code, guard against session
Expand All @@ -155,16 +182,19 @@ pub async fn callback(
Query(params): Query<CallbackParams>,
) -> Response {
if let Some(err) = params.error {
return OidcError::invalid_argument()
.with_field_violation("error", err, "IDP_ERROR")
.create()
.into_response();
// Client-reachable log path: strip control characters so the
// IdP-supplied values cannot forge log lines, and cap the lengths.
let sanitize =
|v: &str| -> String { v.chars().filter(|c| !c.is_control()).take(200).collect() };
tracing::warn!(
error = %sanitize(&err),
error_description = %sanitize(params.error_description.as_deref().unwrap_or("")),
"IdP reported an error at /auth/callback"
);
return login_error_redirect(&state.cfg.default_return_to, "idp_error");
}
let (Some(code), Some(oidc_state)) = (params.code, params.state) else {
return OidcError::invalid_argument()
.with_field_violation("state", "missing code or state", "MISSING")
.create()
.into_response();
return login_error_redirect(&state.cfg.default_return_to, "invalid_callback");
};

// Layer-2 bucket keyed by the presented `state`
Expand All @@ -187,10 +217,10 @@ pub async fn callback(
let login_state = match state.sessions.take_login_state(&oidc_state).await {
Ok(Some(ls)) => ls,
Ok(None) => {
return OidcError::invalid_argument()
.with_field_violation("state", "unknown or expired state", "STATE_MISMATCH")
.create()
.into_response();
// Expired (the 300 s login-state TTL), unknown, or already-consumed
// (replayed callback) state. A fresh login fixes all three, so
// bounce to the SPA instead of dead-ending (#2032).
return login_error_redirect(&state.cfg.default_return_to, "state_expired");
}
Err(e) => return internal_problem("login_state_take", &e),
};
Expand All @@ -212,10 +242,7 @@ pub async fn callback(
error = format!("{e:#}"),
"oidc code exchange / id_token validation failed"
);
return OidcError::invalid_argument()
.with_field_violation("code", "token exchange failed", "EXCHANGE_FAILED")
.create()
.into_response();
return login_error_redirect(&state.cfg.default_return_to, "exchange_failed");
}
};

Expand All @@ -232,10 +259,7 @@ pub async fn callback(
email = %idp.identity.email,
"login denied: id_token carried no tenant and no default_tenant_id is set"
);
return PersonError::permission_denied()
.with_reason("tenant_unresolved")
.create()
.into_response();
return login_error_redirect(&state.cfg.default_return_to, "access_denied");
}

// Session-fixation guard: never reuse an incoming session; revoke any live
Expand Down Expand Up @@ -275,10 +299,7 @@ pub async fn callback(
"idp_sub": idp.identity.sub,
}),
});
return PersonError::permission_denied()
.with_reason("unknown_person")
.create()
.into_response();
return login_error_redirect(&state.cfg.default_return_to, "access_denied");
}
Err(e) => return internal_problem("person_resolution", &e),
};
Expand Down Expand Up @@ -488,12 +509,12 @@ async fn resolve_override(
"override_email": target_email,
}),
});
Err(Box::new(
PersonError::permission_denied()
.with_reason("override_unknown_person")
.create()
.into_response(),
))
// Denied, never a fallback to the caller (PRD 5.16) — but still a
// browser-facing callback failure, so it bounces like the rest.
Err(Box::new(login_error_redirect(
&state.cfg.default_return_to,
"access_denied",
)))
}
Err(e) => Err(Box::new(internal_problem("person_resolution", &e))),
}
Expand Down Expand Up @@ -1598,4 +1619,25 @@ mod tests {
let token = format!("aGVhZGVy.{payload}.c2ln");
assert_eq!(jwt_exp(&token), Some(4_000_000_000));
}

fn location_of(resp: &Response) -> String {
resp.headers()
.get(LOCATION)
.and_then(|h| h.to_str().ok())
.unwrap_or_default()
.to_owned()
}

#[test]
fn login_error_redirect_bounces_into_the_spa() {
let resp = login_error_redirect("/", "state_expired");
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(location_of(&resp), "/?auth_error=state_expired");
}

#[test]
fn login_error_redirect_appends_to_an_existing_query() {
let resp = login_error_redirect("/app?tab=home", "access_denied");
assert_eq!(location_of(&resp), "/app?tab=home&auth_error=access_denied");
}
}
5 changes: 4 additions & 1 deletion src/backend/services/authenticator/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ fn register_auth_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router
.public()
.no_content_response(
StatusCode::FOUND,
"Redirect to the SPA with the session cookie set",
"Redirect to the SPA: with the session cookie set on success, or \
with `auth_error=<reason>` (state_expired, idp_error, \
invalid_callback, exchange_failed, access_denied) on a failed \
login so the SPA can restart the flow",
)
Comment on lines 87 to 93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)src/backend/services/authenticator/src/api/mod.rs$|docs/components/backend/authenticator/openapi\.json$|PRD|DESIGN' || true

echo "== target route registration =="
if [ -f src/backend/services/authenticator/src/api/mod.rs ]; then
  nl -ba src/backend/services/authenticator/src/api/mod.rs | sed -n '1,140p'
fi

echo "== auth error/response mentions in repository =="
rg -n "auth_error|state_expired|idp_error|invalid_callback|exchange_failed|access_denied|429|problem|json|no_content_response|content_response|callback" src backend docs 2>/dev/null | head -n 250

Repository: constructorfabric/insight

Length of output: 6468


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)src/backend/services/authenticator/src/api/mod.rs$|docs/components/backend/authenticator/openapi\.json$|PRD|DESIGN' || true

echo "== target route registration =="
if [ -f src/backend/services/authenticator/src/api/mod.rs ]; then
  nl -ba src/backend/services/authenticator/src/api/mod.rs | sed -n '1,160p'
fi

echo "== relevant docs/PRD/design mentions =="
for f in $(git ls-files | rg '(^|/)(PRD|DESIGN|README|.*\.md)$'); do
  if rg -q "callback|auth_error|state_expired|idp_error|invalid_callback|exchange_failed|access_denied|429|internal problem|problem.*json|rate limit" "$f" 2>/dev/null; then
    echo "--- $f ---"
    rg -n -C 2 "callback|auth_error|state_expired|idp_error|invalid_callback|exchange_failed|access_denied|429|internal problem|problem.*json|rate limit" "$f" || true
  fi
done

echo "== OpenAPI callback endpoints =="
if [ -f docs/components/backend/authenticator/openapi.json ]; then
  python3 - <<'PY'
import json
from pathlib import Path
p=Path('docs/components/backend/authenticator/openapi.json')
data=json.loads(p.read_text())
for name,pathitem in data.get('paths',{}).items():
    for m,(op,) in [(k,v) for k,v in pathitem.items() if k in {'get','post','put','patch','delete'}]:
        if 'callback' in str(pathitem.get('operationId','')).lower() or 'callback' in str(pathitem).lower():
            print(f"{name} {m}: {pathitem.get('operationId')} responses={list(pathitem.get('responses',{}).keys())}")
            if '302' in pathitem.get('responses',{}):
                print(pathitem['responses']['302'])
PY
fi

echo "== response registration declarations =="
rg -n "no_content_response|content_response|problem|json|status_code|ResponseBuilder|responses\\(" src/backend/services/authenticator src/backend 2>/dev/null | head -n 300

echo "== behavioral check for OpenAPI 302 only vs other possible code-emitted responses =="
python3 - <<'PY'
import re, json, subprocess
openapi_path='docs/components/backend/authenticator/openapi.json'
if not __import__('pathlib').Path(openapi_path).exists():
    print("openapi missing")
    raise SystemExit
data=json.load(open(openapi_path))
matches={}
for path,methods in data.get('paths',{}).items():
    for verb,op in methods.items():
        if isinstance(verb,str) and verb.lower() in {'get','post','put','patch','delete','options'}:
            oid=op.get('operationId')
            if oid and 'callback' in oid.lower():
                responses=[]
                for r in op.get('responses',{}):
                    status=int(r) if str(r).isdigit() else -1
                    responses.append((status,r))
                matches[oid]=(path,verb,responses)
print("callback operation responses:")
for k,v in sorted(matches.items()):
    print(k, v[0], v[1], "statuses", sorted(v[2], key=lambda x:x[0]))
print("has_429_callback=", any(429 in {s for s,r in v[2]} for v in matches.values()))
print("has_5xx_callback=", any(any(isinstance(s,int) and 500 <= s < 600 for s,_ in v[2]) for v in matches.values()))
PY

Repository: constructorfabric/insight

Length of output: 6468


🏁 Script executed:

#!/bin/bash
set -euo or
echo "== sed route registration =="
sed -n '1,180p' src/backend/services/authenticator/src/api/mod.rs

echo "== relevant PRD/DESIGN refs =="
for f in docs/components/backend/authenticator/PRD.md docs/components/backend/authenticator/DESIGN.md; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    grep -n -C 3 -i "callback\\|429\\|rate limit\\|problem\\|json\\|auth_error\\|state_expired\\|idp_error\\|invalid_callback\\|exchange_failed\\|access_denied" "$f" || true
  fi
done

echo "== openteapi callback response lines =="
python3 - <<'PY'
import json
from pathlib import Path
p=Path('docs/components/backend/authenticator/openapi.json')
d=json.loads(p.read_text())
for path, methods in d.get('paths', {}).items():
    for verb in ('get', 'post', 'put', 'patch', 'delete'):
        op=methods.get(verb)
        if not op:
            continue
        oid=op.get('operationId', '')
        if 'callback' in oid.lower() or 'callback' in path.lower():
            print(f"{verb.upper()} {path} operationId={oid}")
            for k,v in op.get('responses', {}).items():
                print(f"  {k} {v.get('description')}")
PY

Repository: constructorfabric/insight

Length of output: 206


🏁 Script executed:

#!/bin/bash
set -eu

echo "== sed route registration =="
sed -n '1,180p' src/backend/services/authenticator/src/api/mod.rs

echo "== relevant PRD/DESIGN refs =="
for f in docs/components/backend/authenticator/PRD.md docs/components/backend/authenticator/DESIGN.md; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    grep -n -C 3 -i "callback\\|429\\|rate limit\\|problem\\|json\\|auth_error\\|state_expired\\|idp_error\\|invalid_callback\\|exchange_failed\\|access_denied" "$f" || true
  fi
done

echo "== OpenAPI callback response lines =="
python3 - <<'PY'
import json
from pathlib import Path
p=Path('docs/components/backend/authenticator/openapi.json')
d=json.loads(p.read_text())
for path, methods in d.get('paths', {}).items():
    for verb in ('get', 'post', 'put', 'patch', 'delete'):
        op=methods.get(verb)
        if not op:
            continue
        oid=op.get('operationId', '')
        if 'callback' in oid.lower() or 'callback' in path.lower():
            print(f"{verb.upper()} {path} operationId={oid}")
            for k,v in op.get('responses', {}).items():
                print(f"  {k} {v.get('description')}")
PY

Repository: constructorfabric/insight

Length of output: 44650


Expose callback rate-limit and internal problem responses in OpenAPI.

The authenticator contract leaves /auth/callback as 302 only, while PRD.md and DESIGN.md specify that rate-limit (429) and internal server errors remain the canonical failure responses and serialize as problem+json. Register the callback’s 429 and internal problem responses with the operation builder and regenerate docs/components/backend/authenticator/openapi.json so generated OpenAPI consumers have the full contract.

📍 Affects 2 files
  • src/backend/services/authenticator/src/api/mod.rs#L87-L93 (this comment)
  • docs/components/backend/authenticator/openapi.json#L144-L149
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/authenticator/src/api/mod.rs` around lines 87 - 93,
Update the `/auth/callback` operation builder in
`src/backend/services/authenticator/src/api/mod.rs` to register the canonical
429 rate-limit and internal server-error problem+json responses alongside the
existing 302 response. Regenerate
`docs/components/backend/authenticator/openapi.json` so its callback operation
includes the same response definitions at lines 144-149.

.handler(handlers::callback)
.register(router, openapi);
Expand Down
13 changes: 13 additions & 0 deletions src/backend/services/authenticator/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,19 @@ impl AuthenticatorConfig {
anyhow::ensure!(!value.trim().is_empty(), "{name} is required (empty)");
}

// `default_return_to` lands verbatim in Location headers (login
// fallback and every `auth_error` bounce). A non-site-relative value
// would open-redirect on our own config, and a `#` fragment would hide
// `auth_error=` from the SPA's query parsing — defeating its login
// retry loop guard.
anyhow::ensure!(
self.default_return_to.starts_with('/')
&& !self.default_return_to.starts_with("//")
&& !self.default_return_to.contains('#')
&& !self.default_return_to.chars().any(char::is_control),
"default_return_to must be a site-relative path without a fragment"
Comment on lines +415 to +420

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject backslash-based scheme-relative redirects.

Line 416 accepts /\evil.example; browsers can normalize this to //evil.example, bypassing the // guard and restoring an external redirect. Reject backslashes and add this case to validation coverage.

Proposed fix
             self.default_return_to.starts_with('/')
                 && !self.default_return_to.starts_with("//")
+                && !self.default_return_to.contains('\\')
                 && !self.default_return_to.contains('#')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
anyhow::ensure!(
self.default_return_to.starts_with('/')
&& !self.default_return_to.starts_with("//")
&& !self.default_return_to.contains('#')
&& !self.default_return_to.chars().any(char::is_control),
"default_return_to must be a site-relative path without a fragment"
anyhow::ensure!(
self.default_return_to.starts_with('/')
&& !self.default_return_to.starts_with("//")
&& !self.default_return_to.contains('\\')
&& !self.default_return_to.contains('#')
&& !self.default_return_to.chars().any(char::is_control),
"default_return_to must be a site-relative path without a fragment"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/authenticator/src/config.rs` around lines 415 - 420,
Update the default_return_to validation in the relevant config validation method
to reject any backslash characters before accepting the site-relative path,
preventing values such as `/\evil.example` from bypassing the scheme-relative
redirect guard. Add validation coverage for this backslash-based redirect case
while preserving the existing fragment, control-character, and slash checks.

);

// Service tokens: if any service is registered, the token endpoint must
// know the `aud` it expects on assertions (its own URL). A registry
// entry with zero public keys can never authenticate — reject it early.
Expand Down
44 changes: 44 additions & 0 deletions src/backend/services/authenticator/tests/e2e_login_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,47 @@ async fn full_login_exchange_logout_loop() {
"401 must never be cached"
);
}

/// Failed callbacks bounce back into the SPA (#2032): the browser lands on
/// `/auth/callback` from an IdP redirect, so a problem+json answer would
/// dead-end the login on raw JSON. Every browser-facing failure must 302 to
/// `default_return_to` with a fixed `auth_error=<reason>` the SPA consumes to
/// restart the flow.
#[tokio::test]
#[ignore = "requires a running authenticator + fakeidp + Redis stack"]
async fn failed_callback_redirects_into_the_spa_with_auth_error() {
let auth_base = env("AUTH_BASE", "http://localhost:8083");
let http = client();

// Distinct `state` values per case AND per run: the per-state callback
// rate-limit bucket (5-burst, 10/min refill) must not couple these
// requests — or trip on rapid suite re-runs against the same stack.
let run = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or_default();
let cases = [
(
format!("code=x&state=e2e-auth-error-unknown-{run}"),
"/?auth_error=state_expired",
),
(
format!("error=access_denied&state=e2e-auth-error-idp-{run}"),
"/?auth_error=idp_error",
),
(
format!("state=e2e-auth-error-no-code-{run}"),
"/?auth_error=invalid_callback",
),
];
for (query, expected_location) in cases {
let resp = http
.get(format!("{auth_base}/auth/callback?{query}"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 302, "{query} must redirect");
let location = resp.headers()[reqwest::header::LOCATION].to_str().unwrap();
assert_eq!(location, expected_location, "for {query}");
}
}
10 changes: 8 additions & 2 deletions src/backend/services/authenticator/tests/e2e_override.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,13 +344,19 @@ async fn override_with_unknown_target_is_denied() {
let user = env("E2E_USER", "dev@company.nonpresent");
let http = client();

// The identity stub 404s emails prefixed `unknown-` (test seam).
// The identity stub 404s emails prefixed `unknown-` (test seam). The
// denial is an auth_error bounce back into the SPA (#2032), never a
// fallback to the caller's own identity.
let cb = login_flow(&http, &auth_base, &user, Some("unknown-nobody@example.com")).await;
assert_eq!(
cb.status(),
403,
302,
"an unknown override target must be denied"
);
assert_eq!(
cb.headers()[reqwest::header::LOCATION].to_str().unwrap(),
"/?auth_error=access_denied"
);
assert!(cookie_from(&cb).is_none(), "no session may be minted");
}

Expand Down
7 changes: 4 additions & 3 deletions src/backend/services/authenticator/tests/e2e_ratelimit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ async fn refresh_and_callback_buckets_trip_past_burst() {
let (status, _) = refresh(&http, &auth_base, &other, &other_csrf).await;
assert_eq!(status, 200, "another session must have its own bucket");

// 3. Callback bucket: hammering one (bogus) state flips from 400
// (unknown state) to 429 once the per-state bucket empties.
// 3. Callback bucket: hammering one (bogus) state flips from 302 (the
// unknown-state auth_error redirect, #2032) to 429 once the per-state
// bucket empties.
let mut saw_429 = false;
for _ in 1..=8 {
let resp = http
Expand All @@ -157,7 +158,7 @@ async fn refresh_and_callback_buckets_trip_past_burst() {
.await
.unwrap();
match resp.status().as_u16() {
400 => {}
302 => {}
429 => {
saw_429 = true;
break;
Expand Down
Loading