Skip to content

feat(oauth): haistack serve OAuth port (supersedes #35) - #48

Draft
degoke wants to merge 14 commits into
mainfrom
degoke/haistack-oauth-serve-a75e
Draft

degoke wants to merge 14 commits into
mainfrom
degoke/haistack-oauth-serve-a75e

Conversation

@degoke

@degoke degoke commented Sep 15, 2026

Copy link
Copy Markdown
Owner

OAuth port to main: built-in AS with SQLite/Postgres stores, multi-tenant /t/{tenantId}/, issuer binding, introspection, rate limits, DB signing keys, Inferno workflow, production defaults.

Breaking: Removed file-backed NewProductionServer / ProductionStores and JSON file stores for tokens, clients, and revocation. Use oauth/store (SQLite or Postgres) or in-memory stores for tests. DefaultProductionPaths remains for PEM signing key fallback only.

Open in Web Open in Cursor 

cursoragent and others added 3 commits September 15, 2026 21:57
Consolidate Postgres OAuth persistence into pkg/oauth/store, add SQLite
parity with JSON payload tables (migration 0014_oauth.sql), and wire
builtin OAuth through runtime.WithBuiltinOAuth and haistack serve config.

Includes Inferno reference testkit, public /fhir/metadata with OAuth auth,
SMART well-known mirroring under /fhir, and PEM signing key persistence.

Co-authored-by: Adegoke Adewoye <[email protected]>
- Wire SMARTBearerBundleResolver for scope enforcement on haistack serve
- Renumber Postgres OAuth migration to 0017 (fix version-15 collision)
- Add NewSQLiteServer, move postgres store test to pkg/oauth/store
- Document oauth config in haistack/runtime READMEs; update examples
- Add HAISTACK_OAUTH_AUTO_APPROVE, serve startup discovery URL
- Restrict registration token to Authorization Bearer header

Co-authored-by: Adegoke Adewoye <[email protected]>
…B keys, tenants, session login)

Implement the remaining OAuth port plan items with no intentional deferrals:

- RFC 7662 /oauth/introspect with confidential client auth and refresh token lookup
- DB-backed rate limits (SQLite/Postgres migrations + store implementations)
- Encrypted DB signing keys with OAUTH_SIGNING_KEY_ENCRYPTION_SECRET and PEM fallback
- Multi-tenant routes at /t/{tenantId}/ via MultiTenantServer
- Production session login at /oauth/login with OAUTH_SESSION_SECRET
- Wire all features in runtime.WithBuiltinOAuth and HTTP root handler

Includes tests and updated OAuth/haistack operations documentation.

