Skip to content

feat: add API Key authentication for GTFS-RT feed consumers#68

Open
ShinLiX wants to merge 2 commits into
OneBusAway:mainfrom
ShinLiX:feat/add_api_key_for_feed_consumers
Open

feat: add API Key authentication for GTFS-RT feed consumers#68
ShinLiX wants to merge 2 commits into
OneBusAway:mainfrom
ShinLiX:feat/add_api_key_for_feed_consumers

Conversation

@ShinLiX

@ShinLiX ShinLiX commented Mar 17, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds API key authentication for feed consumers on GET /gtfs-rt/vehicle-positions.

Previously, the GTFS-Realtime vehicle positions feed could be accessed without any feed-specific authentication. As requested in Milestone 2, we need an authentication for accessing the feed.

The implementation hashes raw API keys with SHA-256 before lookup, rejects missing/invalid/inactive keys, and updates last_used_at on successful requests.

Changes

  1. Added a new APIKey model to represent rows in the api_keys table.
  2. Added a new requireAPIKey middleware for feed access.
  3. Added hashAPIKey(raw string) string, which computes the SHA-256 hash of the provided raw key and returns the hex-encoded string.
  4. Added database migration to create api_keys table and a trigger/function pair to automatically maintain updated_at on updates.
  5. Updated seed_dev.sql to include a development API key entry for local manual testing,

Testing

Test file added:

  • api_key_auth_go:
    • TestHashAPIKey
    • TestRequireAPIKey_MissingHeader
    • TestRequireAPIKey_InvalidKey
    • TestRequireAPIKey_InactiveKey
    • TestRequireAPIKey_StoreFailure
    • TestRequireAPIKey_UpdateLastUsedFailure
    • TestRequireAPIKey_ValidKey
  • store_api_keys_test.go:
    • TestStore_CreateAndGetAPIKeyByHash
    • TestStore_UpdateAPIKeyLastUsed
    • TestStore_GetAPIKeyByHash_Inactive

Running go test ./...: All tests passed

Manual Testing with curl:

  • Active api key loaded by seed_dev.sql:
    curl -i -H "X-API-Key: dev-feed-key" http://localhost:8080/gtfs-rt/vehicle-positions": return status 200 OK
  • Incorrect api key:
    curl -i -H "X-API-Key: dev-feed-key-1234567890abcdef" http://localhost:8080/gtfs-rt/vehicle-positions: return 401 unauthorized

@aaronbrethorst aaronbrethorst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Amelia, this is a well-scoped feature — the SHA-256 key hashing, the APIKeyStore interface for testability, and the thorough test coverage across both the middleware and the store layer are all well done. The seed data with a pre-hashed dev key is a nice touch for local testing. A few things need attention before this can merge.

Critical

  1. Needs rebase on latest main — the branch is missing 000004_add_vehicle_received_index which has since landed. Your migration number (000005) is correct and won't need renumbering, but the branch needs to include 000004 for golang-migrate to run them in sequence.

Important

  1. UpdateAPIKeyLastUsed failure blocks feed access (api_key_auth.go:51-54). If the last_used_at update fails (transient DB error, connection hiccup), the middleware returns 500 and the feed consumer gets nothing — even though their API key was already validated. The last_used_at update is an audit convenience, not a security requirement. On a hot path like the GTFS-RT feed, this should not block the response. Log the error and proceed:

    if err := store.UpdateAPIKeyLastUsed(r.Context(), apiKey.ID); err != nil {
        slog.Error("failed to update api key last_used_at", "api_key_id", apiKey.ID, "error", err)
    }
  2. Missing slog logging on 500 responses (api_key_auth.go:42, api_key_auth.go:52). When GetAPIKeyByHash returns a non-pgx error, the handler returns 500 but doesn't log the error. The established pattern in this codebase (see handlers.go, auth.go) is to call slog.Error(...) before returning a 500 so operators can diagnose failures. Please add structured logging to both 500 paths.

  3. docker-compose.yml uses ${JWT_SECRET} from host environment (docker-compose.yml:26). The previous docker-compose had no JWT_SECRET set at all, and the app would fail on startup. Your fix is reasonable, but using ${JWT_SECRET} requires developers to set the variable in their host environment or a .env file, which is a friction change for anyone running docker compose up without extra setup. Consider using a hardcoded dev value (like other PRs have done) to keep local dev zero-config:

    JWT_SECRET: "this-is-a-local-dev-secret-1234567890"

