Auth Login Smoke Test #86
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 | |
| # 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. | |
| # | |
| # 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 | |
| # | |
| # 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 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: go.mod | |
| # ── 1. Build the Go backend ────────────────────────────────── | |
| - name: Build backend | |
| run: go build -o console-bin ./cmd/console | |
| # ── 2. 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 | |
| # ── 3. Wait for health ─────────────────────────────────────── | |
| - name: Wait for backend health | |
| run: | | |
| MAX_WAIT_SECONDS=30 | |
| for i in $(seq 1 "$MAX_WAIT_SECONDS"); do | |
| if curl -sf http://localhost:8081/health > /dev/null 2>&1; then | |
| echo "Backend healthy after ${i}s" | |
| exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "::error::Backend did not become healthy within ${MAX_WAIT_SECONDS}s" | |
| exit 1 | |
| # ── 4. 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" | |
| # ── 5. Dev-mode login via /auth/github ─────────────────────── | |
| - name: Get dev-mode auth cookie | |
| run: | | |
| echo "Calling GET /auth/github (dev mode login)..." | |
| # Dev mode redirects and sets kc_auth HttpOnly cookie. | |
| # Use -c to capture the cookie jar, -L to NOT follow redirects | |
| # (we just need the Set-Cookie header). | |
| 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 | |
| # ── 6. Test /auth/refresh contract (#6590) ─────────────────── | |
| # The refreshed JWT is delivered via the HttpOnly kc_auth cookie | |
| # ONLY. The JSON body must NOT contain a `token` field — any such | |
| # field is readable by XSS/extension content scripts and is the | |
| # regression #8107 fixed. We drive this call exactly like the | |
| # frontend does: cookie jar + CSRF header, no Authorization. | |
| - 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. | |
| # Presence of `token` means the refreshed JWT is JS-readable, | |
| # which is the XSS leak that #8107 fixed. | |
| 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. | |
| # curl writes Netscape-format cookie jars. HttpOnly cookies are prefixed | |
| # with "#HttpOnly_" on the line (e.g. "#HttpOnly_localhost\tFALSE\t/\tTRUE\t...\tkc_auth\t<token>"). | |
| # We WANT the HttpOnly form — it proves the HttpOnly flag is set — so we | |
| # match the cookie name as a tab-separated field regardless of leading | |
| # host/HttpOnly prefix, then separately assert the HttpOnly line exists. | |
| 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. | |
| # curl's Netscape jar encodes this by prefixing the line with "#HttpOnly_". | |
| # Assert that a line starting with "#HttpOnly_" contains "kc_auth" — this | |
| # proves the Set-Cookie response included HttpOnly (the #8107 XSS fix). | |
| 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" | |
| # ── 7. Cleanup ─────────────────────────────────────────────── | |
| - name: Stop backend | |
| if: always() | |
| run: | | |
| if [ -f /tmp/backend.pid ]; then | |
| kill "$(cat /tmp/backend.pid)" 2>/dev/null || true | |
| fi | |
| # ── 8. Alert on failure ────────────────────────────────────── | |
| - name: Create issue on failure | |
| if: failure() | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const title = `Auth login smoke test failed — OAuth login contract may be broken`; | |
| const body = [ | |
| '## Auth Login Smoke Test Failure', | |
| '', | |
| `**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, | |
| `**Time:** ${new Date().toISOString()}`, | |
| '', | |
| 'The auth contract test failed against a local backend in dev mode.', | |
| 'This means the `/auth/refresh` endpoint is not returning the expected response shape.', | |
| '', | |
| '### #6590 contract (enforced by #8107)', | |
| 'The refreshed JWT must be delivered EXCLUSIVELY via the HttpOnly `kc_auth` cookie.', | |
| 'The JSON body carries only `{ refreshed: true, onboarded }`. Any `token` field in the body', | |
| 'is an XSS/extension-readable leak and MUST fail this smoke test.', | |
| '', | |
| '### Action required', | |
| '1. Check the workflow run logs above for the specific failure', | |
| '2. Verify /auth/refresh:', | |
| ' - body contains `refreshed: true` and `onboarded`', | |
| ' - body does NOT contain `token`', | |
| ' - response sets a fresh `kc_auth` HttpOnly cookie', | |
| '3. Run `go test ./pkg/api/handlers/ -run TestAuthRefreshContract` locally', | |
| '4. DO NOT "fix" this by re-adding `token` to the response body — that is the #6590 regression', | |
| ' and will be reverted.', | |
| ].join('\n'); | |
| // Don't create duplicate issues | |
| 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', 'ai-needs-human'], | |
| }); |