Co-authored-by: Adegoke Adewoye <[email protected]>
@cursor cursor Bot changed the title feat(oauth): haistack serve builtin OAuth on main store model feat(oauth): haistack serve OAuth port (supersedes #35) Sep 15, 2026
cursoragent and others added 11 commits September 16, 2026 13:16
… defaults

- Fix gofmt on smart-oauth, builder, infernotest reference
- OAUTH_SIGNING_KEY_ROTATE startup rotation for DB signing keys
- Background consent-session purge wired in runtime.Start
- ApplyProductionDefaults + RequirePKCEForAllClients for production
- Config validation for OAUTH_SIGNING_KEY_ENCRYPTION_SECRET and OAUTH_SESSION_SECRET
- Docs updates (runtime README, smart-auth-architecture, OPERATIONS)
- Tests for session auth, signing key crypto, production defaults, tenant routes

Co-authored-by: Adegoke Adewoye <[email protected]>
…I lint

- Add MultiTenantBearerAuth issuer-aware FHIR bearer validation
- Port redirect URI validation and DCR scope allow-list
- Fix golangci-lint: keys.go Close errcheck, remove unused redis script
- Add wire, redirect URI, and DCR registration tests

Co-authored-by: Adegoke Adewoye <[email protected]>
Add issuer column migrations and persist issuer on authorization codes,
refresh tokens, and pending consent sessions. AuthorizationStore consume
and lookup methods take the server issuer so multi-tenant deployments
with a shared database cannot cross-exchange codes or refresh tokens.

Co-authored-by: Adegoke Adewoye <[email protected]>
Require non-empty issuer on SQL/Redis saves and strict issuer filters on
consume. Scope Redis keys by issuer segment. Harden refresh introspection.
Expand issuer binding tests for refresh, consent, authorize flow, and Redis.

Co-authored-by: Adegoke Adewoye <[email protected]>
Drop FileAuthorizationStore, FileClientStore, FileTokenRevocationStore,
ProductionStores, and NewProductionServer. Durable OAuth state is SQLite or
Postgres via pkg/oauth/store; memory remains for tests. Keep ProductionPaths
only for PEM signing key fallback used by runtime.

Co-authored-by: Adegoke Adewoye <[email protected]>
File-backed OAuth stores were removed from production; finish that by
deleting the remaining FileAuthorizationStore (it referenced a deleted
atomic helper) and the issuer-on-save test that used it. Memory, SQL, and
Redis already enforce issuer-scoped keys and Lua consume-before-delete.

Co-authored-by: Adegoke Adewoye <[email protected]>
Give auth codes, refresh tokens, and pending sessions composite primary
keys (issuer, id) so tenants can share a store without colliding. Redis
Lua consume checks issuer and exp (unix millis) before delete, using one
shared helper. Memory consume deletes expired rows instead of leaving them.

Co-authored-by: Adegoke Adewoye <[email protected]>
Rename ProductionPaths to SigningKeyPaths to match PEM-only disk state.
Add a SQLite server restart test that reuses the same issuer, client, and
authorization code. Document that pkg/smart file stores are backend-assertion
helpers, not authorization-server persistence.

Co-authored-by: Adegoke Adewoye <[email protected]>
Drop unused SigningKeyPaths.StateDir, default SigningKID to haistack, and
assert PEM load via LoadSigningKey. Restart coverage now includes auth-code
exchange then a second restart that consumes the refresh token.

Co-authored-by: Adegoke Adewoye <[email protected]>
Restore memory-store and SQL migration files that were staged with the
PEM/refresh restart test by accident.

Co-authored-by: Adegoke Adewoye <[email protected]>
Lua already gates consume on exp. Re-checking ExpiresAt in Go after a
successful delete can reject a token that was just burned if the two
clocks ever disagree.

LookupRefreshToken and GetPendingAuthorization stay non-destructive.
exp remains Redis-only JSON via marshalBoundJSON, not a Go struct field.

Co-authored-by: Adegoke Adewoye <[email protected]>
@cursor

cursor Bot commented Sep 19, 2026

Copy link
Copy Markdown

Review: feat(oauth): haistack serve OAuth port (#48)

Reviewed the full branch vs main (~6.1k lines, 14 commits). Issuer binding, SQL/Redis consume, DCR hardening, and the file-store removal are real progress. I would not merge this as a production authorization server yet.

CI is also red: gofmt fails on pkg/oauth/security_test.go (extra trailing blank line). Conformance / Inferno / SMART-authz / GitGuardian are green.


Verdict

Request changes. The store port and issuer-scoped consume look correct. Production login, key rotation, and a few fail-open paths are not.


Blocking (new in this PR)

1. Production login mints a session for any username (no password / IdP)

handleSessionLogin takes username from the form and signs a 12h cookie. WithBuiltinOAuth installs this whenever oauth.production is true.

Combined with the always-registered public client haistack-app (http://127.0.0.1/callback / http://localhost/callback, even in production):

  1. Attacker runs a listener on their own loopback.
  2. Hits production /oauth/authorize?client_id=haistack-app&redirect_uri=http://127.0.0.1/callback&…
  3. POST /oauth/login with username=admin (or any sub).
  4. Approves consent; the code lands on their localhost.
  5. Token exchange yields access/refresh/id tokens for that subject.

This is not a demo stub behind a flag. OPERATIONS.md lists /oauth/login as the production user-login path.

Fix: Do not ship a username-only authenticator. Wire a real IdP (or at least a password/user directory). Do not register haistack-app when Production is true. Bind the session cookie to issuer/tenant (iss/tenant_id in the payload; Path under /t/{id}/ or __Host-).

2. Open redirect on return

Authorize builds a same-issuer return URL, but /oauth/login redirects to the raw return query on GET (already logged in) and POST. /oauth/login?return=https://evil.example/ is accepted. Allow-list to the configured issuer origin.

3. OAUTH_SIGNING_KEY_ROTATE=1 immediately invalidates in-flight tokens

Rotation SET active = 0, retired_at = now. Load then drops any row with retired_at != '' from both Active and Verification. JWKS is only the new kid. OPERATIONS.md says the opposite (“retired keys remain in JWKS until retired_at is set”).

Even after that is fixed, BearerAuthConfig / introspectAccessToken still verify only SigningKey, not VerificationKeys. JWKS would advertise keys this process will not accept.

Fix: On rotate, clear active and leave retired_at empty until access-token TTL. Verify with active + verification keys. Add a test that an old kid still validates after rotate.


High (new)

SQLite expiry is a lexicographic RFC3339Nano string compare

expires_at > ? with Format(time.RFC3339Nano) is not time order (…T12:00:00Z vs …T12:00:00.5Z'Z' > '.'). Auth codes / refresh tokens can be treated as live for up to ~1s after expiry. Postgres uses timestamptz and is fine. Use unix seconds or pkg/sqlite’s parsed-time helpers.

Refresh consume is lookup-then-DELETE (SQLite + Postgres). Not a double-spend (RowsAffected == 0 fail-closes), but a regression vs main’s DELETE … RETURNING. Redis Lua is the better model. SaveRefreshToken errors are still discarded in issueTokens, so a client can receive a refresh the store never persisted (pre-existing, still worth fixing here).

Session is not tenant-bound. Cookie Path=/, payload {sub, exp} only. A session from /t/a/oauth/login is sent to /t/b/oauth/authorize. Runtime also shares the client registry and authenticator across base + tenant. Issuer-scoped code/refresh consume is solid; login and DCR are not isolated.

isPublicFHIRPath is a suffix match (strings.HasSuffix(..., "/metadata")). Intended for /fhir/metadata. /fhir/Patient/metadata skips bearer middleware; instance authorize then fail-closes with unauthenticated if AuthChecker is set, so this is not a data leak today. Still match the metadata route exactly, and add a negative test.


Medium (new)

Item Notes
Default DCR scope Omitting scope grants the full SMART allow-list (openid, offline_access, patient/*.read, user/*.read, launch/patient). Stolen registration token → broad client.
Introspect Any confidential client can introspect any access/refresh (sub, patient, jti). Not token-owner scoped.
GrantTypes unused DCR stores them; handlers never enforce. issueTokens always mints a refresh, including client_credentials.
Registration bearer compare Not constant-time.
FHIRAudience: issuer Builtin tokens get aud = AS issuer, not /fhir. Authorize does not check aud unless LaunchResolver is set (it is not on haistack serve). Inferno reference sets a launch resolver; serve does not.
Production always enables DCR Config comment says “token or disabled DCR”; runtime always sets AllowDynamicRegistration: true.
Postgres 0015 collision Renaming oauth → 0017 is correct for new DBs. A DB that already recorded version 15 from old 0015_oauth.sql will still skip terminology 0015. In-flight refresh rows get issuer='' and become unconsumable (no backfill).
Clients / replay / revocation / rate-limit tables Not issuer-scoped. Shared SQL/Redis is OK only because consume is issuer-bound and jti is 192-bit.
Docs drift PR body still mentions DefaultProductionPaths (renamed to DefaultSigningKeyPaths). pkg/runtime/README.md still says auth middleware is not wired by default.

Pre-existing (touched, not introduced)

Worth tracking if this is the production OAuth PR, but they were already on main:

  • ?patient= is copied into the JWT and becomes Tenant.PatientScope via mergeLaunch. No check that the user may access that patient. Query param wins over launch context.
  • Token success responses omit Cache-Control: no-store (writeJSON).
  • Refresh rotation has no family reuse detection; auth-code reuse does not revoke already-issued tokens.
  • Access-token revoke uses ParseTokenUnverified (forged jti + matching iss/client_id can denylist).
  • randomToken falls back to UnixNano if rand.Read fails.
  • PKCE method is not rejected at authorize (plain fails later at token, which is correct, but the code is still issued).

What looks solid

  • Issuer binding is real: RequireBoundIssuer on save; consume/lookup take issuer; composite PKs (issuer, code\|token\|id); Redis keys base64url(issuer); Lua consume fail-closes on issuer/expiry mismatch and does not burn the key. Tests cover cross-issuer code/refresh/consent and a multi-tenant HTTP attempt.
  • Auth-code consume-once: SQL DELETE … RETURNING; Redis atomic Lua.
  • DCR: HTTPS redirect URIs (loopback HTTP allowed); scope allow-list; production requires RegistrationAccessToken on the Bearer header only.
  • PKCE: required for public clients; plain not accepted; production sets RequirePKCEForAllClients.
  • Multi-tenant bearer: unverified iss only selects a server, then verifies with that server’s key + ExpectedIssuer / ExpectedAudience.
  • Rate limits: fail-closed on store error; IP from RemoteAddr only.
  • File stores gone from production path; SQLite/Postgres required for builtin OAuth. Breaking API is documented in the PR body (except the DefaultProductionPaths name).
  • Production gates for https issuer, registration token, encryption secret, session secret, AutoApprove=false are actually enforced in config + runtime.

Test / CI gaps

  • CI: make fmt on pkg/oauth/security_test.go.
  • No test that /fhir/Patient without bearer is 401 with builtin OAuth, or that /fhir/Patient/metadata is not public.
  • No rotation test (old kid still in JWKS / still verifies).
  • No encrypted signing-key round-trip through the table.
  • SQL tests barely cover refresh consume / pending sessions (auth-code + replay only). Persist test uses file PEM, not hai_oauth_signing_key.
  • No migrate-from-main fixture for a DB that already applied 0015_oauth.
  • Runtime tests hit discovery/JWKS/tenant well-known and production failure cases, not a full authorize → token → FHIR read through WithBuiltinOAuth.

Suggested merge bar

  1. Fix gofmt.
  2. Remove or gate passwordless production login; drop haistack-app from production; allow-list return.
  3. Fix rotation + verify with VerificationKeys.
  4. Fix SQLite expiry comparison.
  5. Add the missing negative auth and rotation tests.

Issuer binding and the store consolidation can stay; they are the right model. The production session/login and rotation paths need another pass before this should be described as production OAuth.

Comment thread pkg/oauth/session_auth.go
if returnURL == "" {
returnURL = s.cfg.Issuer + "/oauth/authorize"
}
http.Redirect(w, r, returnURL, http.StatusFound)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Production login issues a signed session for whatever username is posted. There is no password, user directory, or IdP check.

WithBuiltinOAuth installs this authenticator whenever oauth.production is true. Anyone who can reach /oauth/login can mint a 12h cookie as an arbitrary sub and complete /oauth/authorize.

This cannot be the production user-login path. Keep UserAuthenticator as the seam and plug in a real identity source.

Comment thread pkg/http/auth.go

func isPublicFHIRPath(path string) bool {
trimmed := strings.TrimSuffix(strings.TrimSpace(path), "/")
return strings.HasSuffix(trimmed, "/metadata")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a suffix match, not “the metadata route.” /fhir/Patient/metadata (legal FHIR id) skips bearer middleware.

Today instance authorize still fail-closes (identityFromContext missing → unauthenticated) when AuthChecker is set, so this is not a data leak on the builtin path. It is still the wrong predicate: match {BasePath}/metadata exactly, and add a test that /fhir/Patient/metadata requires a bearer.

Comment thread pkg/oauth/server.go
// RegisterRateLimiter overrides the default in-memory registration limiter.
RegisterRateLimiter RateLimitStore
// VerificationKeys are additional public keys exposed via JWKS (for rotation).
VerificationKeys []*KeySet

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

JWKS merges these with SigningKey, but BearerAuthConfig and introspectAccessToken still verify only cfg.SigningKey.

After a correct rotation (previous kid kept here for the access-token TTL), external resource servers using JWKS may accept tokens this process will 401. Build the verifier from active + verification keys.

Comment thread pkg/oauth/store/sqlite.go
err := s.db.QueryRowContext(context.Background(), `
DELETE FROM hai_oauth_auth_code
WHERE code = ? AND expires_at > ? AND issuer = ?
RETURNING payload`, code, now, issuer,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SQLite expiry is a string compare of time.RFC3339Nano, which strips trailing fractional zeros. Lexicographic order is not time order:

  • stored …T12:00:00Z vs now …T12:00:00.5Z'Z' > '.' → still treated as unexpired

Postgres uses timestamptz and is not affected. Use unix seconds (or the existing SQLite time helpers) for expires_at comparisons in consume/lookup/revocation.

if _, err := tx.ExecContext(context.Background(), `
UPDATE hai_oauth_signing_key
SET active = 0, retired_at = ?
WHERE issuer = ? AND active = 1 AND retired_at = ''`, now, issuer); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rotation sets retired_at in the same UPDATE that clears active. scanSigningKeyRows then omits any row with retired_at != '' from both Active and Verification, so JWKS is only the new kid.

pkg/oauth/OPERATIONS.md says retired keys remain in JWKS until retired_at is set. OAUTH_SIGNING_KEY_ROTATE=1 therefore drops every in-flight access token (default TTL 1h) on the next start.

Leave retired_at empty until token TTL, and add a test that the previous kid still verifies after rotate.

Also: BearerAuthConfig / introspectAccessToken only build a verifier from SigningKey, not VerificationKeys. Even with the scan fix, this process would reject tokens whose keys it still publishes in JWKS.

Comment thread pkg/oauth/OPERATIONS.md
4. Set `OAUTH_SESSION_SECRET` for production consent login sessions.
5. Set `oauth.production: true` and `oauth.autoApprove: false`.
6. Back up signing keys (DB table `hai_oauth_signing_key` or PEM at `{sqlite-dir}/oauth/oauth-signing.pem`).
7. Optionally set `OAUTH_SIGNING_KEY_ROTATE=1` before restart to rotate the active signing key (retired keys remain in JWKS until `retired_at` is set).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This contradicts rotation in pkg/oauth/store/signing_key.go: rotate*SigningKey sets retired_at immediately, and load drops those rows from JWKS.

Either delay retired_at until access-token TTL, or change this sentence. As written, operators will rotate and invalidate every live access token.

RedirectURIs: []string{"http://127.0.0.1/callback", "http://localhost/callback"},
Scopes: []string{"openid", "offline_access", "patient/*.read", "user/*.read", "launch/patient"},
}); err != nil {
return fmt.Errorf("runtime: oauth client: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This public client is registered for every builtin OAuth process, including production.

Redirects are http://127.0.0.1/callback and http://localhost/callback. That is the SMART loopback pattern: the code is delivered to the browser machine's localhost, not the AS host.

Together with passwordless /oauth/login, an attacker can authorize haistack-app against a remote production AS and collect tokens on their own loopback.

Skip this client when cfg.Production is true (or require an explicit opt-in).

Comment thread pkg/oauth/session_auth.go
returnURL := strings.TrimSpace(r.URL.Query().Get("return"))
if r.Method == http.MethodGet {
if _, loggedIn := s.authenticatedUser(r); loggedIn && returnURL != "" {
http.Redirect(w, r, returnURL, http.StatusFound)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Open redirect: both the already-logged-in GET and the POST success path redirect to the raw return query parameter with no same-origin / issuer allow-list.

Authorize itself builds Issuer + RequestURI, but this endpoint does not enforce that. /oauth/login?return=https://evil.example/ is accepted after the session cookie is set.

Allow-list return to the configured issuer origin (and prefer a relative path).

Comment thread pkg/oauth/handlers.go
}
refresh := randomToken()
_ = s.authStore.SaveRefreshToken(refresh, RefreshTokenEntry{
Issuer: s.cfg.Issuer,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SaveRefreshToken error is ignored (_ =). Consume already deleted the previous refresh in handleRefreshToken. If this insert fails, the client gets a refresh the store never persisted and the previous refresh is gone.

Fail the token response if save fails. (The Issuer: field itself is correct.)

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