Suggestions

  1. Missing trailing newlines in db/query.sql, migrations/000005_add_api_keys.down.sql, migrations/000005_add_api_keys.up.sql, and seed_dev.sql. Most editors and POSIX tools expect a trailing newline. Minor, but easy to fix while you're in there.

  2. The updated_at trigger (migrations/000005_add_api_keys.up.sql:12-23) is a valid approach, but the rest of the codebase handles updated_at at the application/SQL layer (SET updated_at = NOW() in queries). The trigger works fine, but the inconsistency could confuse future contributors who expect one pattern and find another. Not a blocker — just something to be aware of.

Strengths

  • SHA-256 key hashing: Raw keys are never stored, which is the right approach for API key security.
  • Complete test coverage: Seven middleware tests covering every branch (missing header, invalid key, inactive key, store failure, update failure, valid key) plus three integration tests on the store layer.
  • Clean interface design: APIKeyStore is minimal and follows the project's established pattern of handler-scoped interfaces.
  • Dev seed data: The pre-hashed dev-feed-key in seed_dev.sql with the raw key documented in a comment makes local testing frictionless.

Recommended Action

Request changes. Rebase on latest main, make the UpdateAPIKeyLastUsed failure non-blocking, and add slog logging to the 500 paths.

@ShinLiX ShinLiX force-pushed the feat/add_api_key_for_feed_consumers branch from 6c411eb to 567bcdf Compare March 28, 2026 04:16
@ShinLiX

ShinLiX commented Mar 28, 2026

Copy link
Copy Markdown
Contributor Author

Hi Aaron, thanks for your review! All issues mentioned are addressed:

  • rebased on latest main so migrations run in order
  • made UpdateAPIKeyLastUsed failure non-blocking
  • added slog.Error(...) logging for 500 paths
  • updated local Docker config to include a dev JWT_SECRET
  • fixed trailing newlines in touched SQL files
  • migration number changed to 7 as agreed

Thank you, please let me know if these work!

@ShinLiX ShinLiX requested a review from aaronbrethorst March 28, 2026 04:19

@aaronbrethorst aaronbrethorst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Amelia, thank you for the quick turnaround — you cleanly addressed every item from the last round. I verified each one:

  • Rebased / migration renumbered — migration is now 000007, and the branch picked up 000004/000005.
  • UpdateAPIKeyLastUsed is now non-blocking — it logs and proceeds (api_key_auth.go:53-55), with a test that locks the behavior in.
  • slog.Error on both 500 pathsapi_key_auth.go:43 is covered.
  • JWT_SECRET hardcoded dev value — exactly as suggested (docker-compose.yml:26).
  • Trailing newlines — all four touched SQL files now end with a newline.

The auth design itself is genuinely solid: SHA-256 hex hashing with a UNIQUE indexed key_hash, the errors.Is(err, pgx.ErrNoRows) 401-vs-500 split correctly surviving the %w wrap in the store, and the deliberate non-blocking timestamp update are all done right. Unfortunately one thing has slipped since you last pushed, and it's a hard blocker.

