Auth Login Smoke Test #1276
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auth Login Smoke Test | |
| # Verifies the auth login contract by building and running the Go backend | |
| # AND kc-agent in dev mode. No external dependencies — everything runs | |
| # against localhost. | |
| # | |
| # #6590 contract (enforced by #8107): the refreshed JWT is delivered | |
| # EXCLUSIVELY via the HttpOnly `kc_auth` cookie. The JSON body carries | |
| # only `{ refreshed: true, onboarded }` — any `token` field in the body | |
| # would be an XSS/extension-readable leak and must fail the smoke test. | |
| # | |
| # #10398 contract: ALL frontend fetch calls to kc-agent (LOCAL_AGENT_URL / | |
| # LOCAL_AGENT_HTTP_URL) must go through agentFetch() or fetchWithRetry() | |
| # which inject the KC_AGENT_TOKEN. Raw fetch() to agent URLs bypasses auth | |
| # and produces silent 401 errors on every kc-agent endpoint. | |
| # | |
| # What it checks: | |
| # 1. /health returns JSON with status and oauth_configured fields | |
| # 2. GET /auth/github (dev mode, no OAuth client) sets a kc_auth cookie | |
| # 3. POST /auth/refresh (cookie + CSRF header, NO Authorization) returns | |
| # - response body with { refreshed: true, onboarded } | |
| # - Set-Cookie: kc_auth=... (refreshed JWT) | |
| # - NO `token` field in the body | |
| # 4. kc-agent /health is reachable (no auth required) | |
| # 5. /api/agent/token returns the shared KC_AGENT_TOKEN | |
| # 6. kc-agent rejects requests without the agent token (401) | |
| # 7. kc-agent accepts requests with the correct agent token (200) | |
| # 8. kc-agent rejects unauthenticated WebSocket upgrades | |
| # 9. /auth/refresh rejects expired/invalid cookies (not silent 200) | |
| # 10. /api/agent/token requires authentication (no token leak) | |
| # 11. No raw fetch() calls to agent URLs bypass agentFetch/fetchWithRetry | |
| # | |
| # The test uses DEV_MODE (no GitHub OAuth credentials) so the backend | |
| # auto-creates a dev-user on /auth/github. This takes ~60 seconds. | |
| on: | |
| schedule: | |
| # Every hour | |
| - cron: "0 * * * *" | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| issues: write | |
| concurrency: | |
| group: auth-login-smoke | |
| cancel-in-progress: true | |
| jobs: | |
| auth-smoke: | |
| if: github.repository == 'kubestellar/console' | |
| name: Auth Login Smoke | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| env: | |
| KC_AGENT_TOKEN: smoke-test-agent-token-do-not-use-in-prod | |
| steps: | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: go.mod | |
| # ── 1. Build the Go backend and kc-agent ───────────────────── | |
| - name: Build backend and kc-agent | |
| run: | | |
| go build -o console-bin ./cmd/console | |
| go build -o kc-agent-bin ./cmd/kc-agent | |
| # ── 2. Start kc-agent with token auth ──────────────────────── | |
| - name: Start kc-agent | |
| run: | | |
| DEV_MODE=true \ | |
| ./kc-agent-bin --port 8585 & | |
| echo $! > /tmp/kc-agent.pid | |
| # ── 3. Wait for kc-agent health ────────────────────────────── | |
| - name: Wait for kc-agent health | |
| run: | | |
| MAX_WAIT_SECONDS=30 | |
| for i in $(seq 1 "$MAX_WAIT_SECONDS"); do | |
| HTTP_CODE=$(curl -sS --max-time 2 -o /dev/null -w '%{http_code}' \ | |
| http://127.0.0.1:8585/health 2>/dev/null || echo "000") | |
| if [ "$HTTP_CODE" = "200" ]; then | |
| echo "kc-agent healthy after ${i}s" | |
| exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "::error::kc-agent did not become healthy within ${MAX_WAIT_SECONDS}s" | |
| exit 1 | |
| # ── 4. Test kc-agent rejects unauthenticated requests ──────── | |
| - name: Test kc-agent rejects unauthenticated requests | |
| run: | | |
| echo "Testing kc-agent rejects requests without token..." | |
| HTTP_CODE=$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' \ | |
| http://127.0.0.1:8585/deployments 2>/dev/null) | |
| if [ "$HTTP_CODE" != "401" ]; then | |
| echo "::error::kc-agent /deployments returned $HTTP_CODE without auth — expected 401" | |
| exit 1 | |
| fi | |
| echo "kc-agent correctly rejected unauthenticated request (401)" | |
| echo "Testing kc-agent rejects wrong token..." | |
| HTTP_CODE=$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' \ | |
| -H "Authorization: Bearer wrong-token-value" \ | |
| http://127.0.0.1:8585/deployments 2>/dev/null) | |
| if [ "$HTTP_CODE" != "401" ]; then | |
| echo "::error::kc-agent /deployments returned $HTTP_CODE with wrong token — expected 401" | |
| exit 1 | |
| fi | |
| echo "kc-agent correctly rejected wrong token (401)" | |
| # ── 5. Test kc-agent accepts correct token ─────────────────── | |
| - name: Test kc-agent accepts correct agent token | |
| run: | | |
| echo "Testing kc-agent accepts correct agent token..." | |
| HTTP_CODE=$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' \ | |
| -H "Authorization: Bearer ${KC_AGENT_TOKEN}" \ | |
| http://127.0.0.1:8585/deployments 2>/dev/null) | |
| if [ "$HTTP_CODE" = "401" ]; then | |
| echo "::error::kc-agent /deployments returned 401 with correct token — agent token auth is broken" | |
| exit 1 | |
| fi | |
| echo "kc-agent accepted correct token (HTTP $HTTP_CODE)" | |
| # ── 6. Start backend in dev mode ───────────────────────────── | |
| - name: Start backend | |
| run: | | |
| JWT_SECRET="smoke-test-$(openssl rand -hex 16)" \ | |
| DEV_MODE=true \ | |
| ./console-bin --dev --port 8081 & | |
| echo $! > /tmp/backend.pid | |
| # ── 7. Wait for backend health ────────────────────────────── | |
| - name: Wait for backend health | |
| run: | | |
| MAX_WAIT_SECONDS=60 | |
| for i in $(seq 1 "$MAX_WAIT_SECONDS"); do | |
| RESP=$(curl -sS --max-time 2 http://localhost:8081/health 2>/dev/null || true) | |
| STATUS=$(echo "$RESP" | jq -r '.status // empty' 2>/dev/null || echo "") | |
| if [ "$STATUS" = "ok" ]; then | |
| echo "Backend healthy (status=ok) after ${i}s" | |
| exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "::error::Backend did not reach status=ok within ${MAX_WAIT_SECONDS}s" | |
| exit 1 | |
| # ── 8. Test /health returns JSON with expected fields ──────── | |
| - name: Test /health endpoint | |
| run: | | |
| echo "Checking /health endpoint..." | |
| HEALTH_RESP=$(curl -sS --max-time 5 http://localhost:8081/health) | |
| echo "Response: $HEALTH_RESP" | |
| STATUS=$(echo "$HEALTH_RESP" | jq -r '.status // empty') | |
| if [ -z "$STATUS" ]; then | |
| echo "::error::/health response missing 'status' field" | |
| exit 1 | |
| fi | |
| echo "status=$STATUS" | |
| # In dev mode with no OAuth creds, oauth_configured should be false | |
| OAUTH=$(echo "$HEALTH_RESP" | jq -r '.oauth_configured // empty') | |
| echo "oauth_configured=$OAUTH" | |
| echo "/health OK" | |
| # ── 9. Dev-mode login via /auth/github ─────────────────────── | |
| - name: Get dev-mode auth cookie | |
| run: | | |
| echo "Calling GET /auth/github (dev mode login)..." | |
| COOKIE_JAR=/tmp/cookies.txt | |
| HTTP_CODE=$(curl -sS --max-time 5 \ | |
| -o /dev/null -w '%{http_code}' \ | |
| -c "$COOKIE_JAR" \ | |
| http://localhost:8081/auth/github) | |
| echo "HTTP status: $HTTP_CODE (expect 307 redirect)" | |
| # Extract the kc_auth cookie value | |
| KC_AUTH=$(grep 'kc_auth' "$COOKIE_JAR" | awk '{print $NF}') | |
| if [ -z "$KC_AUTH" ]; then | |
| echo "::error::Dev-mode login did not set kc_auth cookie" | |
| echo "Cookie jar contents:" | |
| cat "$COOKIE_JAR" | |
| exit 1 | |
| fi | |
| echo "Got kc_auth cookie (${#KC_AUTH} chars)" | |
| echo "$KC_AUTH" > /tmp/kc_auth_token.txt | |
| # ── 10. Test /api/agent/token returns the shared secret ────── | |
| # This is the endpoint the frontend calls after login to get the | |
| # kc-agent token. It must return the same KC_AGENT_TOKEN that | |
| # kc-agent was started with. If this breaks, every agentFetch() | |
| # call sends the wrong (or empty) token → 401 on all agent data. | |
| - name: Test /api/agent/token endpoint | |
| run: | | |
| COOKIE_JAR=/tmp/cookies.txt | |
| echo "Testing /api/agent/token..." | |
| AGENT_TOKEN_RESP=$(curl -sS --max-time 5 \ | |
| -b "$COOKIE_JAR" \ | |
| -H "X-Requested-With: XMLHttpRequest" \ | |
| http://localhost:8081/api/agent/token) | |
| echo "Response: $AGENT_TOKEN_RESP" | |
| RETURNED_TOKEN=$(echo "$AGENT_TOKEN_RESP" | jq -r '.token // empty') | |
| if [ -z "$RETURNED_TOKEN" ]; then | |
| echo "::error::/api/agent/token returned empty token — frontend will not be able to auth with kc-agent" | |
| exit 1 | |
| fi | |
| if [ "$RETURNED_TOKEN" != "$KC_AGENT_TOKEN" ]; then | |
| echo "::error::/api/agent/token returned a different token than KC_AGENT_TOKEN — backend/agent token mismatch" | |
| echo "Expected: ${KC_AGENT_TOKEN:0:8}..." | |
| echo "Got: ${RETURNED_TOKEN:0:8}..." | |
| exit 1 | |
| fi | |
| echo "/api/agent/token returns correct KC_AGENT_TOKEN" | |
| # ── 11. End-to-end: token from backend works on kc-agent ───── | |
| # Simulates the full frontend flow: get token from backend, use | |
| # it to call kc-agent. This is the exact path that broke in | |
| # #10398/#10407 — raw fetch() bypassed agentFetch() and sent the | |
| # wrong token (OAuth JWT instead of agent token). | |
| - name: Test end-to-end agent token flow | |
| run: | | |
| COOKIE_JAR=/tmp/cookies.txt | |
| # Get token from backend (like frontend does after login) | |
| E2E_TOKEN=$(curl -sS --max-time 5 \ | |
| -b "$COOKIE_JAR" \ | |
| -H "X-Requested-With: XMLHttpRequest" \ | |
| http://localhost:8081/api/agent/token | jq -r '.token') | |
| # Use that token to call kc-agent (like agentFetch does) | |
| echo "Testing kc-agent with token from /api/agent/token..." | |
| HTTP_CODE=$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' \ | |
| -H "Authorization: Bearer ${E2E_TOKEN}" \ | |
| http://127.0.0.1:8585/deployments 2>/dev/null) | |
| if [ "$HTTP_CODE" = "401" ]; then | |
| echo "::error::END-TO-END FAILURE: token from /api/agent/token was rejected by kc-agent (401)" | |
| echo "This means the backend and kc-agent are using different tokens." | |
| exit 1 | |
| fi | |
| echo "End-to-end agent token flow PASSED (HTTP $HTTP_CODE)" | |
| # ── 12. Test /auth/refresh contract (#6590) ────────────────── | |
| - name: Test /auth/refresh contract | |
| run: | | |
| COOKIE_JAR=/tmp/cookies.txt | |
| REFRESH_COOKIE_JAR=/tmp/cookies-after-refresh.txt | |
| echo "Testing POST /auth/refresh..." | |
| REFRESH_RESP=$(curl -sS --max-time 5 \ | |
| -X POST \ | |
| -b "$COOKIE_JAR" \ | |
| -c "$REFRESH_COOKIE_JAR" \ | |
| -H "X-Requested-With: XMLHttpRequest" \ | |
| http://localhost:8081/auth/refresh) | |
| echo "Response: $REFRESH_RESP" | |
| # #6590 CONTRACT: response body MUST NOT contain a `token` field. | |
| HAS_TOKEN=$(echo "$REFRESH_RESP" | jq 'has("token")') | |
| if [ "$HAS_TOKEN" != "false" ]; then | |
| echo "::error::CONTRACT VIOLATION: /auth/refresh response includes 'token' field — this is the #6590 XSS leak that #8107 fixed. Refreshed JWT must live in the HttpOnly kc_auth cookie only." | |
| echo "Response was: $REFRESH_RESP" | |
| exit 1 | |
| fi | |
| echo "token field absent from body (correct)" | |
| # #6590 CONTRACT: response body MUST contain `onboarded` (boolean) | |
| HAS_ONBOARDED=$(echo "$REFRESH_RESP" | jq 'has("onboarded")') | |
| if [ "$HAS_ONBOARDED" != "true" ]; then | |
| echo "::error::CONTRACT VIOLATION: /auth/refresh response missing 'onboarded' field" | |
| echo "Response was: $REFRESH_RESP" | |
| exit 1 | |
| fi | |
| echo "onboarded field present" | |
| # #6590 CONTRACT: response body MUST contain `refreshed: true` | |
| HAS_REFRESHED=$(echo "$REFRESH_RESP" | jq -r '.refreshed // empty') | |
| if [ "$HAS_REFRESHED" != "true" ]; then | |
| echo "::error::CONTRACT VIOLATION: /auth/refresh response missing 'refreshed' field" | |
| echo "Response was: $REFRESH_RESP" | |
| exit 1 | |
| fi | |
| echo "refreshed field present" | |
| # #6590 CONTRACT: Set-Cookie must refresh the kc_auth cookie. | |
| REFRESHED_KC_AUTH=$(grep -E $'(^|\t)kc_auth\t' "$REFRESH_COOKIE_JAR" 2>/dev/null | awk -F'\t' '{print $NF}' | tail -n1) | |
| if [ -z "$REFRESHED_KC_AUTH" ]; then | |
| echo "::error::CONTRACT VIOLATION: /auth/refresh did not set a new kc_auth cookie. The refreshed JWT must be delivered exclusively via the HttpOnly cookie." | |
| echo "Cookie jar contents:" | |
| cat "$REFRESH_COOKIE_JAR" || true | |
| exit 1 | |
| fi | |
| echo "kc_auth cookie refreshed (${#REFRESHED_KC_AUTH} chars)" | |
| # #6590 CONTRACT: the refreshed kc_auth cookie must be HttpOnly. | |
| if ! grep -E '^#HttpOnly_.*[[:space:]]kc_auth[[:space:]]' "$REFRESH_COOKIE_JAR" >/dev/null 2>&1; then | |
| echo "::error::CONTRACT VIOLATION: refreshed kc_auth cookie is NOT HttpOnly. The #8107 XSS fix requires Set-Cookie: kc_auth=...; HttpOnly." | |
| echo "Cookie jar contents:" | |
| cat "$REFRESH_COOKIE_JAR" || true | |
| exit 1 | |
| fi | |
| echo "kc_auth cookie is HttpOnly (correct)" | |
| echo "" | |
| echo "Auth login smoke test PASSED" | |
| # ── 13. Test kc-agent WebSocket rejects without token ───────── | |
| # Covers gap #3: getWsAuthParams returns no auth subprotocol when | |
| # the token is missing from localStorage. Without the bearer | |
| # subprotocol, the WebSocket upgrade must be rejected. If it's | |
| # accepted, unauthenticated users can stream live data. | |
| - name: Test kc-agent WebSocket rejects unauthenticated upgrade | |
| run: | | |
| echo "Testing WebSocket upgrade without token..." | |
| # kc-agent listens for WS upgrades on /ws. Without the auth | |
| # subprotocol, the upgrade handshake should be rejected | |
| # (HTTP 401 or 403). | |
| WS_CODE=$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' \ | |
| -H "Connection: Upgrade" \ | |
| -H "Upgrade: websocket" \ | |
| -H "Sec-WebSocket-Version: 13" \ | |
| -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ | |
| http://127.0.0.1:8585/ws 2>/dev/null) | |
| if [ "$WS_CODE" = "101" ]; then | |
| echo "::error::kc-agent accepted WebSocket upgrade WITHOUT auth token — unauthenticated WS connections should be rejected" | |
| exit 1 | |
| fi | |
| echo "kc-agent correctly rejected unauthenticated WebSocket upgrade (HTTP $WS_CODE)" | |
| echo "Testing WebSocket upgrade with correct token..." | |
| # A successful WS upgrade (101) keeps the connection open, so curl | |
| # will hit --max-time and exit with code 28 (timeout). Temporarily | |
| # disable errexit so the non-zero curl exit doesn't kill the step. | |
| # The `|| true` pattern inside command substitution is unreliable | |
| # under bash -e in some CI environments. | |
| set +e | |
| WS_CODE_AUTH=$(curl -sS --max-time 5 --connect-timeout 3 \ | |
| -o /dev/null -w '%{http_code}' \ | |
| -H "Connection: Upgrade" \ | |
| -H "Upgrade: websocket" \ | |
| -H "Sec-WebSocket-Version: 13" \ | |
| -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ | |
| -H "Sec-WebSocket-Protocol: bearer.${KC_AGENT_TOKEN}" \ | |
| http://127.0.0.1:8585/ws 2>/dev/null) | |
| WS_CURL_RC=$? | |
| set -e | |
| echo "curl exit=$WS_CURL_RC http_code=$WS_CODE_AUTH" | |
| # 101 = upgrade accepted, or any non-401 = token was accepted. | |
| # curl exit 28 (timeout) is expected for a successful WS upgrade | |
| # because the connection stays open until --max-time fires. | |
| if [ "$WS_CODE_AUTH" = "401" ]; then | |
| echo "::error::kc-agent rejected WebSocket upgrade WITH correct token — WS token auth is broken" | |
| exit 1 | |
| fi | |
| echo "kc-agent accepted WebSocket upgrade with correct token (HTTP $WS_CODE_AUTH)" | |
| # ── 14. Test /auth/refresh rejects expired/invalid cookies ──── | |
| # Covers gap #4: If /auth/refresh accepts garbage cookies and | |
| # returns 200, the silent-catch in the "Refresh Now" handler | |
| # masks a real auth failure. The endpoint must return 401 for | |
| # invalid sessions so the frontend knows the refresh failed. | |
| - name: Test /auth/refresh rejects invalid cookie | |
| run: | | |
| echo "Testing /auth/refresh with no cookie..." | |
| NO_COOKIE_CODE=$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' \ | |
| -X POST \ | |
| -H "X-Requested-With: XMLHttpRequest" \ | |
| http://localhost:8081/auth/refresh) | |
| if [ "$NO_COOKIE_CODE" = "200" ]; then | |
| echo "::error::/auth/refresh returned 200 with NO cookie — should require valid session" | |
| exit 1 | |
| fi | |
| echo "/auth/refresh correctly rejected no-cookie request (HTTP $NO_COOKIE_CODE)" | |
| echo "Testing /auth/refresh with garbage cookie..." | |
| GARBAGE_CODE=$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' \ | |
| -X POST \ | |
| -b "kc_auth=garbage-invalid-jwt-token-value" \ | |
| -H "X-Requested-With: XMLHttpRequest" \ | |
| http://localhost:8081/auth/refresh) | |
| if [ "$GARBAGE_CODE" = "200" ]; then | |
| echo "::error::/auth/refresh returned 200 with garbage cookie — invalid JWTs must be rejected" | |
| exit 1 | |
| fi | |
| echo "/auth/refresh correctly rejected garbage cookie (HTTP $GARBAGE_CODE)" | |
| # ── 15. Test /api/agent/token requires authentication ───────── | |
| # Covers gap #1: If /api/agent/token returns a token to | |
| # unauthenticated requests, anyone can steal the agent shared | |
| # secret. It must require a valid session cookie. | |
| - name: Test /api/agent/token requires auth | |
| run: | | |
| echo "Testing /api/agent/token without cookie..." | |
| NO_AUTH_CODE=$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' \ | |
| -H "X-Requested-With: XMLHttpRequest" \ | |
| http://localhost:8081/api/agent/token) | |
| if [ "$NO_AUTH_CODE" = "200" ]; then | |
| echo "::error::/api/agent/token returned 200 without auth — agent token would leak to unauthenticated users" | |
| exit 1 | |
| fi | |
| echo "/api/agent/token correctly requires auth (HTTP $NO_AUTH_CODE)" | |
| # ── 16. Static analysis: no raw fetch() to agent URLs ────────── | |
| # Catches the exact regression from #10398: someone adds a fetch() | |
| # call to LOCAL_AGENT_HTTP_URL or LOCAL_AGENT_URL without going | |
| # through agentFetch() or fetchWithRetry(). These calls silently | |
| # skip the KC_AGENT_TOKEN header and produce 401s on every | |
| # kc-agent endpoint. | |
| - name: Check for raw fetch to agent URLs | |
| run: | | |
| echo "Scanning for raw fetch() calls to kc-agent URLs..." | |
| # Match: fetch(`${LOCAL_AGENT_HTTP_URL} or fetch(`${LOCAL_AGENT_URL} | |
| # Match: fetch(LOCAL_AGENT_HTTP_URL or fetch(LOCAL_AGENT_URL | |
| # Match: fetch(`http://127.0.0.1:8585 | |
| # Exclude: agentFetch, fetchWithRetry, test files, comments | |
| # /health is intentionally public (no auth required, used for | |
| # agent discovery) so raw fetch() to /health is allowed. | |
| VIOLATIONS=$(grep -rn \ | |
| -e 'fetch(`${LOCAL_AGENT_HTTP_URL}' \ | |
| -e 'fetch(`${LOCAL_AGENT_URL}' \ | |
| -e 'fetch(LOCAL_AGENT_HTTP_URL' \ | |
| -e 'fetch(LOCAL_AGENT_URL' \ | |
| -e 'fetch(`http://127\.0\.0\.1:8585' \ | |
| web/src/ \ | |
| --include='*.ts' --include='*.tsx' \ | |
| | grep -v 'agentFetch' \ | |
| | grep -v 'fetchWithRetry' \ | |
| | grep -v '__tests__' \ | |
| | grep -v '\.test\.' \ | |
| | grep -v '\.spec\.' \ | |
| | grep -v '// ' \ | |
| | grep -v 'shared\.ts' \ | |
| | grep -v '/health' \ | |
| || true) | |
| if [ -n "$VIOLATIONS" ]; then | |
| echo "::error::Found raw fetch() calls to kc-agent URLs that bypass agentFetch()." | |
| echo "These will produce 401 Unauthorized errors because they don't include the KC_AGENT_TOKEN header." | |
| echo "" | |
| echo "Violations:" | |
| echo "$VIOLATIONS" | |
| echo "" | |
| echo "Fix: replace fetch() with agentFetch() from hooks/mcp/shared.ts" | |
| exit 1 | |
| fi | |
| echo "No raw fetch() calls to agent URLs found (correct)" | |
| # ── 17. Static analysis: no raw WebSocket to agent URL ───── | |
| # Catches WebSocket connections that bypass getWsAuthParams. | |
| # Without the auth subprotocol, kc-agent rejects the upgrade. | |
| - name: Check for raw WebSocket to agent URLs | |
| run: | | |
| echo "Scanning for WebSocket connections that bypass getWsAuthParams..." | |
| # Match: new WebSocket( with agent URL patterns, excluding | |
| # getWsAuthParams calls and test files. | |
| # Filter on the code portion only (not the file path), otherwise | |
| # filenames like localAgentChat.ts trigger false positives. | |
| WS_VIOLATIONS=$(grep -rn \ | |
| -e 'new WebSocket(' \ | |
| web/src/ \ | |
| --include='*.ts' --include='*.tsx' \ | |
| | awk 'BEGIN{IGNORECASE=1} { code=$0; sub(/^[^:]+:[0-9]+:/, "", code); if (code ~ /(LOCAL_AGENT|agent|8585)/) print $0 }' \ | |
| | grep -v 'getWsAuthParams' \ | |
| | grep -v '__tests__' \ | |
| | grep -v '\.test\.' \ | |
| | grep -v '\.spec\.' \ | |
| | grep -v '// ' \ | |
| | grep -v 'useActiveUsers' \ | |
| || true) | |
| if [ -n "$WS_VIOLATIONS" ]; then | |
| echo "::error::Found WebSocket connections to kc-agent that bypass getWsAuthParams()." | |
| echo "These will be rejected by kc-agent because they don't include the auth subprotocol." | |
| echo "" | |
| echo "Violations:" | |
| echo "$WS_VIOLATIONS" | |
| echo "" | |
| echo "Fix: call getWsAuthParams() from lib/utils/wsAuth.ts and pass its protocols to new WebSocket()" | |
| exit 1 | |
| fi | |
| echo "No raw WebSocket connections to agent URLs found (correct)" | |
| # ── 19. Cleanup ────────────────────────────────────────────── | |
| - name: Stop backend and kc-agent | |
| if: always() | |
| run: | | |
| if [ -f /tmp/backend.pid ]; then | |
| kill "$(cat /tmp/backend.pid)" 2>/dev/null || true | |
| fi | |
| if [ -f /tmp/kc-agent.pid ]; then | |
| kill "$(cat /tmp/kc-agent.pid)" 2>/dev/null || true | |
| fi | |
| # ── 20. Alert on failure ───────────────────────────────────── | |
| - name: Create issue on failure | |
| if: failure() | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const title = `Auth smoke test failed — login or agent token contract may be broken`; | |
| const body = [ | |
| '## Auth Smoke Test Failure', | |
| '', | |
| `**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, | |
| `**Time:** ${new Date().toISOString()}`, | |
| '', | |
| 'The auth smoke test failed against a local backend + kc-agent in dev mode.', | |
| '', | |
| '### What this test covers', | |
| '', | |
| '**OAuth login contract (#6590, enforced by #8107):**', | |
| '- `/auth/refresh` body contains `{ refreshed: true, onboarded }` only', | |
| '- Refreshed JWT is in the HttpOnly `kc_auth` cookie, NOT in the response body', | |
| '', | |
| '**Agent token contract (#10398, #10407):**', | |
| '- kc-agent rejects requests without `KC_AGENT_TOKEN` (401)', | |
| '- kc-agent accepts requests with the correct token', | |
| '- `/api/agent/token` returns the same token kc-agent was started with', | |
| '- `/api/agent/token` requires authentication (no token leak)', | |
| '- End-to-end: token from backend → kc-agent auth succeeds', | |
| '- kc-agent rejects unauthenticated WebSocket upgrades', | |
| '- `/auth/refresh` rejects expired/invalid cookies', | |
| '- No raw `fetch()` calls to agent URLs bypass `agentFetch()`', | |
| '', | |
| '### Action required', | |
| '1. Check the workflow run logs for the specific failure step', | |
| '2. If **agent token**: verify `KC_AGENT_TOKEN` env var flows from startup-oauth.sh → backend → `/api/agent/token` → frontend → kc-agent', | |
| '3. If **raw fetch**: replace `fetch()` with `agentFetch()` from `hooks/mcp/shared.ts`', | |
| '4. If **OAuth login**: verify `/auth/refresh` contract (see #6590)', | |
| '5. If **WebSocket auth**: verify `getWsAuthParams()` is called and kc-agent validates the bearer subprotocol', | |
| '6. If **agent token leak**: verify `/api/agent/token` middleware requires a valid session cookie', | |
| ].join('\n'); | |
| const existing = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| labels: 'auth-smoke-failure', | |
| per_page: 1, | |
| }); | |
| if (existing.data.length > 0) { | |
| console.log(`Issue already open: #${existing.data[0].number}`); | |
| return; | |
| } | |
| await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title, | |
| body, | |
| labels: ['auth-smoke-failure', 'priority/critical'], | |
| }); |