Critical Issues (1 found)

  1. The branch no longer merges — it conflicts with main. GitHub reports mergeable: CONFLICTING / mergeStateStatus: DIRTY. main has moved well past your branch point (PRs #55, #60, #63 and others landed since), and a test merge produces conflicts in three files:

    • db/query.sql
    • db/query.sql.go
    • main.go

    Your main.go change also re-adds and reorders the /api/v1/admin/users routes, which now collide with the user-management and user-vehicle work already on main. Additionally, main now contains 000006_add_trips and 000008_add_user_vehicles, neither of which is on this branch — so the rebase you did earlier is stale. Please rebase on the current main again, resolve the three conflicts (keeping your requireAPIKey wiring on the feed route and dropping the now-redundant admin-users reshuffle), and re-run cd db && sqlc generate so db/query.sql.go is regenerated rather than hand-merged.

Important Issues (3 found)

  1. The feed endpoint became authenticated, but the docs still present it as open (README.md:87, README.md:95). This is a breaking change for every existing consumer: GET /gtfs-rt/vehicle-positions now returns 401 without an X-API-Key header. The README table makes no mention of the requirement. Please document the header and add a curl example (the dev key dev-feed-key is right there in seed_dev.sql).

  2. Nothing tests that the feed route is actually protected (main.go:75). The existing feed tests call handleGetFeed(tracker) directly, bypassing the middleware entirely. If a future refactor drops the feedAuth(...) wrapper, the feed silently becomes public again and every test still passes. Add a test that exercises the wired route (or requireAPIKey(...)-wrapped handler) and asserts an unauthenticated request gets 401. For a security control, the wiring is the thing most worth pinning.

  3. The store's not-found error contract is untested (store.go:159-162, store_api_keys_test.go). The middleware's 401-vs-500 decision depends on GetAPIKeyByHash returning an error for which errors.Is(err, pgx.ErrNoRows) is true through the fmt.Errorf("...: %w", err) wrap. The middleware test injects a bare pgx.ErrNoRows via the mock, so it never exercises the real wrapped error. Add a store integration test: delete all rows, look up a missing hash, and require.True(t, errors.Is(err, pgx.ErrNoRows)). If someone ever swaps %w for %v, unknown keys would start returning 500 and no test would catch it.

Suggestions (5 found)

  1. Downgrade the last_used_at failure log from Error to Warn (api_key_auth.go:54). You deliberately tolerate this failure, so logging it at the same level as the genuinely-actionable 500 (api_key_auth.go:43) trains operators to ignore Error. A transient DB hiccup or a client disconnect (r.Context() cancellation) would also flood the error stream. slog.Warn keeps Error meaningful.

  2. last_used_at is written on every feed read. The feed is the highest-traffic, short-poll endpoint, so each poll becomes a write (plus the updated_at trigger fires). It's correctly non-fatal, but consider throttling — only update when last_used_at is older than, say, a few minutes — to avoid write amplification under load. Fine to defer, but worth a follow-up.

  3. Non-idiomatic and misleading comments in the tests:

    • store_api_keys_test.go:11-13 uses a Javadoc-style /** ... */ block; Go convention is // lines, and as written it's attached to the first test rather than the file. Convert to // and leave a blank line before the function.
    • api_key_auth_test.go:71 — the doc comment describes all six scenarios but sits on TestRequireAPIKey_MissingHeader. Scope it to that one function.
    • api_key_auth_test.go:81,96,127,170 restate the test name/assertion; either drop them or promote to proper per-function doc comments. (Your comment at :152 explaining the non-blocking intent is the model to follow — that one's great.)
  4. CreateAPIKey has no production caller (store.go, db/query.sql). Keys can currently only be minted via raw SQL/seed. Fine for this milestone, but worth a follow-up ticket for an admin endpoint to create/rotate keys.

  5. A couple of low-cost test additions: assert the updated_at trigger actually advances updated_at on update (store_api_keys_test.go:38 only checks last_used_at), and add a duplicate-hash insert test to exercise the UNIQUE constraint on key_hash.

Strengths

  • Correct, defensible auth design — raw keys never stored, hashed lookup on a UNIQUE indexed column, no secret-dependent branching to worry about timing-wise.
  • The %w error chain is righterrors.Is(err, pgx.ErrNoRows) survives the store's wrapping, which is the easy thing to get wrong here.
  • Thoughtful non-blocking designUpdateAPIKeyLastUsed failure logs and proceeds, with TestRequireAPIKey_UpdateLastUsedFailure_DoesNotBlockRequest and the inactive-key test asserting the update is skipped on the reject path.
  • Strong branch coverage on the middleware (missing/invalid/inactive/store-failure/update-failure/valid) plus store round-trip integration tests.
  • You implemented all five pieces of prior feedback exactly as requested, including the JWT_SECRET dev default and the non-blocking refactor.

Recommended Action

Request changes.

  1. Rebase on current main and resolve the three conflicts; regenerate db/query.sql.go with sqlc.
  2. Document the new X-API-Key requirement in the README.
  3. Add the route-protection test and the store not-found errors.Is test.
  4. Consider the suggestions (log level, comment cleanup) while you're in there.
  5. Re-request review after the rebase — once it merges cleanly and the two tests are in, this is ready.

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