From 1c479d60f72180afee4fc6d7e5ce8f85580a74a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 21:57:28 +0000 Subject: [PATCH 01/14] feat(oauth): haistack serve builtin OAuth on main store model 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 --- .github/workflows/inferno.yml | 73 +++++ cmd/haistack/command/integration_test.go | 18 +- cmd/haistack/internal/app/app.go | 13 + cmd/haistack/internal/config/config.go | 94 ++++++ cmd/haistack/internal/config/config_test.go | 53 ++++ cmd/inferno-reference/main.go | 32 ++ docs/smart-auth-architecture.md | 2 +- pkg/http/auth.go | 10 + pkg/http/oauth_wellknown_test.go | 34 ++ pkg/http/sync.go | 21 +- pkg/oauth/OPERATIONS.md | 37 +++ pkg/oauth/README.md | 17 +- pkg/oauth/handlers.go | 21 ++ pkg/oauth/keys.go | 55 ++++ pkg/oauth/postgres/server.go | 22 -- pkg/oauth/production.go | 16 +- pkg/oauth/server.go | 2 + pkg/oauth/store/apply.go | 54 ++++ pkg/oauth/store/doc.go | 5 + .../{postgres/store.go => store/postgres.go} | 6 +- pkg/oauth/store/sqlite.go | 290 ++++++++++++++++++ pkg/oauth/store/sqlite_test.go | 64 ++++ pkg/postgres/oauth_store_test.go | 4 +- pkg/runtime/builder.go | 9 + pkg/runtime/integration_test.go | 42 +++ pkg/runtime/oauth_builtin.go | 203 ++++++++++++ pkg/runtime/oauth_builtin_internal_test.go | 23 ++ pkg/runtime/oauth_builtin_test.go | 156 ++++++++++ pkg/runtime/wire.go | 8 + pkg/sqlite/migrations/0014_oauth.sql | 45 +++ pkg/testkit/infernotest/discovery.go | 150 +++++++++ .../infernotest/discovery_stu2_test.go | 73 +++++ pkg/testkit/infernotest/doc.go | 2 + pkg/testkit/infernotest/reference.go | 253 +++++++++++++++ pkg/testkit/infernotest/standalone_launch.go | 79 +++++ .../infernotest/standalone_launch_test.go | 41 +++ 36 files changed, 1988 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/inferno.yml create mode 100644 cmd/inferno-reference/main.go create mode 100644 pkg/http/oauth_wellknown_test.go create mode 100644 pkg/oauth/OPERATIONS.md delete mode 100644 pkg/oauth/postgres/server.go create mode 100644 pkg/oauth/store/apply.go create mode 100644 pkg/oauth/store/doc.go rename pkg/oauth/{postgres/store.go => store/postgres.go} (97%) create mode 100644 pkg/oauth/store/sqlite.go create mode 100644 pkg/oauth/store/sqlite_test.go create mode 100644 pkg/runtime/oauth_builtin.go create mode 100644 pkg/runtime/oauth_builtin_internal_test.go create mode 100644 pkg/runtime/oauth_builtin_test.go create mode 100644 pkg/sqlite/migrations/0014_oauth.sql create mode 100644 pkg/testkit/infernotest/discovery.go create mode 100644 pkg/testkit/infernotest/discovery_stu2_test.go create mode 100644 pkg/testkit/infernotest/doc.go create mode 100644 pkg/testkit/infernotest/reference.go create mode 100644 pkg/testkit/infernotest/standalone_launch.go create mode 100644 pkg/testkit/infernotest/standalone_launch_test.go diff --git a/.github/workflows/inferno.yml b/.github/workflows/inferno.yml new file mode 100644 index 0000000..1fa3afe --- /dev/null +++ b/.github/workflows/inferno.yml @@ -0,0 +1,73 @@ +name: Inferno + +on: + workflow_dispatch: + pull_request: + paths: + - "pkg/oauth/**" + - "pkg/http/**" + - "pkg/testkit/infernotest/**" + - "cmd/inferno-reference/**" + - ".github/workflows/inferno.yml" + +permissions: + contents: read + +jobs: + inferno-conformance: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run Inferno discovery and standalone launch tests + run: go test -count=1 -timeout 10m ./pkg/testkit/infernotest/... + + - name: Build reference host + run: go build -o inferno-reference ./cmd/inferno-reference + + - name: Smoke test reference host + run: | + ./inferno-reference & + pid=$! + trap 'kill $pid' EXIT + for i in $(seq 1 30); do + if curl -fsS -H 'Accept: application/json' http://127.0.0.1:8080/fhir/.well-known/smart-configuration >/tmp/smart-config.json; then + python3 - <<'PY' + import json, sys + cfg = json.load(open("/tmp/smart-config.json")) + required = [ + "authorization_endpoint", + "token_endpoint", + "capabilities", + "grant_types_supported", + "code_challenge_methods_supported", + ] + missing = [k for k in required if not cfg.get(k)] + if missing: + print("missing fields:", ", ".join(missing)) + sys.exit(1) + if "authorization_code" not in cfg.get("grant_types_supported", []): + sys.exit("grant_types_supported must include authorization_code") + if "S256" not in cfg.get("code_challenge_methods_supported", []): + sys.exit("code_challenge_methods_supported must include S256") + if "plain" in cfg.get("code_challenge_methods_supported", []): + sys.exit("code_challenge_methods_supported must not include plain") + if "sso-openid-connect" in cfg.get("capabilities", []): + for key in ("issuer", "jwks_uri"): + if not cfg.get(key): + sys.exit(f"{key} required when sso-openid-connect is advertised") + print("inferno reference host discovery OK") + PY + exit 0 + fi + sleep 1 + done + echo "reference host did not become ready" + exit 1 diff --git a/cmd/haistack/command/integration_test.go b/cmd/haistack/command/integration_test.go index 26391ea..1dc865e 100644 --- a/cmd/haistack/command/integration_test.go +++ b/cmd/haistack/command/integration_test.go @@ -3,6 +3,7 @@ package command_test import ( "context" "fmt" + "net" "net/http" "os" "path/filepath" @@ -29,7 +30,14 @@ func TestServeBuildsAndStartsSQLite(t *testing.T) { t.Fatalf("load config: %v", err) } ctx := context.Background() - rt, err := app.BuildRuntime(ctx, cfg, "127.0.0.1:0") + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + cfg.OAuth.IssuerURL = "http://" + addr + rt, err := app.BuildRuntime(ctx, cfg, addr) if err != nil { t.Fatalf("build runtime: %v", err) } @@ -48,6 +56,14 @@ func TestServeBuildsAndStartsSQLite(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("metadata status = %d", resp.StatusCode) } + smartResp, err := http.Get("http://" + rt.HTTPAddr().String() + "/fhir/.well-known/smart-configuration") + if err != nil { + t.Fatalf("GET smart-configuration: %v", err) + } + defer func() { _ = smartResp.Body.Close() }() + if smartResp.StatusCode != http.StatusOK { + t.Fatalf("smart-configuration status = %d", smartResp.StatusCode) + } for _, path := range []string{"/healthz", "/readyz"} { probe, err := http.Get("http://" + rt.HTTPAddr().String() + path) if err != nil { diff --git a/cmd/haistack/internal/app/app.go b/cmd/haistack/internal/app/app.go index e40b193..38f4529 100644 --- a/cmd/haistack/internal/app/app.go +++ b/cmd/haistack/internal/app/app.go @@ -120,6 +120,19 @@ func BuildRuntime(ctx context.Context, cfg config.Config, httpAddr string) (*run if httpAddr != "" { b.WithHTTP(httpAddr) } + if httpAddr != "" && cfg.OAuthEnabled() { + tenantID := cfg.Storage.SQLiteTenantID + if cfg.Storage.Driver == config.DriverPostgres { + tenantID = cfg.Storage.TenantID + } + b.WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + Production: cfg.OAuthProduction(), + RegistrationAccessToken: cfg.OAuth.RegistrationAccessToken, + AutoApprove: cfg.OAuth.AutoApprove, + IssuerURL: cfg.OAuth.IssuerURL, + TenantID: tenantID, + }) + } return b.Build(ctx) } diff --git a/cmd/haistack/internal/config/config.go b/cmd/haistack/internal/config/config.go index 206143e..900064b 100644 --- a/cmd/haistack/internal/config/config.go +++ b/cmd/haistack/internal/config/config.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/degoke/health-ai-stack/pkg/oauth" "gopkg.in/yaml.v3" ) @@ -27,6 +28,7 @@ const ( type Config struct { Storage StorageConfig `yaml:"storage" json:"storage"` Runtime RuntimeConfig `yaml:"runtime" json:"runtime"` + OAuth OAuthConfig `yaml:"oauth" json:"oauth"` Sync SyncConfig `yaml:"sync" json:"sync"` } @@ -47,6 +49,20 @@ type PackageInstallConfig struct { Path string `yaml:"path" json:"path"` } +// OAuthConfig controls the built-in SMART authorization server for haistack serve. +type OAuthConfig struct { + // Enabled mounts pkg/oauth on the managed HTTP server. Defaults to true. + Enabled *bool `yaml:"enabled" json:"enabled"` + // Production applies stricter OAuth defaults (requires registration token or disabled DCR). + Production *bool `yaml:"production" json:"production"` + // RegistrationAccessToken gates POST /oauth/register. Falls back to OAUTH_REGISTRATION_TOKEN. + RegistrationAccessToken string `yaml:"registrationAccessToken" json:"registrationAccessToken"` + // AutoApprove skips interactive consent. Defaults to true in non-production mode. + AutoApprove *bool `yaml:"autoApprove" json:"autoApprove"` + // IssuerURL overrides the OAuth issuer (defaults to http://{runtime.httpAddr}). + IssuerURL string `yaml:"issuerURL" json:"issuerURL"` +} + // RuntimeConfig controls local runtime capabilities. type RuntimeConfig struct { HTTPAddr string `yaml:"httpAddr" json:"httpAddr"` @@ -76,6 +92,9 @@ func Defaults() Config { EnableSearch: true, ModulePaths: []string{}, }, + OAuth: OAuthConfig{ + Enabled: boolPtr(true), + }, Sync: SyncConfig{ NodeID: DefaultSyncNodeID, }, @@ -107,6 +126,30 @@ func (c Config) Validate() error { return fmt.Errorf("runtime.packages[%d]: %w", i, err) } } + if err := c.validateOAuthProduction(); err != nil { + return err + } + return nil +} + +func (c Config) validateOAuthProduction() error { + if !c.OAuthEnabled() || !c.OAuthProduction() { + return nil + } + token := strings.TrimSpace(c.OAuth.RegistrationAccessToken) + if token == "" { + return fmt.Errorf("oauth.production requires OAUTH_REGISTRATION_TOKEN or oauth.registrationAccessToken") + } + issuer := strings.TrimSpace(c.OAuth.IssuerURL) + if issuer == "" { + return fmt.Errorf("oauth.production requires oauth.issuerURL (https) pinned for signing key continuity") + } + if err := oauth.ValidateProductionIssuer(issuer); err != nil { + return fmt.Errorf("oauth.issuerURL: %w", err) + } + if c.OAuth.AutoApprove != nil && *c.OAuth.AutoApprove { + return fmt.Errorf("oauth.autoApprove must be false when oauth.production is enabled") + } return nil } @@ -235,6 +278,11 @@ runtime: modulePaths: [] packages: [] preExpandValueSets: false +oauth: + enabled: true + production: false + registrationAccessToken: "" + issuerURL: "" sync: hubURL: "" nodeID: runtime-node @@ -286,6 +334,32 @@ func applyEnv(cfg *Config) error { if v := os.Getenv("HAISTACK_SYNC_NODE_ID"); v != "" { cfg.Sync.NodeID = v } + if v := os.Getenv("HAISTACK_OAUTH_ENABLED"); v != "" { + parsed, err := strconv.ParseBool(v) + if err != nil { + return fmt.Errorf("HAISTACK_OAUTH_ENABLED must be true or false: %w", err) + } + cfg.OAuth.Enabled = &parsed + } + if v := os.Getenv("HAISTACK_OAUTH_PRODUCTION"); v != "" { + parsed, err := strconv.ParseBool(v) + if err != nil { + return fmt.Errorf("HAISTACK_OAUTH_PRODUCTION must be true or false: %w", err) + } + cfg.OAuth.Production = &parsed + } + if v := os.Getenv("OAUTH_REGISTRATION_TOKEN"); v != "" { + cfg.OAuth.RegistrationAccessToken = v + } + if v := os.Getenv("HAISTACK_OAUTH_REGISTRATION_TOKEN"); v != "" { + cfg.OAuth.RegistrationAccessToken = v + } + if v := os.Getenv("HAISTACK_OAUTH_ISSUER_URL"); v != "" { + cfg.OAuth.IssuerURL = v + } + if os.Getenv("HAISTACK_PRODUCTION") == "1" { + cfg.OAuth.Production = boolPtr(true) + } return nil } @@ -336,3 +410,23 @@ func splitList(value string) []string { } return out } + +func boolPtr(v bool) *bool { + return &v +} + +// OAuthEnabled reports whether haistack serve should mount the built-in OAuth server. +func (c Config) OAuthEnabled() bool { + if c.OAuth.Enabled == nil { + return true + } + return *c.OAuth.Enabled +} + +// OAuthProduction reports whether production OAuth defaults should be applied. +func (c Config) OAuthProduction() bool { + if c.OAuth.Production != nil { + return *c.OAuth.Production + } + return os.Getenv("HAISTACK_PRODUCTION") == "1" +} diff --git a/cmd/haistack/internal/config/config_test.go b/cmd/haistack/internal/config/config_test.go index f7c6ecc..81aec63 100644 --- a/cmd/haistack/internal/config/config_test.go +++ b/cmd/haistack/internal/config/config_test.go @@ -215,3 +215,56 @@ func TestPostgresRequiresTenant(t *testing.T) { t.Fatal("expected tenant validation error") } } + +func TestOAuthEnvOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "haistack.yaml") + if err := os.WriteFile(path, config.StarterYAML(), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("HAISTACK_OAUTH_PRODUCTION", "true") + t.Setenv("OAUTH_REGISTRATION_TOKEN", "register-token") + t.Setenv("HAISTACK_OAUTH_ISSUER_URL", "https://auth.example.test") + + cfg, err := config.Load(path, config.Overrides{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.OAuthProduction() { + t.Fatal("expected oauth production from env") + } + if cfg.OAuth.RegistrationAccessToken != "register-token" { + t.Fatalf("registration token = %q", cfg.OAuth.RegistrationAccessToken) + } + if cfg.OAuth.IssuerURL != "https://auth.example.test" { + t.Fatalf("issuer url = %q", cfg.OAuth.IssuerURL) + } +} + +func TestOAuthProductionRequiresIssuerAndToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "haistack.yaml") + if err := os.WriteFile(path, config.StarterYAML(), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("HAISTACK_OAUTH_PRODUCTION", "true") + t.Setenv("OAUTH_REGISTRATION_TOKEN", "register-token") + + if _, err := config.Load(path, config.Overrides{}); err == nil { + t.Fatal("expected production config to require oauth.issuerURL") + } + + t.Setenv("HAISTACK_OAUTH_ISSUER_URL", "http://auth.example.test") + if _, err := config.Load(path, config.Overrides{}); err == nil { + t.Fatal("expected production config to reject non-https issuer") + } + + t.Setenv("HAISTACK_OAUTH_ISSUER_URL", "https://auth.example.test") + cfg, err := config.Load(path, config.Overrides{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.OAuthProduction() { + t.Fatal("expected production config to load") + } +} diff --git a/cmd/inferno-reference/main.go b/cmd/inferno-reference/main.go new file mode 100644 index 0000000..3f662c9 --- /dev/null +++ b/cmd/inferno-reference/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/degoke/health-ai-stack/pkg/testkit/infernotest" +) + +func main() { + addr := os.Getenv("INFERNO_REFERENCE_ADDR") + if addr == "" { + addr = "127.0.0.1:8080" + } + _, meta, cleanup, err := infernotest.StartReferenceServer(context.Background(), addr) + if err != nil { + fmt.Fprintf(os.Stderr, "inferno-reference: %v\n", err) + os.Exit(1) + } + defer cleanup() + + fmt.Printf("Inferno reference host listening on %s\n", addr) + fmt.Printf("SMART discovery: %s/.well-known/smart-configuration\n", meta.FHIRBaseURL) + fmt.Printf("OAuth issuer: %s\n", meta.BaseURL) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + <-ctx.Done() +} diff --git a/docs/smart-auth-architecture.md b/docs/smart-auth-architecture.md index e57754a..3d5da8d 100644 --- a/docs/smart-auth-architecture.md +++ b/docs/smart-auth-architecture.md @@ -94,7 +94,7 @@ and `pkg/oauth` authorization-server tests. ## Built-in OAuth server (`pkg/oauth`) -Production deployment uses `oauthpostgres.NewServer` (`pkg/oauth/postgres`) with Postgres-backed client, token, replay, and revocation stores. Set `UserAuthenticator` for end-user consent, `LaunchResolver` for EHR launch, and persist `oauth-signing.pem` across restarts. File-backed `oauth.NewProductionServer` remains for single-node dev. See `pkg/oauth/README.md`. +Production deployment uses `oauthstore.ApplyPostgresStores` (`pkg/oauth/store`) with Postgres-backed client, token, replay, and revocation stores. `haistack serve` wires builtin OAuth via `runtime.WithBuiltinOAuth`. Set `UserAuthenticator` for end-user consent, `LaunchResolver` for EHR launch, and persist `oauth-signing.pem` across restarts. File-backed `oauth.NewProductionServer` remains for single-node dev. See `pkg/oauth/README.md`. ## Non-goals diff --git a/pkg/http/auth.go b/pkg/http/auth.go index bfd48a4..76870de 100644 --- a/pkg/http/auth.go +++ b/pkg/http/auth.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" "github.com/degoke/health-ai-stack/pkg/auth" "github.com/degoke/health-ai-stack/pkg/jobs" @@ -23,6 +24,10 @@ type requestIdentity struct { func withAuth(next http.Handler, resolver PrincipalResolver, checker AuthChecker, bundleResolver AuthBundleResolver) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isPublicFHIRPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } format, err := negotiateResponseFormat(r) if err != nil { writeError(w, err) @@ -49,6 +54,11 @@ func withAuth(next http.Handler, resolver PrincipalResolver, checker AuthChecker }) } +func isPublicFHIRPath(path string) bool { + trimmed := strings.TrimSuffix(strings.TrimSpace(path), "/") + return strings.HasSuffix(trimmed, "/metadata") +} + func identityFromContext(ctx context.Context) (auth.Principal, auth.TenantContext, bool) { value, ok := ctx.Value(authContextKey{}).(requestIdentity) if !ok { diff --git a/pkg/http/oauth_wellknown_test.go b/pkg/http/oauth_wellknown_test.go new file mode 100644 index 0000000..1971305 --- /dev/null +++ b/pkg/http/oauth_wellknown_test.go @@ -0,0 +1,34 @@ +package http_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + hahttp "github.com/degoke/health-ai-stack/pkg/http" +) + +func TestRootHandlerMirrorsOAuthWellKnownUnderFHIR(t *testing.T) { + oauthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/smart-configuration" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"authorization_endpoint":"https://example.com/oauth/authorize"}`)) + }) + root := hahttp.NewRootHandlerFromConfig(hahttp.RootConfig{ + FHIR: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }), + OAuth: oauthHandler, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/fhir/.well-known/smart-configuration", nil) + root.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("Content-Type") != "application/json" { + t.Fatalf("content-type = %q", rec.Header().Get("Content-Type")) + } +} diff --git a/pkg/http/sync.go b/pkg/http/sync.go index 76f895a..7e4e0ef 100644 --- a/pkg/http/sync.go +++ b/pkg/http/sync.go @@ -174,7 +174,15 @@ func NewRootHandlerFromConfig(cfg RootConfig) http.Handler { } mux.Handle("/sync/", syncHandler) } - if cfg.FHIR == nil && cfg.Sync == nil { + if cfg.OAuth != nil { + mux.Handle("/oauth/", cfg.OAuth) + mux.Handle("/oauth", cfg.OAuth) + mux.Handle("/.well-known/", cfg.OAuth) + if cfg.FHIR != nil { + mux.HandleFunc("/fhir/.well-known/", mirrorOAuthWellKnown(cfg.OAuth)) + } + } + if cfg.FHIR == nil && cfg.Sync == nil && cfg.OAuth == nil { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeError(w, unsupportedEndpoint(r.URL.Path)) }) @@ -192,6 +200,17 @@ type RootConfig struct { // SyncMiddleware must enforce the caller's node/tenant authentication and // authorization when sync routes are exposed. SyncMiddleware func(http.Handler) http.Handler + // OAuth serves /.well-known/smart-configuration and /oauth/* when set. + // When FHIR is also mounted, the same well-known routes are mirrored under /fhir/.well-known/. + OAuth http.Handler +} + +func mirrorOAuthWellKnown(oauthHandler http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := r.Clone(r.Context()) + req.URL.Path = strings.TrimPrefix(r.URL.Path, "/fhir") + oauthHandler.ServeHTTP(w, req) + } } // NotImplemented returns an error mapped to HTTP 501 by the HTTP adapter. diff --git a/pkg/oauth/OPERATIONS.md b/pkg/oauth/OPERATIONS.md new file mode 100644 index 0000000..2a0eacc --- /dev/null +++ b/pkg/oauth/OPERATIONS.md @@ -0,0 +1,37 @@ +# OAuth operations guide + +This document describes how to run the built-in `pkg/oauth` authorization server in **production-like** deployments versus the **Inferno reference** host used for SMART conformance testing. + +## Deployment profiles + +| Setting | Inferno reference (`infernotest`, `cmd/inferno-reference`) | `haistack serve` production | +|---------|----------------------------------------------------------|-----------------------------| +| Purpose | Inferno STU2 discovery + standalone launch CI | Edge, demo, air-gapped | +| SQLite | Ephemeral temp DB per process | Persistent DB + migration `0014_oauth.sql` | +| Postgres | N/A | Persistent DB + migration `0015_oauth.sql` | +| `store.ApplySQLiteStores` / `ApplyPostgresStores` | Yes | Yes (via `runtime.WithBuiltinOAuth`) | +| `RegistrationAccessToken` | Unset (open DCR) | Required when `oauth.production` is true | +| `AutoApprove` | `true` | `false` in production | +| Signing key | Ephemeral per process | PEM at `{sqlite-dir}/oauth/oauth-signing.pem` | +| TLS | Plain http on loopback | Pin `oauth.issuerURL` to https in production | + +**Do not** point production traffic at `infernotest.BuildReferenceHandler` or `cmd/inferno-reference`. + +## Inferno reference host + +- `go test ./pkg/testkit/infernotest/...` +- `go run ./cmd/inferno-reference` +- `.github/workflows/inferno.yml` + +Built in `pkg/testkit/infernotest/reference.go` with open DCR, `AutoApprove`, and ephemeral SQLite. + +## haistack serve + +Enable with `oauth.enabled: true` (default). Production checklist: + +1. Set `oauth.issuerURL` to your public https issuer. +2. Set `OAUTH_REGISTRATION_TOKEN` (or `oauth.registrationAccessToken`). +3. Set `oauth.production: true` and `oauth.autoApprove: false`. +4. Back up `oauth-signing.pem` beside the SQLite database (or use Postgres + shared PEM path via `runtime.BuiltinOAuthConfig.StateDir`). + +SMART discovery is served at `/.well-known/smart-configuration` and mirrored under `/fhir/.well-known/smart-configuration`. diff --git a/pkg/oauth/README.md b/pkg/oauth/README.md index eed5f30..1749fe2 100644 --- a/pkg/oauth/README.md +++ b/pkg/oauth/README.md @@ -19,20 +19,21 @@ Production-capable OAuth2/OIDC authorization server for SMART on FHIR. ## Production deployment (recommended: Postgres) -Use `oauthpostgres.NewServer` for multi-instance clusters. Postgres provides transactional -`DELETE … RETURNING` consume semantics and row-level locking — no shared filesystem required. +Use `oauthstore.NewPostgresServer` (or `ApplyPostgresStores` + `oauth.NewServer`) for +multi-instance clusters. Postgres provides transactional `DELETE … RETURNING` consume +semantics and row-level locking — no shared filesystem required. ```go import ( "github.com/degoke/health-ai-stack/pkg/oauth" - oauthpostgres "github.com/degoke/health-ai-stack/pkg/oauth/postgres" + oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" "github.com/degoke/health-ai-stack/pkg/postgres" ) db, _ := postgres.Open(ctx, dsn) _ = db.Migrate(ctx) -server, err := oauthpostgres.NewServer(oauth.Config{ +server, err := oauthstore.NewPostgresServer(oauth.Config{ Issuer: "https://auth.example", FHIRAudience: "https://fhir.example", RequireConsentForm: true, @@ -41,7 +42,7 @@ server, err := oauthpostgres.NewServer(oauth.Config{ }, db.Pool()) ``` -`oauthpostgres.Stores` wires: +`oauthstore.PostgresStores` wires: - `AuthorizationStore` — auth codes, refresh tokens, consent sessions - `ClientRegistry` — clients with bcrypt-hashed secrets @@ -72,13 +73,13 @@ revocation denylist. **Client registration stays on Postgres or file** — pass ```go import ( "github.com/degoke/health-ai-stack/pkg/oauth" - oauthpostgres "github.com/degoke/health-ai-stack/pkg/oauth/postgres" + oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" oauthredis "github.com/degoke/health-ai-stack/pkg/oauth/redis" goredis "github.com/redis/go-redis/v9" ) db, _ := postgres.Open(ctx, dsn) -_, clientStore, _, _ := oauthpostgres.Stores(db.Pool()) +_, clientStore, _, _ := oauthstore.PostgresStores(db.Pool()) rdb := goredis.NewClient(&goredis.Options{Addr: "localhost:6379"}) server, err := oauthredis.NewServer(oauth.Config{ Issuer: "https://auth.example", @@ -128,7 +129,7 @@ All access tokens include a `client_id` claim; revoke rejects tokens without it. ## Multi-instance checklist -1. Use `oauthpostgres.NewServer` (recommended) or shared file stores for dev only. +1. Use `oauthstore.NewPostgresServer` (recommended) or shared file stores for dev only. 2. Persist `oauth-signing.pem` across restarts (`LoadKeySetFromPEM`). 3. Set `UserAuthenticator` for end-user consent binding. 4. Keep `AutoApprove: false` in production. diff --git a/pkg/oauth/handlers.go b/pkg/oauth/handlers.go index f9e9131..3b7bfcc 100644 --- a/pkg/oauth/handlers.go +++ b/pkg/oauth/handlers.go @@ -339,6 +339,12 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { writeOAuthError(w, http.StatusMethodNotAllowed, "invalid_request", "method not allowed") return } + if token := strings.TrimSpace(s.cfg.RegistrationAccessToken); token != "" { + if !registrationTokenMatches(r, token) { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "registration token required") + return + } + } var req struct { RedirectURIs []string `json:"redirect_uris"` GrantTypes []string `json:"grant_types"` @@ -545,3 +551,18 @@ func writeJSON(w http.ResponseWriter, status int, payload any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } + +func registrationTokenMatches(r *http.Request, expected string) bool { + if r == nil || strings.TrimSpace(expected) == "" { + return false + } + if auth := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(auth, "Bearer ") { + if strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == expected { + return true + } + } + if r.URL.Query().Get("registration_access_token") == expected { + return true + } + return false +} diff --git a/pkg/oauth/keys.go b/pkg/oauth/keys.go index ea2cae5..1e53ffd 100644 --- a/pkg/oauth/keys.go +++ b/pkg/oauth/keys.go @@ -5,9 +5,11 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "fmt" "math/big" "os" + "path/filepath" "strings" ) @@ -46,6 +48,59 @@ func LoadKeySetFromPEM(path string, keyID string) (*KeySet, error) { return &KeySet{PrivateKey: privateKey, KeyID: keyID, Algorithm: "RS256"}, nil } +// SaveKeySetToPEM writes an RSA private key to path, creating parent directories as needed. +func SaveKeySetToPEM(path string, key *KeySet) error { + if key == nil || key.PrivateKey == nil { + return fmt.Errorf("oauth: key set is required") + } + if strings.TrimSpace(path) == "" { + return fmt.Errorf("oauth: signing key path required") + } + der, err := x509.MarshalPKCS8PrivateKey(key.PrivateKey) + if err != nil { + return fmt.Errorf("oauth: marshal signing key: %w", err) + } + block := &pem.Block{Type: "PRIVATE KEY", Bytes: der} + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("oauth: create signing key dir: %w", err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("oauth: write signing key: %w", err) + } + defer f.Close() + if err := pem.Encode(f, block); err != nil { + return fmt.Errorf("oauth: encode signing key: %w", err) + } + return nil +} + +// LoadOrCreateSigningKey loads a PEM signing key from path or generates and persists one. +func LoadOrCreateSigningKey(path string, keyID string) (*KeySet, error) { + if _, err := os.Stat(path); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("oauth: stat signing key: %w", err) + } + } else { + key, err := LoadKeySetFromPEM(path, keyID) + if err != nil { + return nil, err + } + return key, nil + } + key, err := NewKeySet(2048) + if err != nil { + return nil, err + } + if strings.TrimSpace(keyID) != "" { + key.KeyID = keyID + } + if err := SaveKeySetToPEM(path, key); err != nil { + return nil, err + } + return key, nil +} + // NewKeySet generates an RSA signing key set. func NewKeySet(bits int) (*KeySet, error) { if bits <= 0 { diff --git a/pkg/oauth/postgres/server.go b/pkg/oauth/postgres/server.go deleted file mode 100644 index 72a8fac..0000000 --- a/pkg/oauth/postgres/server.go +++ /dev/null @@ -1,22 +0,0 @@ -package postgres - -import ( - "fmt" - - "github.com/degoke/health-ai-stack/pkg/oauth" - "github.com/jackc/pgx/v5/pgxpool" -) - -// NewServer constructs an authorization server with Postgres-backed stores. -// This is the recommended production deployment for multi-instance clusters. -func NewServer(cfg oauth.Config, pool *pgxpool.Pool) (*oauth.Server, error) { - if pool == nil { - return nil, fmt.Errorf("oauth/postgres: pool is required") - } - authStore, clientStore, replayStore, revocationStore := Stores(pool) - cfg.AuthorizationStore = authStore - cfg.Clients = clientStore - cfg.ReplayStore = replayStore - cfg.RevocationStore = revocationStore - return oauth.NewServer(cfg) -} diff --git a/pkg/oauth/production.go b/pkg/oauth/production.go index 46a94d9..ba602da 100644 --- a/pkg/oauth/production.go +++ b/pkg/oauth/production.go @@ -3,6 +3,7 @@ package oauth import ( "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -32,8 +33,21 @@ func DefaultProductionPaths(stateDir string) ProductionPaths { } } +// ValidateProductionIssuer requires a non-empty https issuer URL. +func ValidateProductionIssuer(issuer string) error { + issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") + if issuer == "" { + return fmt.Errorf("oauth: issuer URL is required for production") + } + u, err := url.Parse(issuer) + if err != nil || strings.ToLower(u.Scheme) != "https" || u.Host == "" { + return fmt.Errorf("oauth: production issuer must use https") + } + return nil +} + // ProductionStores wires file-backed stores for single-host or shared-filesystem deployments. -// Prefer oauthpostgres.NewServer for multi-instance production clusters. +// Prefer store.ApplyPostgresStores for multi-instance production clusters. func ProductionStores(paths ProductionPaths) (AuthorizationStore, ClientRegistry, smart.ReplayStore, TokenRevocationStore, error) { authStore, err := NewFileAuthorizationStore(paths.Tokens) if err != nil { diff --git a/pkg/oauth/server.go b/pkg/oauth/server.go index aec3d40..e8fc4b8 100644 --- a/pkg/oauth/server.go +++ b/pkg/oauth/server.go @@ -33,6 +33,8 @@ type Config struct { RequireConsentForm bool // AllowDynamicRegistration enables POST /oauth/register. Disabled by default. AllowDynamicRegistration bool + // RegistrationAccessToken protects POST /oauth/register when set. + RegistrationAccessToken string // LaunchResolver resolves EHR launch tokens for /oauth/launch and authorize. LaunchResolver LaunchResolver // UserAuthenticator identifies the end user approving access in production flows. diff --git a/pkg/oauth/store/apply.go b/pkg/oauth/store/apply.go new file mode 100644 index 0000000..9e5c0f2 --- /dev/null +++ b/pkg/oauth/store/apply.go @@ -0,0 +1,54 @@ +package store + +import ( + "database/sql" + "fmt" + + "github.com/degoke/health-ai-stack/pkg/oauth" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ApplyPostgresStores wires Postgres-backed OAuth stores into cfg. +func ApplyPostgresStores(cfg *oauth.Config, pool *pgxpool.Pool) error { + if cfg == nil { + return fmt.Errorf("oauth/store: config is required") + } + if pool == nil { + return fmt.Errorf("oauth/store: postgres pool is required") + } + authStore, clientStore, replayStore, revocationStore := PostgresStores(pool) + cfg.AuthorizationStore = authStore + cfg.Clients = clientStore + cfg.ReplayStore = replayStore + cfg.RevocationStore = revocationStore + return nil +} + +// ApplySQLiteStores wires SQLite-backed OAuth stores into cfg. +func ApplySQLiteStores(cfg *oauth.Config, db *sql.DB) error { + if cfg == nil { + return fmt.Errorf("oauth/store: config is required") + } + if db == nil { + return fmt.Errorf("oauth/store: sqlite db is required") + } + authStore, clientStore, replayStore, revocationStore := SQLiteStores(db) + cfg.AuthorizationStore = authStore + cfg.Clients = clientStore + cfg.ReplayStore = replayStore + cfg.RevocationStore = revocationStore + return nil +} + +// NewServer constructs an authorization server after stores are applied to cfg. +func NewServer(cfg oauth.Config) (*oauth.Server, error) { + return oauth.NewServer(cfg) +} + +// NewPostgresServer applies Postgres stores and constructs an authorization server. +func NewPostgresServer(cfg oauth.Config, pool *pgxpool.Pool) (*oauth.Server, error) { + if err := ApplyPostgresStores(&cfg, pool); err != nil { + return nil, err + } + return NewServer(cfg) +} diff --git a/pkg/oauth/store/doc.go b/pkg/oauth/store/doc.go new file mode 100644 index 0000000..e27ab7d --- /dev/null +++ b/pkg/oauth/store/doc.go @@ -0,0 +1,5 @@ +// Package store provides durable OAuth authorization state backed by Postgres or SQLite. +// +// Use ApplyPostgresStores or ApplySQLiteStores to wire an oauth.Config, then construct +// the server with oauth.NewServer or store.NewServer. +package store diff --git a/pkg/oauth/postgres/store.go b/pkg/oauth/store/postgres.go similarity index 97% rename from pkg/oauth/postgres/store.go rename to pkg/oauth/store/postgres.go index 9fc9e58..f2a8e12 100644 --- a/pkg/oauth/postgres/store.go +++ b/pkg/oauth/store/postgres.go @@ -1,4 +1,4 @@ -package postgres +package store import ( "context" @@ -283,8 +283,8 @@ func (s *RevocationStore) IsRevoked(jti string) bool { return err == nil && exists } -// Stores returns production OAuth stores backed by Postgres. -func Stores(pool *pgxpool.Pool) (oauth.AuthorizationStore, oauth.ClientRegistry, smart.ReplayStore, oauth.TokenRevocationStore) { +// PostgresStores returns production OAuth stores backed by Postgres. +func PostgresStores(pool *pgxpool.Pool) (oauth.AuthorizationStore, oauth.ClientRegistry, smart.ReplayStore, oauth.TokenRevocationStore) { return NewAuthorizationStore(pool), NewClientRegistry(pool), NewReplayStore(pool), diff --git a/pkg/oauth/store/sqlite.go b/pkg/oauth/store/sqlite.go new file mode 100644 index 0000000..8729e7e --- /dev/null +++ b/pkg/oauth/store/sqlite.go @@ -0,0 +1,290 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/degoke/health-ai-stack/pkg/oauth" + "github.com/degoke/health-ai-stack/pkg/smart" +) + +// AuthorizationStore persists OAuth authorization state in SQLite. +type SQLiteAuthorizationStore struct { + db *sql.DB + now func() time.Time +} + +// NewSQLiteAuthorizationStore constructs a SQLite-backed AuthorizationStore. +func NewSQLiteAuthorizationStore(db *sql.DB) *SQLiteAuthorizationStore { + return &SQLiteAuthorizationStore{db: db, now: time.Now} +} + +func (s *SQLiteAuthorizationStore) SaveAuthorizationCode(code string, entry oauth.AuthorizationCode) error { + payload, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("encode auth code: %w", err) + } + _, err = s.db.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_auth_code (code, payload, expires_at) + VALUES (?, ?, ?) + ON CONFLICT (code) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, + code, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("save auth code: %w", err) + } + return nil +} + +func (s *SQLiteAuthorizationStore) ConsumeAuthorizationCode(code string) (oauth.AuthorizationCode, bool) { + now := s.now().UTC().Format(time.RFC3339Nano) + var payload string + err := s.db.QueryRowContext(context.Background(), ` + DELETE FROM hai_oauth_auth_code + WHERE code = ? AND expires_at > ? + RETURNING payload`, code, now, + ).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) || err != nil { + return oauth.AuthorizationCode{}, false + } + var entry oauth.AuthorizationCode + if err := json.Unmarshal([]byte(payload), &entry); err != nil { + return oauth.AuthorizationCode{}, false + } + return entry, true +} + +func (s *SQLiteAuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshTokenEntry) error { + payload, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("encode refresh token: %w", err) + } + _, err = s.db.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_refresh_token (token, payload, expires_at) + VALUES (?, ?, ?) + ON CONFLICT (token) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, + token, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("save refresh token: %w", err) + } + return nil +} + +func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { + now := s.now().UTC().Format(time.RFC3339Nano) + var payload string + err := s.db.QueryRowContext(context.Background(), ` + DELETE FROM hai_oauth_refresh_token + WHERE token = ? AND expires_at > ? + RETURNING payload`, token, now, + ).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) || err != nil { + return oauth.RefreshTokenEntry{}, false + } + var entry oauth.RefreshTokenEntry + if err := json.Unmarshal([]byte(payload), &entry); err != nil { + return oauth.RefreshTokenEntry{}, false + } + return entry, true +} + +func (s *SQLiteAuthorizationStore) SavePendingAuthorization(id string, entry oauth.PendingAuthorization) error { + payload, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("encode pending auth: %w", err) + } + _, err = s.db.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_pending_auth (id, payload, expires_at) + VALUES (?, ?, ?) + ON CONFLICT (id) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, + id, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("save pending auth: %w", err) + } + return nil +} + +func (s *SQLiteAuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAuthorization, bool) { + now := s.now().UTC().Format(time.RFC3339Nano) + var payload string + err := s.db.QueryRowContext(context.Background(), ` + SELECT payload FROM hai_oauth_pending_auth + WHERE id = ? AND expires_at > ?`, id, now, + ).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) || err != nil { + return oauth.PendingAuthorization{}, false + } + var entry oauth.PendingAuthorization + if err := json.Unmarshal([]byte(payload), &entry); err != nil { + return oauth.PendingAuthorization{}, false + } + return entry, true +} + +func (s *SQLiteAuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { + now := s.now().UTC().Format(time.RFC3339Nano) + var payload string + err := s.db.QueryRowContext(context.Background(), ` + DELETE FROM hai_oauth_pending_auth + WHERE id = ? AND expires_at > ? + RETURNING payload`, id, now, + ).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) || err != nil { + return oauth.PendingAuthorization{}, false + } + var entry oauth.PendingAuthorization + if err := json.Unmarshal([]byte(payload), &entry); err != nil { + return oauth.PendingAuthorization{}, false + } + return entry, true +} + +func (s *SQLiteAuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { + now := s.now().UTC().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(context.Background(), ` + DELETE FROM hai_oauth_refresh_token + WHERE token = ? AND expires_at > ? AND json_extract(payload, '$.clientId') = ?`, + token, now, clientID, + ) + if err != nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +// SQLiteClientRegistry persists OAuth clients in SQLite. +type SQLiteClientRegistry struct { + db *sql.DB +} + +// NewSQLiteClientRegistry constructs a SQLite-backed ClientRegistry. +func NewSQLiteClientRegistry(db *sql.DB) *SQLiteClientRegistry { + return &SQLiteClientRegistry{db: db} +} + +func (s *SQLiteClientRegistry) Get(clientID string) (oauth.Client, bool) { + var payload string + err := s.db.QueryRowContext(context.Background(), ` + SELECT payload FROM hai_oauth_client WHERE client_id = ?`, clientID, + ).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) || err != nil { + return oauth.Client{}, false + } + var client oauth.Client + if err := json.Unmarshal([]byte(payload), &client); err != nil { + return oauth.Client{}, false + } + return client, true +} + +func (s *SQLiteClientRegistry) Register(client oauth.Client) error { + if err := oauth.PrepareClientSecret(&client); err != nil { + return err + } + payload, err := json.Marshal(client) + if err != nil { + return fmt.Errorf("encode oauth client: %w", err) + } + _, err = s.db.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_client (client_id, payload, updated_at) + VALUES (?, ?, ?) + ON CONFLICT (client_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`, + client.ClientID, payload, time.Now().UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("register oauth client: %w", err) + } + return nil +} + +// SQLiteReplayStore persists backend assertion replay protection in SQLite. +type SQLiteReplayStore struct { + db *sql.DB + now func() time.Time +} + +// NewSQLiteReplayStore constructs a SQLite-backed ReplayStore. +func NewSQLiteReplayStore(db *sql.DB) *SQLiteReplayStore { + return &SQLiteReplayStore{db: db, now: time.Now} +} + +func (s *SQLiteReplayStore) CheckAndStore(jti string, expiresAt time.Time) error { + if jti == "" { + return fmt.Errorf("%w: jti required", smart.ErrReplay) + } + now := s.now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(context.Background(), ` + DELETE FROM hai_oauth_replay_jti WHERE expires_at <= ?`, now) + if err != nil { + return fmt.Errorf("purge replay jti: %w", err) + } + res, err := s.db.ExecContext(context.Background(), ` + INSERT OR IGNORE INTO hai_oauth_replay_jti (jti, expires_at) + VALUES (?, ?)`, jti, expiresAt.UTC().Format(time.RFC3339Nano)) + if err != nil { + return fmt.Errorf("store replay jti: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("%w: jti %q", smart.ErrReplay, jti) + } + return nil +} + +// SQLiteRevocationStore persists revoked access-token JTIs in SQLite. +type SQLiteRevocationStore struct { + db *sql.DB + now func() time.Time +} + +// NewSQLiteRevocationStore constructs a SQLite-backed TokenRevocationStore. +func NewSQLiteRevocationStore(db *sql.DB) *SQLiteRevocationStore { + return &SQLiteRevocationStore{db: db, now: time.Now} +} + +func (s *SQLiteRevocationStore) Revoke(jti string, expiresAt time.Time) error { + if jti == "" { + return fmt.Errorf("oauth: jti required") + } + _, err := s.db.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_revoked_jti (jti, expires_at) + VALUES (?, ?) + ON CONFLICT (jti) DO UPDATE SET expires_at = excluded.expires_at`, + jti, expiresAt.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("revoke jti: %w", err) + } + return nil +} + +func (s *SQLiteRevocationStore) IsRevoked(jti string) bool { + now := s.now().UTC().Format(time.RFC3339Nano) + var exists int + err := s.db.QueryRowContext(context.Background(), ` + SELECT 1 FROM hai_oauth_revoked_jti + WHERE jti = ? AND expires_at > ? + LIMIT 1`, jti, now, + ).Scan(&exists) + return err == nil +} + +// SQLiteStores returns production OAuth stores backed by SQLite. +func SQLiteStores(db *sql.DB) (oauth.AuthorizationStore, oauth.ClientRegistry, smart.ReplayStore, oauth.TokenRevocationStore) { + return NewSQLiteAuthorizationStore(db), + NewSQLiteClientRegistry(db), + NewSQLiteReplayStore(db), + NewSQLiteRevocationStore(db) +} + +var _ oauth.AuthorizationStore = (*SQLiteAuthorizationStore)(nil) +var _ oauth.ClientRegistry = (*SQLiteClientRegistry)(nil) +var _ smart.ReplayStore = (*SQLiteReplayStore)(nil) +var _ oauth.TokenRevocationStore = (*SQLiteRevocationStore)(nil) diff --git a/pkg/oauth/store/sqlite_test.go b/pkg/oauth/store/sqlite_test.go new file mode 100644 index 0000000..47cadd1 --- /dev/null +++ b/pkg/oauth/store/sqlite_test.go @@ -0,0 +1,64 @@ +package store_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/degoke/health-ai-stack/pkg/oauth" + oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" + "github.com/degoke/health-ai-stack/pkg/sqlite" +) + +func TestSQLiteStores_RoundTrip(t *testing.T) { + ctx := context.Background() + db, err := sqlite.Open(filepath.Join(t.TempDir(), "oauth.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + if err := db.Migrate(ctx); err != nil { + t.Fatal(err) + } + + authStore, clientStore, replayStore, revocationStore := oauthstore.SQLiteStores(db.SQL()) + now := time.Now() + if err := clientStore.Register(oauth.Client{ + ClientID: "sqlite-client", + ClientSecret: "sqlite-secret", + RedirectURIs: []string{"https://localhost/callback"}, + Scopes: []string{"patient/Patient.rs"}, + }); err != nil { + t.Fatal(err) + } + client, ok := clientStore.Get("sqlite-client") + if !ok || client.ClientID != "sqlite-client" || client.ClientSecretHash == "" { + t.Fatalf("client = %+v ok=%v", client, ok) + } + + if err := authStore.SaveAuthorizationCode("code-1", oauth.AuthorizationCode{ + ClientID: "sqlite-client", RedirectURI: "https://localhost/callback", + Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), + }); err != nil { + t.Fatal(err) + } + entry, ok := authStore.ConsumeAuthorizationCode("code-1") + if !ok || entry.ClientID != "sqlite-client" { + t.Fatalf("code = %+v ok=%v", entry, ok) + } + + if err := replayStore.CheckAndStore("jti-1", now.Add(5*time.Minute)); err != nil { + t.Fatal(err) + } + if err := replayStore.CheckAndStore("jti-1", now.Add(5*time.Minute)); err == nil { + t.Fatal("expected replay rejection") + } + + if err := revocationStore.Revoke("access-jti-1", now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if !revocationStore.IsRevoked("access-jti-1") { + t.Fatal("expected revoked jti") + } +} diff --git a/pkg/postgres/oauth_store_test.go b/pkg/postgres/oauth_store_test.go index 1c8d17d..4451f4c 100644 --- a/pkg/postgres/oauth_store_test.go +++ b/pkg/postgres/oauth_store_test.go @@ -5,14 +5,14 @@ import ( "time" "github.com/degoke/health-ai-stack/pkg/oauth" - oauthpostgres "github.com/degoke/health-ai-stack/pkg/oauth/postgres" + oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" ) func TestOAuthPostgresStores_RoundTrip(t *testing.T) { db, cleanup := openTestDB(t) defer cleanup() - authStore, clientStore, replayStore, revocationStore := oauthpostgres.Stores(db.Pool()) + authStore, clientStore, replayStore, revocationStore := oauthstore.PostgresStores(db.Pool()) now := time.Now() if err := clientStore.Register(oauth.Client{ ClientID: "pg-client", diff --git a/pkg/runtime/builder.go b/pkg/runtime/builder.go index 755bcbb..ea9261c 100644 --- a/pkg/runtime/builder.go +++ b/pkg/runtime/builder.go @@ -57,6 +57,9 @@ type Builder struct { preExpandValueSets bool maxExpansion int + + builtinOAuth *BuiltinOAuthConfig + oauthHandler http.Handler } // New returns a new runtime builder. @@ -323,6 +326,12 @@ func (b *Builder) WithDataDir(dir string) *Builder { return b } +// WithBuiltinOAuth enables the built-in SMART OAuth authorization server. +func (b *Builder) WithBuiltinOAuth(cfg BuiltinOAuthConfig) *Builder { + b.builtinOAuth = &cfg + return b +} + // resolveMode infers the effective mode from builder selections. func (b *Builder) resolveMode() (Mode, error) { hasSQLite := b.sqlitePath != "" diff --git a/pkg/runtime/integration_test.go b/pkg/runtime/integration_test.go index 8a608d0..633fa21 100644 --- a/pkg/runtime/integration_test.go +++ b/pkg/runtime/integration_test.go @@ -143,6 +143,48 @@ func TestSQLiteIntegrationBuildStartHTTPShutdown(t *testing.T) { } } +func TestPostgresBuiltinOAuthDiscovery(t *testing.T) { + if testing.Short() { + t.Skip("skipping postgres integration in short mode") + } + ctx := context.Background() + dsn, cleanup := openPostgresDSN(t) + defer cleanup() + + tenantID := fmt.Sprintf("oauth-%d", time.Now().UnixNano()) + rt, err := runtime.New(). + WithPostgresAllInOne(dsn, tenantID). + WithHTTP("127.0.0.1:8080"). + WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + IssuerURL: "http://127.0.0.1:8080", + TenantID: tenantID, + }). + Build(ctx) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer func() { _ = rt.Shutdown(ctx) }() + + ts := httptest.NewServer(rt.Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/fhir/.well-known/smart-configuration") + if err != nil { + t.Fatalf("GET discovery: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("discovery status = %d", resp.StatusCode) + } + var doc map[string]any + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + t.Fatal(err) + } + if doc["authorization_endpoint"] == "" || doc["token_endpoint"] == "" { + t.Fatalf("doc = %#v", doc) + } +} + type stubSyncHub struct{} func (stubSyncHub) Push(_ context.Context, events []hasync.LocalEvent) ([]hasync.PushResult, error) { diff --git a/pkg/runtime/oauth_builtin.go b/pkg/runtime/oauth_builtin.go new file mode 100644 index 0000000..3cc4c29 --- /dev/null +++ b/pkg/runtime/oauth_builtin.go @@ -0,0 +1,203 @@ +package runtime + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "github.com/degoke/health-ai-stack/pkg/auth" + hahttp "github.com/degoke/health-ai-stack/pkg/http" + "github.com/degoke/health-ai-stack/pkg/oauth" + oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" + "github.com/degoke/health-ai-stack/pkg/smart" +) + +// BuiltinOAuthConfig enables the built-in pkg/oauth authorization server during runtime wire. +// IssuerURL defaults to http://{HTTPAddr} when empty. +type BuiltinOAuthConfig struct { + Production bool + RegistrationAccessToken string + AutoApprove *bool + IssuerURL string + TenantID string + StateDir string +} + +func validateBuiltinOAuthConfig(cfg BuiltinOAuthConfig) error { + if !cfg.Production { + return nil + } + issuer := strings.TrimSpace(cfg.IssuerURL) + if issuer == "" { + return fmt.Errorf("runtime: production builtin oauth requires issuer URL") + } + if err := oauth.ValidateProductionIssuer(issuer); err != nil { + return fmt.Errorf("runtime: %w", err) + } + token := strings.TrimSpace(cfg.RegistrationAccessToken) + if token == "" { + token = strings.TrimSpace(os.Getenv("OAUTH_REGISTRATION_TOKEN")) + } + if token == "" { + return fmt.Errorf("runtime: production builtin oauth requires OAUTH_REGISTRATION_TOKEN") + } + if cfg.AutoApprove != nil && *cfg.AutoApprove { + return fmt.Errorf("runtime: production builtin oauth requires AutoApprove false") + } + return nil +} + +func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error { + if b == nil || b.builtinOAuth == nil { + return nil + } + if state == nil { + return fmt.Errorf("runtime: builtin oauth requires persistence") + } + cfg := b.builtinOAuth + if err := validateBuiltinOAuthConfig(*cfg); err != nil { + return err + } + issuer, err := resolveBuiltinOAuthIssuer(cfg.IssuerURL, b.httpAddr) + if err != nil { + return err + } + + stateDir := strings.TrimSpace(cfg.StateDir) + if stateDir == "" { + stateDir = defaultOAuthStateDir(b, state) + } + signingKeyPath := oauth.DefaultProductionPaths(stateDir).SigningKey + keySet, err := oauth.LoadOrCreateSigningKey(signingKeyPath, "haistack") + if err != nil { + return fmt.Errorf("runtime: oauth signing key: %w", err) + } + + oauthCfg := oauth.Config{ + Issuer: issuer, + FHIRAudience: issuer, + SigningKey: keySet, + } + switch { + case state.sqliteDB != nil: + if err := oauthstore.ApplySQLiteStores(&oauthCfg, state.sqliteDB.SQL()); err != nil { + return fmt.Errorf("runtime: oauth sqlite stores: %w", err) + } + case state.postgresDB != nil: + if err := oauthstore.ApplyPostgresStores(&oauthCfg, state.postgresDB.Pool()); err != nil { + return fmt.Errorf("runtime: oauth postgres stores: %w", err) + } + default: + return fmt.Errorf("runtime: builtin oauth requires sqlite or postgres storage") + } + + autoApprove := false + if cfg.AutoApprove != nil { + autoApprove = *cfg.AutoApprove + } else if !cfg.Production { + autoApprove = true + } + oauthCfg.AutoApprove = autoApprove + + regToken := strings.TrimSpace(cfg.RegistrationAccessToken) + if regToken == "" { + regToken = strings.TrimSpace(os.Getenv("OAUTH_REGISTRATION_TOKEN")) + } + if cfg.Production { + oauthCfg.RequireConsentForm = true + oauthCfg.AutoApprove = false + oauthCfg.AllowDynamicRegistration = true + if regToken == "" { + return fmt.Errorf("runtime: production builtin oauth requires OAUTH_REGISTRATION_TOKEN") + } + oauthCfg.RegistrationAccessToken = regToken + } else if regToken != "" { + oauthCfg.AllowDynamicRegistration = true + oauthCfg.RegistrationAccessToken = regToken + } + + tenantID := firstNonEmptyString(cfg.TenantID, "local") + if err := oauthCfg.Clients.Register(oauth.Client{ + ClientID: "haistack-app", + 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) + } + + srv, err := oauth.NewServer(oauthCfg) + if err != nil { + return fmt.Errorf("runtime: oauth server: %w", err) + } + + adapter := smart.NewAuthAdapter(smart.AuthAdapterConfig{ + DefaultTenantID: tenantID, + DefaultUserRoles: []string{"clinician"}, + }) + bearer := srv.BearerAuthConfig(adapter) + engine, err := auth.NewEngine(auth.Config{ + Roles: []auth.Role{{Name: "clinician", Permissions: []auth.Permission{"*.read", "*.write", "Patient.read"}}}, + PolicyBytes: []byte(`{ + "version":"1", + "rules":[ + {"name":"read","effect":"allow","match":{"actions":["read","search"],"anyPermissions":["*.read"]}}, + {"name":"write","effect":"allow","match":{"actions":["write"],"anyPermissions":["*.write"]}} + ] + }`), + }) + if err != nil { + return fmt.Errorf("runtime: oauth auth engine: %w", err) + } + + b.oauthHandler = srv.Handler() + b.httpPrincipalResolver = hahttp.SMARTBearerPrincipalResolver(bearer) + b.httpAuthChecker = smart.ScopePolicyAuthChecker{Engine: engine, Adapter: adapter} + return nil +} + +func defaultOAuthStateDir(b *Builder, state *wireState) string { + if strings.TrimSpace(b.dataDir) != "" { + return filepath.Join(b.dataDir, "oauth") + } + if state.sqliteDB != nil && b.sqlitePath != "" { + return filepath.Join(filepath.Dir(b.sqlitePath), "oauth") + } + return ".haistack/oauth" +} + +func resolveBuiltinOAuthIssuer(issuerURL, httpAddr string) (string, error) { + issuer := strings.TrimSpace(issuerURL) + if issuer != "" { + return strings.TrimRight(issuer, "/"), nil + } + addr := strings.TrimSpace(httpAddr) + if addr == "" { + addr = "127.0.0.1:8080" + } + if strings.Contains(addr, "://") { + return strings.TrimRight(addr, "/"), nil + } + host, port, err := net.SplitHostPort(addr) + if err != nil { + return "", fmt.Errorf("runtime: builtin oauth issuer from http addr %q: %w", addr, err) + } + if port == "0" { + return "", fmt.Errorf("runtime: builtin oauth requires explicit IssuerURL when HTTP listen address uses port 0") + } + if host == "" { + host = "127.0.0.1" + } + return strings.TrimRight("http://"+net.JoinHostPort(host, port), "/"), nil +} + +func firstNonEmptyString(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/pkg/runtime/oauth_builtin_internal_test.go b/pkg/runtime/oauth_builtin_internal_test.go new file mode 100644 index 0000000..973c2fd --- /dev/null +++ b/pkg/runtime/oauth_builtin_internal_test.go @@ -0,0 +1,23 @@ +package runtime + +import "testing" + +func TestResolveBuiltinOAuthIssuerHostlessListenAddr(t *testing.T) { + issuer, err := resolveBuiltinOAuthIssuer("", ":8080") + if err != nil { + t.Fatalf("resolve issuer: %v", err) + } + if issuer != "http://127.0.0.1:8080" { + t.Fatalf("issuer = %q", issuer) + } +} + +func TestResolveBuiltinOAuthIssuerExplicitURL(t *testing.T) { + issuer, err := resolveBuiltinOAuthIssuer("https://auth.example.test/", "") + if err != nil { + t.Fatalf("resolve issuer: %v", err) + } + if issuer != "https://auth.example.test" { + t.Fatalf("issuer = %q", issuer) + } +} diff --git a/pkg/runtime/oauth_builtin_test.go b/pkg/runtime/oauth_builtin_test.go new file mode 100644 index 0000000..2bb5f39 --- /dev/null +++ b/pkg/runtime/oauth_builtin_test.go @@ -0,0 +1,156 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/degoke/health-ai-stack/pkg/runtime" +) + +func TestBuiltinOAuthWiresDiscovery(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "oauth-runtime.db") + rt, err := runtime.New(). + WithSQLite(dbPath). + WithHTTP("127.0.0.1:8080"). + WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + IssuerURL: "http://127.0.0.1:8080", + TenantID: "local", + }). + Build(ctx) + if err != nil { + t.Fatal(err) + } + if err := rt.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { _ = rt.Shutdown(ctx) }() + + ts := httptest.NewServer(rt.Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/fhir/.well-known/smart-configuration") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var doc map[string]any + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + t.Fatal(err) + } + if doc["authorization_endpoint"] == "" || doc["token_endpoint"] == "" { + t.Fatalf("doc = %#v", doc) + } +} + +func TestBuiltinOAuthRequiresIssuerWhenListenPortZero(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "oauth-runtime-port-zero.db") + _, err := runtime.New(). + WithSQLite(dbPath). + WithHTTP("127.0.0.1:0"). + WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + TenantID: "local", + }). + Build(ctx) + if err == nil { + t.Fatal("expected port 0 without issuer URL to fail") + } + if !strings.Contains(err.Error(), "IssuerURL") { + t.Fatalf("err = %v", err) + } +} + +func TestBuiltinOAuthProductionRequiresRegistrationToken(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "oauth-runtime-prod.db") + _, err := runtime.New(). + WithSQLite(dbPath). + WithHTTP("127.0.0.1:8080"). + WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + Production: true, + IssuerURL: "https://auth.example.test", + TenantID: "local", + }). + Build(ctx) + if err == nil { + t.Fatal("expected production defaults to require registration token") + } + if !strings.Contains(err.Error(), "OAUTH_REGISTRATION_TOKEN") { + t.Fatalf("err = %v", err) + } +} + +func TestBuiltinOAuthPersistsSigningKey(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + dbPath := filepath.Join(dir, "oauth-persist.db") + + fetchJWKS := func() string { + rt, err := runtime.New(). + WithSQLite(dbPath). + WithHTTP("127.0.0.1:0"). + WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + IssuerURL: "http://127.0.0.1:8080", + TenantID: "local", + StateDir: filepath.Join(dir, "oauth"), + }). + Build(ctx) + if err != nil { + t.Fatal(err) + } + if err := rt.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { _ = rt.Shutdown(ctx) }() + + ts := httptest.NewServer(rt.Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/oauth/jwks") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return string(body) + } + + first := fetchJWKS() + second := fetchJWKS() + if first == "" || first != second { + t.Fatalf("jwks changed across restarts:\nfirst=%q\nsecond=%q", first, second) + } +} + +func TestBuiltinOAuthProductionRejectsHTTPDerivedIssuer(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "oauth-runtime-http-prod.db") + t.Setenv("OAUTH_REGISTRATION_TOKEN", "register-token") + _, err := runtime.New(). + WithSQLite(dbPath). + WithHTTP("127.0.0.1:8080"). + WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + Production: true, + TenantID: "local", + }). + Build(ctx) + if err == nil { + t.Fatal("expected production without pinned issuer to fail") + } +} diff --git a/pkg/runtime/wire.go b/pkg/runtime/wire.go index a52fb0d..1b6269b 100644 --- a/pkg/runtime/wire.go +++ b/pkg/runtime/wire.go @@ -709,6 +709,11 @@ func (b *Builder) wireCommon(ctx context.Context, state *wireState, pc persisten state.services.RegistrySnapshot = snap } }) + if b.builtinOAuth != nil { + if err := b.wireBuiltinOAuth(ctx, state); err != nil { + return err + } + } handler, err := hahttp.NewHandler(hahttp.Config{ ResourceService: hahttp.CoreResourceService{Svc: state.services.ResourceService}, SearchService: httpSearchSvc, @@ -762,6 +767,9 @@ func (b *Builder) wireCommon(ctx context.Context, state *wireState, pc persisten rootCfg.Sync = b.syncServer rootCfg.SyncMiddleware = b.syncMiddleware } + if b.oauthHandler != nil { + rootCfg.OAuth = b.oauthHandler + } state.httpHandler = hahttp.NewRootHandlerFromConfig(rootCfg) return nil } diff --git a/pkg/sqlite/migrations/0014_oauth.sql b/pkg/sqlite/migrations/0014_oauth.sql new file mode 100644 index 0000000..784ddfe --- /dev/null +++ b/pkg/sqlite/migrations/0014_oauth.sql @@ -0,0 +1,45 @@ +-- OAuth authorization server state (JSON payloads mirror Postgres 0015_oauth.sql) + +CREATE TABLE IF NOT EXISTS hai_oauth_client ( + client_id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS hai_oauth_auth_code ( + code TEXT PRIMARY KEY, + payload TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_expires_idx ON hai_oauth_auth_code (expires_at); + +CREATE TABLE IF NOT EXISTS hai_oauth_refresh_token ( + token TEXT PRIMARY KEY, + payload TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_expires_idx ON hai_oauth_refresh_token (expires_at); + +CREATE TABLE IF NOT EXISTS hai_oauth_pending_auth ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_expires_idx ON hai_oauth_pending_auth (expires_at); + +CREATE TABLE IF NOT EXISTS hai_oauth_replay_jti ( + jti TEXT PRIMARY KEY, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS hai_oauth_replay_jti_expires_idx ON hai_oauth_replay_jti (expires_at); + +CREATE TABLE IF NOT EXISTS hai_oauth_revoked_jti ( + jti TEXT PRIMARY KEY, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS hai_oauth_revoked_jti_expires_idx ON hai_oauth_revoked_jti (expires_at); diff --git a/pkg/testkit/infernotest/discovery.go b/pkg/testkit/infernotest/discovery.go new file mode 100644 index 0000000..ef29d4c --- /dev/null +++ b/pkg/testkit/infernotest/discovery.go @@ -0,0 +1,150 @@ +package infernotest + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +// WellKnownURL returns the Inferno SMART discovery URL for a FHIR base endpoint. +func WellKnownURL(fhirBase string) string { + return strings.TrimRight(strings.TrimSpace(fhirBase), "/") + "/.well-known/smart-configuration" +} + +// FetchWellKnownConfiguration performs the GET request used by Inferno's +// well_known_endpoint test. +func FetchWellKnownConfiguration(ctx context.Context, client *http.Client, fhirBase string) ([]byte, http.Header, int, error) { + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, WellKnownURL(fhirBase), nil) + if err != nil { + return nil, nil, 0, err + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, nil, 0, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.Header, resp.StatusCode, err + } + return body, resp.Header, resp.StatusCode, nil +} + +// ParseWellKnownConfiguration parses a SMART discovery JSON document. +func ParseWellKnownConfiguration(raw []byte) (map[string]any, error) { + var config map[string]any + if err := json.Unmarshal(raw, &config); err != nil { + return nil, fmt.Errorf("parse well-known configuration: %w", err) + } + return config, nil +} + +// AssertWellKnownEndpoint mirrors Inferno's well_known_endpoint test. +func AssertWellKnownEndpoint(t *testing.T, status int, headers http.Header, raw []byte) map[string]any { + t.Helper() + if status != http.StatusOK { + t.Fatalf("well-known status = %d, want 200; body=%s", status, raw) + } + if len(raw) == 0 { + t.Fatal("well-known configuration body is empty") + } + config, err := ParseWellKnownConfiguration(raw) + if err != nil { + t.Fatal(err) + } + contentType := headers.Get("Content-Type") + if contentType == "" { + t.Fatal("no Content-Type header received") + } + if !strings.HasPrefix(contentType, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", contentType) + } + for _, key := range []string{"authorization_endpoint", "token_endpoint"} { + value, ok := config[key].(string) + if !ok || strings.TrimSpace(value) == "" { + t.Fatalf("well-known configuration missing %q", key) + } + } + return config +} + +// AssertWellKnownCapabilitiesSTU2 mirrors Inferno's well_known_capabilities_stu2 test. +func AssertWellKnownCapabilitiesSTU2(t *testing.T, config map[string]any) { + t.Helper() + if config == nil { + t.Fatal("well-known configuration is nil") + } + required := map[string]string{ + "authorization_endpoint": "string", + "token_endpoint": "string", + "capabilities": "array", + "grant_types_supported": "array", + "code_challenge_methods_supported": "array", + } + for key, kind := range required { + value, ok := config[key] + if !ok { + t.Fatalf("well-known configuration does not include %q", key) + } + if value == nil { + t.Fatalf("well-known configuration field %q is blank", key) + } + switch kind { + case "string": + if _, ok := value.(string); !ok || strings.TrimSpace(value.(string)) == "" { + t.Fatalf("well-known %q must be a non-empty string", key) + } + case "array": + items, ok := value.([]any) + if !ok || len(items) == 0 { + t.Fatalf("well-known %q must be a non-empty array", key) + } + } + } + + grants, _ := config["grant_types_supported"].([]any) + if !containsString(grants, "authorization_code") { + t.Fatalf("grant_types_supported = %#v, want authorization_code", grants) + } + methods, _ := config["code_challenge_methods_supported"].([]any) + if !containsString(methods, "S256") { + t.Fatalf("code_challenge_methods_supported = %#v, want S256", methods) + } + if containsString(methods, "plain") { + t.Fatalf("code_challenge_methods_supported must not include plain: %#v", methods) + } + + capabilities, _ := config["capabilities"].([]any) + for _, capability := range capabilities { + if _, ok := capability.(string); !ok { + t.Fatalf("capabilities must be strings, found %#v", capability) + } + } + if containsString(capabilities, "sso-openid-connect") { + issuer, ok := config["issuer"].(string) + if !ok || strings.TrimSpace(issuer) == "" { + t.Fatal("issuer must be present when capabilities includes sso-openid-connect") + } + jwksURI, ok := config["jwks_uri"].(string) + if !ok || strings.TrimSpace(jwksURI) == "" { + t.Fatal("jwks_uri must be present when capabilities includes sso-openid-connect") + } + } +} + +func containsString(items []any, target string) bool { + for _, item := range items { + if s, ok := item.(string); ok && s == target { + return true + } + } + return false +} diff --git a/pkg/testkit/infernotest/discovery_stu2_test.go b/pkg/testkit/infernotest/discovery_stu2_test.go new file mode 100644 index 0000000..c8dd17b --- /dev/null +++ b/pkg/testkit/infernotest/discovery_stu2_test.go @@ -0,0 +1,73 @@ +package infernotest_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/degoke/health-ai-stack/pkg/testkit/infernotest" +) + +func TestInfernoDiscoverySTU2(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + + baseURL := "http://" + listener.Addr().String() + handler, meta, cleanup, err := infernotest.BuildReferenceHandler(context.Background(), baseURL) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + srv := httptest.NewUnstartedServer(handler) + srv.Listener = listener + srv.Start() + defer srv.Close() + + if meta.FHIRBaseURL != baseURL+"/fhir" { + t.Fatalf("FHIR base = %q, want %q/fhir", meta.FHIRBaseURL, baseURL) + } + + raw, headers, status, err := infernotest.FetchWellKnownConfiguration( + context.Background(), + http.DefaultClient, + meta.FHIRBaseURL, + ) + if err != nil { + t.Fatal(err) + } + + config := infernotest.AssertWellKnownEndpoint(t, status, headers, raw) + infernotest.AssertWellKnownCapabilitiesSTU2(t, config) +} + +func TestInfernoReferenceServerLive(t *testing.T) { + if testing.Short() { + t.Skip("skipping live reference server smoke test in short mode") + } + + _, meta, cleanup, err := infernotest.StartReferenceServer(context.Background(), "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer cleanup() + + raw, headers, status, err := infernotest.FetchWellKnownConfiguration( + context.Background(), + http.DefaultClient, + meta.FHIRBaseURL, + ) + if err != nil { + t.Fatal(err) + } + + config := infernotest.AssertWellKnownEndpoint(t, status, headers, raw) + infernotest.AssertWellKnownCapabilitiesSTU2(t, config) +} diff --git a/pkg/testkit/infernotest/doc.go b/pkg/testkit/infernotest/doc.go new file mode 100644 index 0000000..d68e483 --- /dev/null +++ b/pkg/testkit/infernotest/doc.go @@ -0,0 +1,2 @@ +// Package infernotest provides helpers and a reference SMART host profile for Inferno conformance checks. +package infernotest diff --git a/pkg/testkit/infernotest/reference.go b/pkg/testkit/infernotest/reference.go new file mode 100644 index 0000000..1fc3cda --- /dev/null +++ b/pkg/testkit/infernotest/reference.go @@ -0,0 +1,253 @@ +package infernotest + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/degoke/health-ai-stack/pkg/auth" + "github.com/degoke/health-ai-stack/pkg/core" + "github.com/degoke/health-ai-stack/pkg/fhirpath" + hahttp "github.com/degoke/health-ai-stack/pkg/http" + "github.com/degoke/health-ai-stack/pkg/oauth" + oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" + "github.com/degoke/health-ai-stack/pkg/registry" + "github.com/degoke/health-ai-stack/pkg/search" + "github.com/degoke/health-ai-stack/pkg/smart" + "github.com/degoke/health-ai-stack/pkg/sqlite" + "github.com/degoke/health-ai-stack/pkg/types" +) + +const ( + // DefaultClientID is the registered SMART client on the reference host. + DefaultClientID = "inferno-reference" + // DefaultRedirectURI is accepted for Inferno launcher redirects. + DefaultRedirectURI = "https://inferno.healthit.gov/launcher/custom/smart/redirect" +) + +// ReferenceMeta describes the URLs exposed by the reference host. +type ReferenceMeta struct { + BaseURL string + FHIRBaseURL string +} + +// BuildReferenceHandler wires pkg/oauth and a minimal FHIR API for Inferno discovery checks. +func BuildReferenceHandler(ctx context.Context, baseURL string) (http.Handler, ReferenceMeta, func(), error) { + if ctx == nil { + ctx = context.Background() + } + baseURL = trimSlash(baseURL) + if baseURL == "" { + return nil, ReferenceMeta{}, nil, fmt.Errorf("infernotest: base URL required") + } + meta := ReferenceMeta{ + BaseURL: baseURL, + FHIRBaseURL: baseURL + "/fhir", + } + + tempDir, err := os.MkdirTemp("", "haistack-inferno-reference-*") + if err != nil { + return nil, ReferenceMeta{}, nil, err + } + db, err := sqlite.Open(filepath.Join(tempDir, "oauth.db")) + if err != nil { + _ = os.RemoveAll(tempDir) + return nil, ReferenceMeta{}, nil, fmt.Errorf("open sqlite: %w", err) + } + if err := db.Migrate(ctx); err != nil { + _ = db.Close() + _ = os.RemoveAll(tempDir) + return nil, ReferenceMeta{}, nil, fmt.Errorf("migrate sqlite: %w", err) + } + cleanup := func() { + _ = db.Close() + _ = os.RemoveAll(tempDir) + } + + manager := registry.NewManager(registry.Config{ + Definitions: db.DefinitionStore(), + Installs: db.RegistryInstallStore(), + }) + if err := manager.SeedBundled(ctx); err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("seed registry: %w", err) + } + if err := manager.EnableResource(ctx, "Patient"); err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("enable Patient: %w", err) + } + snapshot, err := manager.RebuildSnapshot(ctx) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("rebuild snapshot: %w", err) + } + + engine, err := fhirpath.NewEngine(fhirpath.Config{}) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("new fhirpath engine: %w", err) + } + indexer, err := search.NewRegistryIndexer(search.RegistryIndexerConfig{ + Registry: search.NewSnapshotRegistry(snapshot), + Engine: engine, + }) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("new search indexer: %w", err) + } + + resourceService, err := core.NewResourceService(core.ResourceServiceConfig{ + Resources: db.ResourceStore(), + History: db.HistoryStore(), + Sessions: db, + Indexer: indexer, + }) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("new resource service: %w", err) + } + + searchService, err := search.NewService(search.ServiceConfig{ + Registry: search.NewSnapshotRegistry(snapshot), + Executor: search.NewStoreExecutor(db.SearchStore(), db.ResourceStore()), + Resources: db.ResourceStore(), + BaseURL: "/fhir", + }) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("new search service: %w", err) + } + + patient, err := types.NewJSONCodec().ParseJSON("Patient", []byte(`{ + "resourceType": "Patient", + "name": [{"given": ["Inferno"], "family": "Patient"}], + "telecom": [{"system": "phone", "value": "+1-555-0100"}] +}`)) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("parse patient: %w", err) + } + created, err := resourceService.Create(ctx, patient) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("create patient: %w", err) + } + + authEngine, err := auth.NewEngine(auth.Config{ + Roles: []auth.Role{{ + Name: "clinician", + Permissions: []auth.Permission{"Patient.read"}, + }}, + PolicyBytes: []byte(`{ + "version": "1", + "rules": [{ + "name": "allow-patient-read", + "effect": "allow", + "match": { + "actions": ["read"], + "resourceTypes": ["Patient"], + "anyPermissions": ["Patient.read"] + }, + "reason": "reference host allows patient reads" + }] +}`), + PolicyFormat: auth.PolicyFormatJSON, + }) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, fmt.Errorf("new auth engine: %w", err) + } + + keySet, err := oauth.NewKeySet(2048) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, err + } + + oauthCfg := oauth.Config{ + Issuer: meta.BaseURL, + FHIRAudience: meta.BaseURL, + SigningKey: keySet, + AutoApprove: true, + AllowDynamicRegistration: true, + LaunchResolver: oauth.StaticLaunchResolver(oauth.LaunchContext{ + PatientID: created.ID, + }), + } + if err := oauthstore.ApplySQLiteStores(&oauthCfg, db.SQL()); err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, err + } + if err := oauthCfg.Clients.Register(oauth.Client{ + ClientID: DefaultClientID, + RedirectURIs: []string{DefaultRedirectURI, "http://127.0.0.1/callback"}, + Scopes: []string{"patient/Patient.read", "patient/*.read", "launch/patient", "openid", "fhirUser", "offline_access"}, + }); err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, err + } + + oauthSrv, err := oauth.NewServer(oauthCfg) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, err + } + + adapter := smart.NewAuthAdapter(smart.AuthAdapterConfig{ + DefaultTenantID: "tenant-inferno", + DefaultUserRoles: []string{"clinician"}, + }) + bearer := oauthSrv.BearerAuthConfig(adapter) + + fhirHandler, err := hahttp.NewHandler(hahttp.Config{ + ResourceService: hahttp.CoreResourceService{Svc: resourceService}, + SearchService: hahttp.SearchServiceAdapter{Svc: searchService}, + PrincipalResolver: hahttp.SMARTBearerPrincipalResolver(bearer), + AuthBundleResolver: hahttp.SMARTBearerBundleResolver(bearer), + AuthChecker: smart.ScopePolicyAuthChecker{Engine: authEngine, Adapter: adapter}, + }) + if err != nil { + cleanup() + return nil, ReferenceMeta{}, nil, err + } + + handler := hahttp.NewRootHandlerFromConfig(hahttp.RootConfig{ + FHIR: fhirHandler, + OAuth: oauthSrv.Handler(), + }) + return handler, meta, cleanup, nil +} + +// StartReferenceServer listens on addr and serves the Inferno reference host. +func StartReferenceServer(ctx context.Context, addr string) (*http.Server, ReferenceMeta, func(), error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, ReferenceMeta{}, nil, err + } + baseURL := "http://" + listener.Addr().String() + handler, meta, cleanupStack, err := BuildReferenceHandler(ctx, baseURL) + if err != nil { + _ = listener.Close() + return nil, ReferenceMeta{}, nil, err + } + srv := &http.Server{Handler: handler} + go func() { _ = srv.Serve(listener) }() + cleanup := func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + cleanupStack() + } + return srv, meta, cleanup, nil +} + +func trimSlash(u string) string { + for len(u) > 0 && u[len(u)-1] == '/' { + u = u[:len(u)-1] + } + return u +} diff --git a/pkg/testkit/infernotest/standalone_launch.go b/pkg/testkit/infernotest/standalone_launch.go new file mode 100644 index 0000000..dde85cc --- /dev/null +++ b/pkg/testkit/infernotest/standalone_launch.go @@ -0,0 +1,79 @@ +package infernotest + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/degoke/health-ai-stack/pkg/client" +) + +// AssertStandaloneLaunchFlow exercises Inferno-aligned standalone SMART launch: +// discovery at FHIR base, PKCE authorization code flow, and token exchange. +func AssertStandaloneLaunchFlow(t *testing.T, baseURL, fhirBase, clientID, redirectURI, scope string) { + t.Helper() + smartClient, err := client.New(client.Config{BaseURL: baseURL}) + if err != nil { + t.Fatal(err) + } + cfg, err := smartClient.SMART().Discover(context.Background(), fhirBase) + if err != nil { + t.Fatal(err) + } + if cfg.AuthorizationEndpoint == "" || cfg.TokenEndpoint == "" { + t.Fatalf("discovery = %#v", cfg) + } + pkce, err := client.NewPKCEChallenge() + if err != nil { + t.Fatal(err) + } + authURL, err := smartClient.SMART().BuildAuthURL(client.AuthCodeRequest{ + Config: cfg, + ClientID: clientID, + RedirectURI: redirectURI, + Scope: scope, + State: "inferno-standalone", + PKCE: pkce, + Aud: fhirBase, + }) + if err != nil { + t.Fatal(err) + } + noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := noRedirect.Get(authURL) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusFound { + t.Fatalf("authorize status = %d", resp.StatusCode) + } + loc := resp.Header.Get("Location") + if !strings.Contains(loc, "code=") { + t.Fatalf("redirect = %q", loc) + } + u, err := url.Parse(loc) + if err != nil { + t.Fatal(err) + } + if u.Query().Get("state") != "inferno-standalone" { + t.Fatalf("state = %q", u.Query().Get("state")) + } + tokenResp, err := smartClient.SMART().ExchangeAuthCode(context.Background(), client.AuthCodeExchangeRequest{ + TokenEndpoint: cfg.TokenEndpoint, + ClientID: clientID, + RedirectURI: redirectURI, + Code: u.Query().Get("code"), + PKCE: pkce, + }) + if err != nil { + t.Fatal(err) + } + if tokenResp.AccessToken == "" { + t.Fatal("missing access token") + } +} diff --git a/pkg/testkit/infernotest/standalone_launch_test.go b/pkg/testkit/infernotest/standalone_launch_test.go new file mode 100644 index 0000000..fe3b983 --- /dev/null +++ b/pkg/testkit/infernotest/standalone_launch_test.go @@ -0,0 +1,41 @@ +package infernotest_test + +import ( + "context" + "net" + "net/http/httptest" + "testing" + + "github.com/degoke/health-ai-stack/pkg/testkit/infernotest" +) + +func TestInfernoStandaloneLaunch(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + + baseURL := "http://" + listener.Addr().String() + handler, meta, cleanup, err := infernotest.BuildReferenceHandler(context.Background(), baseURL) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + srv := httptest.NewUnstartedServer(handler) + srv.Listener = listener + srv.Start() + defer srv.Close() + + infernotest.AssertStandaloneLaunchFlow( + t, + meta.BaseURL, + meta.FHIRBaseURL, + infernotest.DefaultClientID, + "http://127.0.0.1/callback", + "patient/Patient.read openid", + ) +} From 0fe9b1e69cd60433adf00d2cc7a36b3c37b85ea4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 22:34:24 +0000 Subject: [PATCH 02/14] =?UTF-8?q?fix(oauth):=20close=20port=20gaps=20?= =?UTF-8?q?=E2=80=94=20AuthBundle,=20migration=200017,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 4 ++- cmd/haistack/README.md | 22 +++++++++++++ cmd/haistack/command/serve.go | 11 +++++++ cmd/haistack/internal/config/config.go | 7 +++++ cmd/haistack/internal/config/config_test.go | 16 ++++++++++ cmd/haistack/internal/config/doc.go | 13 ++++++-- examples/README.md | 4 ++- examples/smart-oauth/main.go | 31 ++++++++++++++++--- pkg/oauth/OPERATIONS.md | 2 +- pkg/oauth/README.md | 4 +-- pkg/oauth/doc.go | 4 +++ pkg/oauth/handlers.go | 11 ++----- pkg/oauth/store/apply.go | 8 +++++ .../store/postgres_test.go} | 11 ++++--- .../{0015_oauth.sql => 0017_oauth.sql} | 0 pkg/runtime/README.md | 18 +++++++++++ pkg/runtime/builder.go | 6 ++-- pkg/runtime/config.go | 4 +++ pkg/runtime/oauth_builtin.go | 2 ++ pkg/runtime/wire.go | 5 +++ pkg/sqlite/migrations/0014_oauth.sql | 2 +- pkg/testkit/README.md | 1 + 22 files changed, 158 insertions(+), 28 deletions(-) rename pkg/{postgres/oauth_store_test.go => oauth/store/postgres_test.go} (85%) rename pkg/postgres/migrations/{0015_oauth.sql => 0017_oauth.sql} (100%) diff --git a/README.md b/README.md index c8ec319..ce8a2fb 100644 --- a/README.md +++ b/README.md @@ -374,9 +374,11 @@ See **[cmd/haistack/README.md](cmd/haistack/README.md)** for the full command re ```bash go build -o bin/haistack ./cmd/haistack haistack init -haistack serve +haistack serve # builtin SMART OAuth enabled by default; see oauth.* in haistack.yaml ``` +For Inferno conformance checks, run `go test ./pkg/testkit/infernotest/...` or `go run ./cmd/inferno-reference`. + ## Examples Runnable example applications live in [examples/README.md](examples/README.md). diff --git a/cmd/haistack/README.md b/cmd/haistack/README.md index 25b179b..676ae31 100644 --- a/cmd/haistack/README.md +++ b/cmd/haistack/README.md @@ -107,11 +107,26 @@ runtime: httpAddr: 127.0.0.1:8080 enableSearch: true modulePaths: [] +oauth: + enabled: true + production: false + issuerURL: "" # defaults to http://{runtime.httpAddr} + registrationAccessToken: "" # or OAUTH_REGISTRATION_TOKEN + autoApprove: null # defaults true in dev, false in production sync: hubURL: "" nodeID: runtime-node ``` +`haistack serve` mounts the built-in SMART OAuth server when `oauth.enabled` is true (default) and HTTP is configured. SMART discovery is available at `{issuer}/fhir/.well-known/smart-configuration`. Signing keys persist to `{sqlite-dir}/oauth/oauth-signing.pem`. + +**Production checklist** + +1. Set `oauth.issuerURL` to your public https issuer (pin before first start). +2. Set `oauth.production: true` and `OAUTH_REGISTRATION_TOKEN`. +3. Set `oauth.autoApprove: false` (enforced when production is on). +4. Back up `oauth-signing.pem` beside your database. + ### Precedence 1. Built-in defaults @@ -138,6 +153,13 @@ If the default `haistack.yaml` is missing, built-in defaults are used so command | `HAISTACK_MODULE_PATHS` | `runtime.modulePaths` (comma-separated) | | `HAISTACK_SYNC_HUB_URL` | `sync.hubURL` | | `HAISTACK_SYNC_NODE_ID` | `sync.nodeID` | +| `HAISTACK_OAUTH_ENABLED` | `oauth.enabled` | +| `HAISTACK_OAUTH_PRODUCTION` | `oauth.production` | +| `HAISTACK_OAUTH_ISSUER_URL` | `oauth.issuerURL` | +| `HAISTACK_OAUTH_AUTO_APPROVE` | `oauth.autoApprove` | +| `HAISTACK_OAUTH_REGISTRATION_TOKEN` | `oauth.registrationAccessToken` | +| `OAUTH_REGISTRATION_TOKEN` | `oauth.registrationAccessToken` | +| `HAISTACK_PRODUCTION=1` | enables `oauth.production` | ### Persistent flags diff --git a/cmd/haistack/command/serve.go b/cmd/haistack/command/serve.go index 0c75afe..3d07cce 100644 --- a/cmd/haistack/command/serve.go +++ b/cmd/haistack/command/serve.go @@ -44,11 +44,22 @@ until interrupted and prints the bound listen address on startup.`, "address": rt.HTTPAddr().String(), "search": cfg.Runtime.EnableSearch, } + if cfg.OAuthEnabled() { + startMsg["oauth"] = true + if issuer := rt.Config().OAuthIssuer; issuer != "" { + startMsg["oauthDiscovery"] = issuer + "/fhir/.well-known/smart-configuration" + } + } if printer.Format == app.OutputJSON { _ = printer.Print(startMsg) } else { writeStdout(printer, fmt.Sprintf("listening on http://%s (mode=%s, search=%v)", rt.HTTPAddr().String(), rt.Mode(), cfg.Runtime.EnableSearch)) + if cfg.OAuthEnabled() { + if issuer := rt.Config().OAuthIssuer; issuer != "" { + writeStdout(printer, fmt.Sprintf("oauth discovery: %s/fhir/.well-known/smart-configuration", issuer)) + } + } } sigCh := make(chan os.Signal, 1) diff --git a/cmd/haistack/internal/config/config.go b/cmd/haistack/internal/config/config.go index 900064b..ff0de79 100644 --- a/cmd/haistack/internal/config/config.go +++ b/cmd/haistack/internal/config/config.go @@ -357,6 +357,13 @@ func applyEnv(cfg *Config) error { if v := os.Getenv("HAISTACK_OAUTH_ISSUER_URL"); v != "" { cfg.OAuth.IssuerURL = v } + if v := os.Getenv("HAISTACK_OAUTH_AUTO_APPROVE"); v != "" { + parsed, err := strconv.ParseBool(v) + if err != nil { + return fmt.Errorf("HAISTACK_OAUTH_AUTO_APPROVE must be true or false: %w", err) + } + cfg.OAuth.AutoApprove = &parsed + } if os.Getenv("HAISTACK_PRODUCTION") == "1" { cfg.OAuth.Production = boolPtr(true) } diff --git a/cmd/haistack/internal/config/config_test.go b/cmd/haistack/internal/config/config_test.go index 81aec63..0e69ef7 100644 --- a/cmd/haistack/internal/config/config_test.go +++ b/cmd/haistack/internal/config/config_test.go @@ -216,6 +216,22 @@ func TestPostgresRequiresTenant(t *testing.T) { } } +func TestOAuthAutoApproveEnvOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "haistack.yaml") + if err := os.WriteFile(path, config.StarterYAML(), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("HAISTACK_OAUTH_AUTO_APPROVE", "false") + cfg, err := config.Load(path, config.Overrides{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OAuth.AutoApprove == nil || *cfg.OAuth.AutoApprove { + t.Fatalf("autoApprove = %v", cfg.OAuth.AutoApprove) + } +} + func TestOAuthEnvOverrides(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "haistack.yaml") diff --git a/cmd/haistack/internal/config/doc.go b/cmd/haistack/internal/config/doc.go index 673f478..ddf855a 100644 --- a/cmd/haistack/internal/config/doc.go +++ b/cmd/haistack/internal/config/doc.go @@ -10,6 +10,7 @@ // // - storage — driver (sqlite or postgres), paths, DSN, and tenant namespaces // - runtime — HTTP address, search enablement, module install paths +// - oauth — built-in SMART authorization server for haistack serve // - sync — hub URL and device node ID // // Defaults target a local SQLite workspace at .haistack/haistack.db with @@ -29,8 +30,9 @@ // # Validation // // Validate enforces driver-specific requirements: sqlitePath for SQLite, -// postgresDSN and tenantID for Postgres, and sync.nodeID when sync.hubURL -// is set. +// postgresDSN and tenantID for Postgres, sync.nodeID when sync.hubURL is set, +// and oauth production settings (https issuerURL, registration token) when +// oauth.production is enabled. // // # Environment variables // @@ -45,6 +47,13 @@ // - HAISTACK_MODULE_PATHS (comma-separated) // - HAISTACK_SYNC_HUB_URL // - HAISTACK_SYNC_NODE_ID +// - HAISTACK_OAUTH_ENABLED +// - HAISTACK_OAUTH_PRODUCTION +// - HAISTACK_OAUTH_ISSUER_URL +// - HAISTACK_OAUTH_AUTO_APPROVE +// - HAISTACK_OAUTH_REGISTRATION_TOKEN +// - OAUTH_REGISTRATION_TOKEN +// - HAISTACK_PRODUCTION (sets oauth.production when "1") // // StarterYAML returns the bytes written by haistack init. package config diff --git a/examples/README.md b/examples/README.md index bfbc074..9bcb169 100644 --- a/examples/README.md +++ b/examples/README.md @@ -101,10 +101,12 @@ go run ./examples/smart-authz Production-shaped SMART OAuth + FHIR demo: -- `oauth.NewProductionServer` with file-backed client/token/replay stores +- `oauthstore.ApplySQLiteStores` + `oauth.NewServer` (JSON payload tables) - HTML consent with CSRF, EHR launch resolver, and end-user identity - PKCE auth-code flow and FHIR read via issued bearer token +For zero-config local serving with the same stack, use `haistack serve` (see `cmd/haistack/README.md`). + ```bash go run ./examples/smart-oauth ``` diff --git a/examples/smart-oauth/main.go b/examples/smart-oauth/main.go index 6a3ca86..76b6243 100644 --- a/examples/smart-oauth/main.go +++ b/examples/smart-oauth/main.go @@ -16,7 +16,9 @@ import ( "github.com/degoke/health-ai-stack/pkg/client" hahttp "github.com/degoke/health-ai-stack/pkg/http" "github.com/degoke/health-ai-stack/pkg/oauth" + oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" "github.com/degoke/health-ai-stack/pkg/registry" + "github.com/degoke/health-ai-stack/pkg/sqlite" "github.com/degoke/health-ai-stack/pkg/smart" ) @@ -55,9 +57,22 @@ func run() error { defer asServer.Close() issuer := asServer.URL - oauthServer, err := oauth.NewProductionServer(oauth.Config{ + oauthDB, err := sqlite.Open(filepath.Join(tempDir, "oauth.db")) + if err != nil { + return err + } + defer func() { _ = oauthDB.Close() }() + if err := oauthDB.Migrate(ctx); err != nil { + return err + } + keySet, err := oauth.NewKeySet(2048) + if err != nil { + return err + } + oauthCfg := oauth.Config{ Issuer: issuer, FHIRAudience: issuer, + SigningKey: keySet, RequireConsentForm: true, LaunchResolver: oauth.StaticLaunchResolver(oauth.LaunchContext{ PatientID: created.ID, @@ -66,15 +81,21 @@ func run() error { Subject: "practitioner-demo", FHIRUser: "Practitioner/demo", }), - }, oauth.DefaultProductionPaths(filepath.Join(tempDir, "oauth"))) - if err != nil { + } + if err := oauthstore.ApplySQLiteStores(&oauthCfg, oauthDB.SQL()); err != nil { return err } - _ = oauthServer.RegisterClient(oauth.Client{ + if err := oauthCfg.Clients.Register(oauth.Client{ ClientID: "demo-app", RedirectURIs: []string{"https://localhost/callback"}, Scopes: []string{"patient/Patient.rs", "openid", "fhirUser"}, - }) + }); err != nil { + return err + } + oauthServer, err := oauth.NewServer(oauthCfg) + if err != nil { + return err + } asMux.Handle("/", oauthServer.Handler()) adapter := smart.NewAuthAdapter(smart.AuthAdapterConfig{ diff --git a/pkg/oauth/OPERATIONS.md b/pkg/oauth/OPERATIONS.md index 2a0eacc..774fba4 100644 --- a/pkg/oauth/OPERATIONS.md +++ b/pkg/oauth/OPERATIONS.md @@ -8,7 +8,7 @@ This document describes how to run the built-in `pkg/oauth` authorization server |---------|----------------------------------------------------------|-----------------------------| | Purpose | Inferno STU2 discovery + standalone launch CI | Edge, demo, air-gapped | | SQLite | Ephemeral temp DB per process | Persistent DB + migration `0014_oauth.sql` | -| Postgres | N/A | Persistent DB + migration `0015_oauth.sql` | +| Postgres | N/A | Persistent DB + migration `0017_oauth.sql` | | `store.ApplySQLiteStores` / `ApplyPostgresStores` | Yes | Yes (via `runtime.WithBuiltinOAuth`) | | `RegistrationAccessToken` | Unset (open DCR) | Required when `oauth.production` is true | | `AutoApprove` | `true` | `false` in production | diff --git a/pkg/oauth/README.md b/pkg/oauth/README.md index 1749fe2..a451664 100644 --- a/pkg/oauth/README.md +++ b/pkg/oauth/README.md @@ -49,7 +49,7 @@ server, err := oauthstore.NewPostgresServer(oauth.Config{ - `ReplayStore` — backend/client-assertion `jti` replay protection - `RevocationStore` — revoked access-token `jti` denylist -Schema: migration `0015_oauth.sql`. +Schema: migration `0017_oauth.sql` (Postgres) or `0014_oauth.sql` (SQLite). ### Why file stores existed @@ -135,4 +135,4 @@ All access tokens include a `client_id` claim; revoke rejects tokens without it. 4. Keep `AutoApprove: false` in production. 5. Enable `AllowDynamicRegistration` only when required. -See `examples/smart-oauth` for a runnable demo. +See `examples/smart-oauth` for a runnable demo, or `haistack serve` for built-in OAuth with SQLite/Postgres stores (`runtime.WithBuiltinOAuth`). Operations guidance: `OPERATIONS.md`. diff --git a/pkg/oauth/doc.go b/pkg/oauth/doc.go index 47625ae..8139d9e 100644 --- a/pkg/oauth/doc.go +++ b/pkg/oauth/doc.go @@ -9,4 +9,8 @@ // - /oauth/token // - /oauth/jwks // - /oauth/register +// +// Durable state is provided by pkg/oauth/store (Postgres or SQLite JSON payload tables) +// or file-backed helpers in production.go for single-node dev. haistack serve wires +// builtin OAuth via runtime.WithBuiltinOAuth when oauth.enabled is true. package oauth diff --git a/pkg/oauth/handlers.go b/pkg/oauth/handlers.go index 3b7bfcc..f9081a9 100644 --- a/pkg/oauth/handlers.go +++ b/pkg/oauth/handlers.go @@ -556,13 +556,6 @@ func registrationTokenMatches(r *http.Request, expected string) bool { if r == nil || strings.TrimSpace(expected) == "" { return false } - if auth := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(auth, "Bearer ") { - if strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == expected { - return true - } - } - if r.URL.Query().Get("registration_access_token") == expected { - return true - } - return false + auth := strings.TrimSpace(r.Header.Get("Authorization")) + return strings.HasPrefix(auth, "Bearer ") && strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == expected } diff --git a/pkg/oauth/store/apply.go b/pkg/oauth/store/apply.go index 9e5c0f2..d5a6939 100644 --- a/pkg/oauth/store/apply.go +++ b/pkg/oauth/store/apply.go @@ -52,3 +52,11 @@ func NewPostgresServer(cfg oauth.Config, pool *pgxpool.Pool) (*oauth.Server, err } return NewServer(cfg) } + +// NewSQLiteServer applies SQLite stores and constructs an authorization server. +func NewSQLiteServer(cfg oauth.Config, db *sql.DB) (*oauth.Server, error) { + if err := ApplySQLiteStores(&cfg, db); err != nil { + return nil, err + } + return NewServer(cfg) +} diff --git a/pkg/postgres/oauth_store_test.go b/pkg/oauth/store/postgres_test.go similarity index 85% rename from pkg/postgres/oauth_store_test.go rename to pkg/oauth/store/postgres_test.go index 4451f4c..00d07e1 100644 --- a/pkg/postgres/oauth_store_test.go +++ b/pkg/oauth/store/postgres_test.go @@ -1,4 +1,4 @@ -package postgres_test +package store_test import ( "testing" @@ -6,11 +6,14 @@ import ( "github.com/degoke/health-ai-stack/pkg/oauth" oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" + "github.com/degoke/health-ai-stack/pkg/testkit/postgrestest" ) -func TestOAuthPostgresStores_RoundTrip(t *testing.T) { - db, cleanup := openTestDB(t) - defer cleanup() +func TestPostgresStores_RoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("skipping postgres store test in short mode") + } + db := postgrestest.SharedDB(t) authStore, clientStore, replayStore, revocationStore := oauthstore.PostgresStores(db.Pool()) now := time.Now() diff --git a/pkg/postgres/migrations/0015_oauth.sql b/pkg/postgres/migrations/0017_oauth.sql similarity index 100% rename from pkg/postgres/migrations/0015_oauth.sql rename to pkg/postgres/migrations/0017_oauth.sql diff --git a/pkg/runtime/README.md b/pkg/runtime/README.md index 9d01412..58d07f0 100644 --- a/pkg/runtime/README.md +++ b/pkg/runtime/README.md @@ -102,6 +102,23 @@ rt, err := runtime.New(). Postgres mode with `WithSearch` also wires a **background reindex worker** and registers `search.NewReindexNotifier` on the registry manager so SearchParameter changes enqueue reindex jobs. +### Builtin SMART OAuth (`haistack serve`) + +When HTTP is enabled, `WithBuiltinOAuth` mounts `pkg/oauth` on the root handler (`/oauth/*`, `/.well-known/*`, mirrored under `/fhir/.well-known/*`). Stores use `oauthstore.ApplySQLiteStores` or `ApplyPostgresStores`; signing keys persist as PEM files under `{data-dir}/oauth` or beside the SQLite database. + +```go +rt, err := runtime.New(). + WithSQLite("/data/haistack.db"). + WithHTTP(":8080"). + WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + IssuerURL: "https://auth.example.test", + TenantID: "local", + }). + Build(ctx) +``` + +`haistack serve` enables this automatically when `oauth.enabled` is true. See `pkg/oauth/OPERATIONS.md` for production vs Inferno reference profiles. + ### Cloud Postgres + external adapters ```go @@ -141,6 +158,7 @@ Concrete provider implementations belong outside `pkg/runtime`. The adapter inte | `WithSyncNode(nodeID)` | Device node ID (default: `runtime-node`) | | `WithModules(paths...)` | Install local module directories at build time | | `WithHTTP(addr)` | Managed HTTP listen address (optional) | +| `WithBuiltinOAuth(cfg)` | Mount built-in SMART OAuth server with durable stores and FHIR bearer auth | | `WithHTTPAuth(...)` / `WithHTTPMiddleware(...)` | Configure managed HTTP authentication and policy middleware | | `WithHTTPRateLimit(config)` | Configure process-local managed HTTP rate limiting | | `WithModuleAuthorizer(authorizer)` | Authorize module installs and upgrades | diff --git a/pkg/runtime/builder.go b/pkg/runtime/builder.go index ea9261c..e3cfcb0 100644 --- a/pkg/runtime/builder.go +++ b/pkg/runtime/builder.go @@ -40,6 +40,7 @@ type Builder struct { syncMiddleware func(http.Handler) http.Handler httpMiddleware func(http.Handler) http.Handler httpPrincipalResolver hahttp.PrincipalResolver + httpAuthBundleResolver hahttp.AuthBundleResolver httpAuthChecker hahttp.AuthChecker httpRateLimit hahttp.RateLimitConfig moduleAuthorizer modules.InstallAuthorizer @@ -58,8 +59,9 @@ type Builder struct { preExpandValueSets bool maxExpansion int - builtinOAuth *BuiltinOAuthConfig - oauthHandler http.Handler + builtinOAuth *BuiltinOAuthConfig + oauthHandler http.Handler + oauthIssuerURL string } // New returns a new runtime builder. diff --git a/pkg/runtime/config.go b/pkg/runtime/config.go index 0da9349..13fcf66 100644 --- a/pkg/runtime/config.go +++ b/pkg/runtime/config.go @@ -21,4 +21,8 @@ type Config struct { SyncEnabled bool SyncHubURL string SyncNodeID string + + // OAuth (builtin authorization server) + OAuthEnabled bool + OAuthIssuer string } diff --git a/pkg/runtime/oauth_builtin.go b/pkg/runtime/oauth_builtin.go index 3cc4c29..e22fd27 100644 --- a/pkg/runtime/oauth_builtin.go +++ b/pkg/runtime/oauth_builtin.go @@ -153,7 +153,9 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error } b.oauthHandler = srv.Handler() + b.oauthIssuerURL = issuer b.httpPrincipalResolver = hahttp.SMARTBearerPrincipalResolver(bearer) + b.httpAuthBundleResolver = hahttp.SMARTBearerBundleResolver(bearer) b.httpAuthChecker = smart.ScopePolicyAuthChecker{Engine: engine, Adapter: adapter} return nil } diff --git a/pkg/runtime/wire.go b/pkg/runtime/wire.go index 1b6269b..8d642e4 100644 --- a/pkg/runtime/wire.go +++ b/pkg/runtime/wire.go @@ -92,6 +92,10 @@ func (b *Builder) wire(ctx context.Context, rt *Runtime) error { } rt.services = state.services + if b.oauthIssuerURL != "" { + rt.config.OAuthEnabled = true + rt.config.OAuthIssuer = b.oauthIssuerURL + } rt.handler = hahttp.WithHealthEndpoints(state.httpHandler, rt.IsStarted) rt.jobRunner = state.jobRunner rt.syncProcessor = state.syncProcessor @@ -744,6 +748,7 @@ func (b *Builder) wireCommon(ctx context.Context, state *wireState, pc persisten PatientReferenceResolver: patientRefResolver, AuthMiddleware: b.httpMiddleware, PrincipalResolver: b.httpPrincipalResolver, + AuthBundleResolver: b.httpAuthBundleResolver, AuthChecker: b.httpAuthChecker, BulkExportService: state.services.BulkExportService, ViewMaterializeService: state.services.MaterializeService, diff --git a/pkg/sqlite/migrations/0014_oauth.sql b/pkg/sqlite/migrations/0014_oauth.sql index 784ddfe..4877de8 100644 --- a/pkg/sqlite/migrations/0014_oauth.sql +++ b/pkg/sqlite/migrations/0014_oauth.sql @@ -1,4 +1,4 @@ --- OAuth authorization server state (JSON payloads mirror Postgres 0015_oauth.sql) +-- OAuth authorization server state (JSON payloads mirror Postgres 0017_oauth.sql) CREATE TABLE IF NOT EXISTS hai_oauth_client ( client_id TEXT PRIMARY KEY, diff --git a/pkg/testkit/README.md b/pkg/testkit/README.md index 8080cca..2d7efb4 100644 --- a/pkg/testkit/README.md +++ b/pkg/testkit/README.md @@ -23,6 +23,7 @@ files into importable Go packages (not `_test.go` sources). Downstream tests can | **fhirpathtest** | FHIRPath evaluation and assertion wrappers | | **aitest** | Reusable `ai.Executor` harness with optional search/views/core | | **authztest** | Authorization scenario catalog (≥30 cases) across auth, SMART, view, AI, sync | +| **infernotest** | Inferno STU2 discovery + standalone SMART launch helpers and reference host | It does **not**: From 90e5c67504b2ec8a0312dab8acee2824dd6a43cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 23:16:59 +0000 Subject: [PATCH 03/14] feat(oauth): complete deferred port items (introspect, rate limits, DB 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 --- cmd/haistack/README.md | 9 +- pkg/http/sync.go | 1 + pkg/oauth/OPERATIONS.md | 12 +- pkg/oauth/README.md | 22 +- pkg/oauth/client_auth.go | 9 + pkg/oauth/errors.go | 13 + pkg/oauth/handlers.go | 37 ++- pkg/oauth/introspect.go | 161 +++++++++++ pkg/oauth/introspect_test.go | 75 +++++ pkg/oauth/persistent.go | 32 ++- pkg/oauth/ratelimit.go | 195 +++++++++++++ pkg/oauth/redis/store.go | 16 +- pkg/oauth/server.go | 50 +++- pkg/oauth/session_auth.go | 191 +++++++++++++ pkg/oauth/signing_key_crypto.go | 92 ++++++ pkg/oauth/store/apply.go | 46 +++ pkg/oauth/store/postgres.go | 20 +- pkg/oauth/store/rate_limit_store.go | 250 +++++++++++++++++ pkg/oauth/store/rate_limit_store_test.go | 44 +++ pkg/oauth/store/signing_key.go | 262 ++++++++++++++++++ pkg/oauth/store/signing_key_test.go | 42 +++ pkg/oauth/store/sqlite.go | 24 +- pkg/oauth/tenant.go | 261 +++++++++++++++++ pkg/oauth/tenant_test.go | 56 ++++ .../migrations/0018_oauth_rate_limit.sql | 7 + .../migrations/0019_oauth_signing_key.sql | 14 + pkg/runtime/oauth_builtin.go | 82 +++++- .../migrations/0015_oauth_rate_limit.sql | 7 + .../migrations/0016_oauth_signing_key.sql | 14 + 29 files changed, 2002 insertions(+), 42 deletions(-) create mode 100644 pkg/oauth/introspect.go create mode 100644 pkg/oauth/introspect_test.go create mode 100644 pkg/oauth/ratelimit.go create mode 100644 pkg/oauth/session_auth.go create mode 100644 pkg/oauth/signing_key_crypto.go create mode 100644 pkg/oauth/store/rate_limit_store.go create mode 100644 pkg/oauth/store/rate_limit_store_test.go create mode 100644 pkg/oauth/store/signing_key.go create mode 100644 pkg/oauth/store/signing_key_test.go create mode 100644 pkg/oauth/tenant.go create mode 100644 pkg/oauth/tenant_test.go create mode 100644 pkg/postgres/migrations/0018_oauth_rate_limit.sql create mode 100644 pkg/postgres/migrations/0019_oauth_signing_key.sql create mode 100644 pkg/sqlite/migrations/0015_oauth_rate_limit.sql create mode 100644 pkg/sqlite/migrations/0016_oauth_signing_key.sql diff --git a/cmd/haistack/README.md b/cmd/haistack/README.md index 676ae31..7ba7e20 100644 --- a/cmd/haistack/README.md +++ b/cmd/haistack/README.md @@ -118,14 +118,15 @@ sync: nodeID: runtime-node ``` -`haistack serve` mounts the built-in SMART OAuth server when `oauth.enabled` is true (default) and HTTP is configured. SMART discovery is available at `{issuer}/fhir/.well-known/smart-configuration`. Signing keys persist to `{sqlite-dir}/oauth/oauth-signing.pem`. +`haistack serve` mounts the built-in SMART OAuth server when `oauth.enabled` is true (default) and HTTP is configured. SMART discovery is available at `{issuer}/fhir/.well-known/smart-configuration` and tenant-scoped routes at `{issuer}/t/{tenantId}/.well-known/smart-configuration`. Signing keys persist in the database when `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is set, otherwise to `{sqlite-dir}/oauth/oauth-signing.pem`. **Production checklist** 1. Set `oauth.issuerURL` to your public https issuer (pin before first start). 2. Set `oauth.production: true` and `OAUTH_REGISTRATION_TOKEN`. -3. Set `oauth.autoApprove: false` (enforced when production is on). -4. Back up `oauth-signing.pem` beside your database. +3. Set `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` and `OAUTH_SESSION_SECRET`. +4. Set `oauth.autoApprove: false` (enforced when production is on). +5. Back up DB signing keys or `oauth-signing.pem` beside your database. ### Precedence @@ -159,6 +160,8 @@ If the default `haistack.yaml` is missing, built-in defaults are used so command | `HAISTACK_OAUTH_AUTO_APPROVE` | `oauth.autoApprove` | | `HAISTACK_OAUTH_REGISTRATION_TOKEN` | `oauth.registrationAccessToken` | | `OAUTH_REGISTRATION_TOKEN` | `oauth.registrationAccessToken` | +| `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` | DB signing key encryption (production) | +| `OAUTH_SESSION_SECRET` | `/oauth/login` session cookie signing (production) | | `HAISTACK_PRODUCTION=1` | enables `oauth.production` | ### Persistent flags diff --git a/pkg/http/sync.go b/pkg/http/sync.go index 7e4e0ef..3ed3543 100644 --- a/pkg/http/sync.go +++ b/pkg/http/sync.go @@ -178,6 +178,7 @@ func NewRootHandlerFromConfig(cfg RootConfig) http.Handler { mux.Handle("/oauth/", cfg.OAuth) mux.Handle("/oauth", cfg.OAuth) mux.Handle("/.well-known/", cfg.OAuth) + mux.Handle("/t/", cfg.OAuth) if cfg.FHIR != nil { mux.HandleFunc("/fhir/.well-known/", mirrorOAuthWellKnown(cfg.OAuth)) } diff --git a/pkg/oauth/OPERATIONS.md b/pkg/oauth/OPERATIONS.md index 774fba4..45d8d2c 100644 --- a/pkg/oauth/OPERATIONS.md +++ b/pkg/oauth/OPERATIONS.md @@ -12,7 +12,11 @@ This document describes how to run the built-in `pkg/oauth` authorization server | `store.ApplySQLiteStores` / `ApplyPostgresStores` | Yes | Yes (via `runtime.WithBuiltinOAuth`) | | `RegistrationAccessToken` | Unset (open DCR) | Required when `oauth.production` is true | | `AutoApprove` | `true` | `false` in production | -| Signing key | Ephemeral per process | PEM at `{sqlite-dir}/oauth/oauth-signing.pem` | +| Signing key | Ephemeral per process | DB keys with `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET`, or PEM fallback | +| User login | N/A | `/oauth/login` session cookies via `OAUTH_SESSION_SECRET` | +| Rate limits | In-memory | DB-backed (`hai_oauth_rate_limit`) | +| Introspection | Available | `POST /oauth/introspect` (confidential clients) | +| Tenant routes | N/A | `/t/{tenantId}/oauth/*` | | TLS | Plain http on loopback | Pin `oauth.issuerURL` to https in production | **Do not** point production traffic at `infernotest.BuildReferenceHandler` or `cmd/inferno-reference`. @@ -31,7 +35,9 @@ Enable with `oauth.enabled: true` (default). Production checklist: 1. Set `oauth.issuerURL` to your public https issuer. 2. Set `OAUTH_REGISTRATION_TOKEN` (or `oauth.registrationAccessToken`). -3. Set `oauth.production: true` and `oauth.autoApprove: false`. -4. Back up `oauth-signing.pem` beside the SQLite database (or use Postgres + shared PEM path via `runtime.BuiltinOAuthConfig.StateDir`). +3. Set `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` for DB-backed signing keys (recommended). +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`). SMART discovery is served at `/.well-known/smart-configuration` and mirrored under `/fhir/.well-known/smart-configuration`. diff --git a/pkg/oauth/README.md b/pkg/oauth/README.md index a451664..8154cf5 100644 --- a/pkg/oauth/README.md +++ b/pkg/oauth/README.md @@ -11,7 +11,10 @@ Production-capable OAuth2/OIDC authorization server for SMART on FHIR. | `/oauth/authorize` | Authorization code + PKCE | | `/oauth/token` | Token exchange (auth code, client credentials, refresh) | | `/oauth/revoke` | Revoke refresh tokens and JWT access tokens (by `jti`) | +| `/oauth/introspect` | RFC 7662 token introspection (confidential clients only) | | `/oauth/jwks` | Signing key set | +| `/oauth/login` | Session login for production consent (when `UserAuthenticator` is configured) | +| `/t/{tenantId}/oauth/*` | Tenant-scoped OAuth routes (via `MultiTenantServer`) | | `/oauth/register` | Dynamic client registration (opt-in) | | `/oauth/consent` | Built-in HTML consent form | | `/oauth/launch` | EHR launch context (JSON) | @@ -48,8 +51,10 @@ server, err := oauthstore.NewPostgresServer(oauth.Config{ - `ClientRegistry` — clients with bcrypt-hashed secrets - `ReplayStore` — backend/client-assertion `jti` replay protection - `RevocationStore` — revoked access-token `jti` denylist +- `TokenRateLimiter` / `RegisterRateLimiter` — DB-backed endpoint rate limits +- DB signing keys via `ApplyPostgresSigningKey` / `ApplySQLiteSigningKey` when `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is set -Schema: migration `0017_oauth.sql` (Postgres) or `0014_oauth.sql` (SQLite). +Schema: migrations `0017_oauth.sql` + `0018_oauth_rate_limit.sql` + `0019_oauth_signing_key.sql` (Postgres), or `0014_oauth.sql` + `0015_oauth_rate_limit.sql` + `0016_oauth_signing_key.sql` (SQLite). ### Why file stores existed @@ -127,12 +132,23 @@ form.Set("code_verifier", pkceVerifier) Revoked access tokens are rejected by `server.BearerAuthConfig()` via `TokenValidateOptions.IsJWTRevoked`. All access tokens include a `client_id` claim; revoke rejects tokens without it. +## Production environment variables + +| Variable | Purpose | +|----------|---------| +| `OAUTH_REGISTRATION_TOKEN` | Bearer token for `POST /oauth/register` | +| `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` | AES key for DB-stored signing keys (required for `haistack serve` production) | +| `OAUTH_SESSION_SECRET` | HMAC secret for `/oauth/login` session cookies (required for production consent) | + +When `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is unset, signing keys fall back to PEM at `{state-dir}/oauth-signing.pem`. + ## Multi-instance checklist 1. Use `oauthstore.NewPostgresServer` (recommended) or shared file stores for dev only. -2. Persist `oauth-signing.pem` across restarts (`LoadKeySetFromPEM`). -3. Set `UserAuthenticator` for end-user consent binding. +2. Persist signing keys in DB (`OAUTH_SIGNING_KEY_ENCRYPTION_SECRET`) or `oauth-signing.pem` across restarts. +3. Set `UserAuthenticator` (or use `haistack serve` production session login) for end-user consent binding. 4. Keep `AutoApprove: false` in production. 5. Enable `AllowDynamicRegistration` only when required. +6. Mount tenant routes at `/t/{tenantId}/` when using `MultiTenantServer`. See `examples/smart-oauth` for a runnable demo, or `haistack serve` for built-in OAuth with SQLite/Postgres stores (`runtime.WithBuiltinOAuth`). Operations guidance: `OPERATIONS.md`. diff --git a/pkg/oauth/client_auth.go b/pkg/oauth/client_auth.go index 84a42f0..9e346c6 100644 --- a/pkg/oauth/client_auth.go +++ b/pkg/oauth/client_auth.go @@ -101,3 +101,12 @@ func isPublicClient(client Client) bool { strings.TrimSpace(client.ClientSecret) == "" && strings.TrimSpace(client.PublicKeyPEM) == "" } + +func (s *Server) requireConfidentialClient(w http.ResponseWriter, r *http.Request) (Client, bool) { + client, _, err := s.lookupAuthenticatedClient(r, r.Form.Get("client_id")) + if err != nil || isPublicClient(client) { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "confidential client authentication required") + return Client{}, false + } + return client, true +} diff --git a/pkg/oauth/errors.go b/pkg/oauth/errors.go index bd6f1c3..43578e9 100644 --- a/pkg/oauth/errors.go +++ b/pkg/oauth/errors.go @@ -2,9 +2,22 @@ package oauth import ( "encoding/json" + "errors" "net/http" + "strings" ) +// ErrInvalidConfig indicates invalid OAuth server configuration. +var ErrInvalidConfig = errors.New("oauth: invalid config") + +func writeMethodNotAllowed(w http.ResponseWriter, allowed ...string) { + msg := "method not allowed" + if len(allowed) > 0 { + msg = "method not allowed; use " + strings.Join(allowed, " or ") + } + writeOAuthError(w, http.StatusMethodNotAllowed, "invalid_request", msg) +} + func writeOAuthError(w http.ResponseWriter, status int, code, description string) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") diff --git a/pkg/oauth/handlers.go b/pkg/oauth/handlers.go index f9081a9..2e018c6 100644 --- a/pkg/oauth/handlers.go +++ b/pkg/oauth/handlers.go @@ -28,7 +28,36 @@ func (s *Server) handleSMARTConfiguration(w http.ResponseWriter, _ *http.Request } func (s *Server) handleJWKS(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, s.cfg.SigningKey.JWKS()) + keys := []map[string]any{} + seen := map[string]struct{}{} + for _, keySet := range s.verificationKeySets() { + doc := keySet.JWKS() + raw, _ := doc["keys"].([]map[string]any) + for _, entry := range raw { + kid, _ := entry["kid"].(string) + if kid != "" { + if _, ok := seen[kid]; ok { + continue + } + seen[kid] = struct{}{} + } + keys = append(keys, entry) + } + } + writeJSON(w, http.StatusOK, map[string]any{"keys": keys}) +} + +func (s *Server) verificationKeySets() []*KeySet { + out := []*KeySet{} + if s.cfg.SigningKey != nil { + out = append(out, s.cfg.SigningKey) + } + for _, keySet := range s.cfg.VerificationKeys { + if keySet != nil { + out = append(out, keySet) + } + } + return out } func (s *Server) handleAuthorize(w http.ResponseWriter, r *http.Request) { @@ -78,6 +107,11 @@ func (s *Server) handleAuthorize(w http.ResponseWriter, r *http.Request) { authReq.Subject = user.Subject authReq.FHIRUser = user.FHIRUser } else if s.cfg.UserAuthenticator != nil { + if loginPath := strings.TrimSpace(s.cfg.LoginPath); loginPath != "" { + returnURL := s.cfg.Issuer + r.URL.RequestURI() + http.Redirect(w, r, loginPath+"?return="+url.QueryEscape(returnURL), http.StatusFound) + return + } http.Error(w, "user authentication required", http.StatusUnauthorized) return } @@ -480,6 +514,7 @@ func (s *Server) openIDConfiguration() map[string]any { "authorization_endpoint": s.cfg.Issuer + "/oauth/authorize", "token_endpoint": s.cfg.Issuer + "/oauth/token", "revocation_endpoint": s.cfg.Issuer + "/oauth/revoke", + "introspection_endpoint": s.cfg.Issuer + "/oauth/introspect", "registration_endpoint": registration, "response_types_supported": []string{"code"}, "grant_types_supported": []string{"authorization_code", "client_credentials", "refresh_token"}, diff --git a/pkg/oauth/introspect.go b/pkg/oauth/introspect.go new file mode 100644 index 0000000..9c89964 --- /dev/null +++ b/pkg/oauth/introspect.go @@ -0,0 +1,161 @@ +package oauth + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/degoke/health-ai-stack/pkg/smart" +) + +// IntrospectionResponse is the RFC 7662 token introspection payload. +type IntrospectionResponse struct { + Active bool `json:"active"` + Scope string `json:"scope,omitempty"` + ClientID string `json:"client_id,omitempty"` + Username string `json:"username,omitempty"` + TokenType string `json:"token_type,omitempty"` + Exp int64 `json:"exp,omitempty"` + Iat int64 `json:"iat,omitempty"` + Sub string `json:"sub,omitempty"` + Aud string `json:"aud,omitempty"` + Iss string `json:"iss,omitempty"` + JTI string `json:"jti,omitempty"` + Patient string `json:"patient,omitempty"` + FHIRUser string `json:"fhirUser,omitempty"` + Tenant string `json:"tenant,omitempty"` +} + +func (s *Server) handleIntrospect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + if err := r.ParseForm(); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "malformed form body") + return + } + token := strings.TrimSpace(r.Form.Get("token")) + if token == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "token is required") + return + } + if _, ok := s.requireConfidentialClient(w, r); !ok { + return + } + + hint := strings.TrimSpace(r.Form.Get("token_type_hint")) + resp := s.IntrospectToken(token, hint) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +// IntrospectToken validates a token and returns RFC 7662 metadata. +func (s *Server) IntrospectToken(token, tokenTypeHint string) IntrospectionResponse { + if strings.TrimSpace(token) == "" { + return IntrospectionResponse{Active: false} + } + switch tokenTypeHint { + case "refresh_token": + if resp, ok := s.introspectRefreshToken(token); ok { + return resp + } + case "access_token": + if resp, ok := s.introspectAccessToken(token); ok { + return resp + } + } + if resp, ok := s.introspectAccessToken(token); ok { + return resp + } + if resp, ok := s.introspectRefreshToken(token); ok { + return resp + } + return IntrospectionResponse{Active: false} +} + +func (s *Server) introspectAccessToken(token string) (IntrospectionResponse, bool) { + pem, err := s.cfg.SigningKey.PublicKeyPEM() + if err != nil { + return IntrospectionResponse{}, false + } + verifier := smart.PEMVerifier{ + PublicKeyPEM: pem, + Algorithm: s.cfg.SigningKey.Algorithm, + } + opts := smart.TokenValidateOptions{ + ExpectedIssuer: s.cfg.Issuer, + ExpectedAudience: s.cfg.FHIRAudience, + RequireIssuer: true, + RequireAudience: true, + RequireExpiry: true, + RequireSubject: true, + Now: s.cfg.Now, + } + if s.revocationStore != nil { + opts.IsJWTRevoked = s.revocationStore.IsRevoked + } + claims, err := smart.ValidateToken(token, verifier, opts) + if err != nil { + return IntrospectionResponse{}, false + } + return claimsToIntrospection(claims, "Bearer"), true +} + +func (s *Server) introspectRefreshToken(token string) (IntrospectionResponse, bool) { + if s.authStore == nil { + return IntrospectionResponse{}, false + } + record, ok := s.authStore.LookupRefreshToken(token) + if !ok { + return IntrospectionResponse{}, false + } + return IntrospectionResponse{ + Active: true, + Scope: record.Scope, + ClientID: record.ClientID, + Username: record.Subject, + TokenType: "refresh_token", + Exp: record.ExpiresAt.Unix(), + Sub: record.Subject, + Iss: s.cfg.Issuer, + Patient: record.Patient, + FHIRUser: record.FHIRUser, + }, true +} + +func claimsToIntrospection(claims smart.TokenClaims, tokenType string) IntrospectionResponse { + resp := IntrospectionResponse{ + Active: true, + Scope: claims.Scope, + ClientID: firstNonEmpty(claims.ClientID, claims.Subject), + Username: claims.Subject, + TokenType: tokenType, + Sub: claims.Subject, + Iss: claims.Issuer, + JTI: claims.JWTID, + Patient: claims.Patient, + FHIRUser: claims.FHIRUser, + Tenant: claims.TenantHint, + } + if !claims.ExpiresAt.IsZero() { + resp.Exp = claims.ExpiresAt.Unix() + } + if !claims.IssuedAt.IsZero() { + resp.Iat = claims.IssuedAt.Unix() + } + if len(claims.Audience) > 0 { + resp.Aud = claims.Audience[0] + } + return resp +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/pkg/oauth/introspect_test.go b/pkg/oauth/introspect_test.go new file mode 100644 index 0000000..1b10e06 --- /dev/null +++ b/pkg/oauth/introspect_test.go @@ -0,0 +1,75 @@ +package oauth_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/degoke/health-ai-stack/pkg/client" + "github.com/degoke/health-ai-stack/pkg/oauth" +) + +func TestOAuthServer_IntrospectAccessToken(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + base := strings.TrimSuffix(srv.URL, "/") + server, err := oauth.NewServer(oauth.Config{Issuer: base, FHIRAudience: base, AutoApprove: true}) + if err != nil { + t.Fatal(err) + } + secret := "introspect-secret" + _ = server.RegisterClient(oauth.Client{ + ClientID: "intro-client", + ClientSecret: secret, + TokenEndpointAuthMethod: oauth.AuthMethodClientSecretPost, + RedirectURIs: []string{"https://localhost/callback"}, + Scopes: []string{"patient/Patient.rs"}, + }) + mux.Handle("/", server.Handler()) + + httpClient, _ := client.New(client.Config{BaseURL: base}) + cfg, _ := httpClient.SMART().Discover(context.Background(), base) + pkce, _ := client.NewPKCEChallenge() + authURL, _ := httpClient.SMART().BuildAuthURL(client.AuthCodeRequest{ + Config: cfg, ClientID: "intro-client", RedirectURI: "https://localhost/callback", + Scope: "patient/Patient.rs", PKCE: pkce, + }) + noRedirect := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }} + authResp, _ := noRedirect.Get(authURL) + _ = authResp.Body.Close() + code := strings.Split(strings.Split(authResp.Header.Get("Location"), "code=")[1], "&")[0] + tokenResp, err := httpClient.SMART().ExchangeAuthCode(context.Background(), client.AuthCodeExchangeRequest{ + TokenEndpoint: cfg.TokenEndpoint, ClientID: "intro-client", ClientSecret: secret, + RedirectURI: "https://localhost/callback", Code: code, PKCE: pkce, + }) + if err != nil { + t.Fatal(err) + } + + form := url.Values{} + form.Set("token", tokenResp.AccessToken) + form.Set("client_id", "intro-client") + form.Set("client_secret", secret) + resp, err := http.PostForm(base+"/oauth/introspect", form) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var doc map[string]any + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + t.Fatal(err) + } + if doc["active"] != true { + t.Fatalf("doc = %#v", doc) + } +} diff --git a/pkg/oauth/persistent.go b/pkg/oauth/persistent.go index de68447..46817fd 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -18,6 +18,7 @@ type AuthorizationStore interface { ConsumeAuthorizationCode(code string) (AuthorizationCode, bool) SaveRefreshToken(token string, entry RefreshTokenEntry) error ConsumeRefreshToken(token string) (RefreshTokenEntry, bool) + LookupRefreshToken(token string) (RefreshTokenEntry, bool) SavePendingAuthorization(id string, entry PendingAuthorization) error GetPendingAuthorization(id string) (PendingAuthorization, bool) ConsumePendingAuthorization(id string) (PendingAuthorization, bool) @@ -106,12 +107,20 @@ func (s *MemoryAuthorizationStore) SaveRefreshToken(token string, entry RefreshT } func (s *MemoryAuthorizationStore) ConsumeRefreshToken(token string) (RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(token) + if !ok { + return RefreshTokenEntry{}, false + } + s.mu.Lock() + delete(s.refreshTokens, token) + s.mu.Unlock() + return entry, true +} + +func (s *MemoryAuthorizationStore) LookupRefreshToken(token string) (RefreshTokenEntry, bool) { now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.refreshTokens[token] - if ok { - delete(s.refreshTokens, token) - } s.mu.Unlock() if !ok || now.After(entry.ExpiresAt) { return RefreshTokenEntry{}, false @@ -228,14 +237,25 @@ func (s *FileAuthorizationStore) SaveRefreshToken(token string, entry RefreshTok } func (s *FileAuthorizationStore) ConsumeRefreshToken(token string) (RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(token) + if !ok { + return RefreshTokenEntry{}, false + } + err := s.update(func(state *fileAuthorizationState) { + delete(state.Refresh, token) + }) + if err != nil { + return RefreshTokenEntry{}, false + } + return entry, true +} + +func (s *FileAuthorizationStore) LookupRefreshToken(token string) (RefreshTokenEntry, bool) { now := s.now() var entry RefreshTokenEntry var ok bool err := s.update(func(state *fileAuthorizationState) { entry, ok = state.Refresh[token] - if ok { - delete(state.Refresh, token) - } }) if err != nil || !ok || now.After(entry.ExpiresAt) { return RefreshTokenEntry{}, false diff --git a/pkg/oauth/ratelimit.go b/pkg/oauth/ratelimit.go new file mode 100644 index 0000000..204246e --- /dev/null +++ b/pkg/oauth/ratelimit.go @@ -0,0 +1,195 @@ +package oauth + +import ( + "context" + "net" + "net/http" + "strings" + "sync" + "time" +) + +const ( + defaultOAuthTokenRateLimit = 120 + defaultOAuthRegisterRateLimit = 30 + defaultOAuthRateLimitWindow = time.Minute +) + +// RateLimitStore persists or tracks OAuth endpoint request counters. +type RateLimitStore interface { + Allow(ctx context.Context, endpoint, bucketKey string, limit int, window time.Duration, now time.Time) (bool, error) +} + +type memoryRateLimiter struct { + limit int + window time.Duration + inner *oauthRateLimiter +} + +func newMemoryRateLimiter(limit int, window time.Duration) *memoryRateLimiter { + return &memoryRateLimiter{ + limit: limit, + window: window, + inner: newOAuthRateLimiter(limit, window), + } +} + +func (l *memoryRateLimiter) Allow(_ context.Context, _, bucketKey string, limit int, window time.Duration, _ time.Time) (bool, error) { + if l == nil || l.inner == nil { + return true, nil + } + if limit <= 0 { + limit = l.limit + } + if window <= 0 { + window = l.window + } + if limit != l.limit || window != l.window { + l.inner = newOAuthRateLimiter(limit, window) + l.limit = limit + l.window = window + } + return l.inner.allow(bucketKey), nil +} + +type oauthRateLimiter struct { + mu sync.Mutex + entries map[string]rateLimitEntry + limit int + window time.Duration +} + +type rateLimitEntry struct { + windowStart time.Time + count int +} + +func newOAuthRateLimiter(limit int, window time.Duration) *oauthRateLimiter { + if limit <= 0 { + limit = defaultOAuthTokenRateLimit + } + if window <= 0 { + window = defaultOAuthRateLimitWindow + } + return &oauthRateLimiter{ + entries: make(map[string]rateLimitEntry), + limit: limit, + window: window, + } +} + +func (l *oauthRateLimiter) allow(key string) bool { + if l == nil { + return true + } + key = strings.TrimSpace(key) + if key == "" { + key = "anonymous" + } + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + for existingKey, entry := range l.entries { + if now.Sub(entry.windowStart) >= l.window { + delete(l.entries, existingKey) + } + } + entry, ok := l.entries[key] + if !ok || now.Sub(entry.windowStart) >= l.window { + l.entries[key] = rateLimitEntry{windowStart: now, count: 1} + return true + } + if entry.count >= l.limit { + return false + } + entry.count++ + l.entries[key] = entry + return true +} + +func oauthClientIP(r *http.Request) string { + if r == nil { + return "anonymous" + } + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err == nil && host != "" { + return host + } + return strings.TrimSpace(r.RemoteAddr) +} + +func (s *Server) rateLimitOAuth(w http.ResponseWriter, r *http.Request, endpoint string, limiter RateLimitStore, limit int, window time.Duration) bool { + if limiter == nil { + return true + } + allowed, err := limiter.Allow(r.Context(), endpoint, oauthClientIP(r), limit, window, s.cfg.Now()) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "rate limit unavailable") + return false + } + if !allowed { + writeOAuthError(w, http.StatusTooManyRequests, "slow_down", "rate limit exceeded") + return false + } + return true +} + +func (s *Server) tokenRateLimiter(limit int, window time.Duration) RateLimitStore { + if s == nil { + return nil + } + if s.tokenLimiter != nil { + return s.tokenLimiter + } + return newMemoryRateLimiter(limit, window) +} + +func (s *Server) registerRateLimiter(limit int, window time.Duration) RateLimitStore { + if s == nil { + return nil + } + if s.registerLimiter != nil { + return s.registerLimiter + } + return newMemoryRateLimiter(limit, window) +} + +func rateLimitConfigFrom(cfg RateLimitConfig) (tokenLimit, registerLimit int, window time.Duration) { + tokenLimit = cfg.TokenRequests + registerLimit = cfg.RegisterRequests + window = cfg.Window + if tokenLimit <= 0 { + tokenLimit = defaultOAuthTokenRateLimit + } + if registerLimit <= 0 { + registerLimit = defaultOAuthRegisterRateLimit + } + if window <= 0 { + window = defaultOAuthRateLimitWindow + } + return tokenLimit, registerLimit, window +} + +// RateLimitConfig configures OAuth endpoint rate limits. +type RateLimitConfig struct { + TokenRequests int + RegisterRequests int + Window time.Duration +} + +func applyRateLimitConfig(s *Server, cfg RateLimitConfig, tokenStore, registerStore RateLimitStore) { + if s == nil { + return + } + tokenLimit, registerLimit, window := rateLimitConfigFrom(cfg) + if tokenStore != nil { + s.tokenLimiter = tokenStore + } else if s.tokenLimiter == nil { + s.tokenLimiter = newMemoryRateLimiter(tokenLimit, window) + } + if registerStore != nil { + s.registerLimiter = registerStore + } else if s.registerLimiter == nil { + s.registerLimiter = newMemoryRateLimiter(registerLimit, window) + } +} diff --git a/pkg/oauth/redis/store.go b/pkg/oauth/redis/store.go index 8d826b7..37d0403 100644 --- a/pkg/oauth/redis/store.go +++ b/pkg/oauth/redis/store.go @@ -75,13 +75,23 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT } func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(token) + if !ok { + return oauth.RefreshTokenEntry{}, false + } key := s.key("refresh:" + token) - payload, err := consumeRefreshTokenScript.Run(context.Background(), s.client, []string{key}).Text() - if err != nil || payload == "" { + _ = s.client.Del(context.Background(), key).Err() + return entry, true +} + +func (s *AuthorizationStore) LookupRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { + key := s.key("refresh:" + token) + payload, err := s.client.Get(context.Background(), key).Bytes() + if err == goredis.Nil || err != nil { return oauth.RefreshTokenEntry{}, false } var entry oauth.RefreshTokenEntry - if err := json.Unmarshal([]byte(payload), &entry); err != nil { + if err := json.Unmarshal(payload, &entry); err != nil { return oauth.RefreshTokenEntry{}, false } if s.now().After(entry.ExpiresAt) { diff --git a/pkg/oauth/server.go b/pkg/oauth/server.go index e8fc4b8..2ef249a 100644 --- a/pkg/oauth/server.go +++ b/pkg/oauth/server.go @@ -39,6 +39,16 @@ type Config struct { LaunchResolver LaunchResolver // UserAuthenticator identifies the end user approving access in production flows. UserAuthenticator UserAuthenticator + // LoginPath redirects unauthenticated authorize requests when UserAuthenticator is set. + LoginPath string + // RateLimit configures token and registration endpoint rate limits. + RateLimit RateLimitConfig + // TokenRateLimiter overrides the default in-memory token endpoint limiter. + TokenRateLimiter RateLimitStore + // RegisterRateLimiter overrides the default in-memory registration limiter. + RegisterRateLimiter RateLimitStore + // VerificationKeys are additional public keys exposed via JWKS (for rotation). + VerificationKeys []*KeySet } // Server is a SMART-compatible OAuth2/OIDC authorization server. @@ -48,6 +58,8 @@ type Server struct { replayStore smart.ReplayStore revocationStore TokenRevocationStore backendAuth *smart.BackendServiceAuth + tokenLimiter RateLimitStore + registerLimiter RateLimitStore } // NewServer constructs an authorization server. @@ -101,13 +113,15 @@ func NewServer(cfg Config) (*Server, error) { if err != nil { return nil, err } - return &Server{ + srv := &Server{ cfg: cfg, authStore: cfg.AuthorizationStore, replayStore: replayStore, revocationStore: cfg.RevocationStore, backendAuth: backendAuth, - }, nil + } + applyRateLimitConfig(srv, cfg.RateLimit, cfg.TokenRateLimiter, cfg.RegisterRateLimiter) + return srv, nil } // Issuer returns the configured issuer URL. @@ -192,16 +206,44 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/.well-known/openid-configuration", s.handleOpenIDConfiguration) mux.HandleFunc("/.well-known/smart-configuration", s.handleSMARTConfiguration) mux.HandleFunc("/oauth/authorize", s.handleAuthorize) - mux.HandleFunc("/oauth/token", s.handleToken) + mux.HandleFunc("/oauth/token", s.wrapTokenRateLimit(s.handleToken)) mux.HandleFunc("/oauth/revoke", s.handleRevoke) + mux.HandleFunc("/oauth/introspect", s.handleIntrospect) mux.HandleFunc("/oauth/jwks", s.handleJWKS) - mux.HandleFunc("/oauth/register", s.handleRegister) + if s.cfg.AllowDynamicRegistration { + mux.HandleFunc("/oauth/register", s.wrapRegisterRateLimit(s.handleRegister)) + } else { + mux.HandleFunc("/oauth/register", s.handleRegister) + } mux.HandleFunc("/oauth/consent", s.handleConsent) mux.HandleFunc("/oauth/launch", s.handleLaunch) mux.HandleFunc("/oauth/launch/ui", s.handleLaunchUI) + if s.cfg.LoginPath != "" { + mux.HandleFunc(s.cfg.LoginPath, s.handleSessionLogin) + } return mux } +func (s *Server) wrapTokenRateLimit(next http.HandlerFunc) http.HandlerFunc { + tokenLimit, _, window := rateLimitConfigFrom(s.cfg.RateLimit) + return func(w http.ResponseWriter, r *http.Request) { + if !s.rateLimitOAuth(w, r, "token", s.tokenRateLimiter(tokenLimit, window), tokenLimit, window) { + return + } + next(w, r) + } +} + +func (s *Server) wrapRegisterRateLimit(next http.HandlerFunc) http.HandlerFunc { + _, registerLimit, window := rateLimitConfigFrom(s.cfg.RateLimit) + return func(w http.ResponseWriter, r *http.Request) { + if !s.rateLimitOAuth(w, r, "register", s.registerRateLimiter(registerLimit, window), registerLimit, window) { + return + } + next(w, r) + } +} + func randomToken() string { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { diff --git a/pkg/oauth/session_auth.go b/pkg/oauth/session_auth.go new file mode 100644 index 0000000..f7e70bf --- /dev/null +++ b/pkg/oauth/session_auth.go @@ -0,0 +1,191 @@ +package oauth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +const sessionCookieName = "haistack_oauth_session" +const sessionSecretEnv = "OAUTH_SESSION_SECRET" + +// SessionUserAuthenticator authenticates users from signed session cookies. +type SessionUserAuthenticator struct { + secret []byte + now func() time.Time + ttl time.Duration +} + +// SessionAuthConfig configures signed cookie sessions for production consent login. +type SessionAuthConfig struct { + Secret string + TTL time.Duration + Now func() time.Time +} + +// NewSessionUserAuthenticator constructs a cookie session authenticator. +func NewSessionUserAuthenticator(cfg SessionAuthConfig) (*SessionUserAuthenticator, error) { + secret := strings.TrimSpace(cfg.Secret) + if secret == "" { + secret = strings.TrimSpace(os.Getenv(sessionSecretEnv)) + } + if secret == "" { + return nil, fmt.Errorf("%w: set %s for production user login", ErrInvalidConfig, sessionSecretEnv) + } + ttl := cfg.TTL + if ttl <= 0 { + ttl = 12 * time.Hour + } + now := cfg.Now + if now == nil { + now = time.Now + } + return &SessionUserAuthenticator{ + secret: []byte(secret), + now: now, + ttl: ttl, + }, nil +} + +func (a *SessionUserAuthenticator) AuthenticateUser(r *http.Request) (UserIdentity, bool) { + if a == nil { + return UserIdentity{}, false + } + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie == nil || strings.TrimSpace(cookie.Value) == "" { + return UserIdentity{}, false + } + subject, ok := a.verifySession(cookie.Value) + if !ok { + return UserIdentity{}, false + } + return UserIdentity{Subject: subject}, true +} + +func (a *SessionUserAuthenticator) SetSession(w http.ResponseWriter, subject string) error { + if a == nil || strings.TrimSpace(subject) == "" { + return fmt.Errorf("oauth: session subject required") + } + value, err := a.signSession(subject) + if err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: value, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: true, + Expires: a.now().Add(a.ttl), + }) + return nil +} + +type sessionPayload struct { + Subject string `json:"sub"` + Exp int64 `json:"exp"` +} + +func (a *SessionUserAuthenticator) signSession(subject string) (string, error) { + payload := sessionPayload{ + Subject: strings.TrimSpace(subject), + Exp: a.now().Add(a.ttl).Unix(), + } + raw, err := json.Marshal(payload) + if err != nil { + return "", err + } + encoded := base64.RawURLEncoding.EncodeToString(raw) + mac := hmac.New(sha256.New, a.secret) + _, _ = mac.Write([]byte(encoded)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return encoded + "." + sig, nil +} + +func (a *SessionUserAuthenticator) verifySession(value string) (string, bool) { + parts := strings.Split(value, ".") + if len(parts) != 2 { + return "", false + } + mac := hmac.New(sha256.New, a.secret) + _, _ = mac.Write([]byte(parts[0])) + expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(parts[1])) { + return "", false + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", false + } + var payload sessionPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return "", false + } + if payload.Exp > 0 && a.now().Unix() > payload.Exp { + return "", false + } + subject := strings.TrimSpace(payload.Subject) + if subject == "" { + return "", false + } + return subject, true +} + +func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) { + auth, ok := s.cfg.UserAuthenticator.(*SessionUserAuthenticator) + if !ok || auth == nil { + http.NotFound(w, r) + return + } + 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) + return + } + serveLoginPage(w, returnURL) + return + } + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodGet, http.MethodPost) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + subject := strings.TrimSpace(r.FormValue("username")) + if subject == "" { + http.Error(w, "username required", http.StatusBadRequest) + return + } + if err := auth.SetSession(w, subject); err != nil { + http.Error(w, "session unavailable", http.StatusInternalServerError) + return + } + if returnURL == "" { + returnURL = s.cfg.Issuer + "/oauth/authorize" + } + http.Redirect(w, r, returnURL, http.StatusFound) +} + +func serveLoginPage(w http.ResponseWriter, returnURL string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(` +Sign in + +

Sign in

+
+ + + +
+`)) +} diff --git a/pkg/oauth/signing_key_crypto.go b/pkg/oauth/signing_key_crypto.go new file mode 100644 index 0000000..c14b16a --- /dev/null +++ b/pkg/oauth/signing_key_crypto.go @@ -0,0 +1,92 @@ +package oauth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "os" + "strings" +) + +const signingKeyEncryptionEnv = "OAUTH_SIGNING_KEY_ENCRYPTION_SECRET" + +// SigningKeyEncryptionSecret returns the configured signing-key encryption secret. +func SigningKeyEncryptionSecret() string { + return strings.TrimSpace(os.Getenv(signingKeyEncryptionEnv)) +} + +// RequireSigningKeyEncryptionSecret returns an error when secret is empty. +func RequireSigningKeyEncryptionSecret() error { + if SigningKeyEncryptionSecret() == "" { + return fmt.Errorf("%w: set %s for production signing key storage", ErrInvalidConfig, signingKeyEncryptionEnv) + } + return nil +} + +func deriveSigningKeyEncryptionKey(secret string) ([]byte, error) { + secret = strings.TrimSpace(secret) + if secret == "" { + return nil, fmt.Errorf("%w: signing key encryption secret required", ErrInvalidConfig) + } + sum := sha256.Sum256([]byte(secret)) + return sum[:], nil +} + +// EncryptSigningKeyPEM encrypts a PEM-encoded private key with AES-GCM. +func EncryptSigningKeyPEM(pem []byte, secret string) (ciphertext string, nonce string, err error) { + return encryptSigningKeyPEM(pem, secret) +} + +// DecryptSigningKeyPEM decrypts a PEM-encoded private key encrypted with EncryptSigningKeyPEM. +func DecryptSigningKeyPEM(ciphertext, nonce, secret string) ([]byte, error) { + return decryptSigningKeyPEM(ciphertext, nonce, secret) +} + +func encryptSigningKeyPEM(pem []byte, secret string) (ciphertext string, nonce string, err error) { + key, err := deriveSigningKeyEncryptionKey(secret) + if err != nil { + return "", "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", "", err + } + iv := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", "", err + } + sealed := gcm.Seal(nil, iv, pem, nil) + return base64.StdEncoding.EncodeToString(sealed), base64.StdEncoding.EncodeToString(iv), nil +} + +func decryptSigningKeyPEM(ciphertext, nonce, secret string) ([]byte, error) { + key, err := deriveSigningKeyEncryptionKey(secret) + if err != nil { + return nil, err + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(ciphertext)) + if err != nil { + return nil, fmt.Errorf("decode signing key ciphertext: %w", err) + } + iv, err := base64.StdEncoding.DecodeString(strings.TrimSpace(nonce)) + if err != nil { + return nil, fmt.Errorf("decode signing key nonce: %w", err) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return gcm.Open(nil, iv, raw, nil) +} diff --git a/pkg/oauth/store/apply.go b/pkg/oauth/store/apply.go index d5a6939..8cfe404 100644 --- a/pkg/oauth/store/apply.go +++ b/pkg/oauth/store/apply.go @@ -21,6 +21,8 @@ func ApplyPostgresStores(cfg *oauth.Config, pool *pgxpool.Pool) error { cfg.Clients = clientStore cfg.ReplayStore = replayStore cfg.RevocationStore = revocationStore + cfg.TokenRateLimiter = &PostgresRateLimitStore{Pool: pool} + cfg.RegisterRateLimiter = &PostgresRateLimitStore{Pool: pool} return nil } @@ -37,9 +39,53 @@ func ApplySQLiteStores(cfg *oauth.Config, db *sql.DB) error { cfg.Clients = clientStore cfg.ReplayStore = replayStore cfg.RevocationStore = revocationStore + cfg.TokenRateLimiter = &SQLiteRateLimitStore{DB: db} + cfg.RegisterRateLimiter = &SQLiteRateLimitStore{DB: db} return nil } +// ApplyPostgresSigningKey loads or creates DB-backed signing keys for issuer. +func ApplyPostgresSigningKey(cfg *oauth.Config, pool *pgxpool.Pool, issuer string, opts SigningKeyOptions) error { + if cfg == nil { + return fmt.Errorf("oauth/store: config is required") + } + set, err := LoadOrCreatePostgresSigningKeySet(pool, issuer, opts) + if err != nil { + return err + } + cfg.SigningKey = set.Active + cfg.VerificationKeys = verificationKeysWithoutActive(set) + return nil +} + +// ApplySQLiteSigningKey loads or creates DB-backed signing keys for issuer. +func ApplySQLiteSigningKey(cfg *oauth.Config, db *sql.DB, issuer string, opts SigningKeyOptions) error { + if cfg == nil { + return fmt.Errorf("oauth/store: config is required") + } + set, err := LoadOrCreateSQLiteSigningKeySet(db, issuer, opts) + if err != nil { + return err + } + cfg.SigningKey = set.Active + cfg.VerificationKeys = verificationKeysWithoutActive(set) + return nil +} + +func verificationKeysWithoutActive(set SigningKeySet) []*oauth.KeySet { + if set.Active == nil { + return set.Verification + } + out := make([]*oauth.KeySet, 0, len(set.Verification)) + for _, key := range set.Verification { + if key == nil || key.KeyID == set.Active.KeyID { + continue + } + out = append(out, key) + } + return out +} + // NewServer constructs an authorization server after stores are applied to cfg. func NewServer(cfg oauth.Config) (*oauth.Server, error) { return oauth.NewServer(cfg) diff --git a/pkg/oauth/store/postgres.go b/pkg/oauth/store/postgres.go index f2a8e12..ef3a5e9 100644 --- a/pkg/oauth/store/postgres.go +++ b/pkg/oauth/store/postgres.go @@ -83,12 +83,26 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT } func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(token) + if !ok { + return oauth.RefreshTokenEntry{}, false + } + now := s.now() + tag, err := s.pool.Exec(context.Background(), ` + DELETE FROM hai_oauth_refresh_token + WHERE token = $1 AND expires_at > $2`, token, now) + if err != nil || tag.RowsAffected() == 0 { + return oauth.RefreshTokenEntry{}, false + } + return entry, true +} + +func (s *AuthorizationStore) LookupRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { now := s.now() var payload []byte err := s.pool.QueryRow(context.Background(), ` - DELETE FROM hai_oauth_refresh_token - WHERE token = $1 AND expires_at > $2 - RETURNING payload`, token, now, + SELECT payload FROM hai_oauth_refresh_token + WHERE token = $1 AND expires_at > $2`, token, now, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { return oauth.RefreshTokenEntry{}, false diff --git a/pkg/oauth/store/rate_limit_store.go b/pkg/oauth/store/rate_limit_store.go new file mode 100644 index 0000000..bea6585 --- /dev/null +++ b/pkg/oauth/store/rate_limit_store.go @@ -0,0 +1,250 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/degoke/health-ai-stack/pkg/oauth" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// SQLiteRateLimitStore persists OAuth endpoint counters in SQLite. +type SQLiteRateLimitStore struct { + DB *sql.DB + Now func() time.Time +} + +func (s *SQLiteRateLimitStore) now() time.Time { + if s != nil && s.Now != nil { + return s.Now() + } + return time.Now() +} + +func (s *SQLiteRateLimitStore) Allow(ctx context.Context, endpoint, bucketKey string, limit int, window time.Duration, now time.Time) (bool, error) { + if s == nil || s.DB == nil { + return true, nil + } + if limit <= 0 { + return true, nil + } + if window <= 0 { + window = time.Minute + } + if now.IsZero() { + now = s.now() + } + windowStart := formatOAuthTime(now.Truncate(window)) + + for attempt := 0; attempt < 2; attempt++ { + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return false, err + } + allowed, done, err := sqliteAllowInTx(ctx, tx, endpoint, bucketKey, limit, windowStart) + if err != nil { + _ = tx.Rollback() + return false, err + } + if !done { + _ = tx.Rollback() + continue + } + if err := tx.Commit(); err != nil { + return false, err + } + return allowed, nil + } + return false, errors.New("oauth rate limit: concurrent update retry exhausted") +} + +func sqliteAllowInTx(ctx context.Context, tx *sql.Tx, endpoint, bucketKey string, limit int, windowStart string) (allowed, done bool, err error) { + res, err := tx.ExecContext(ctx, ` + UPDATE hai_oauth_rate_limit + SET request_count = request_count + 1 + WHERE bucket_key = ? AND endpoint = ? + AND window_start = ? + AND request_count < ?`, + bucketKey, endpoint, windowStart, limit) + if err != nil { + return false, false, err + } + n, err := res.RowsAffected() + if err != nil { + return false, false, err + } + if n == 1 { + return true, true, nil + } + + var storedWindow string + var count int + err = tx.QueryRowContext(ctx, ` + SELECT window_start, request_count + FROM hai_oauth_rate_limit + WHERE bucket_key = ? AND endpoint = ?`, bucketKey, endpoint). + Scan(&storedWindow, &count) + if errors.Is(err, sql.ErrNoRows) { + _, err = tx.ExecContext(ctx, ` + INSERT INTO hai_oauth_rate_limit (bucket_key, endpoint, window_start, request_count) + VALUES (?, ?, ?, 1)`, bucketKey, endpoint, windowStart) + if err != nil { + if isUniqueConstraintError(err) { + return false, false, nil + } + return false, false, err + } + return true, true, nil + } + if err != nil { + return false, false, err + } + if storedWindow == windowStart && count >= limit { + return false, true, nil + } + if storedWindow != windowStart { + res, err = tx.ExecContext(ctx, ` + UPDATE hai_oauth_rate_limit + SET window_start = ?, request_count = 1 + WHERE bucket_key = ? AND endpoint = ?`, + windowStart, bucketKey, endpoint) + if err != nil { + return false, false, err + } + n, err = res.RowsAffected() + if err != nil { + return false, false, err + } + if n == 1 { + return true, true, nil + } + } + return false, false, nil +} + +// PostgresRateLimitStore persists OAuth endpoint counters in Postgres. +type PostgresRateLimitStore struct { + Pool *pgxpool.Pool + Now func() time.Time +} + +func (s *PostgresRateLimitStore) now() time.Time { + if s != nil && s.Now != nil { + return s.Now() + } + return time.Now() +} + +func (s *PostgresRateLimitStore) Allow(ctx context.Context, endpoint, bucketKey string, limit int, window time.Duration, now time.Time) (bool, error) { + if s == nil || s.Pool == nil { + return true, nil + } + if limit <= 0 { + return true, nil + } + if window <= 0 { + window = time.Minute + } + if now.IsZero() { + now = s.now() + } + windowStart := formatOAuthTime(now.Truncate(window)) + + for attempt := 0; attempt < 2; attempt++ { + tx, err := s.Pool.Begin(ctx) + if err != nil { + return false, err + } + allowed, done, err := postgresAllowInTx(ctx, tx, endpoint, bucketKey, limit, windowStart) + if err != nil { + _ = tx.Rollback(ctx) + return false, err + } + if !done { + _ = tx.Rollback(ctx) + continue + } + if err := tx.Commit(ctx); err != nil { + return false, err + } + return allowed, nil + } + return false, errors.New("oauth rate limit: concurrent update retry exhausted") +} + +func postgresAllowInTx(ctx context.Context, tx pgx.Tx, endpoint, bucketKey string, limit int, windowStart string) (allowed, done bool, err error) { + tag, err := tx.Exec(ctx, ` + UPDATE hai_oauth_rate_limit + SET request_count = request_count + 1 + WHERE bucket_key = $1 AND endpoint = $2 + AND window_start = $3 + AND request_count < $4`, + bucketKey, endpoint, windowStart, limit) + if err != nil { + return false, false, err + } + if tag.RowsAffected() == 1 { + return true, true, nil + } + + var storedWindow string + var count int + err = tx.QueryRow(ctx, ` + SELECT window_start, request_count + FROM hai_oauth_rate_limit + WHERE bucket_key = $1 AND endpoint = $2`, bucketKey, endpoint). + Scan(&storedWindow, &count) + if errors.Is(err, pgx.ErrNoRows) { + _, err = tx.Exec(ctx, ` + INSERT INTO hai_oauth_rate_limit (bucket_key, endpoint, window_start, request_count) + VALUES ($1, $2, $3, 1)`, bucketKey, endpoint, windowStart) + if err != nil { + if isUniqueConstraintError(err) { + return false, false, nil + } + return false, false, err + } + return true, true, nil + } + if err != nil { + return false, false, err + } + if storedWindow == windowStart && count >= limit { + return false, true, nil + } + if storedWindow != windowStart { + tag, err = tx.Exec(ctx, ` + UPDATE hai_oauth_rate_limit + SET window_start = $1, request_count = 1 + WHERE bucket_key = $2 AND endpoint = $3`, + windowStart, bucketKey, endpoint) + if err != nil { + return false, false, err + } + if tag.RowsAffected() == 1 { + return true, true, nil + } + } + return false, false, nil +} + +func isUniqueConstraintError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique constraint") || + strings.Contains(msg, "duplicate key") || + strings.Contains(msg, "constraint failed") +} + +func formatOAuthTime(t time.Time) string { + return t.UTC().Format(time.RFC3339Nano) +} + +var _ oauth.RateLimitStore = (*SQLiteRateLimitStore)(nil) +var _ oauth.RateLimitStore = (*PostgresRateLimitStore)(nil) diff --git a/pkg/oauth/store/rate_limit_store_test.go b/pkg/oauth/store/rate_limit_store_test.go new file mode 100644 index 0000000..d22069c --- /dev/null +++ b/pkg/oauth/store/rate_limit_store_test.go @@ -0,0 +1,44 @@ +package store_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/degoke/health-ai-stack/pkg/oauth/store" + "github.com/degoke/health-ai-stack/pkg/sqlite" +) + +func TestSQLiteRateLimitStoreAllow(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db, err := sqlite.OpenAndMigrate(ctx, filepath.Join(t.TempDir(), "oauth-rate-limit.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + now := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) + limiter := &store.SQLiteRateLimitStore{DB: db.SQL(), Now: func() time.Time { return now }} + window := time.Minute + endpoint := "token" + bucket := "127.0.0.1" + + allowed, err := limiter.Allow(ctx, endpoint, bucket, 2, window, now) + if err != nil || !allowed { + t.Fatalf("first allow = %v err = %v", allowed, err) + } + allowed, err = limiter.Allow(ctx, endpoint, bucket, 2, window, now) + if err != nil || !allowed { + t.Fatalf("second allow = %v err = %v", allowed, err) + } + allowed, err = limiter.Allow(ctx, endpoint, bucket, 2, window, now) + if err != nil { + t.Fatalf("third allow err = %v", err) + } + if allowed { + t.Fatal("third should be denied") + } +} diff --git a/pkg/oauth/store/signing_key.go b/pkg/oauth/store/signing_key.go new file mode 100644 index 0000000..0690f2d --- /dev/null +++ b/pkg/oauth/store/signing_key.go @@ -0,0 +1,262 @@ +package store + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "database/sql" + "encoding/pem" + "errors" + "fmt" + "strings" + "time" + + "github.com/degoke/health-ai-stack/pkg/oauth" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const defaultOAuthSigningKeyID = "haistack" + +// SigningKeyOptions configures persisted OAuth signing keys. +type SigningKeyOptions struct { + ActiveKeyID string + EncryptionSecret string +} + +// SigningKeySet holds the active signer and verification keys for JWKS. +type SigningKeySet struct { + Active *oauth.KeySet + Verification []*oauth.KeySet + EncryptionUsed bool +} + +// LoadOrCreateSQLiteSigningKeySet loads or creates signing keys for issuer in SQLite. +func LoadOrCreateSQLiteSigningKeySet(db *sql.DB, issuer string, opts SigningKeyOptions) (SigningKeySet, error) { + if db == nil { + return SigningKeySet{}, fmt.Errorf("%w: db is nil", oauth.ErrInvalidConfig) + } + issuer = trimOAuthIssuer(issuer) + secret := signingSecret(opts) + keyID := activeKeyID(opts) + set, err := loadSQLiteSigningKeySet(db, issuer, secret) + if err == nil && set.Active != nil { + return set, nil + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return SigningKeySet{}, err + } + if err := insertSQLiteSigningKey(db, issuer, keyID, secret); err != nil { + return SigningKeySet{}, err + } + return loadSQLiteSigningKeySet(db, issuer, secret) +} + +// LoadOrCreatePostgresSigningKeySet loads or creates signing keys for issuer in Postgres. +func LoadOrCreatePostgresSigningKeySet(pool *pgxpool.Pool, issuer string, opts SigningKeyOptions) (SigningKeySet, error) { + if pool == nil { + return SigningKeySet{}, fmt.Errorf("%w: pool is nil", oauth.ErrInvalidConfig) + } + issuer = trimOAuthIssuer(issuer) + secret := signingSecret(opts) + keyID := activeKeyID(opts) + set, err := loadPostgresSigningKeySet(pool, issuer, secret) + if err == nil && set.Active != nil { + return set, nil + } + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return SigningKeySet{}, err + } + if err := insertPostgresSigningKey(pool, issuer, keyID, secret); err != nil { + return SigningKeySet{}, err + } + return loadPostgresSigningKeySet(pool, issuer, secret) +} + +func signingSecret(opts SigningKeyOptions) string { + secret := strings.TrimSpace(opts.EncryptionSecret) + if secret == "" { + secret = oauth.SigningKeyEncryptionSecret() + } + return secret +} + +func activeKeyID(opts SigningKeyOptions) string { + keyID := strings.TrimSpace(opts.ActiveKeyID) + if keyID == "" { + keyID = defaultOAuthSigningKeyID + } + return keyID +} + +func loadSQLiteSigningKeySet(db *sql.DB, issuer, secret string) (SigningKeySet, error) { + rows, err := db.QueryContext(context.Background(), ` + SELECT key_id, private_key_pem, encryption_nonce, active, retired_at + FROM hai_oauth_signing_key + WHERE issuer = ? + ORDER BY active DESC, created_at DESC`, issuer) + if err != nil { + return SigningKeySet{}, err + } + defer func() { _ = rows.Close() }() + return scanSigningKeyRows(rows, secret) +} + +func loadPostgresSigningKeySet(pool *pgxpool.Pool, issuer, secret string) (SigningKeySet, error) { + rows, err := pool.Query(context.Background(), ` + SELECT key_id, private_key_pem, encryption_nonce, active, retired_at + FROM hai_oauth_signing_key + WHERE issuer = $1 + ORDER BY active DESC, created_at DESC`, issuer) + if err != nil { + return SigningKeySet{}, err + } + defer rows.Close() + return scanSigningKeyRows(rows, secret) +} + +type signingKeyRowScanner interface { + Next() bool + Scan(dest ...any) error + Err() error +} + +func scanSigningKeyRows(rows signingKeyRowScanner, secret string) (SigningKeySet, error) { + var set SigningKeySet + for rows.Next() { + var keyID, pemRaw, nonce, retiredAt string + var active int + if err := rows.Scan(&keyID, &pemRaw, &nonce, &active, &retiredAt); err != nil { + return SigningKeySet{}, err + } + keySet, err := decodeStoredKeySet(keyID, pemRaw, nonce, secret) + if err != nil { + return SigningKeySet{}, err + } + if strings.TrimSpace(nonce) != "" { + set.EncryptionUsed = true + } + if active != 0 && retiredAt == "" { + set.Active = keySet + } + if retiredAt == "" { + set.Verification = append(set.Verification, keySet) + } + } + if err := rows.Err(); err != nil { + return SigningKeySet{}, err + } + if set.Active == nil { + return SigningKeySet{}, sql.ErrNoRows + } + return set, nil +} + +func insertSQLiteSigningKey(db *sql.DB, issuer, keyID, secret string) error { + keySet, pemRaw, nonce, err := generateStoredKeySet(keyID, secret) + if err != nil { + return err + } + _, err = db.ExecContext(context.Background(), ` + INSERT OR IGNORE INTO hai_oauth_signing_key ( + issuer, key_id, private_key_pem, encryption_nonce, active, created_at, retired_at + ) VALUES (?, ?, ?, ?, 1, ?, '')`, + issuer, keySet.KeyID, pemRaw, nonce, formatOAuthTime(time.Now())) + return err +} + +func insertPostgresSigningKey(pool *pgxpool.Pool, issuer, keyID, secret string) error { + keySet, pemRaw, nonce, err := generateStoredKeySet(keyID, secret) + if err != nil { + return err + } + _, err = pool.Exec(context.Background(), ` + INSERT INTO hai_oauth_signing_key ( + issuer, key_id, private_key_pem, encryption_nonce, active, created_at, retired_at + ) VALUES ($1, $2, $3, $4, 1, $5, '') + ON CONFLICT (issuer, key_id) DO NOTHING`, + issuer, keySet.KeyID, pemRaw, nonce, formatOAuthTime(time.Now())) + return err +} + +func generateStoredKeySet(keyID, secret string) (*oauth.KeySet, string, string, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, "", "", fmt.Errorf("oauth signing key: %w", err) + } + if strings.TrimSpace(keyID) == "" { + keyID = defaultOAuthSigningKeyID + } + keySet := &oauth.KeySet{PrivateKey: key, KeyID: keyID, Algorithm: "RS256"} + pemBytes, err := marshalRSAPrivateKeyPEM(key) + if err != nil { + return nil, "", "", err + } + secret = strings.TrimSpace(secret) + if secret == "" { + return keySet, string(pemBytes), "", nil + } + ciphertext, nonce, err := oauth.EncryptSigningKeyPEM(pemBytes, secret) + if err != nil { + return nil, "", "", err + } + return keySet, ciphertext, nonce, nil +} + +func decodeStoredKeySet(keyID, pemRaw, nonce, secret string) (*oauth.KeySet, error) { + raw := []byte(pemRaw) + if strings.TrimSpace(nonce) != "" { + if strings.TrimSpace(secret) == "" { + return nil, fmt.Errorf("%w: encrypted signing key requires OAUTH_SIGNING_KEY_ENCRYPTION_SECRET", oauth.ErrInvalidConfig) + } + decrypted, err := oauth.DecryptSigningKeyPEM(pemRaw, nonce, secret) + if err != nil { + return nil, err + } + raw = decrypted + } + key, err := parseRSAPrivateKeyPEM(string(raw)) + if err != nil { + return nil, err + } + if strings.TrimSpace(keyID) == "" { + keyID = defaultOAuthSigningKeyID + } + return &oauth.KeySet{PrivateKey: key, KeyID: keyID, Algorithm: "RS256"}, nil +} + +func trimOAuthIssuer(issuer string) string { + issuer = strings.TrimSpace(issuer) + if issuer == "" { + return "" + } + return strings.TrimRight(issuer, "/") +} + +func marshalRSAPrivateKeyPEM(key *rsa.PrivateKey) ([]byte, error) { + if key == nil { + return nil, fmt.Errorf("%w: rsa private key required", oauth.ErrInvalidConfig) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil +} + +func parseRSAPrivateKeyPEM(raw string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(raw)) + if block == nil { + return nil, fmt.Errorf("%w: invalid signing key pem", oauth.ErrInvalidConfig) + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse signing key: %w", err) + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("%w: signing key must be rsa", oauth.ErrInvalidConfig) + } + return key, nil +} diff --git a/pkg/oauth/store/signing_key_test.go b/pkg/oauth/store/signing_key_test.go new file mode 100644 index 0000000..fa80e64 --- /dev/null +++ b/pkg/oauth/store/signing_key_test.go @@ -0,0 +1,42 @@ +package store_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/degoke/health-ai-stack/pkg/oauth/store" + "github.com/degoke/health-ai-stack/pkg/sqlite" +) + +func TestLoadOrCreateSQLiteSigningKeySet(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db, err := sqlite.OpenAndMigrate(ctx, filepath.Join(t.TempDir(), "oauth-signing-key.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + issuer := "https://auth.example.test" + set, err := store.LoadOrCreateSQLiteSigningKeySet(db.SQL(), issuer, store.SigningKeyOptions{ + ActiveKeyID: "haistack", + }) + if err != nil { + t.Fatalf("load signing key: %v", err) + } + if set.Active == nil || set.Active.PrivateKey == nil { + t.Fatal("expected active signing key") + } + + reloaded, err := store.LoadOrCreateSQLiteSigningKeySet(db.SQL(), issuer, store.SigningKeyOptions{ + ActiveKeyID: "haistack", + }) + if err != nil { + t.Fatalf("reload signing key: %v", err) + } + if reloaded.Active.KeyID != set.Active.KeyID { + t.Fatalf("key id changed: %q vs %q", reloaded.Active.KeyID, set.Active.KeyID) + } +} diff --git a/pkg/oauth/store/sqlite.go b/pkg/oauth/store/sqlite.go index 8729e7e..4550388 100644 --- a/pkg/oauth/store/sqlite.go +++ b/pkg/oauth/store/sqlite.go @@ -76,12 +76,30 @@ func (s *SQLiteAuthorizationStore) SaveRefreshToken(token string, entry oauth.Re } func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(token) + if !ok { + return oauth.RefreshTokenEntry{}, false + } + now := s.now().UTC().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(context.Background(), ` + DELETE FROM hai_oauth_refresh_token + WHERE token = ? AND expires_at > ?`, token, now) + if err != nil { + return oauth.RefreshTokenEntry{}, false + } + n, _ := res.RowsAffected() + if n == 0 { + return oauth.RefreshTokenEntry{}, false + } + return entry, true +} + +func (s *SQLiteAuthorizationStore) LookupRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { now := s.now().UTC().Format(time.RFC3339Nano) var payload string err := s.db.QueryRowContext(context.Background(), ` - DELETE FROM hai_oauth_refresh_token - WHERE token = ? AND expires_at > ? - RETURNING payload`, token, now, + SELECT payload FROM hai_oauth_refresh_token + WHERE token = ? AND expires_at > ?`, token, now, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { return oauth.RefreshTokenEntry{}, false diff --git a/pkg/oauth/tenant.go b/pkg/oauth/tenant.go new file mode 100644 index 0000000..93dd94e --- /dev/null +++ b/pkg/oauth/tenant.go @@ -0,0 +1,261 @@ +package oauth + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "sync" +) + +// TenantIssuerConfig describes OAuth issuer settings for one tenant namespace. +type TenantIssuerConfig struct { + TenantID string + Issuer string + FHIRAudience string + SigningKey *KeySet + Clients ClientRegistry + AutoApprove *bool + ConsentHandler ConsentHandler + UserAuthenticator UserAuthenticator + LoginPath string +} + +// TenantRegistry stores per-tenant issuer configuration. +type TenantRegistry struct { + mu sync.RWMutex + tenants map[string]TenantIssuerConfig + byIssuer map[string]string +} + +// NewTenantRegistry returns an empty tenant registry. +func NewTenantRegistry() *TenantRegistry { + return &TenantRegistry{ + tenants: make(map[string]TenantIssuerConfig), + byIssuer: make(map[string]string), + } +} + +// Register upserts a tenant issuer configuration. +func (r *TenantRegistry) Register(cfg TenantIssuerConfig) error { + if r == nil { + return fmt.Errorf("%w: tenant registry is nil", ErrInvalidConfig) + } + cfg.TenantID = strings.TrimSpace(cfg.TenantID) + cfg.Issuer = strings.TrimRight(strings.TrimSpace(cfg.Issuer), "/") + cfg.FHIRAudience = strings.TrimRight(strings.TrimSpace(cfg.FHIRAudience), "/") + if cfg.TenantID == "" { + return fmt.Errorf("%w: tenant id required", ErrInvalidConfig) + } + if cfg.Issuer == "" || cfg.FHIRAudience == "" { + return fmt.Errorf("%w: tenant %q requires issuer and FHIR audience", ErrInvalidConfig, cfg.TenantID) + } + r.mu.Lock() + defer r.mu.Unlock() + if r.tenants == nil { + r.tenants = make(map[string]TenantIssuerConfig) + } + if r.byIssuer == nil { + r.byIssuer = make(map[string]string) + } + for iss, id := range r.byIssuer { + if id != cfg.TenantID && iss == cfg.Issuer { + return fmt.Errorf("%w: issuer %q already registered for tenant %q", ErrInvalidConfig, cfg.Issuer, id) + } + } + r.tenants[cfg.TenantID] = cfg + r.byIssuer[cfg.Issuer] = cfg.TenantID + return nil +} + +// Lookup returns configuration for a tenant id. +func (r *TenantRegistry) Lookup(tenantID string) (TenantIssuerConfig, error) { + if r == nil { + return TenantIssuerConfig{}, fmt.Errorf("%w: tenant %q not found", ErrInvalidConfig, tenantID) + } + r.mu.RLock() + cfg, ok := r.tenants[strings.TrimSpace(tenantID)] + r.mu.RUnlock() + if !ok { + return TenantIssuerConfig{}, fmt.Errorf("%w: tenant %q not found", ErrInvalidConfig, tenantID) + } + return cfg, nil +} + +// LookupByIssuer returns tenant configuration matching an OAuth issuer URL. +func (r *TenantRegistry) LookupByIssuer(issuer string) (TenantIssuerConfig, error) { + if r == nil { + return TenantIssuerConfig{}, fmt.Errorf("%w: issuer not found", ErrInvalidConfig) + } + issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") + r.mu.RLock() + id, ok := r.byIssuer[issuer] + r.mu.RUnlock() + if !ok { + return TenantIssuerConfig{}, fmt.Errorf("%w: issuer %q not found", ErrInvalidConfig, issuer) + } + return r.Lookup(id) +} + +// IDs returns registered tenant ids. +func (r *TenantRegistry) IDs() []string { + if r == nil { + return nil + } + r.mu.RLock() + out := make([]string, 0, len(r.tenants)) + for id := range r.tenants { + out = append(out, id) + } + r.mu.RUnlock() + return out +} + +// MultiTenantConfig configures a shared OAuth backend with per-tenant issuers. +type MultiTenantConfig struct { + Base Config + Tenants *TenantRegistry +} + +// MultiTenantServer hosts OAuth endpoints for multiple tenant issuers. +type MultiTenantServer struct { + base Config + tenants *TenantRegistry + servers map[string]*Server + mu sync.RWMutex +} + +// NewMultiTenantServer validates config and returns a multi-tenant OAuth server. +func NewMultiTenantServer(cfg MultiTenantConfig) (*MultiTenantServer, error) { + if cfg.Tenants == nil || len(cfg.Tenants.IDs()) == 0 { + return nil, fmt.Errorf("%w: at least one tenant required", ErrInvalidConfig) + } + if cfg.Base.SigningKey == nil { + return nil, ErrInvalidConfig + } + if cfg.Base.Clients == nil { + cfg.Base.Clients = NewClientStore() + } + return &MultiTenantServer{ + base: cfg.Base, + tenants: cfg.Tenants, + servers: make(map[string]*Server), + }, nil +} + +// ServerForTenant returns a tenant-scoped authorization server view. +func (m *MultiTenantServer) ServerForTenant(tenantID string) (*Server, error) { + if m == nil { + return nil, ErrInvalidConfig + } + m.mu.RLock() + if srv, ok := m.servers[tenantID]; ok { + m.mu.RUnlock() + return srv, nil + } + m.mu.RUnlock() + + tenant, err := m.tenants.Lookup(tenantID) + if err != nil { + return nil, err + } + cfg := m.base + cfg.Issuer = tenant.Issuer + cfg.FHIRAudience = tenant.FHIRAudience + if tenant.SigningKey != nil { + cfg.SigningKey = tenant.SigningKey + } + if tenant.Clients != nil { + cfg.Clients = tenant.Clients + } + if tenant.ConsentHandler != nil { + cfg.ConsentHandler = tenant.ConsentHandler + } + if tenant.UserAuthenticator != nil { + cfg.UserAuthenticator = tenant.UserAuthenticator + } + if tenant.LoginPath != "" { + cfg.LoginPath = tenant.LoginPath + } + if tenant.AutoApprove != nil { + cfg.AutoApprove = *tenant.AutoApprove + } + srv, err := NewServer(cfg) + if err != nil { + return nil, err + } + m.mu.Lock() + m.servers[tenantID] = srv + m.mu.Unlock() + return srv, nil +} + +// Handler mounts tenant-scoped routes under /t/{tenantID}/. +func (m *MultiTenantServer) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenantID, rest, ok := splitTenantPath(r.URL.Path) + if !ok { + writeOAuthError(w, http.StatusNotFound, "not_found", "tenant route must begin with /t/{tenantId}/") + return + } + srv, err := m.ServerForTenant(tenantID) + if err != nil { + writeOAuthError(w, http.StatusNotFound, "not_found", "unknown tenant") + return + } + r2 := *r + r2.URL = cloneURL(r.URL) + r2.URL.Path = rest + srv.Handler().ServeHTTP(w, &r2) + }) +} + +// CombineHandlers mounts multiple OAuth handlers on one mux by path prefix. +func CombineHandlers(primary http.Handler, extras ...http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/t/") { + for _, extra := range extras { + if extra != nil { + extra.ServeHTTP(w, r) + return + } + } + } + if primary != nil { + primary.ServeHTTP(w, r) + return + } + for _, extra := range extras { + if extra != nil { + extra.ServeHTTP(w, r) + return + } + } + http.NotFound(w, r) + }) +} + +func splitTenantPath(path string) (tenantID, rest string, ok bool) { + if !strings.HasPrefix(path, "/t/") { + return "", "", false + } + trimmed := strings.TrimPrefix(path, "/t/") + parts := strings.SplitN(trimmed, "/", 2) + if len(parts) == 0 || parts[0] == "" { + return "", "", false + } + tenantID = parts[0] + rest = "/" + if len(parts) == 2 { + rest = "/" + parts[1] + } + return tenantID, rest, true +} + +func cloneURL(u *url.URL) *url.URL { + if u == nil { + return &url.URL{} + } + copy := *u + return © +} diff --git a/pkg/oauth/tenant_test.go b/pkg/oauth/tenant_test.go new file mode 100644 index 0000000..982cce2 --- /dev/null +++ b/pkg/oauth/tenant_test.go @@ -0,0 +1,56 @@ +package oauth_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/degoke/health-ai-stack/pkg/oauth" +) + +func TestMultiTenantServerRoutes(t *testing.T) { + base, err := oauth.NewKeySet(2048) + if err != nil { + t.Fatal(err) + } + registry := oauth.NewTenantRegistry() + issuer := "http://127.0.0.1:8080" + tenantIssuer := issuer + "/t/local" + if err := registry.Register(oauth.TenantIssuerConfig{ + TenantID: "local", + Issuer: tenantIssuer, + FHIRAudience: issuer, + SigningKey: base, + AutoApprove: boolPtr(true), + }); err != nil { + t.Fatal(err) + } + multi, err := oauth.NewMultiTenantServer(oauth.MultiTenantConfig{ + Base: oauth.Config{ + Issuer: issuer, + FHIRAudience: issuer, + SigningKey: base, + AutoApprove: true, + }, + Tenants: registry, + }) + if err != nil { + t.Fatal(err) + } + handler := oauth.CombineHandlers(nil, multi.Handler()) + + req := httptest.NewRequest(http.MethodGet, "/t/local/.well-known/smart-configuration", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), tenantIssuer) { + t.Fatalf("body = %s", rec.Body.String()) + } +} + +func boolPtr(v bool) *bool { + return &v +} diff --git a/pkg/postgres/migrations/0018_oauth_rate_limit.sql b/pkg/postgres/migrations/0018_oauth_rate_limit.sql new file mode 100644 index 0000000..5c0d212 --- /dev/null +++ b/pkg/postgres/migrations/0018_oauth_rate_limit.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS hai_oauth_rate_limit ( + bucket_key TEXT NOT NULL, + endpoint TEXT NOT NULL, + window_start TEXT NOT NULL, + request_count INTEGER NOT NULL, + PRIMARY KEY (bucket_key, endpoint) +); diff --git a/pkg/postgres/migrations/0019_oauth_signing_key.sql b/pkg/postgres/migrations/0019_oauth_signing_key.sql new file mode 100644 index 0000000..65764e8 --- /dev/null +++ b/pkg/postgres/migrations/0019_oauth_signing_key.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS hai_oauth_signing_key ( + issuer TEXT NOT NULL, + key_id TEXT NOT NULL, + private_key_pem TEXT NOT NULL, + encryption_nonce TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + retired_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (issuer, key_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_signing_key_active + ON hai_oauth_signing_key (issuer) + WHERE active = 1 AND retired_at = ''; diff --git a/pkg/runtime/oauth_builtin.go b/pkg/runtime/oauth_builtin.go index e22fd27..a351536 100644 --- a/pkg/runtime/oauth_builtin.go +++ b/pkg/runtime/oauth_builtin.go @@ -47,6 +47,12 @@ func validateBuiltinOAuthConfig(cfg BuiltinOAuthConfig) error { if cfg.AutoApprove != nil && *cfg.AutoApprove { return fmt.Errorf("runtime: production builtin oauth requires AutoApprove false") } + if strings.TrimSpace(os.Getenv("OAUTH_SIGNING_KEY_ENCRYPTION_SECRET")) == "" { + return fmt.Errorf("runtime: production builtin oauth requires OAUTH_SIGNING_KEY_ENCRYPTION_SECRET") + } + if strings.TrimSpace(os.Getenv("OAUTH_SESSION_SECRET")) == "" { + return fmt.Errorf("runtime: production builtin oauth requires OAUTH_SESSION_SECRET") + } return nil } @@ -66,30 +72,25 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error return err } - stateDir := strings.TrimSpace(cfg.StateDir) - if stateDir == "" { - stateDir = defaultOAuthStateDir(b, state) - } - signingKeyPath := oauth.DefaultProductionPaths(stateDir).SigningKey - keySet, err := oauth.LoadOrCreateSigningKey(signingKeyPath, "haistack") - if err != nil { - return fmt.Errorf("runtime: oauth signing key: %w", err) - } - oauthCfg := oauth.Config{ Issuer: issuer, FHIRAudience: issuer, - SigningKey: keySet, } switch { case state.sqliteDB != nil: if err := oauthstore.ApplySQLiteStores(&oauthCfg, state.sqliteDB.SQL()); err != nil { return fmt.Errorf("runtime: oauth sqlite stores: %w", err) } + if err := b.applyBuiltinSigningKey(&oauthCfg, state, issuer); err != nil { + return fmt.Errorf("runtime: oauth signing key: %w", err) + } case state.postgresDB != nil: if err := oauthstore.ApplyPostgresStores(&oauthCfg, state.postgresDB.Pool()); err != nil { return fmt.Errorf("runtime: oauth postgres stores: %w", err) } + if err := b.applyBuiltinSigningKey(&oauthCfg, state, issuer); err != nil { + return fmt.Errorf("runtime: oauth signing key: %w", err) + } default: return fmt.Errorf("runtime: builtin oauth requires sqlite or postgres storage") } @@ -110,10 +111,16 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error oauthCfg.RequireConsentForm = true oauthCfg.AutoApprove = false oauthCfg.AllowDynamicRegistration = true + oauthCfg.LoginPath = "/oauth/login" if regToken == "" { return fmt.Errorf("runtime: production builtin oauth requires OAUTH_REGISTRATION_TOKEN") } oauthCfg.RegistrationAccessToken = regToken + sessionAuth, err := oauth.NewSessionUserAuthenticator(oauth.SessionAuthConfig{}) + if err != nil { + return fmt.Errorf("runtime: oauth session auth: %w", err) + } + oauthCfg.UserAuthenticator = sessionAuth } else if regToken != "" { oauthCfg.AllowDynamicRegistration = true oauthCfg.RegistrationAccessToken = regToken @@ -133,6 +140,29 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error return fmt.Errorf("runtime: oauth server: %w", err) } + tenantRegistry := oauth.NewTenantRegistry() + tenantIssuer := strings.TrimRight(issuer, "/") + "/t/" + tenantID + autoApprovePtr := oauthCfg.AutoApprove + if err := tenantRegistry.Register(oauth.TenantIssuerConfig{ + TenantID: tenantID, + Issuer: tenantIssuer, + FHIRAudience: issuer, + SigningKey: oauthCfg.SigningKey, + Clients: oauthCfg.Clients, + UserAuthenticator: oauthCfg.UserAuthenticator, + LoginPath: oauthCfg.LoginPath, + AutoApprove: &autoApprovePtr, + }); err != nil { + return fmt.Errorf("runtime: oauth tenant registry: %w", err) + } + multiTenant, err := oauth.NewMultiTenantServer(oauth.MultiTenantConfig{ + Base: oauthCfg, + Tenants: tenantRegistry, + }) + if err != nil { + return fmt.Errorf("runtime: oauth multi-tenant server: %w", err) + } + adapter := smart.NewAuthAdapter(smart.AuthAdapterConfig{ DefaultTenantID: tenantID, DefaultUserRoles: []string{"clinician"}, @@ -152,7 +182,7 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error return fmt.Errorf("runtime: oauth auth engine: %w", err) } - b.oauthHandler = srv.Handler() + b.oauthHandler = oauth.CombineHandlers(srv.Handler(), multiTenant.Handler()) b.oauthIssuerURL = issuer b.httpPrincipalResolver = hahttp.SMARTBearerPrincipalResolver(bearer) b.httpAuthBundleResolver = hahttp.SMARTBearerBundleResolver(bearer) @@ -160,7 +190,33 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error return nil } -func defaultOAuthStateDir(b *Builder, state *wireState) string { +func (b *Builder) applyBuiltinSigningKey(cfg *oauth.Config, state *wireState, issuer string) error { + opts := oauthstore.SigningKeyOptions{ + ActiveKeyID: "haistack", + EncryptionSecret: oauth.SigningKeyEncryptionSecret(), + } + if opts.EncryptionSecret != "" { + switch { + case state.sqliteDB != nil: + return oauthstore.ApplySQLiteSigningKey(cfg, state.sqliteDB.SQL(), issuer, opts) + case state.postgresDB != nil: + return oauthstore.ApplyPostgresSigningKey(cfg, state.postgresDB.Pool(), issuer, opts) + } + } + stateDir := strings.TrimSpace(b.builtinOAuth.StateDir) + if stateDir == "" { + stateDir = b.defaultOAuthStateDir(state) + } + signingKeyPath := oauth.DefaultProductionPaths(stateDir).SigningKey + keySet, err := oauth.LoadOrCreateSigningKey(signingKeyPath, "haistack") + if err != nil { + return err + } + cfg.SigningKey = keySet + return nil +} + +func (b *Builder) defaultOAuthStateDir(state *wireState) string { if strings.TrimSpace(b.dataDir) != "" { return filepath.Join(b.dataDir, "oauth") } diff --git a/pkg/sqlite/migrations/0015_oauth_rate_limit.sql b/pkg/sqlite/migrations/0015_oauth_rate_limit.sql new file mode 100644 index 0000000..5c0d212 --- /dev/null +++ b/pkg/sqlite/migrations/0015_oauth_rate_limit.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS hai_oauth_rate_limit ( + bucket_key TEXT NOT NULL, + endpoint TEXT NOT NULL, + window_start TEXT NOT NULL, + request_count INTEGER NOT NULL, + PRIMARY KEY (bucket_key, endpoint) +); diff --git a/pkg/sqlite/migrations/0016_oauth_signing_key.sql b/pkg/sqlite/migrations/0016_oauth_signing_key.sql new file mode 100644 index 0000000..65764e8 --- /dev/null +++ b/pkg/sqlite/migrations/0016_oauth_signing_key.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS hai_oauth_signing_key ( + issuer TEXT NOT NULL, + key_id TEXT NOT NULL, + private_key_pem TEXT NOT NULL, + encryption_nonce TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + retired_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (issuer, key_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_signing_key_active + ON hai_oauth_signing_key (issuer) + WHERE active = 1 AND retired_at = ''; From 96fb143d9ec3957148d168662cddb7c1e6c4ca12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 13:16:27 +0000 Subject: [PATCH 04/14] =?UTF-8?q?feat(oauth):=20finish=20port=20polish=20?= =?UTF-8?q?=E2=80=94=20rotation,=20consent=20purge,=20production=20default?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/haistack/README.md | 1 + cmd/haistack/internal/config/config.go | 6 ++ cmd/haistack/internal/config/config_test.go | 10 +++ cmd/haistack/internal/config/doc.go | 6 +- docs/smart-auth-architecture.md | 15 ++++- examples/smart-oauth/main.go | 2 +- pkg/oauth/OPERATIONS.md | 4 +- pkg/oauth/README.md | 2 +- pkg/oauth/consent_cleanup.go | 33 +++++++++ pkg/oauth/handlers.go | 9 ++- pkg/oauth/introspect_test.go | 23 +++++++ pkg/oauth/persistent.go | 30 +++++++++ pkg/oauth/production.go | 22 ++++++ pkg/oauth/production_defaults_test.go | 44 ++++++++++++ pkg/oauth/redis/store.go | 4 ++ pkg/oauth/server.go | 2 + pkg/oauth/session_auth_test.go | 28 ++++++++ pkg/oauth/signing_key_crypto.go | 11 ++- pkg/oauth/signing_key_crypto_test.go | 22 ++++++ pkg/oauth/store/postgres.go | 10 +++ pkg/oauth/store/signing_key.go | 75 +++++++++++++++++++++ pkg/oauth/store/sqlite.go | 11 +++ pkg/runtime/README.md | 2 +- pkg/runtime/builder.go | 8 ++- pkg/runtime/oauth_builtin.go | 5 ++ pkg/runtime/oauth_builtin_test.go | 32 +++++++++ pkg/runtime/runtime.go | 14 +++- pkg/runtime/wire.go | 1 + pkg/testkit/infernotest/reference.go | 8 +-- 29 files changed, 421 insertions(+), 19 deletions(-) create mode 100644 pkg/oauth/consent_cleanup.go create mode 100644 pkg/oauth/production_defaults_test.go create mode 100644 pkg/oauth/session_auth_test.go create mode 100644 pkg/oauth/signing_key_crypto_test.go diff --git a/cmd/haistack/README.md b/cmd/haistack/README.md index 7ba7e20..26eceac 100644 --- a/cmd/haistack/README.md +++ b/cmd/haistack/README.md @@ -161,6 +161,7 @@ If the default `haistack.yaml` is missing, built-in defaults are used so command | `HAISTACK_OAUTH_REGISTRATION_TOKEN` | `oauth.registrationAccessToken` | | `OAUTH_REGISTRATION_TOKEN` | `oauth.registrationAccessToken` | | `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` | DB signing key encryption (production) | +| `OAUTH_SIGNING_KEY_ROTATE` | Set to `1` to rotate the active DB signing key on startup | | `OAUTH_SESSION_SECRET` | `/oauth/login` session cookie signing (production) | | `HAISTACK_PRODUCTION=1` | enables `oauth.production` | diff --git a/cmd/haistack/internal/config/config.go b/cmd/haistack/internal/config/config.go index ff0de79..2c38659 100644 --- a/cmd/haistack/internal/config/config.go +++ b/cmd/haistack/internal/config/config.go @@ -150,6 +150,12 @@ func (c Config) validateOAuthProduction() error { if c.OAuth.AutoApprove != nil && *c.OAuth.AutoApprove { return fmt.Errorf("oauth.autoApprove must be false when oauth.production is enabled") } + if strings.TrimSpace(os.Getenv("OAUTH_SIGNING_KEY_ENCRYPTION_SECRET")) == "" { + return fmt.Errorf("oauth.production requires OAUTH_SIGNING_KEY_ENCRYPTION_SECRET") + } + if strings.TrimSpace(os.Getenv("OAUTH_SESSION_SECRET")) == "" { + return fmt.Errorf("oauth.production requires OAUTH_SESSION_SECRET") + } return nil } diff --git a/cmd/haistack/internal/config/config_test.go b/cmd/haistack/internal/config/config_test.go index 0e69ef7..6a79825 100644 --- a/cmd/haistack/internal/config/config_test.go +++ b/cmd/haistack/internal/config/config_test.go @@ -241,6 +241,8 @@ func TestOAuthEnvOverrides(t *testing.T) { t.Setenv("HAISTACK_OAUTH_PRODUCTION", "true") t.Setenv("OAUTH_REGISTRATION_TOKEN", "register-token") t.Setenv("HAISTACK_OAUTH_ISSUER_URL", "https://auth.example.test") + t.Setenv("OAUTH_SIGNING_KEY_ENCRYPTION_SECRET", "signing-secret") + t.Setenv("OAUTH_SESSION_SECRET", "session-secret") cfg, err := config.Load(path, config.Overrides{}) if err != nil { @@ -276,6 +278,14 @@ func TestOAuthProductionRequiresIssuerAndToken(t *testing.T) { } t.Setenv("HAISTACK_OAUTH_ISSUER_URL", "https://auth.example.test") + if _, err := config.Load(path, config.Overrides{}); err == nil { + t.Fatal("expected production config to require OAUTH_SIGNING_KEY_ENCRYPTION_SECRET") + } + t.Setenv("OAUTH_SIGNING_KEY_ENCRYPTION_SECRET", "signing-secret") + if _, err := config.Load(path, config.Overrides{}); err == nil { + t.Fatal("expected production config to require OAUTH_SESSION_SECRET") + } + t.Setenv("OAUTH_SESSION_SECRET", "session-secret") cfg, err := config.Load(path, config.Overrides{}) if err != nil { t.Fatalf("Load: %v", err) diff --git a/cmd/haistack/internal/config/doc.go b/cmd/haistack/internal/config/doc.go index ddf855a..803a3c0 100644 --- a/cmd/haistack/internal/config/doc.go +++ b/cmd/haistack/internal/config/doc.go @@ -31,7 +31,8 @@ // // Validate enforces driver-specific requirements: sqlitePath for SQLite, // postgresDSN and tenantID for Postgres, sync.nodeID when sync.hubURL is set, -// and oauth production settings (https issuerURL, registration token) when +// and oauth production settings (https issuerURL, registration token, +// OAUTH_SIGNING_KEY_ENCRYPTION_SECRET, OAUTH_SESSION_SECRET) when // oauth.production is enabled. // // # Environment variables @@ -53,6 +54,9 @@ // - HAISTACK_OAUTH_AUTO_APPROVE // - HAISTACK_OAUTH_REGISTRATION_TOKEN // - OAUTH_REGISTRATION_TOKEN +// - OAUTH_SIGNING_KEY_ENCRYPTION_SECRET +// - OAUTH_SIGNING_KEY_ROTATE +// - OAUTH_SESSION_SECRET // - HAISTACK_PRODUCTION (sets oauth.production when "1") // // StarterYAML returns the bytes written by haistack init. diff --git a/docs/smart-auth-architecture.md b/docs/smart-auth-architecture.md index 3d5da8d..0b37270 100644 --- a/docs/smart-auth-architecture.md +++ b/docs/smart-auth-architecture.md @@ -37,13 +37,24 @@ Policy deny always overrides an apparently valid SMART scope. ### Built-in authorization server (`pkg/oauth`) ```go -oauthServer, _ := oauth.NewServer(oauth.Config{Issuer: issuer, FHIRAudience: fhirBaseURL}) -http.Handle("/", oauthServer.Handler()) // authorize, token, jwks, register, discovery +oauthServer, _ := oauth.NewServer(oauth.Config{ + Issuer: issuer, + FHIRAudience: fhirBaseURL, + UserAuthenticator: sessionAuth, // production consent login + LoginPath: "/oauth/login", +}) +http.Handle("/", oauthServer.Handler()) // authorize, token, introspect, jwks, register, discovery, login + +// Tenant-scoped issuer: +multi, _ := oauth.NewMultiTenantServer(oauth.MultiTenantConfig{Base: cfg, Tenants: registry}) +http.Handle("/t/", multi.Handler()) adapter := smart.NewAuthAdapter(smart.AuthAdapterConfig{...}) bearer := oauthServer.BearerAuthConfig(adapter) ``` +Production hosts store signing keys in the database when `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is set (see `oauthstore.ApplyPostgresSigningKey`). PEM fallback remains for development. + ### FHIR resource server ```go diff --git a/examples/smart-oauth/main.go b/examples/smart-oauth/main.go index 76b6243..ce89549 100644 --- a/examples/smart-oauth/main.go +++ b/examples/smart-oauth/main.go @@ -18,8 +18,8 @@ import ( "github.com/degoke/health-ai-stack/pkg/oauth" oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" "github.com/degoke/health-ai-stack/pkg/registry" - "github.com/degoke/health-ai-stack/pkg/sqlite" "github.com/degoke/health-ai-stack/pkg/smart" + "github.com/degoke/health-ai-stack/pkg/sqlite" ) func main() { diff --git a/pkg/oauth/OPERATIONS.md b/pkg/oauth/OPERATIONS.md index 45d8d2c..4c150aa 100644 --- a/pkg/oauth/OPERATIONS.md +++ b/pkg/oauth/OPERATIONS.md @@ -39,5 +39,7 @@ Enable with `oauth.enabled: true` (default). Production checklist: 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). +8. Consent sessions are purged in the background every five minutes when using `haistack serve` with SQL stores. -SMART discovery is served at `/.well-known/smart-configuration` and mirrored under `/fhir/.well-known/smart-configuration`. +SMART discovery is served at `/.well-known/smart-configuration` and mirrored under `/fhir/.well-known/smart-configuration`. Tenant-scoped discovery is also available at `/t/{tenantId}/.well-known/smart-configuration`. diff --git a/pkg/oauth/README.md b/pkg/oauth/README.md index 8154cf5..fec807b 100644 --- a/pkg/oauth/README.md +++ b/pkg/oauth/README.md @@ -140,7 +140,7 @@ All access tokens include a `client_id` claim; revoke rejects tokens without it. | `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` | AES key for DB-stored signing keys (required for `haistack serve` production) | | `OAUTH_SESSION_SECRET` | HMAC secret for `/oauth/login` session cookies (required for production consent) | -When `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is unset, signing keys fall back to PEM at `{state-dir}/oauth-signing.pem`. +When `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is unset, signing keys fall back to PEM at `{state-dir}/oauth-signing.pem`. Set `OAUTH_SIGNING_KEY_ROTATE=1` before restart to rotate the active DB key. ## Multi-instance checklist diff --git a/pkg/oauth/consent_cleanup.go b/pkg/oauth/consent_cleanup.go new file mode 100644 index 0000000..32b6fd0 --- /dev/null +++ b/pkg/oauth/consent_cleanup.go @@ -0,0 +1,33 @@ +package oauth + +import ( + "context" + "time" +) + +// DefaultPendingAuthorizationCleanupInterval is the recommended background purge +// interval for SQL-backed consent sessions in production-like hosts. +const DefaultPendingAuthorizationCleanupInterval = 5 * time.Minute + +// RunPendingAuthorizationCleanup periodically purges expired consent sessions until ctx is cancelled. +func RunPendingAuthorizationCleanup(ctx context.Context, store AuthorizationStore, interval time.Duration) { + if store == nil || interval <= 0 { + <-ctx.Done() + return + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + store.PurgeExpiredPendingAuthorizations() + } + } +} + +// StartPendingAuthorizationCleanup runs RunPendingAuthorizationCleanup in a background goroutine. +func StartPendingAuthorizationCleanup(ctx context.Context, store AuthorizationStore, interval time.Duration) { + go RunPendingAuthorizationCleanup(ctx, store, interval) +} diff --git a/pkg/oauth/handlers.go b/pkg/oauth/handlers.go index 2e018c6..3b82aab 100644 --- a/pkg/oauth/handlers.go +++ b/pkg/oauth/handlers.go @@ -81,9 +81,12 @@ func (s *Server) handleAuthorize(w http.ResponseWriter, r *http.Request) { http.Error(w, "unsupported response_type", http.StatusBadRequest) return } - if challenge := q.Get("code_challenge"); challenge == "" && client.PublicKeyPEM == "" && client.ClientSecret == "" && client.ClientSecretHash == "" { - http.Error(w, "code_challenge required for public clients", http.StatusBadRequest) - return + if challenge := q.Get("code_challenge"); challenge == "" { + requirePKCE := s.cfg.RequirePKCEForAllClients || isPublicClient(client) + if requirePKCE { + http.Error(w, "code_challenge required", http.StatusBadRequest) + return + } } authReq := AuthorizationRequest{ ClientID: clientID, diff --git a/pkg/oauth/introspect_test.go b/pkg/oauth/introspect_test.go index 1b10e06..3d43a9f 100644 --- a/pkg/oauth/introspect_test.go +++ b/pkg/oauth/introspect_test.go @@ -73,3 +73,26 @@ func TestOAuthServer_IntrospectAccessToken(t *testing.T) { t.Fatalf("doc = %#v", doc) } } + +func TestOAuthServer_IntrospectRequiresConfidentialClient(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + base := strings.TrimSuffix(srv.URL, "/") + server, err := oauth.NewServer(oauth.Config{Issuer: base, FHIRAudience: base, AutoApprove: true}) + if err != nil { + t.Fatal(err) + } + mux.Handle("/", server.Handler()) + + form := url.Values{} + form.Set("token", "opaque") + resp, err := http.PostForm(base+"/oauth/introspect", form) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d", resp.StatusCode) + } +} diff --git a/pkg/oauth/persistent.go b/pkg/oauth/persistent.go index 46817fd..668a8e3 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -23,6 +23,8 @@ type AuthorizationStore interface { GetPendingAuthorization(id string) (PendingAuthorization, bool) ConsumePendingAuthorization(id string) (PendingAuthorization, bool) DeleteRefreshTokenForClient(token, clientID string) bool + // PurgeExpiredPendingAuthorizations removes expired consent sessions. + PurgeExpiredPendingAuthorizations() int } // PendingAuthorization stores an in-progress authorize/consent/launch session. @@ -160,6 +162,20 @@ func (s *MemoryAuthorizationStore) DeleteRefreshTokenForClient(token, clientID s return ok } +func (s *MemoryAuthorizationStore) PurgeExpiredPendingAuthorizations() int { + now := memoryStoreNow(s) + s.mu.Lock() + n := 0 + for id, entry := range s.pending { + if now.After(entry.ExpiresAt) { + delete(s.pending, id) + n++ + } + } + s.mu.Unlock() + return n +} + func (s *MemoryAuthorizationStore) ConsumePendingAuthorization(id string) (PendingAuthorization, bool) { now := memoryStoreNow(s) s.mu.Lock() @@ -308,6 +324,20 @@ func (s *FileAuthorizationStore) now() time.Time { return time.Now() } +func (s *FileAuthorizationStore) PurgeExpiredPendingAuthorizations() int { + now := s.now() + var n int + _ = s.update(func(state *fileAuthorizationState) { + for id, entry := range state.Pending { + if now.After(entry.ExpiresAt) { + delete(state.Pending, id) + n++ + } + } + }) + return n +} + func (s *FileAuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { now := s.now() var ok bool diff --git a/pkg/oauth/production.go b/pkg/oauth/production.go index ba602da..13f7fb3 100644 --- a/pkg/oauth/production.go +++ b/pkg/oauth/production.go @@ -33,6 +33,28 @@ func DefaultProductionPaths(stateDir string) ProductionPaths { } } +// ApplyProductionDefaults applies safer OAuth defaults for production-like hosts. +// Call after store wiring and before NewServer. +func ApplyProductionDefaults(cfg *Config) error { + if cfg == nil { + return ErrInvalidConfig + } + if err := ValidateProductionIssuer(cfg.Issuer); err != nil { + return err + } + if cfg.AutoApprove { + return fmt.Errorf("%w: AutoApprove must be false for production", ErrInvalidConfig) + } + if err := RequireSigningKeyEncryptionSecret(); err != nil { + return err + } + cfg.RequirePKCEForAllClients = true + if cfg.AllowDynamicRegistration && strings.TrimSpace(cfg.RegistrationAccessToken) == "" { + return fmt.Errorf("%w: set RegistrationAccessToken or disable dynamic client registration for production", ErrInvalidConfig) + } + return nil +} + // ValidateProductionIssuer requires a non-empty https issuer URL. func ValidateProductionIssuer(issuer string) error { issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") diff --git a/pkg/oauth/production_defaults_test.go b/pkg/oauth/production_defaults_test.go new file mode 100644 index 0000000..a9b6470 --- /dev/null +++ b/pkg/oauth/production_defaults_test.go @@ -0,0 +1,44 @@ +package oauth_test + +import ( + "testing" + + "github.com/degoke/health-ai-stack/pkg/oauth" +) + +func TestApplyProductionDefaults(t *testing.T) { + t.Setenv("OAUTH_SIGNING_KEY_ENCRYPTION_SECRET", "test-encryption-secret") + cfg := oauth.Config{ + Issuer: "https://auth.example.test", + AllowDynamicRegistration: true, + RegistrationAccessToken: "register-token", + } + if err := oauth.ApplyProductionDefaults(&cfg); err != nil { + t.Fatal(err) + } + if !cfg.RequirePKCEForAllClients { + t.Fatal("expected PKCE required for all clients") + } +} + +func TestApplyProductionDefaultsRejectsAutoApprove(t *testing.T) { + t.Setenv("OAUTH_SIGNING_KEY_ENCRYPTION_SECRET", "test-encryption-secret") + cfg := oauth.Config{ + Issuer: "https://auth.example.test", + AutoApprove: true, + } + if err := oauth.ApplyProductionDefaults(&cfg); err == nil { + t.Fatal("expected auto approve rejection") + } +} + +func TestSigningKeyRotateOnStartupEnv(t *testing.T) { + t.Setenv("OAUTH_SIGNING_KEY_ROTATE", "1") + if !oauth.SigningKeyRotateOnStartup() { + t.Fatal("expected rotate on startup") + } + t.Setenv("OAUTH_SIGNING_KEY_ROTATE", "") + if oauth.SigningKeyRotateOnStartup() { + t.Fatal("expected rotate disabled") + } +} diff --git a/pkg/oauth/redis/store.go b/pkg/oauth/redis/store.go index 37d0403..7b4e2f2 100644 --- a/pkg/oauth/redis/store.go +++ b/pkg/oauth/redis/store.go @@ -124,6 +124,10 @@ func (s *AuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAu return entry, true } +func (s *AuthorizationStore) PurgeExpiredPendingAuthorizations() int { + return 0 +} + func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { payload, ok := consumeJSONValue(s.client, s.key("pending:"+id)) if !ok { diff --git a/pkg/oauth/server.go b/pkg/oauth/server.go index 2ef249a..a074dc5 100644 --- a/pkg/oauth/server.go +++ b/pkg/oauth/server.go @@ -49,6 +49,8 @@ type Config struct { RegisterRateLimiter RateLimitStore // VerificationKeys are additional public keys exposed via JWKS (for rotation). VerificationKeys []*KeySet + // RequirePKCEForAllClients requires code_challenge for every client at authorize. + RequirePKCEForAllClients bool } // Server is a SMART-compatible OAuth2/OIDC authorization server. diff --git a/pkg/oauth/session_auth_test.go b/pkg/oauth/session_auth_test.go new file mode 100644 index 0000000..89bfc8d --- /dev/null +++ b/pkg/oauth/session_auth_test.go @@ -0,0 +1,28 @@ +package oauth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/degoke/health-ai-stack/pkg/oauth" +) + +func TestSessionUserAuthenticator(t *testing.T) { + auth, err := oauth.NewSessionUserAuthenticator(oauth.SessionAuthConfig{Secret: "session-secret"}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + if err := auth.SetSession(rec, "clinician-1"); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + user, ok := auth.AuthenticateUser(req) + if !ok || user.Subject != "clinician-1" { + t.Fatalf("user = %#v ok = %v", user, ok) + } +} diff --git a/pkg/oauth/signing_key_crypto.go b/pkg/oauth/signing_key_crypto.go index c14b16a..338288f 100644 --- a/pkg/oauth/signing_key_crypto.go +++ b/pkg/oauth/signing_key_crypto.go @@ -12,13 +12,22 @@ import ( "strings" ) -const signingKeyEncryptionEnv = "OAUTH_SIGNING_KEY_ENCRYPTION_SECRET" +const ( + signingKeyEncryptionEnv = "OAUTH_SIGNING_KEY_ENCRYPTION_SECRET" + signingKeyRotateEnv = "OAUTH_SIGNING_KEY_ROTATE" +) // SigningKeyEncryptionSecret returns the configured signing-key encryption secret. func SigningKeyEncryptionSecret() string { return strings.TrimSpace(os.Getenv(signingKeyEncryptionEnv)) } +// SigningKeyRotateOnStartup reports whether signing keys should rotate on process start. +func SigningKeyRotateOnStartup() bool { + v := strings.TrimSpace(os.Getenv(signingKeyRotateEnv)) + return v == "1" || strings.EqualFold(v, "true") +} + // RequireSigningKeyEncryptionSecret returns an error when secret is empty. func RequireSigningKeyEncryptionSecret() error { if SigningKeyEncryptionSecret() == "" { diff --git a/pkg/oauth/signing_key_crypto_test.go b/pkg/oauth/signing_key_crypto_test.go new file mode 100644 index 0000000..134a3c7 --- /dev/null +++ b/pkg/oauth/signing_key_crypto_test.go @@ -0,0 +1,22 @@ +package oauth_test + +import ( + "testing" + + "github.com/degoke/health-ai-stack/pkg/oauth" +) + +func TestEncryptDecryptSigningKeyPEM(t *testing.T) { + pem := []byte("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----") + ciphertext, nonce, err := oauth.EncryptSigningKeyPEM(pem, "encryption-secret") + if err != nil { + t.Fatal(err) + } + raw, err := oauth.DecryptSigningKeyPEM(ciphertext, nonce, "encryption-secret") + if err != nil { + t.Fatal(err) + } + if string(raw) != string(pem) { + t.Fatalf("round trip failed: %q", raw) + } +} diff --git a/pkg/oauth/store/postgres.go b/pkg/oauth/store/postgres.go index ef3a5e9..71a034b 100644 --- a/pkg/oauth/store/postgres.go +++ b/pkg/oauth/store/postgres.go @@ -148,6 +148,16 @@ func (s *AuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAu return entry, true } +func (s *AuthorizationStore) PurgeExpiredPendingAuthorizations() int { + now := s.now() + tag, err := s.pool.Exec(context.Background(), ` + DELETE FROM hai_oauth_pending_auth WHERE expires_at <= $1`, now) + if err != nil { + return 0 + } + return int(tag.RowsAffected()) +} + func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { now := s.now() var payload []byte diff --git a/pkg/oauth/store/signing_key.go b/pkg/oauth/store/signing_key.go index 0690f2d..6785fcb 100644 --- a/pkg/oauth/store/signing_key.go +++ b/pkg/oauth/store/signing_key.go @@ -23,6 +23,7 @@ const defaultOAuthSigningKeyID = "haistack" type SigningKeyOptions struct { ActiveKeyID string EncryptionSecret string + RotateOnStartup bool } // SigningKeySet holds the active signer and verification keys for JWKS. @@ -40,6 +41,11 @@ func LoadOrCreateSQLiteSigningKeySet(db *sql.DB, issuer string, opts SigningKeyO issuer = trimOAuthIssuer(issuer) secret := signingSecret(opts) keyID := activeKeyID(opts) + if opts.RotateOnStartup { + if err := rotateSQLiteSigningKey(db, issuer, keyID, secret); err != nil { + return SigningKeySet{}, err + } + } set, err := loadSQLiteSigningKeySet(db, issuer, secret) if err == nil && set.Active != nil { return set, nil @@ -61,6 +67,11 @@ func LoadOrCreatePostgresSigningKeySet(pool *pgxpool.Pool, issuer string, opts S issuer = trimOAuthIssuer(issuer) secret := signingSecret(opts) keyID := activeKeyID(opts) + if opts.RotateOnStartup { + if err := rotatePostgresSigningKey(pool, issuer, keyID, secret); err != nil { + return SigningKeySet{}, err + } + } set, err := loadPostgresSigningKeySet(pool, issuer, secret) if err == nil && set.Active != nil { return set, nil @@ -153,6 +164,70 @@ func scanSigningKeyRows(rows signingKeyRowScanner, secret string) (SigningKeySet return set, nil } +func rotateSQLiteSigningKey(db *sql.DB, issuer, keyID, secret string) error { + newID := keyID + "-" + randomSigningKeySuffix() + tx, err := db.BeginTx(context.Background(), nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + now := formatOAuthTime(time.Now()) + 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 { + return err + } + keySet, pemRaw, nonce, err := generateStoredKeySet(newID, secret) + if err != nil { + return err + } + if _, err := tx.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_signing_key ( + issuer, key_id, private_key_pem, encryption_nonce, active, created_at, retired_at + ) VALUES (?, ?, ?, ?, 1, ?, '')`, + issuer, keySet.KeyID, pemRaw, nonce, now); err != nil { + return err + } + return tx.Commit() +} + +func rotatePostgresSigningKey(pool *pgxpool.Pool, issuer, keyID, secret string) error { + newID := keyID + "-" + randomSigningKeySuffix() + tx, err := pool.Begin(context.Background()) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(context.Background()) }() + now := formatOAuthTime(time.Now()) + if _, err := tx.Exec(context.Background(), ` + UPDATE hai_oauth_signing_key + SET active = 0, retired_at = $1 + WHERE issuer = $2 AND active = 1 AND retired_at = ''`, now, issuer); err != nil { + return err + } + keySet, pemRaw, nonce, err := generateStoredKeySet(newID, secret) + if err != nil { + return err + } + if _, err := tx.Exec(context.Background(), ` + INSERT INTO hai_oauth_signing_key ( + issuer, key_id, private_key_pem, encryption_nonce, active, created_at, retired_at + ) VALUES ($1, $2, $3, $4, 1, $5, '')`, + issuer, keySet.KeyID, pemRaw, nonce, now); err != nil { + return err + } + return tx.Commit(context.Background()) +} + +func randomSigningKeySuffix() string { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "rot" + } + return fmt.Sprintf("%x", b) +} + func insertSQLiteSigningKey(db *sql.DB, issuer, keyID, secret string) error { keySet, pemRaw, nonce, err := generateStoredKeySet(keyID, secret) if err != nil { diff --git a/pkg/oauth/store/sqlite.go b/pkg/oauth/store/sqlite.go index 4550388..fb25c13 100644 --- a/pkg/oauth/store/sqlite.go +++ b/pkg/oauth/store/sqlite.go @@ -145,6 +145,17 @@ func (s *SQLiteAuthorizationStore) GetPendingAuthorization(id string) (oauth.Pen return entry, true } +func (s *SQLiteAuthorizationStore) PurgeExpiredPendingAuthorizations() int { + now := s.now().UTC().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(context.Background(), ` + DELETE FROM hai_oauth_pending_auth WHERE expires_at <= ?`, now) + if err != nil { + return 0 + } + n, _ := res.RowsAffected() + return int(n) +} + func (s *SQLiteAuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { now := s.now().UTC().Format(time.RFC3339Nano) var payload string diff --git a/pkg/runtime/README.md b/pkg/runtime/README.md index 58d07f0..2ed8de2 100644 --- a/pkg/runtime/README.md +++ b/pkg/runtime/README.md @@ -104,7 +104,7 @@ Postgres mode with `WithSearch` also wires a **background reindex worker** and r ### Builtin SMART OAuth (`haistack serve`) -When HTTP is enabled, `WithBuiltinOAuth` mounts `pkg/oauth` on the root handler (`/oauth/*`, `/.well-known/*`, mirrored under `/fhir/.well-known/*`). Stores use `oauthstore.ApplySQLiteStores` or `ApplyPostgresStores`; signing keys persist as PEM files under `{data-dir}/oauth` or beside the SQLite database. +When HTTP is enabled, `WithBuiltinOAuth` mounts `pkg/oauth` on the root handler (`/oauth/*`, `/.well-known/*`, mirrored under `/fhir/.well-known/*`, plus tenant routes at `/t/{tenantId}/*`). Stores use `oauthstore.ApplySQLiteStores` or `ApplyPostgresStores` with DB-backed rate limits. Signing keys persist in `hai_oauth_signing_key` when `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is set (optional `OAUTH_SIGNING_KEY_ROTATE=1` on startup); otherwise PEM files under `{data-dir}/oauth` or beside the SQLite database. Production mode also enables `/oauth/login` session cookies via `OAUTH_SESSION_SECRET` and background consent-session purge. ```go rt, err := runtime.New(). diff --git a/pkg/runtime/builder.go b/pkg/runtime/builder.go index e3cfcb0..b8c52ab 100644 --- a/pkg/runtime/builder.go +++ b/pkg/runtime/builder.go @@ -9,6 +9,7 @@ import ( "github.com/degoke/health-ai-stack/pkg/fhirpath" hahttp "github.com/degoke/health-ai-stack/pkg/http" "github.com/degoke/health-ai-stack/pkg/modules" + "github.com/degoke/health-ai-stack/pkg/oauth" "github.com/degoke/health-ai-stack/pkg/packages" hasync "github.com/degoke/health-ai-stack/pkg/sync" ) @@ -59,9 +60,10 @@ type Builder struct { preExpandValueSets bool maxExpansion int - builtinOAuth *BuiltinOAuthConfig - oauthHandler http.Handler - oauthIssuerURL string + builtinOAuth *BuiltinOAuthConfig + oauthHandler http.Handler + oauthIssuerURL string + oauthAuthStore oauth.AuthorizationStore } // New returns a new runtime builder. diff --git a/pkg/runtime/oauth_builtin.go b/pkg/runtime/oauth_builtin.go index a351536..9d915d6 100644 --- a/pkg/runtime/oauth_builtin.go +++ b/pkg/runtime/oauth_builtin.go @@ -121,6 +121,9 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error return fmt.Errorf("runtime: oauth session auth: %w", err) } oauthCfg.UserAuthenticator = sessionAuth + if err := oauth.ApplyProductionDefaults(&oauthCfg); err != nil { + return fmt.Errorf("runtime: oauth production defaults: %w", err) + } } else if regToken != "" { oauthCfg.AllowDynamicRegistration = true oauthCfg.RegistrationAccessToken = regToken @@ -183,6 +186,7 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error } b.oauthHandler = oauth.CombineHandlers(srv.Handler(), multiTenant.Handler()) + b.oauthAuthStore = oauthCfg.AuthorizationStore b.oauthIssuerURL = issuer b.httpPrincipalResolver = hahttp.SMARTBearerPrincipalResolver(bearer) b.httpAuthBundleResolver = hahttp.SMARTBearerBundleResolver(bearer) @@ -194,6 +198,7 @@ func (b *Builder) applyBuiltinSigningKey(cfg *oauth.Config, state *wireState, is opts := oauthstore.SigningKeyOptions{ ActiveKeyID: "haistack", EncryptionSecret: oauth.SigningKeyEncryptionSecret(), + RotateOnStartup: oauth.SigningKeyRotateOnStartup(), } if opts.EncryptionSecret != "" { switch { diff --git a/pkg/runtime/oauth_builtin_test.go b/pkg/runtime/oauth_builtin_test.go index 2bb5f39..f232698 100644 --- a/pkg/runtime/oauth_builtin_test.go +++ b/pkg/runtime/oauth_builtin_test.go @@ -138,6 +138,38 @@ func TestBuiltinOAuthPersistsSigningKey(t *testing.T) { } } +func TestBuiltinOAuthTenantRoute(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "oauth-tenant.db") + rt, err := runtime.New(). + WithSQLite(dbPath). + WithHTTP("127.0.0.1:8080"). + WithBuiltinOAuth(runtime.BuiltinOAuthConfig{ + IssuerURL: "http://127.0.0.1:8080", + TenantID: "local", + }). + Build(ctx) + if err != nil { + t.Fatal(err) + } + if err := rt.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { _ = rt.Shutdown(ctx) }() + + ts := httptest.NewServer(rt.Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/t/local/.well-known/smart-configuration") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + func TestBuiltinOAuthProductionRejectsHTTPDerivedIssuer(t *testing.T) { ctx := context.Background() dbPath := filepath.Join(t.TempDir(), "oauth-runtime-http-prod.db") diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index cfce3ae..40229fc 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -11,6 +11,7 @@ import ( "github.com/degoke/health-ai-stack/pkg/analytics" "github.com/degoke/health-ai-stack/pkg/jobs" + "github.com/degoke/health-ai-stack/pkg/oauth" "github.com/degoke/health-ai-stack/pkg/postgres" "github.com/degoke/health-ai-stack/pkg/search" "github.com/degoke/health-ai-stack/pkg/sqlite" @@ -52,6 +53,8 @@ type Runtime struct { shutdownErr error backgroundErr error + + oauthAuthStore oauth.AuthorizationStore } // Build constructs a wired runtime from the builder configuration. @@ -111,14 +114,23 @@ func (rt *Runtime) Start(ctx context.Context) error { } rt.starting = true - if rt.jobRunner != nil || rt.syncProcessor != nil || rt.analyticsCDC != nil { + if rt.jobRunner != nil || rt.syncProcessor != nil || rt.analyticsCDC != nil || rt.oauthAuthStore != nil { rt.jobCtx, rt.jobCancel = context.WithCancel(ctx) + } + if rt.jobRunner != nil || rt.syncProcessor != nil || rt.analyticsCDC != nil { rt.jobWG.Add(1) go func() { defer rt.jobWG.Done() rt.runJobLoop(rt.jobCtx) }() } + if rt.oauthAuthStore != nil { + rt.jobWG.Add(1) + go func() { + defer rt.jobWG.Done() + oauth.RunPendingAuthorizationCleanup(rt.jobCtx, rt.oauthAuthStore, oauth.DefaultPendingAuthorizationCleanupInterval) + }() + } if rt.config.HTTPAddr != "" { server := &http.Server{ diff --git a/pkg/runtime/wire.go b/pkg/runtime/wire.go index 8d642e4..926018f 100644 --- a/pkg/runtime/wire.go +++ b/pkg/runtime/wire.go @@ -104,6 +104,7 @@ func (b *Builder) wire(ctx context.Context, rt *Runtime) error { rt.syncEngine = state.syncEngine rt.sqliteDB = state.sqliteDB rt.postgresDB = state.postgresDB + rt.oauthAuthStore = b.oauthAuthStore // Transfer only the cleanup functions. Copying cleanupStack itself would // copy its sync.Once state, which is both unsafe and rejected by vet. rt.cleanup.fns = state.cleanup.fns diff --git a/pkg/testkit/infernotest/reference.go b/pkg/testkit/infernotest/reference.go index 1fc3cda..c408540 100644 --- a/pkg/testkit/infernotest/reference.go +++ b/pkg/testkit/infernotest/reference.go @@ -169,10 +169,10 @@ func BuildReferenceHandler(ctx context.Context, baseURL string) (http.Handler, R } oauthCfg := oauth.Config{ - Issuer: meta.BaseURL, - FHIRAudience: meta.BaseURL, - SigningKey: keySet, - AutoApprove: true, + Issuer: meta.BaseURL, + FHIRAudience: meta.BaseURL, + SigningKey: keySet, + AutoApprove: true, AllowDynamicRegistration: true, LaunchResolver: oauth.StaticLaunchResolver(oauth.LaunchContext{ PatientID: created.ID, From c48eef36ed2e98d1fd22dbe7829e07d2d3a89dbd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 16:01:55 +0000 Subject: [PATCH 05/14] =?UTF-8?q?fix(oauth):=20close=20review=20gaps=20?= =?UTF-8?q?=E2=80=94=20multi-tenant=20bearer,=20DCR=20hardening,=20CI=20li?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pkg/oauth/errors.go | 3 + pkg/oauth/handlers.go | 13 ++- pkg/oauth/keys.go | 5 +- pkg/oauth/redirect_uri.go | 48 ++++++++++ pkg/oauth/redirect_uri_test.go | 19 ++++ pkg/oauth/redis/scripts.go | 9 -- pkg/oauth/scopes.go | 46 ++++++++++ pkg/oauth/server.go | 2 + pkg/oauth/tenant.go | 8 ++ pkg/oauth/wire.go | 98 ++++++++++++++++++++ pkg/oauth/wire_test.go | 159 +++++++++++++++++++++++++++++++++ pkg/runtime/oauth_builtin.go | 11 ++- 12 files changed, 405 insertions(+), 16 deletions(-) create mode 100644 pkg/oauth/redirect_uri.go create mode 100644 pkg/oauth/redirect_uri_test.go create mode 100644 pkg/oauth/scopes.go create mode 100644 pkg/oauth/wire.go create mode 100644 pkg/oauth/wire_test.go diff --git a/pkg/oauth/errors.go b/pkg/oauth/errors.go index 43578e9..31eec10 100644 --- a/pkg/oauth/errors.go +++ b/pkg/oauth/errors.go @@ -10,6 +10,9 @@ import ( // ErrInvalidConfig indicates invalid OAuth server configuration. var ErrInvalidConfig = errors.New("oauth: invalid config") +// ErrInvalidScope indicates a requested OAuth scope is not allowed. +var ErrInvalidScope = errors.New("oauth: invalid scope") + func writeMethodNotAllowed(w http.ResponseWriter, allowed ...string) { msg := "method not allowed" if len(allowed) > 0 { diff --git a/pkg/oauth/handlers.go b/pkg/oauth/handlers.go index 3b82aab..a09f82b 100644 --- a/pkg/oauth/handlers.go +++ b/pkg/oauth/handlers.go @@ -399,6 +399,15 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { http.Error(w, "redirect_uris required for authorization_code grant", http.StatusBadRequest) return } + if err := validateRedirectURIs(req.RedirectURIs); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", err.Error()) + return + } + scopes, err := normalizeRegisteredClientScopes(s.cfg, strings.Fields(req.Scope)) + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_scope", err.Error()) + return + } clientID := randomToken() secret := generateClientSecret() authMethod := normalizeAuthMethod(req.TokenEndpointAuthMethod) @@ -409,7 +418,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { RedirectURIs: req.RedirectURIs, GrantTypes: grantTypes, ResponseTypes: defaultIfEmpty(req.ResponseTypes, []string{"code"}), - Scopes: strings.Fields(req.Scope), + Scopes: scopes, TokenEndpointAuthMethod: authMethod, } if err := s.cfg.Clients.Register(client); err != nil { @@ -423,7 +432,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { "grant_types": client.GrantTypes, "response_types": client.ResponseTypes, "token_endpoint_auth_method": client.TokenEndpointAuthMethod, - "scope": req.Scope, + "scope": strings.Join(scopes, " "), }) } diff --git a/pkg/oauth/keys.go b/pkg/oauth/keys.go index 1e53ffd..b5fe966 100644 --- a/pkg/oauth/keys.go +++ b/pkg/oauth/keys.go @@ -68,10 +68,13 @@ func SaveKeySetToPEM(path string, key *KeySet) error { if err != nil { return fmt.Errorf("oauth: write signing key: %w", err) } - defer f.Close() if err := pem.Encode(f, block); err != nil { + _ = f.Close() return fmt.Errorf("oauth: encode signing key: %w", err) } + if err := f.Close(); err != nil { + return fmt.Errorf("oauth: close signing key: %w", err) + } return nil } diff --git a/pkg/oauth/redirect_uri.go b/pkg/oauth/redirect_uri.go new file mode 100644 index 0000000..dc7060d --- /dev/null +++ b/pkg/oauth/redirect_uri.go @@ -0,0 +1,48 @@ +package oauth + +import ( + "fmt" + "net/url" + "strings" +) + +func validateRedirectURIs(redirectURIs []string) error { + for _, raw := range redirectURIs { + if err := validateRedirectURI(raw); err != nil { + return err + } + } + return nil +} + +// ValidateRedirectURI checks redirect URIs allowed for dynamic client registration. +func ValidateRedirectURI(raw string) error { + return validateRedirectURI(raw) +} + +func validateRedirectURI(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("%w: redirect_uri must not be empty", ErrInvalidConfig) + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("%w: invalid redirect_uri %q", ErrInvalidConfig, raw) + } + switch strings.ToLower(u.Scheme) { + case "https": + return nil + case "http": + if isLoopbackHost(u.Hostname()) { + return nil + } + return fmt.Errorf("%w: redirect_uri must use https except for loopback hosts", ErrInvalidConfig) + default: + return fmt.Errorf("%w: redirect_uri scheme %q not allowed", ErrInvalidConfig, u.Scheme) + } +} + +func isLoopbackHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" +} diff --git a/pkg/oauth/redirect_uri_test.go b/pkg/oauth/redirect_uri_test.go new file mode 100644 index 0000000..e00191a --- /dev/null +++ b/pkg/oauth/redirect_uri_test.go @@ -0,0 +1,19 @@ +package oauth_test + +import ( + "testing" + + "github.com/degoke/health-ai-stack/pkg/oauth" +) + +func TestValidateRedirectURI(t *testing.T) { + if err := oauth.ValidateRedirectURI("https://app.example/callback"); err != nil { + t.Fatal(err) + } + if err := oauth.ValidateRedirectURI("http://127.0.0.1/callback"); err != nil { + t.Fatal(err) + } + if err := oauth.ValidateRedirectURI("http://app.example/callback"); err == nil { + t.Fatal("expected non-loopback http redirect to fail") + } +} diff --git a/pkg/oauth/redis/scripts.go b/pkg/oauth/redis/scripts.go index aa0efe5..3227d86 100644 --- a/pkg/oauth/redis/scripts.go +++ b/pkg/oauth/redis/scripts.go @@ -17,15 +17,6 @@ return payload redis.call('HSET', KEYS[1], 'clientId', ARGV[1], 'payload', ARGV[2]) redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) return 1 -`) - - consumeRefreshTokenScript = goredis.NewScript(` -local payload = redis.call('HGET', KEYS[1], 'payload') -if not payload then - return '' -end -redis.call('DEL', KEYS[1]) -return payload `) deleteRefreshTokenForClientScript = goredis.NewScript(` diff --git a/pkg/oauth/scopes.go b/pkg/oauth/scopes.go new file mode 100644 index 0000000..83e83d7 --- /dev/null +++ b/pkg/oauth/scopes.go @@ -0,0 +1,46 @@ +package oauth + +import ( + "fmt" + "strings" + + "github.com/degoke/health-ai-stack/pkg/smart" +) + +// DefaultRegisteredClientScopes is the SMART scope allow-list applied to dynamically +// registered clients that omit scope, and the maximum scopes DCR may request. +func DefaultRegisteredClientScopes() []string { + return []string{ + "openid", + "offline_access", + "patient/*.read", + "user/*.read", + "launch/patient", + } +} + +func registeredClientScopeAllowList(cfg Config) []string { + if len(cfg.RegisteredClientScopes) > 0 { + return append([]string(nil), cfg.RegisteredClientScopes...) + } + return DefaultRegisteredClientScopes() +} + +func normalizeRegisteredClientScopes(cfg Config, requested []string) ([]string, error) { + allowed := registeredClientScopeAllowList(cfg) + if len(requested) == 0 { + return append([]string(nil), allowed...), nil + } + granted, err := smart.ParseScopes(strings.Join(requested, " ")) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidScope, err) + } + allowedSet, err := smart.ParseScopes(strings.Join(allowed, " ")) + if err != nil { + return nil, err + } + if !granted.SubsetOf(allowedSet) { + return nil, fmt.Errorf("%w: requested scopes exceed server allow-list", ErrInvalidScope) + } + return granted.Strings(), nil +} diff --git a/pkg/oauth/server.go b/pkg/oauth/server.go index a074dc5..8a3cc62 100644 --- a/pkg/oauth/server.go +++ b/pkg/oauth/server.go @@ -51,6 +51,8 @@ type Config struct { VerificationKeys []*KeySet // RequirePKCEForAllClients requires code_challenge for every client at authorize. RequirePKCEForAllClients bool + // RegisteredClientScopes limits scopes for dynamic client registration. + RegisteredClientScopes []string } // Server is a SMART-compatible OAuth2/OIDC authorization server. diff --git a/pkg/oauth/tenant.go b/pkg/oauth/tenant.go index 93dd94e..acd2e0d 100644 --- a/pkg/oauth/tenant.go +++ b/pkg/oauth/tenant.go @@ -143,6 +143,14 @@ func NewMultiTenantServer(cfg MultiTenantConfig) (*MultiTenantServer, error) { }, nil } +// LookupByIssuer returns tenant configuration for a token issuer URL. +func (m *MultiTenantServer) LookupByIssuer(issuer string) (TenantIssuerConfig, error) { + if m == nil || m.tenants == nil { + return TenantIssuerConfig{}, ErrInvalidConfig + } + return m.tenants.LookupByIssuer(issuer) +} + // ServerForTenant returns a tenant-scoped authorization server view. func (m *MultiTenantServer) ServerForTenant(tenantID string) (*Server, error) { if m == nil { diff --git a/pkg/oauth/wire.go b/pkg/oauth/wire.go new file mode 100644 index 0000000..01d33c2 --- /dev/null +++ b/pkg/oauth/wire.go @@ -0,0 +1,98 @@ +package oauth + +import ( + "context" + "fmt" + "net/http" + + "github.com/degoke/health-ai-stack/pkg/auth" + hahttp "github.com/degoke/health-ai-stack/pkg/http" + "github.com/degoke/health-ai-stack/pkg/smart" +) + +// MultiTenantBearerAuth validates Bearer tokens issued by the base OAuth server or +// any registered tenant issuer. +type MultiTenantBearerAuth struct { + Base *Server + Tenants *MultiTenantServer + Adapter *smart.AuthAdapter +} + +// BearerConfigForRequest selects the bearer validation config for the request token issuer. +func (m MultiTenantBearerAuth) BearerConfigForRequest(r *http.Request) (smart.BearerAuthConfig, error) { + if m.Base == nil || m.Adapter == nil { + return smart.BearerAuthConfig{}, fmt.Errorf("%w: multi-tenant bearer auth is not configured", ErrInvalidConfig) + } + token, err := bearerTokenFromRequest(r) + if err != nil { + return smart.BearerAuthConfig{}, err + } + unverified, err := smart.ParseTokenUnverified(token) + if err != nil { + return smart.BearerAuthConfig{}, err + } + if m.Tenants != nil { + tenantCfg, err := m.Tenants.LookupByIssuer(unverified.Issuer) + if err == nil { + srv, err := m.Tenants.ServerForTenant(tenantCfg.TenantID) + if err != nil { + return smart.BearerAuthConfig{}, err + } + return srv.BearerAuthConfig(m.Adapter), nil + } + } + if unverified.Issuer == m.Base.Issuer() { + return m.Base.BearerAuthConfig(m.Adapter), nil + } + return smart.BearerAuthConfig{}, smart.ErrInvalidToken +} + +// ResolveBearerTokenCached validates the Bearer token against the matching issuer. +func (m MultiTenantBearerAuth) ResolveBearerTokenCached(ctx context.Context, r *http.Request) (smart.BearerAuthResult, error) { + cfg, err := m.BearerConfigForRequest(r) + if err != nil { + return smart.BearerAuthResult{}, err + } + return cfg.ResolveBearerTokenCached(ctx, r) +} + +// PrincipalResolver returns an HTTP principal resolver for multi-tenant OAuth tokens. +func (m MultiTenantBearerAuth) PrincipalResolver() hahttp.PrincipalResolver { + return func(ctx context.Context, r *http.Request) (auth.Principal, auth.TenantContext, error) { + result, err := m.ResolveBearerTokenCached(ctx, r) + if err != nil { + return auth.Principal{}, auth.TenantContext{}, err + } + return result.Bundle.Principal, result.Bundle.Tenant, nil + } +} + +// BundleResolver returns an HTTP auth bundle resolver for multi-tenant OAuth tokens. +func (m MultiTenantBearerAuth) BundleResolver() hahttp.AuthBundleResolver { + return func(ctx context.Context, r *http.Request) (smart.AuthBundle, bool) { + result, err := m.ResolveBearerTokenCached(ctx, r) + if err != nil { + return smart.AuthBundle{}, false + } + return result.Bundle, true + } +} + +func bearerTokenFromRequest(r *http.Request) (string, error) { + if r == nil { + return "", fmt.Errorf("%w: request is nil", ErrInvalidConfig) + } + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return "", smart.ErrUnauthorized + } + const prefix = "Bearer " + if len(authHeader) < len(prefix) || authHeader[:len(prefix)] != prefix { + return "", smart.ErrUnauthorized + } + token := authHeader[len(prefix):] + if token == "" { + return "", smart.ErrUnauthorized + } + return token, nil +} diff --git a/pkg/oauth/wire_test.go b/pkg/oauth/wire_test.go new file mode 100644 index 0000000..ec53020 --- /dev/null +++ b/pkg/oauth/wire_test.go @@ -0,0 +1,159 @@ +package oauth_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/degoke/health-ai-stack/pkg/client" + "github.com/degoke/health-ai-stack/pkg/oauth" + "github.com/degoke/health-ai-stack/pkg/smart" +) + +func TestMultiTenantBearerAuthResolvesTenantIssuer(t *testing.T) { + baseKey, err := oauth.NewKeySet(2048) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + defer ts.Close() + + baseIssuer := strings.TrimSuffix(ts.URL, "") + liveTenantIssuer := baseIssuer + "/t/local" + registry := oauth.NewTenantRegistry() + if err := registry.Register(oauth.TenantIssuerConfig{ + TenantID: "local", + Issuer: liveTenantIssuer, + FHIRAudience: baseIssuer, + SigningKey: baseKey, + AutoApprove: boolPtr(true), + }); err != nil { + t.Fatal(err) + } + baseCfg := oauth.Config{ + Issuer: baseIssuer, + FHIRAudience: baseIssuer, + SigningKey: baseKey, + AutoApprove: true, + } + baseSrv, err := oauth.NewServer(baseCfg) + if err != nil { + t.Fatal(err) + } + multi, err := oauth.NewMultiTenantServer(oauth.MultiTenantConfig{Base: baseCfg, Tenants: registry}) + if err != nil { + t.Fatal(err) + } + tenantSrv, err := multi.ServerForTenant("local") + if err != nil { + t.Fatal(err) + } + _ = tenantSrv.RegisterClient(oauth.Client{ + ClientID: "tenant-client", + ClientSecret: "tenant-secret", + TokenEndpointAuthMethod: oauth.AuthMethodClientSecretPost, + RedirectURIs: []string{"http://127.0.0.1/callback"}, + Scopes: []string{"patient/Patient.read"}, + }) + + mux.Handle("/t/", multi.Handler()) + httpClient, err := client.New(client.Config{BaseURL: ts.URL}) + if err != nil { + t.Fatal(err) + } + pkce, _ := client.NewPKCEChallenge() + authURL, _ := httpClient.SMART().BuildAuthURL(client.AuthCodeRequest{ + Config: &client.SMARTConfiguration{ + AuthorizationEndpoint: liveTenantIssuer + "/oauth/authorize", + TokenEndpoint: liveTenantIssuer + "/oauth/token", + }, + ClientID: "tenant-client", RedirectURI: "http://127.0.0.1/callback", + Scope: "patient/Patient.read", PKCE: pkce, + }) + noRedirect := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }} + authResp, err := noRedirect.Get(authURL) + if err != nil { + t.Fatal(err) + } + _ = authResp.Body.Close() + if authResp.StatusCode != http.StatusFound { + t.Fatalf("authorize status = %d", authResp.StatusCode) + } + location := authResp.Header.Get("Location") + code := strings.Split(strings.Split(location, "code=")[1], "&")[0] + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", "http://127.0.0.1/callback") + form.Set("client_id", "tenant-client") + form.Set("client_secret", "tenant-secret") + form.Set("code_verifier", pkce.Verifier) + tokenResp, err := http.PostForm(liveTenantIssuer+"/oauth/token", form) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tokenResp.Body.Close() }() + if tokenResp.StatusCode != http.StatusOK { + t.Fatalf("token status = %d", tokenResp.StatusCode) + } + var tokenDoc map[string]any + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenDoc); err != nil { + t.Fatal(err) + } + accessToken, _ := tokenDoc["access_token"].(string) + if accessToken == "" { + t.Fatalf("token doc = %#v", tokenDoc) + } + + adapter := smart.NewAuthAdapter(smart.AuthAdapterConfig{DefaultTenantID: "local"}) + mtAuth := oauth.MultiTenantBearerAuth{Base: baseSrv, Tenants: multi, Adapter: adapter} + req := httptest.NewRequest(http.MethodGet, "/fhir/Patient", nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + principal, tenant, err := mtAuth.PrincipalResolver()(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if principal.ID == "" || tenant.TenantID == "" { + t.Fatalf("principal=%#v tenant=%#v", principal, tenant) + } + cfg, err := mtAuth.BearerConfigForRequest(req) + if err != nil { + t.Fatal(err) + } + if cfg.Options.ExpectedIssuer != liveTenantIssuer { + t.Fatalf("issuer = %q", cfg.Options.ExpectedIssuer) + } +} + +func TestRegisterRejectsInvalidRedirectURI(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + base := strings.TrimSuffix(srv.URL, "/") + server, err := oauth.NewServer(oauth.Config{ + Issuer: base, + FHIRAudience: base, + AllowDynamicRegistration: true, + }) + if err != nil { + t.Fatal(err) + } + mux.Handle("/", server.Handler()) + + body := `{"redirect_uris":["http://app.example/callback"],"grant_types":["authorization_code"]}` + resp, err := http.Post(base+"/oauth/register", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d", resp.StatusCode) + } +} diff --git a/pkg/runtime/oauth_builtin.go b/pkg/runtime/oauth_builtin.go index 9d915d6..38feafe 100644 --- a/pkg/runtime/oauth_builtin.go +++ b/pkg/runtime/oauth_builtin.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/degoke/health-ai-stack/pkg/auth" - hahttp "github.com/degoke/health-ai-stack/pkg/http" "github.com/degoke/health-ai-stack/pkg/oauth" oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" "github.com/degoke/health-ai-stack/pkg/smart" @@ -170,7 +169,11 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error DefaultTenantID: tenantID, DefaultUserRoles: []string{"clinician"}, }) - bearer := srv.BearerAuthConfig(adapter) + mtBearer := oauth.MultiTenantBearerAuth{ + Base: srv, + Tenants: multiTenant, + Adapter: adapter, + } engine, err := auth.NewEngine(auth.Config{ Roles: []auth.Role{{Name: "clinician", Permissions: []auth.Permission{"*.read", "*.write", "Patient.read"}}}, PolicyBytes: []byte(`{ @@ -188,8 +191,8 @@ func (b *Builder) wireBuiltinOAuth(ctx context.Context, state *wireState) error b.oauthHandler = oauth.CombineHandlers(srv.Handler(), multiTenant.Handler()) b.oauthAuthStore = oauthCfg.AuthorizationStore b.oauthIssuerURL = issuer - b.httpPrincipalResolver = hahttp.SMARTBearerPrincipalResolver(bearer) - b.httpAuthBundleResolver = hahttp.SMARTBearerBundleResolver(bearer) + b.httpPrincipalResolver = mtBearer.PrincipalResolver() + b.httpAuthBundleResolver = mtBearer.BundleResolver() b.httpAuthChecker = smart.ScopePolicyAuthChecker{Engine: engine, Adapter: adapter} return nil } From 40c40f924d2b31002f24e422114aad0f29fc59ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:06:59 +0000 Subject: [PATCH 06/14] feat(oauth): bind auth codes and refresh tokens to issuer 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 --- pkg/oauth/README.md | 4 +- pkg/oauth/handlers.go | 13 +- pkg/oauth/introspect.go | 2 +- pkg/oauth/issuer_binding.go | 20 +++ pkg/oauth/issuer_binding_test.go | 121 ++++++++++++++++++ pkg/oauth/persistent.go | 75 ++++++----- pkg/oauth/redis/store.go | 28 ++-- pkg/oauth/redis/store_test.go | 13 +- pkg/oauth/revoke.go | 2 +- pkg/oauth/security_test.go | 4 +- pkg/oauth/store/postgres.go | 77 +++++++---- pkg/oauth/store/postgres_test.go | 5 +- pkg/oauth/store/sqlite.go | 77 +++++++---- pkg/oauth/store/sqlite_test.go | 8 +- .../migrations/0020_oauth_issuer_binding.sql | 9 ++ .../migrations/0017_oauth_issuer_binding.sql | 9 ++ 16 files changed, 349 insertions(+), 118 deletions(-) create mode 100644 pkg/oauth/issuer_binding.go create mode 100644 pkg/oauth/issuer_binding_test.go create mode 100644 pkg/postgres/migrations/0020_oauth_issuer_binding.sql create mode 100644 pkg/sqlite/migrations/0017_oauth_issuer_binding.sql diff --git a/pkg/oauth/README.md b/pkg/oauth/README.md index fec807b..ac0862b 100644 --- a/pkg/oauth/README.md +++ b/pkg/oauth/README.md @@ -54,7 +54,9 @@ server, err := oauthstore.NewPostgresServer(oauth.Config{ - `TokenRateLimiter` / `RegisterRateLimiter` — DB-backed endpoint rate limits - DB signing keys via `ApplyPostgresSigningKey` / `ApplySQLiteSigningKey` when `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is set -Schema: migrations `0017_oauth.sql` + `0018_oauth_rate_limit.sql` + `0019_oauth_signing_key.sql` (Postgres), or `0014_oauth.sql` + `0015_oauth_rate_limit.sql` + `0016_oauth_signing_key.sql` (SQLite). +Schema: migrations `0017_oauth.sql` + `0018_oauth_rate_limit.sql` + `0019_oauth_signing_key.sql` + `0020_oauth_issuer_binding.sql` (Postgres), or `0014_oauth.sql` + `0015_oauth_rate_limit.sql` + `0016_oauth_signing_key.sql` + `0017_oauth_issuer_binding.sql` (SQLite). + +Auth codes, refresh tokens, and pending consent rows store an `issuer` column (and JSON `issuer` field) so a shared SQL store can enforce that tokens minted under `/t/{tenantId}/` are only consumed by the matching tenant issuer. ### Why file stores existed diff --git a/pkg/oauth/handlers.go b/pkg/oauth/handlers.go index a09f82b..79bcb88 100644 --- a/pkg/oauth/handlers.go +++ b/pkg/oauth/handlers.go @@ -121,6 +121,7 @@ func (s *Server) handleAuthorize(w http.ResponseWriter, r *http.Request) { if s.cfg.RequireConsentForm && s.cfg.ConsentHandler == nil && !s.cfg.AutoApprove { id := randomToken() _ = s.authStore.SavePendingAuthorization(id, PendingAuthorization{ + Issuer: s.cfg.Issuer, Request: authReq, Subject: authReq.Subject, FHIRUser: authReq.FHIRUser, @@ -149,7 +150,7 @@ func (s *Server) handleConsent(w http.ResponseWriter, r *http.Request) { return } if r.Method == http.MethodGet { - pending, ok := s.authStore.GetPendingAuthorization(id) + pending, ok := s.authStore.GetPendingAuthorization(s.cfg.Issuer, id) if !ok { http.Error(w, "consent session expired", http.StatusBadRequest) return @@ -165,7 +166,7 @@ func (s *Server) handleConsent(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid form", http.StatusBadRequest) return } - pending, ok := s.authStore.GetPendingAuthorization(id) + pending, ok := s.authStore.GetPendingAuthorization(s.cfg.Issuer, id) if !ok { http.Error(w, "consent session expired", http.StatusBadRequest) return @@ -174,7 +175,7 @@ func (s *Server) handleConsent(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid csrf token", http.StatusForbidden) return } - pending, ok = s.authStore.ConsumePendingAuthorization(id) + pending, ok = s.authStore.ConsumePendingAuthorization(s.cfg.Issuer, id) if !ok { http.Error(w, "consent session expired", http.StatusBadRequest) return @@ -212,6 +213,7 @@ func (s *Server) issueAuthorizationRedirect(w http.ResponseWriter, r *http.Reque subject = clientID } _ = s.authStore.SaveAuthorizationCode(code, AuthorizationCode{ + Issuer: s.cfg.Issuer, ClientID: clientID, RedirectURI: req.RedirectURI, Scope: req.Scope, @@ -281,7 +283,7 @@ func (s *Server) handleAuthorizationCode(w http.ResponseWriter, r *http.Request) writeOAuthError(w, http.StatusUnauthorized, "invalid_client", err.Error()) return } - entry, ok := s.authStore.ConsumeAuthorizationCode(code) + entry, ok := s.authStore.ConsumeAuthorizationCode(s.cfg.Issuer, code) if !ok || entry.ClientID != clientID || entry.RedirectURI != redirectURI { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid authorization code") return @@ -354,7 +356,7 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request) { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", err.Error()) return } - entry, ok := s.authStore.ConsumeRefreshToken(token) + entry, ok := s.authStore.ConsumeRefreshToken(s.cfg.Issuer, token) if !ok || entry.ClientID != clientID { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid refresh token") return @@ -472,6 +474,7 @@ func (s *Server) issueTokens(clientID, scope, patient, encounter, subject, fhirU } refresh := randomToken() _ = s.authStore.SaveRefreshToken(refresh, RefreshTokenEntry{ + Issuer: s.cfg.Issuer, ClientID: clientID, Scope: scope, Patient: patient, diff --git a/pkg/oauth/introspect.go b/pkg/oauth/introspect.go index 9c89964..fb5b9df 100644 --- a/pkg/oauth/introspect.go +++ b/pkg/oauth/introspect.go @@ -107,7 +107,7 @@ func (s *Server) introspectRefreshToken(token string) (IntrospectionResponse, bo if s.authStore == nil { return IntrospectionResponse{}, false } - record, ok := s.authStore.LookupRefreshToken(token) + record, ok := s.authStore.LookupRefreshToken(s.cfg.Issuer, token) if !ok { return IntrospectionResponse{}, false } diff --git a/pkg/oauth/issuer_binding.go b/pkg/oauth/issuer_binding.go new file mode 100644 index 0000000..6364fa2 --- /dev/null +++ b/pkg/oauth/issuer_binding.go @@ -0,0 +1,20 @@ +package oauth + +import "strings" + +// NormalizeIssuerURL trims and removes a trailing slash from an issuer URL. +func NormalizeIssuerURL(issuer string) string { + return strings.TrimRight(strings.TrimSpace(issuer), "/") +} + +// EntryIssuerMatches reports whether a stored row may be used by a server configured +// with serverIssuer. Legacy rows with an empty stored issuer remain valid for any +// server (single-issuer deployments before issuer binding). +func EntryIssuerMatches(entryIssuer, serverIssuer string) bool { + entryIssuer = NormalizeIssuerURL(entryIssuer) + serverIssuer = NormalizeIssuerURL(serverIssuer) + if entryIssuer == "" { + return true + } + return entryIssuer == serverIssuer +} diff --git a/pkg/oauth/issuer_binding_test.go b/pkg/oauth/issuer_binding_test.go new file mode 100644 index 0000000..756676b --- /dev/null +++ b/pkg/oauth/issuer_binding_test.go @@ -0,0 +1,121 @@ +package oauth_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/degoke/health-ai-stack/pkg/oauth" +) + +func TestIssuerBinding_RejectsCrossIssuerAuthorizationCode(t *testing.T) { + store := oauth.NewMemoryAuthorizationStore() + issuerA := "https://auth.example/t/clinic-a" + issuerB := "https://auth.example/t/clinic-b" + now := time.Now() + + code := "shared-code-test" + if err := store.SaveAuthorizationCode(code, oauth.AuthorizationCode{ + Issuer: issuerA, + ClientID: "client", + RedirectURI: "https://app/cb", + Scope: "openid", + ExpiresAt: now.Add(5 * time.Minute), + }); err != nil { + t.Fatal(err) + } + if _, ok := store.ConsumeAuthorizationCode(issuerB, code); ok { + t.Fatal("expected cross-issuer code consume to fail") + } + entry, ok := store.ConsumeAuthorizationCode(issuerA, code) + if !ok || entry.ClientID != "client" { + t.Fatalf("expected code for issuer A: ok=%v entry=%+v", ok, entry) + } +} + +func TestIssuerBinding_MultiTenantTokenExchange(t *testing.T) { + store := oauth.NewMemoryAuthorizationStore() + base := "https://auth.example" + issuerA := base + "/t/clinic-a" + issuerB := base + "/t/clinic-b" + + key, err := oauth.NewKeySet(2048) + if err != nil { + t.Fatal(err) + } + registry := oauth.NewTenantRegistry() + for _, iss := range []string{issuerA, issuerB} { + if err := registry.Register(oauth.TenantIssuerConfig{ + TenantID: strings.TrimPrefix(iss, base+"/t/"), + Issuer: iss, + FHIRAudience: base, + }); err != nil { + t.Fatal(err) + } + } + multi, err := oauth.NewMultiTenantServer(oauth.MultiTenantConfig{ + Base: oauth.Config{ + SigningKey: key, + AuthorizationStore: store, + AutoApprove: true, + }, + Tenants: registry, + }) + if err != nil { + t.Fatal(err) + } + + srvA, err := multi.ServerForTenant("clinic-a") + if err != nil { + t.Fatal(err) + } + clientID := "app" + clientSecret := "secret" + if err := srvA.RegisterClient(oauth.Client{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenEndpointAuthMethod: oauth.AuthMethodClientSecretPost, + RedirectURIs: []string{"https://app/cb"}, + Scopes: []string{"openid", "patient/Patient.rs"}, + }); err != nil { + t.Fatal(err) + } + code := "tenant-a-code" + _ = store.SaveAuthorizationCode(code, oauth.AuthorizationCode{ + Issuer: issuerA, + ClientID: clientID, + RedirectURI: "https://app/cb", + Scope: "openid patient/Patient.rs", + ExpiresAt: time.Now().Add(5 * time.Minute), + }) + + srvB, err := multi.ServerForTenant("clinic-b") + if err != nil { + t.Fatal(err) + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {"https://app/cb"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + } + req := httptest.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + srvB.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("tenant B token status = %d body=%s", rec.Code, rec.Body.String()) + } + + req2 := httptest.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec2 := httptest.NewRecorder() + srvA.Handler().ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("tenant A token status = %d body=%s", rec2.Code, rec2.Body.String()) + } +} diff --git a/pkg/oauth/persistent.go b/pkg/oauth/persistent.go index 668a8e3..822ba9b 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -15,20 +15,21 @@ import ( // deployments that share a filesystem. type AuthorizationStore interface { SaveAuthorizationCode(code string, entry AuthorizationCode) error - ConsumeAuthorizationCode(code string) (AuthorizationCode, bool) + ConsumeAuthorizationCode(issuer, code string) (AuthorizationCode, bool) SaveRefreshToken(token string, entry RefreshTokenEntry) error - ConsumeRefreshToken(token string) (RefreshTokenEntry, bool) - LookupRefreshToken(token string) (RefreshTokenEntry, bool) + ConsumeRefreshToken(issuer, token string) (RefreshTokenEntry, bool) + LookupRefreshToken(issuer, token string) (RefreshTokenEntry, bool) SavePendingAuthorization(id string, entry PendingAuthorization) error - GetPendingAuthorization(id string) (PendingAuthorization, bool) - ConsumePendingAuthorization(id string) (PendingAuthorization, bool) - DeleteRefreshTokenForClient(token, clientID string) bool + GetPendingAuthorization(issuer, id string) (PendingAuthorization, bool) + ConsumePendingAuthorization(issuer, id string) (PendingAuthorization, bool) + DeleteRefreshTokenForClient(issuer, token, clientID string) bool // PurgeExpiredPendingAuthorizations removes expired consent sessions. PurgeExpiredPendingAuthorizations() int } // PendingAuthorization stores an in-progress authorize/consent/launch session. type PendingAuthorization struct { + Issuer string `json:"issuer,omitempty"` Request AuthorizationRequest `json:"request"` Subject string `json:"subject,omitempty"` FHIRUser string `json:"fhirUser,omitempty"` @@ -38,6 +39,7 @@ type PendingAuthorization struct { // AuthorizationCode is a short-lived authorization code entry. type AuthorizationCode struct { + Issuer string `json:"issuer,omitempty"` ClientID string `json:"clientId"` RedirectURI string `json:"redirectUri"` Scope string `json:"scope"` @@ -52,6 +54,7 @@ type AuthorizationCode struct { // RefreshTokenEntry stores refresh-token metadata. type RefreshTokenEntry struct { + Issuer string `json:"issuer,omitempty"` ClientID string `json:"clientId"` Scope string `json:"scope"` Patient string `json:"patient,omitempty"` @@ -87,15 +90,18 @@ func (s *MemoryAuthorizationStore) SaveAuthorizationCode(code string, entry Auth return nil } -func (s *MemoryAuthorizationStore) ConsumeAuthorizationCode(code string) (AuthorizationCode, bool) { +func (s *MemoryAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (AuthorizationCode, bool) { now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.codes[code] + if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt)) { + ok = false + } if ok { delete(s.codes, code) } s.mu.Unlock() - if !ok || now.After(entry.ExpiresAt) { + if !ok { return AuthorizationCode{}, false } return entry, true @@ -108,8 +114,8 @@ func (s *MemoryAuthorizationStore) SaveRefreshToken(token string, entry RefreshT return nil } -func (s *MemoryAuthorizationStore) ConsumeRefreshToken(token string) (RefreshTokenEntry, bool) { - entry, ok := s.LookupRefreshToken(token) +func (s *MemoryAuthorizationStore) ConsumeRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return RefreshTokenEntry{}, false } @@ -119,12 +125,12 @@ func (s *MemoryAuthorizationStore) ConsumeRefreshToken(token string) (RefreshTok return entry, true } -func (s *MemoryAuthorizationStore) LookupRefreshToken(token string) (RefreshTokenEntry, bool) { +func (s *MemoryAuthorizationStore) LookupRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.refreshTokens[token] s.mu.Unlock() - if !ok || now.After(entry.ExpiresAt) { + if !ok || !EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt) { return RefreshTokenEntry{}, false } return entry, true @@ -137,22 +143,22 @@ func (s *MemoryAuthorizationStore) SavePendingAuthorization(id string, entry Pen return nil } -func (s *MemoryAuthorizationStore) GetPendingAuthorization(id string) (PendingAuthorization, bool) { +func (s *MemoryAuthorizationStore) GetPendingAuthorization(issuer, id string) (PendingAuthorization, bool) { now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.pending[id] s.mu.Unlock() - if !ok || now.After(entry.ExpiresAt) { + if !ok || !EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt) { return PendingAuthorization{}, false } return entry, true } -func (s *MemoryAuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { +func (s *MemoryAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.refreshTokens[token] - if ok && (entry.ClientID != clientID || now.After(entry.ExpiresAt)) { + if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || entry.ClientID != clientID || now.After(entry.ExpiresAt)) { ok = false } if ok { @@ -176,15 +182,18 @@ func (s *MemoryAuthorizationStore) PurgeExpiredPendingAuthorizations() int { return n } -func (s *MemoryAuthorizationStore) ConsumePendingAuthorization(id string) (PendingAuthorization, bool) { +func (s *MemoryAuthorizationStore) ConsumePendingAuthorization(issuer, id string) (PendingAuthorization, bool) { now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.pending[id] + if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt)) { + ok = false + } if ok { delete(s.pending, id) } s.mu.Unlock() - if !ok || now.After(entry.ExpiresAt) { + if !ok { return PendingAuthorization{}, false } return entry, true @@ -227,17 +236,20 @@ func (s *FileAuthorizationStore) SaveAuthorizationCode(code string, entry Author }) } -func (s *FileAuthorizationStore) ConsumeAuthorizationCode(code string) (AuthorizationCode, bool) { +func (s *FileAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (AuthorizationCode, bool) { now := s.now() var entry AuthorizationCode var ok bool err := s.update(func(state *fileAuthorizationState) { entry, ok = state.Codes[code] + if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt)) { + ok = false + } if ok { delete(state.Codes, code) } }) - if err != nil || !ok || now.After(entry.ExpiresAt) { + if err != nil || !ok { return AuthorizationCode{}, false } return entry, true @@ -252,8 +264,8 @@ func (s *FileAuthorizationStore) SaveRefreshToken(token string, entry RefreshTok }) } -func (s *FileAuthorizationStore) ConsumeRefreshToken(token string) (RefreshTokenEntry, bool) { - entry, ok := s.LookupRefreshToken(token) +func (s *FileAuthorizationStore) ConsumeRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return RefreshTokenEntry{}, false } @@ -266,14 +278,14 @@ func (s *FileAuthorizationStore) ConsumeRefreshToken(token string) (RefreshToken return entry, true } -func (s *FileAuthorizationStore) LookupRefreshToken(token string) (RefreshTokenEntry, bool) { +func (s *FileAuthorizationStore) LookupRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { now := s.now() var entry RefreshTokenEntry var ok bool err := s.update(func(state *fileAuthorizationState) { entry, ok = state.Refresh[token] }) - if err != nil || !ok || now.After(entry.ExpiresAt) { + if err != nil || !ok || !EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt) { return RefreshTokenEntry{}, false } return entry, true @@ -288,30 +300,33 @@ func (s *FileAuthorizationStore) SavePendingAuthorization(id string, entry Pendi }) } -func (s *FileAuthorizationStore) GetPendingAuthorization(id string) (PendingAuthorization, bool) { +func (s *FileAuthorizationStore) GetPendingAuthorization(issuer, id string) (PendingAuthorization, bool) { now := s.now() var entry PendingAuthorization var ok bool err := s.update(func(state *fileAuthorizationState) { entry, ok = state.Pending[id] }) - if err != nil || !ok || now.After(entry.ExpiresAt) { + if err != nil || !ok || !EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt) { return PendingAuthorization{}, false } return entry, true } -func (s *FileAuthorizationStore) ConsumePendingAuthorization(id string) (PendingAuthorization, bool) { +func (s *FileAuthorizationStore) ConsumePendingAuthorization(issuer, id string) (PendingAuthorization, bool) { now := s.now() var entry PendingAuthorization var ok bool err := s.update(func(state *fileAuthorizationState) { entry, ok = state.Pending[id] + if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt)) { + ok = false + } if ok { delete(state.Pending, id) } }) - if err != nil || !ok || now.After(entry.ExpiresAt) { + if err != nil || !ok { return PendingAuthorization{}, false } return entry, true @@ -338,12 +353,12 @@ func (s *FileAuthorizationStore) PurgeExpiredPendingAuthorizations() int { return n } -func (s *FileAuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { +func (s *FileAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { now := s.now() var ok bool err := s.update(func(state *fileAuthorizationState) { entry, found := state.Refresh[token] - if !found || entry.ClientID != clientID || now.After(entry.ExpiresAt) { + if !found || !EntryIssuerMatches(entry.Issuer, issuer) || entry.ClientID != clientID || now.After(entry.ExpiresAt) { return } delete(state.Refresh, token) diff --git a/pkg/oauth/redis/store.go b/pkg/oauth/redis/store.go index 7b4e2f2..8d9ac86 100644 --- a/pkg/oauth/redis/store.go +++ b/pkg/oauth/redis/store.go @@ -41,7 +41,7 @@ func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.Auth return s.client.Set(context.Background(), s.key("authcode:"+code), payload, ttl).Err() } -func (s *AuthorizationStore) ConsumeAuthorizationCode(code string) (oauth.AuthorizationCode, bool) { +func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { payload, ok := consumeJSONValue(s.client, s.key("authcode:"+code)) if !ok { return oauth.AuthorizationCode{}, false @@ -50,7 +50,7 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(code string) (oauth.Author if err := json.Unmarshal(payload, &entry); err != nil { return oauth.AuthorizationCode{}, false } - if s.now().After(entry.ExpiresAt) { + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) || s.now().After(entry.ExpiresAt) { return oauth.AuthorizationCode{}, false } return entry, true @@ -74,8 +74,8 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT return err } -func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { - entry, ok := s.LookupRefreshToken(token) +func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return oauth.RefreshTokenEntry{}, false } @@ -84,9 +84,9 @@ func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTok return entry, true } -func (s *AuthorizationStore) LookupRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { +func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { key := s.key("refresh:" + token) - payload, err := s.client.Get(context.Background(), key).Bytes() + payload, err := s.client.HGet(context.Background(), key, "payload").Bytes() if err == goredis.Nil || err != nil { return oauth.RefreshTokenEntry{}, false } @@ -94,7 +94,7 @@ func (s *AuthorizationStore) LookupRefreshToken(token string) (oauth.RefreshToke if err := json.Unmarshal(payload, &entry); err != nil { return oauth.RefreshTokenEntry{}, false } - if s.now().After(entry.ExpiresAt) { + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) || s.now().After(entry.ExpiresAt) { return oauth.RefreshTokenEntry{}, false } return entry, true @@ -109,7 +109,7 @@ func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.Pen return s.client.Set(context.Background(), s.key("pending:"+id), payload, ttl).Err() } -func (s *AuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAuthorization, bool) { +func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { payload, err := s.client.Get(context.Background(), s.key("pending:"+id)).Bytes() if err == goredis.Nil || err != nil { return oauth.PendingAuthorization{}, false @@ -118,7 +118,7 @@ func (s *AuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAu if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if s.now().After(entry.ExpiresAt) { + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) || s.now().After(entry.ExpiresAt) { return oauth.PendingAuthorization{}, false } return entry, true @@ -128,7 +128,7 @@ func (s *AuthorizationStore) PurgeExpiredPendingAuthorizations() int { return 0 } -func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { +func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { payload, ok := consumeJSONValue(s.client, s.key("pending:"+id)) if !ok { return oauth.PendingAuthorization{}, false @@ -137,7 +137,7 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.Pendi if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if s.now().After(entry.ExpiresAt) { + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) || s.now().After(entry.ExpiresAt) { return oauth.PendingAuthorization{}, false } return entry, true @@ -151,7 +151,11 @@ func consumeJSONValue(client goredis.Cmdable, key string) ([]byte, bool) { return []byte(payload), true } -func (s *AuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { +func (s *AuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { + entry, ok := s.LookupRefreshToken(issuer, token) + if !ok || entry.ClientID != clientID { + return false + } key := s.key("refresh:" + token) n, err := deleteRefreshTokenForClientScript.Run(context.Background(), s.client, []string{key}, clientID).Int() return err == nil && n > 0 diff --git a/pkg/oauth/redis/store_test.go b/pkg/oauth/redis/store_test.go index 5678701..778657e 100644 --- a/pkg/oauth/redis/store_test.go +++ b/pkg/oauth/redis/store_test.go @@ -21,30 +21,31 @@ func TestEphemeralStores_RoundTrip(t *testing.T) { authStore, replayStore, revocationStore := oauthredis.EphemeralStores(client, "test:") now := time.Now() + const issuer = "https://auth.example.test" if err := authStore.SaveAuthorizationCode("code-1", oauth.AuthorizationCode{ - ClientID: "redis-client", RedirectURI: "https://localhost/callback", + Issuer: issuer, ClientID: "redis-client", RedirectURI: "https://localhost/callback", Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), }); err != nil { t.Fatal(err) } - entry, ok := authStore.ConsumeAuthorizationCode("code-1") + entry, ok := authStore.ConsumeAuthorizationCode(issuer, "code-1") if !ok || entry.ClientID != "redis-client" { t.Fatalf("code = %+v ok=%v", entry, ok) } refresh := "refresh-1" if err := authStore.SaveRefreshToken(refresh, oauth.RefreshTokenEntry{ - ClientID: "owner", ExpiresAt: now.Add(time.Hour), + Issuer: issuer, ClientID: "owner", ExpiresAt: now.Add(time.Hour), }); err != nil { t.Fatal(err) } - if authStore.DeleteRefreshTokenForClient(refresh, "other") { + if authStore.DeleteRefreshTokenForClient(issuer, refresh, "other") { t.Fatal("expected foreign client revoke to fail") } - if !authStore.DeleteRefreshTokenForClient(refresh, "owner") { + if !authStore.DeleteRefreshTokenForClient(issuer, refresh, "owner") { t.Fatal("expected owner revoke to succeed") } - if authStore.DeleteRefreshTokenForClient(refresh, "owner") { + if authStore.DeleteRefreshTokenForClient(issuer, refresh, "owner") { t.Fatal("expected second revoke to fail after delete") } diff --git a/pkg/oauth/revoke.go b/pkg/oauth/revoke.go index e2835c8..45d1c0d 100644 --- a/pkg/oauth/revoke.go +++ b/pkg/oauth/revoke.go @@ -42,7 +42,7 @@ func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) { } func (s *Server) revokeRefreshToken(token, clientID string) bool { - return s.authStore.DeleteRefreshTokenForClient(token, clientID) + return s.authStore.DeleteRefreshTokenForClient(s.cfg.Issuer, token, clientID) } func (s *Server) revokeAccessToken(token, clientID string) { diff --git a/pkg/oauth/security_test.go b/pkg/oauth/security_test.go index 051ce40..6dc1392 100644 --- a/pkg/oauth/security_test.go +++ b/pkg/oauth/security_test.go @@ -61,7 +61,7 @@ func TestFileAuthorizationStore_PersistsPendingSessions(t *testing.T) { if err != nil { t.Fatal(err) } - entry, ok := reloaded.ConsumePendingAuthorization("sess-1") + entry, ok := reloaded.ConsumePendingAuthorization("", "sess-1") if !ok || entry.Request.ClientID != "client" { t.Fatalf("entry = %+v ok=%v", entry, ok) } @@ -226,7 +226,7 @@ func TestFileAuthorizationStore_PersistsCodes(t *testing.T) { if err != nil { t.Fatal(err) } - entry, ok := reloaded.ConsumeAuthorizationCode("code-1") + entry, ok := reloaded.ConsumeAuthorizationCode("", "code-1") if !ok || entry.ClientID != "client" { t.Fatalf("entry = %+v ok=%v", entry, ok) } diff --git a/pkg/oauth/store/postgres.go b/pkg/oauth/store/postgres.go index 71a034b..64385a6 100644 --- a/pkg/oauth/store/postgres.go +++ b/pkg/oauth/store/postgres.go @@ -35,11 +35,12 @@ func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.Auth if err != nil { return fmt.Errorf("encode auth code: %w", err) } + issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.pool.Exec(context.Background(), ` - INSERT INTO hai_oauth_auth_code (code, payload, expires_at) - VALUES ($1, $2::jsonb, $3) - ON CONFLICT (code) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, - code, payload, entry.ExpiresAt, + INSERT INTO hai_oauth_auth_code (code, issuer, payload, expires_at) + VALUES ($1, $2, $3::jsonb, $4) + ON CONFLICT (code) DO UPDATE SET issuer = EXCLUDED.issuer, payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, + code, issuer, payload, entry.ExpiresAt, ) if err != nil { return fmt.Errorf("save auth code: %w", err) @@ -47,13 +48,14 @@ func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.Auth return nil } -func (s *AuthorizationStore) ConsumeAuthorizationCode(code string) (oauth.AuthorizationCode, bool) { +func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { now := s.now() + issuer = oauth.NormalizeIssuerURL(issuer) var payload []byte err := s.pool.QueryRow(context.Background(), ` DELETE FROM hai_oauth_auth_code - WHERE code = $1 AND expires_at > $2 - RETURNING payload`, code, now, + WHERE code = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '') + RETURNING payload`, code, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { return oauth.AuthorizationCode{}, false @@ -62,6 +64,9 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(code string) (oauth.Author if err := json.Unmarshal(payload, &entry); err != nil { return oauth.AuthorizationCode{}, false } + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { + return oauth.AuthorizationCode{}, false + } return entry, true } @@ -70,11 +75,12 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT if err != nil { return fmt.Errorf("encode refresh token: %w", err) } + issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.pool.Exec(context.Background(), ` - INSERT INTO hai_oauth_refresh_token (token, payload, expires_at) - VALUES ($1, $2::jsonb, $3) - ON CONFLICT (token) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, - token, payload, entry.ExpiresAt, + INSERT INTO hai_oauth_refresh_token (token, issuer, payload, expires_at) + VALUES ($1, $2, $3::jsonb, $4) + ON CONFLICT (token) DO UPDATE SET issuer = EXCLUDED.issuer, payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, + token, issuer, payload, entry.ExpiresAt, ) if err != nil { return fmt.Errorf("save refresh token: %w", err) @@ -82,27 +88,29 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT return nil } -func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { - entry, ok := s.LookupRefreshToken(token) +func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return oauth.RefreshTokenEntry{}, false } now := s.now() + issuer = oauth.NormalizeIssuerURL(issuer) tag, err := s.pool.Exec(context.Background(), ` DELETE FROM hai_oauth_refresh_token - WHERE token = $1 AND expires_at > $2`, token, now) + WHERE token = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '')`, token, now, issuer) if err != nil || tag.RowsAffected() == 0 { return oauth.RefreshTokenEntry{}, false } return entry, true } -func (s *AuthorizationStore) LookupRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { +func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { now := s.now() + issuer = oauth.NormalizeIssuerURL(issuer) var payload []byte err := s.pool.QueryRow(context.Background(), ` SELECT payload FROM hai_oauth_refresh_token - WHERE token = $1 AND expires_at > $2`, token, now, + WHERE token = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '')`, token, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { return oauth.RefreshTokenEntry{}, false @@ -111,6 +119,9 @@ func (s *AuthorizationStore) LookupRefreshToken(token string) (oauth.RefreshToke if err := json.Unmarshal(payload, &entry); err != nil { return oauth.RefreshTokenEntry{}, false } + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { + return oauth.RefreshTokenEntry{}, false + } return entry, true } @@ -119,11 +130,12 @@ func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.Pen if err != nil { return fmt.Errorf("encode pending auth: %w", err) } + issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.pool.Exec(context.Background(), ` - INSERT INTO hai_oauth_pending_auth (id, payload, expires_at) - VALUES ($1, $2::jsonb, $3) - ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, - id, payload, entry.ExpiresAt, + INSERT INTO hai_oauth_pending_auth (id, issuer, payload, expires_at) + VALUES ($1, $2, $3::jsonb, $4) + ON CONFLICT (id) DO UPDATE SET issuer = EXCLUDED.issuer, payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, + id, issuer, payload, entry.ExpiresAt, ) if err != nil { return fmt.Errorf("save pending auth: %w", err) @@ -131,12 +143,13 @@ func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.Pen return nil } -func (s *AuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAuthorization, bool) { +func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { now := s.now() + issuer = oauth.NormalizeIssuerURL(issuer) var payload []byte err := s.pool.QueryRow(context.Background(), ` SELECT payload FROM hai_oauth_pending_auth - WHERE id = $1 AND expires_at > $2`, id, now, + WHERE id = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '')`, id, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { return oauth.PendingAuthorization{}, false @@ -145,6 +158,9 @@ func (s *AuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAu if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { + return oauth.PendingAuthorization{}, false + } return entry, true } @@ -158,13 +174,14 @@ func (s *AuthorizationStore) PurgeExpiredPendingAuthorizations() int { return int(tag.RowsAffected()) } -func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { +func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { now := s.now() + issuer = oauth.NormalizeIssuerURL(issuer) var payload []byte err := s.pool.QueryRow(context.Background(), ` DELETE FROM hai_oauth_pending_auth - WHERE id = $1 AND expires_at > $2 - RETURNING payload`, id, now, + WHERE id = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '') + RETURNING payload`, id, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { return oauth.PendingAuthorization{}, false @@ -173,15 +190,19 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.Pendi if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { + return oauth.PendingAuthorization{}, false + } return entry, true } -func (s *AuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { +func (s *AuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { now := s.now() + issuer = oauth.NormalizeIssuerURL(issuer) tag, err := s.pool.Exec(context.Background(), ` DELETE FROM hai_oauth_refresh_token - WHERE token = $1 AND expires_at > $2 AND payload->>'clientId' = $3`, - token, now, clientID, + WHERE token = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '') AND payload->>'clientId' = $4`, + token, now, issuer, clientID, ) if err != nil { return false diff --git a/pkg/oauth/store/postgres_test.go b/pkg/oauth/store/postgres_test.go index 00d07e1..9b81c0e 100644 --- a/pkg/oauth/store/postgres_test.go +++ b/pkg/oauth/store/postgres_test.go @@ -30,13 +30,14 @@ func TestPostgresStores_RoundTrip(t *testing.T) { t.Fatalf("client = %+v ok=%v", client, ok) } + const issuer = "https://auth.example.test" if err := authStore.SaveAuthorizationCode("code-1", oauth.AuthorizationCode{ - ClientID: "pg-client", RedirectURI: "https://localhost/callback", + Issuer: issuer, ClientID: "pg-client", RedirectURI: "https://localhost/callback", Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), }); err != nil { t.Fatal(err) } - entry, ok := authStore.ConsumeAuthorizationCode("code-1") + entry, ok := authStore.ConsumeAuthorizationCode(issuer, "code-1") if !ok || entry.ClientID != "pg-client" { t.Fatalf("code = %+v ok=%v", entry, ok) } diff --git a/pkg/oauth/store/sqlite.go b/pkg/oauth/store/sqlite.go index fb25c13..7446812 100644 --- a/pkg/oauth/store/sqlite.go +++ b/pkg/oauth/store/sqlite.go @@ -28,11 +28,12 @@ func (s *SQLiteAuthorizationStore) SaveAuthorizationCode(code string, entry oaut if err != nil { return fmt.Errorf("encode auth code: %w", err) } + issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.db.ExecContext(context.Background(), ` - INSERT INTO hai_oauth_auth_code (code, payload, expires_at) - VALUES (?, ?, ?) - ON CONFLICT (code) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, - code, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), + INSERT INTO hai_oauth_auth_code (code, issuer, payload, expires_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (code) DO UPDATE SET issuer = excluded.issuer, payload = excluded.payload, expires_at = excluded.expires_at`, + code, issuer, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), ) if err != nil { return fmt.Errorf("save auth code: %w", err) @@ -40,13 +41,14 @@ func (s *SQLiteAuthorizationStore) SaveAuthorizationCode(code string, entry oaut return nil } -func (s *SQLiteAuthorizationStore) ConsumeAuthorizationCode(code string) (oauth.AuthorizationCode, bool) { +func (s *SQLiteAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { now := s.now().UTC().Format(time.RFC3339Nano) + issuer = oauth.NormalizeIssuerURL(issuer) var payload string err := s.db.QueryRowContext(context.Background(), ` DELETE FROM hai_oauth_auth_code - WHERE code = ? AND expires_at > ? - RETURNING payload`, code, now, + WHERE code = ? AND expires_at > ? AND (issuer = ? OR issuer = '') + RETURNING payload`, code, now, issuer, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { return oauth.AuthorizationCode{}, false @@ -55,6 +57,9 @@ func (s *SQLiteAuthorizationStore) ConsumeAuthorizationCode(code string) (oauth. if err := json.Unmarshal([]byte(payload), &entry); err != nil { return oauth.AuthorizationCode{}, false } + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { + return oauth.AuthorizationCode{}, false + } return entry, true } @@ -63,11 +68,12 @@ func (s *SQLiteAuthorizationStore) SaveRefreshToken(token string, entry oauth.Re if err != nil { return fmt.Errorf("encode refresh token: %w", err) } + issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.db.ExecContext(context.Background(), ` - INSERT INTO hai_oauth_refresh_token (token, payload, expires_at) - VALUES (?, ?, ?) - ON CONFLICT (token) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, - token, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), + INSERT INTO hai_oauth_refresh_token (token, issuer, payload, expires_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (token) DO UPDATE SET issuer = excluded.issuer, payload = excluded.payload, expires_at = excluded.expires_at`, + token, issuer, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), ) if err != nil { return fmt.Errorf("save refresh token: %w", err) @@ -75,15 +81,16 @@ func (s *SQLiteAuthorizationStore) SaveRefreshToken(token string, entry oauth.Re return nil } -func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { - entry, ok := s.LookupRefreshToken(token) +func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { + entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return oauth.RefreshTokenEntry{}, false } now := s.now().UTC().Format(time.RFC3339Nano) + issuer = oauth.NormalizeIssuerURL(issuer) res, err := s.db.ExecContext(context.Background(), ` DELETE FROM hai_oauth_refresh_token - WHERE token = ? AND expires_at > ?`, token, now) + WHERE token = ? AND expires_at > ? AND (issuer = ? OR issuer = '')`, token, now, issuer) if err != nil { return oauth.RefreshTokenEntry{}, false } @@ -94,12 +101,13 @@ func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(token string) (oauth.Refr return entry, true } -func (s *SQLiteAuthorizationStore) LookupRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { +func (s *SQLiteAuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { now := s.now().UTC().Format(time.RFC3339Nano) + issuer = oauth.NormalizeIssuerURL(issuer) var payload string err := s.db.QueryRowContext(context.Background(), ` SELECT payload FROM hai_oauth_refresh_token - WHERE token = ? AND expires_at > ?`, token, now, + WHERE token = ? AND expires_at > ? AND (issuer = ? OR issuer = '')`, token, now, issuer, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { return oauth.RefreshTokenEntry{}, false @@ -108,6 +116,9 @@ func (s *SQLiteAuthorizationStore) LookupRefreshToken(token string) (oauth.Refre if err := json.Unmarshal([]byte(payload), &entry); err != nil { return oauth.RefreshTokenEntry{}, false } + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { + return oauth.RefreshTokenEntry{}, false + } return entry, true } @@ -116,11 +127,12 @@ func (s *SQLiteAuthorizationStore) SavePendingAuthorization(id string, entry oau if err != nil { return fmt.Errorf("encode pending auth: %w", err) } + issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.db.ExecContext(context.Background(), ` - INSERT INTO hai_oauth_pending_auth (id, payload, expires_at) - VALUES (?, ?, ?) - ON CONFLICT (id) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, - id, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), + INSERT INTO hai_oauth_pending_auth (id, issuer, payload, expires_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET issuer = excluded.issuer, payload = excluded.payload, expires_at = excluded.expires_at`, + id, issuer, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), ) if err != nil { return fmt.Errorf("save pending auth: %w", err) @@ -128,12 +140,13 @@ func (s *SQLiteAuthorizationStore) SavePendingAuthorization(id string, entry oau return nil } -func (s *SQLiteAuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAuthorization, bool) { +func (s *SQLiteAuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { now := s.now().UTC().Format(time.RFC3339Nano) + issuer = oauth.NormalizeIssuerURL(issuer) var payload string err := s.db.QueryRowContext(context.Background(), ` SELECT payload FROM hai_oauth_pending_auth - WHERE id = ? AND expires_at > ?`, id, now, + WHERE id = ? AND expires_at > ? AND (issuer = ? OR issuer = '')`, id, now, issuer, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { return oauth.PendingAuthorization{}, false @@ -142,6 +155,9 @@ func (s *SQLiteAuthorizationStore) GetPendingAuthorization(id string) (oauth.Pen if err := json.Unmarshal([]byte(payload), &entry); err != nil { return oauth.PendingAuthorization{}, false } + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { + return oauth.PendingAuthorization{}, false + } return entry, true } @@ -156,13 +172,14 @@ func (s *SQLiteAuthorizationStore) PurgeExpiredPendingAuthorizations() int { return int(n) } -func (s *SQLiteAuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { +func (s *SQLiteAuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { now := s.now().UTC().Format(time.RFC3339Nano) + issuer = oauth.NormalizeIssuerURL(issuer) var payload string err := s.db.QueryRowContext(context.Background(), ` DELETE FROM hai_oauth_pending_auth - WHERE id = ? AND expires_at > ? - RETURNING payload`, id, now, + WHERE id = ? AND expires_at > ? AND (issuer = ? OR issuer = '') + RETURNING payload`, id, now, issuer, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { return oauth.PendingAuthorization{}, false @@ -171,15 +188,19 @@ func (s *SQLiteAuthorizationStore) ConsumePendingAuthorization(id string) (oauth if err := json.Unmarshal([]byte(payload), &entry); err != nil { return oauth.PendingAuthorization{}, false } + if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { + return oauth.PendingAuthorization{}, false + } return entry, true } -func (s *SQLiteAuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { +func (s *SQLiteAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { now := s.now().UTC().Format(time.RFC3339Nano) + issuer = oauth.NormalizeIssuerURL(issuer) res, err := s.db.ExecContext(context.Background(), ` DELETE FROM hai_oauth_refresh_token - WHERE token = ? AND expires_at > ? AND json_extract(payload, '$.clientId') = ?`, - token, now, clientID, + WHERE token = ? AND expires_at > ? AND (issuer = ? OR issuer = '') AND json_extract(payload, '$.clientId') = ?`, + token, now, issuer, clientID, ) if err != nil { return false diff --git a/pkg/oauth/store/sqlite_test.go b/pkg/oauth/store/sqlite_test.go index 47cadd1..2dec053 100644 --- a/pkg/oauth/store/sqlite_test.go +++ b/pkg/oauth/store/sqlite_test.go @@ -37,13 +37,17 @@ func TestSQLiteStores_RoundTrip(t *testing.T) { t.Fatalf("client = %+v ok=%v", client, ok) } + const issuer = "https://auth.example.test" if err := authStore.SaveAuthorizationCode("code-1", oauth.AuthorizationCode{ - ClientID: "sqlite-client", RedirectURI: "https://localhost/callback", + Issuer: issuer, ClientID: "sqlite-client", RedirectURI: "https://localhost/callback", Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), }); err != nil { t.Fatal(err) } - entry, ok := authStore.ConsumeAuthorizationCode("code-1") + if _, ok := authStore.ConsumeAuthorizationCode("https://other.example", "code-1"); ok { + t.Fatal("expected cross-issuer consume to fail") + } + entry, ok := authStore.ConsumeAuthorizationCode(issuer, "code-1") if !ok || entry.ClientID != "sqlite-client" { t.Fatalf("code = %+v ok=%v", entry, ok) } diff --git a/pkg/postgres/migrations/0020_oauth_issuer_binding.sql b/pkg/postgres/migrations/0020_oauth_issuer_binding.sql new file mode 100644 index 0000000..a311829 --- /dev/null +++ b/pkg/postgres/migrations/0020_oauth_issuer_binding.sql @@ -0,0 +1,9 @@ +-- Bind OAuth ephemeral rows to the issuer URL that created them (multi-tenant shared store). + +ALTER TABLE hai_oauth_auth_code ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_refresh_token ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_pending_auth ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_issuer_expires_idx ON hai_oauth_pending_auth (issuer, expires_at); diff --git a/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql b/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql new file mode 100644 index 0000000..b2994bf --- /dev/null +++ b/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql @@ -0,0 +1,9 @@ +-- Bind OAuth ephemeral rows to the issuer URL that created them (multi-tenant shared store). + +ALTER TABLE hai_oauth_auth_code ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_refresh_token ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_pending_auth ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_issuer_expires_idx ON hai_oauth_pending_auth (issuer, expires_at); From f26d1d3b0a79e829cb7843247436efd95fe4a7c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:25:24 +0000 Subject: [PATCH 07/14] fix(oauth): strict issuer binding without legacy paths 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 --- pkg/oauth/introspect.go | 5 +- pkg/oauth/issuer_binding.go | 22 +++-- pkg/oauth/issuer_binding_test.go | 161 ++++++++++++++++++++++++++++--- pkg/oauth/redis/keys.go | 48 +++++++++ pkg/oauth/redis/store.go | 86 +++++++++++++---- pkg/oauth/redis/store_test.go | 13 +++ pkg/oauth/security_test.go | 9 +- pkg/oauth/store/postgres.go | 68 ++++++++----- pkg/oauth/store/sqlite.go | 64 +++++++----- 9 files changed, 382 insertions(+), 94 deletions(-) create mode 100644 pkg/oauth/redis/keys.go diff --git a/pkg/oauth/introspect.go b/pkg/oauth/introspect.go index fb5b9df..5146a71 100644 --- a/pkg/oauth/introspect.go +++ b/pkg/oauth/introspect.go @@ -108,9 +108,10 @@ func (s *Server) introspectRefreshToken(token string) (IntrospectionResponse, bo return IntrospectionResponse{}, false } record, ok := s.authStore.LookupRefreshToken(s.cfg.Issuer, token) - if !ok { + if !ok || !EntryIssuerMatches(record.Issuer, s.cfg.Issuer) { return IntrospectionResponse{}, false } + iss := NormalizeIssuerURL(record.Issuer) return IntrospectionResponse{ Active: true, Scope: record.Scope, @@ -119,7 +120,7 @@ func (s *Server) introspectRefreshToken(token string) (IntrospectionResponse, bo TokenType: "refresh_token", Exp: record.ExpiresAt.Unix(), Sub: record.Subject, - Iss: s.cfg.Issuer, + Iss: iss, Patient: record.Patient, FHIRUser: record.FHIRUser, }, true diff --git a/pkg/oauth/issuer_binding.go b/pkg/oauth/issuer_binding.go index 6364fa2..d663f9b 100644 --- a/pkg/oauth/issuer_binding.go +++ b/pkg/oauth/issuer_binding.go @@ -1,20 +1,28 @@ package oauth -import "strings" +import ( + "fmt" + "strings" +) // NormalizeIssuerURL trims and removes a trailing slash from an issuer URL. func NormalizeIssuerURL(issuer string) string { return strings.TrimRight(strings.TrimSpace(issuer), "/") } -// EntryIssuerMatches reports whether a stored row may be used by a server configured -// with serverIssuer. Legacy rows with an empty stored issuer remain valid for any -// server (single-issuer deployments before issuer binding). +// EntryIssuerMatches reports whether a stored row belongs to serverIssuer. +// Both issuers must be non-empty and equal after normalization. func EntryIssuerMatches(entryIssuer, serverIssuer string) bool { entryIssuer = NormalizeIssuerURL(entryIssuer) serverIssuer = NormalizeIssuerURL(serverIssuer) - if entryIssuer == "" { - return true + return entryIssuer != "" && entryIssuer == serverIssuer +} + +// RequireBoundIssuer returns a normalized issuer or an error when missing. +func RequireBoundIssuer(issuer string) (string, error) { + iss := NormalizeIssuerURL(issuer) + if iss == "" { + return "", fmt.Errorf("oauth: issuer required") } - return entryIssuer == serverIssuer + return iss, nil } diff --git a/pkg/oauth/issuer_binding_test.go b/pkg/oauth/issuer_binding_test.go index 756676b..328362c 100644 --- a/pkg/oauth/issuer_binding_test.go +++ b/pkg/oauth/issuer_binding_test.go @@ -1,6 +1,7 @@ package oauth_test import ( + "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -8,9 +9,19 @@ import ( "testing" "time" + "github.com/degoke/health-ai-stack/pkg/client" "github.com/degoke/health-ai-stack/pkg/oauth" ) +func TestEntryIssuerMatches_RequiresNonEmptyIssuer(t *testing.T) { + if oauth.EntryIssuerMatches("", "https://auth.example") { + t.Fatal("empty entry issuer must not match") + } + if oauth.EntryIssuerMatches("https://auth.example", "") { + t.Fatal("empty server issuer must not match") + } +} + func TestIssuerBinding_RejectsCrossIssuerAuthorizationCode(t *testing.T) { store := oauth.NewMemoryAuthorizationStore() issuerA := "https://auth.example/t/clinic-a" @@ -36,9 +47,34 @@ func TestIssuerBinding_RejectsCrossIssuerAuthorizationCode(t *testing.T) { } } -func TestIssuerBinding_MultiTenantTokenExchange(t *testing.T) { +func TestIssuerBinding_RejectsCrossIssuerRefreshToken(t *testing.T) { store := oauth.NewMemoryAuthorizationStore() - base := "https://auth.example" + issuerA := "https://auth.example/t/clinic-a" + issuerB := "https://auth.example/t/clinic-b" + token := "refresh-1" + if err := store.SaveRefreshToken(token, oauth.RefreshTokenEntry{ + Issuer: issuerA, + ClientID: "app", + Subject: "sub", + ExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + if _, ok := store.ConsumeRefreshToken(issuerB, token); ok { + t.Fatal("expected cross-issuer refresh consume to fail") + } + if _, ok := store.ConsumeRefreshToken(issuerA, token); !ok { + t.Fatal("expected refresh for issuer A") + } +} + +func TestIssuerBinding_MultiTenantAuthorizeTokenExchange(t *testing.T) { + store := oauth.NewMemoryAuthorizationStore() + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + defer ts.Close() + + base := strings.TrimSuffix(ts.URL, "") issuerA := base + "/t/clinic-a" issuerB := base + "/t/clinic-b" @@ -47,10 +83,13 @@ func TestIssuerBinding_MultiTenantTokenExchange(t *testing.T) { t.Fatal(err) } registry := oauth.NewTenantRegistry() - for _, iss := range []string{issuerA, issuerB} { + for _, spec := range []struct{ id, iss string }{ + {"clinic-a", issuerA}, + {"clinic-b", issuerB}, + } { if err := registry.Register(oauth.TenantIssuerConfig{ - TenantID: strings.TrimPrefix(iss, base+"/t/"), - Issuer: iss, + TenantID: spec.id, + Issuer: spec.iss, FHIRAudience: base, }); err != nil { t.Fatal(err) @@ -67,6 +106,7 @@ func TestIssuerBinding_MultiTenantTokenExchange(t *testing.T) { if err != nil { t.Fatal(err) } + mux.Handle("/t/", multi.Handler()) srvA, err := multi.ServerForTenant("clinic-a") if err != nil { @@ -83,14 +123,26 @@ func TestIssuerBinding_MultiTenantTokenExchange(t *testing.T) { }); err != nil { t.Fatal(err) } - code := "tenant-a-code" - _ = store.SaveAuthorizationCode(code, oauth.AuthorizationCode{ - Issuer: issuerA, - ClientID: clientID, - RedirectURI: "https://app/cb", - Scope: "openid patient/Patient.rs", - ExpiresAt: time.Now().Add(5 * time.Minute), + + httpClient, _ := client.New(client.Config{BaseURL: ts.URL}) + pkce, _ := client.NewPKCEChallenge() + authURL, _ := httpClient.SMART().BuildAuthURL(client.AuthCodeRequest{ + Config: &client.SMARTConfiguration{ + AuthorizationEndpoint: issuerA + "/oauth/authorize", + TokenEndpoint: issuerA + "/oauth/token", + }, + ClientID: clientID, RedirectURI: "https://app/cb", + Scope: "openid patient/Patient.rs", PKCE: pkce, }) + noRedirect := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }} + authResp, err := noRedirect.Get(authURL) + if err != nil { + t.Fatal(err) + } + _ = authResp.Body.Close() + code := strings.Split(strings.Split(authResp.Header.Get("Location"), "code=")[1], "&")[0] srvB, err := multi.ServerForTenant("clinic-b") if err != nil { @@ -102,6 +154,7 @@ func TestIssuerBinding_MultiTenantTokenExchange(t *testing.T) { "redirect_uri": {"https://app/cb"}, "client_id": {clientID}, "client_secret": {clientSecret}, + "code_verifier": {pkce.Verifier}, } req := httptest.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -111,11 +164,89 @@ func TestIssuerBinding_MultiTenantTokenExchange(t *testing.T) { t.Fatalf("tenant B token status = %d body=%s", rec.Code, rec.Body.String()) } - req2 := httptest.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) - req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenResp, err := http.PostForm(issuerA+"/oauth/token", form) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tokenResp.Body.Close() }() + if tokenResp.StatusCode != http.StatusOK { + t.Fatalf("tenant A token status = %d", tokenResp.StatusCode) + } + var tok map[string]any + if err := json.NewDecoder(tokenResp.Body).Decode(&tok); err != nil { + t.Fatal(err) + } + refresh, _ := tok["refresh_token"].(string) + if refresh == "" { + t.Fatal("missing refresh_token") + } + + refreshForm := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {refresh}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + } + badRefresh, err := http.PostForm(issuerB+"/oauth/token", refreshForm) + if err != nil { + t.Fatal(err) + } + _ = badRefresh.Body.Close() + if badRefresh.StatusCode != http.StatusBadRequest { + t.Fatalf("cross-tenant refresh status = %d", badRefresh.StatusCode) + } +} + +func TestIssuerBinding_ConsentSessionScopedToIssuer(t *testing.T) { + store := oauth.NewMemoryAuthorizationStore() + base := "https://auth.example" + issuerA := base + "/t/clinic-a" + issuerB := base + "/t/clinic-b" + + srvA, err := oauth.NewServer(oauth.Config{ + Issuer: issuerA, + FHIRAudience: base, + AuthorizationStore: store, + RequireConsentForm: true, + }) + if err != nil { + t.Fatal(err) + } + _ = srvA.RegisterClient(oauth.Client{ + ClientID: "c", RedirectURIs: []string{"https://app/cb"}, Scopes: []string{"openid"}, + }) + srvB, err := oauth.NewServer(oauth.Config{ + Issuer: issuerB, + FHIRAudience: base, + AuthorizationStore: store, + RequireConsentForm: true, + }) + if err != nil { + t.Fatal(err) + } + _ = srvB.RegisterClient(oauth.Client{ + ClientID: "c", RedirectURIs: []string{"https://app/cb"}, Scopes: []string{"openid"}, + }) + + sessionID := "consent-sess-1" + if err := store.SavePendingAuthorization(sessionID, oauth.PendingAuthorization{ + Issuer: issuerA, + Request: oauth.AuthorizationRequest{ClientID: "c", RedirectURI: "https://app/cb", Scope: "openid"}, + CSRFToken: "csrf", + ExpiresAt: time.Now().Add(5 * time.Minute), + }); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/oauth/consent?id="+sessionID, nil) + rec := httptest.NewRecorder() + srvB.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("cross-issuer consent GET status = %d", rec.Code) + } + req2 := httptest.NewRequest("GET", "/oauth/consent?id="+sessionID, nil) rec2 := httptest.NewRecorder() srvA.Handler().ServeHTTP(rec2, req2) if rec2.Code != http.StatusOK { - t.Fatalf("tenant A token status = %d body=%s", rec2.Code, rec2.Body.String()) + t.Fatalf("issuer A consent GET status = %d", rec2.Code) } } diff --git a/pkg/oauth/redis/keys.go b/pkg/oauth/redis/keys.go new file mode 100644 index 0000000..c0565c8 --- /dev/null +++ b/pkg/oauth/redis/keys.go @@ -0,0 +1,48 @@ +package redis + +import ( + "encoding/base64" + "fmt" + + "github.com/degoke/health-ai-stack/pkg/oauth" +) + +func issuerKeySegment(issuer string) (string, error) { + iss, err := oauth.RequireBoundIssuer(issuer) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString([]byte(iss)), nil +} + +func authCodeRedisKey(prefix, issuer, code string) (string, error) { + seg, err := issuerKeySegment(issuer) + if err != nil { + return "", err + } + return prefix + "authcode:" + seg + ":" + code, nil +} + +func pendingRedisKey(prefix, issuer, id string) (string, error) { + seg, err := issuerKeySegment(issuer) + if err != nil { + return "", err + } + return prefix + "pending:" + seg + ":" + id, nil +} + +func refreshRedisKey(prefix, issuer, token string) (string, error) { + seg, err := issuerKeySegment(issuer) + if err != nil { + return "", err + } + return prefix + "refresh:" + seg + ":" + token, nil +} + +func normalizeConsumeIssuer(issuer string) (string, error) { + iss, err := oauth.RequireBoundIssuer(issuer) + if err != nil { + return "", fmt.Errorf("oauth: issuer required") + } + return iss, nil +} diff --git a/pkg/oauth/redis/store.go b/pkg/oauth/redis/store.go index 8d9ac86..762ca6f 100644 --- a/pkg/oauth/redis/store.go +++ b/pkg/oauth/redis/store.go @@ -28,21 +28,29 @@ func NewAuthorizationStore(client goredis.Cmdable, keyPrefix string) *Authorizat return &AuthorizationStore{client: client, prefix: keyPrefix, now: time.Now} } -func (s *AuthorizationStore) key(parts ...string) string { - return s.prefix + parts[0] -} - func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.AuthorizationCode) error { + key, err := authCodeRedisKey(s.prefix, entry.Issuer, code) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode auth code: %w", err) } ttl := ttlUntil(entry.ExpiresAt, s.now()) - return s.client.Set(context.Background(), s.key("authcode:"+code), payload, ttl).Err() + return s.client.Set(context.Background(), key, payload, ttl).Err() } func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { - payload, ok := consumeJSONValue(s.client, s.key("authcode:"+code)) + bound, err := normalizeConsumeIssuer(issuer) + if err != nil { + return oauth.AuthorizationCode{}, false + } + key, err := authCodeRedisKey(s.prefix, bound, code) + if err != nil { + return oauth.AuthorizationCode{}, false + } + payload, ok := consumeJSONValue(s.client, key) if !ok { return oauth.AuthorizationCode{}, false } @@ -50,18 +58,21 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oaut if err := json.Unmarshal(payload, &entry); err != nil { return oauth.AuthorizationCode{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) || s.now().After(entry.ExpiresAt) { + if !oauth.EntryIssuerMatches(entry.Issuer, bound) || s.now().After(entry.ExpiresAt) { return oauth.AuthorizationCode{}, false } return entry, true } func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshTokenEntry) error { + key, err := refreshRedisKey(s.prefix, entry.Issuer, token) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode refresh token: %w", err) } - key := s.key("refresh:" + token) ttl := ttlUntil(entry.ExpiresAt, s.now()) _, err = saveRefreshTokenScript.Run( context.Background(), @@ -79,13 +90,27 @@ func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.Re if !ok { return oauth.RefreshTokenEntry{}, false } - key := s.key("refresh:" + token) + bound, err := normalizeConsumeIssuer(issuer) + if err != nil { + return oauth.RefreshTokenEntry{}, false + } + key, err := refreshRedisKey(s.prefix, bound, token) + if err != nil { + return oauth.RefreshTokenEntry{}, false + } _ = s.client.Del(context.Background(), key).Err() return entry, true } func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { - key := s.key("refresh:" + token) + bound, err := normalizeConsumeIssuer(issuer) + if err != nil { + return oauth.RefreshTokenEntry{}, false + } + key, err := refreshRedisKey(s.prefix, bound, token) + if err != nil { + return oauth.RefreshTokenEntry{}, false + } payload, err := s.client.HGet(context.Background(), key, "payload").Bytes() if err == goredis.Nil || err != nil { return oauth.RefreshTokenEntry{}, false @@ -94,23 +119,35 @@ func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.Ref if err := json.Unmarshal(payload, &entry); err != nil { return oauth.RefreshTokenEntry{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) || s.now().After(entry.ExpiresAt) { + if !oauth.EntryIssuerMatches(entry.Issuer, bound) || s.now().After(entry.ExpiresAt) { return oauth.RefreshTokenEntry{}, false } return entry, true } func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.PendingAuthorization) error { + key, err := pendingRedisKey(s.prefix, entry.Issuer, id) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode pending auth: %w", err) } ttl := ttlUntil(entry.ExpiresAt, s.now()) - return s.client.Set(context.Background(), s.key("pending:"+id), payload, ttl).Err() + return s.client.Set(context.Background(), key, payload, ttl).Err() } func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { - payload, err := s.client.Get(context.Background(), s.key("pending:"+id)).Bytes() + bound, err := normalizeConsumeIssuer(issuer) + if err != nil { + return oauth.PendingAuthorization{}, false + } + key, err := pendingRedisKey(s.prefix, bound, id) + if err != nil { + return oauth.PendingAuthorization{}, false + } + payload, err := s.client.Get(context.Background(), key).Bytes() if err == goredis.Nil || err != nil { return oauth.PendingAuthorization{}, false } @@ -118,7 +155,7 @@ func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.P if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) || s.now().After(entry.ExpiresAt) { + if !oauth.EntryIssuerMatches(entry.Issuer, bound) || s.now().After(entry.ExpiresAt) { return oauth.PendingAuthorization{}, false } return entry, true @@ -129,7 +166,15 @@ func (s *AuthorizationStore) PurgeExpiredPendingAuthorizations() int { } func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { - payload, ok := consumeJSONValue(s.client, s.key("pending:"+id)) + bound, err := normalizeConsumeIssuer(issuer) + if err != nil { + return oauth.PendingAuthorization{}, false + } + key, err := pendingRedisKey(s.prefix, bound, id) + if err != nil { + return oauth.PendingAuthorization{}, false + } + payload, ok := consumeJSONValue(s.client, key) if !ok { return oauth.PendingAuthorization{}, false } @@ -137,7 +182,7 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oau if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) || s.now().After(entry.ExpiresAt) { + if !oauth.EntryIssuerMatches(entry.Issuer, bound) || s.now().After(entry.ExpiresAt) { return oauth.PendingAuthorization{}, false } return entry, true @@ -156,7 +201,14 @@ func (s *AuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID if !ok || entry.ClientID != clientID { return false } - key := s.key("refresh:" + token) + bound, err := normalizeConsumeIssuer(issuer) + if err != nil { + return false + } + key, err := refreshRedisKey(s.prefix, bound, token) + if err != nil { + return false + } n, err := deleteRefreshTokenForClientScript.Run(context.Background(), s.client, []string{key}, clientID).Int() return err == nil && n > 0 } diff --git a/pkg/oauth/redis/store_test.go b/pkg/oauth/redis/store_test.go index 778657e..339d5f4 100644 --- a/pkg/oauth/redis/store_test.go +++ b/pkg/oauth/redis/store_test.go @@ -32,6 +32,19 @@ func TestEphemeralStores_RoundTrip(t *testing.T) { if !ok || entry.ClientID != "redis-client" { t.Fatalf("code = %+v ok=%v", entry, ok) } + if err := authStore.SaveAuthorizationCode("code-2", oauth.AuthorizationCode{ + Issuer: issuer, ClientID: "redis-client", RedirectURI: "https://localhost/callback", + Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), + }); err != nil { + t.Fatal(err) + } + if _, ok := authStore.ConsumeAuthorizationCode("https://other.example", "code-2"); ok { + t.Fatal("cross-issuer consume must fail") + } + entry, ok = authStore.ConsumeAuthorizationCode(issuer, "code-2") + if !ok || entry.ClientID != "redis-client" { + t.Fatalf("code-2 = %+v ok=%v", entry, ok) + } refresh := "refresh-1" if err := authStore.SaveRefreshToken(refresh, oauth.RefreshTokenEntry{ diff --git a/pkg/oauth/security_test.go b/pkg/oauth/security_test.go index 6dc1392..2a9e453 100644 --- a/pkg/oauth/security_test.go +++ b/pkg/oauth/security_test.go @@ -51,7 +51,9 @@ func TestFileAuthorizationStore_PersistsPendingSessions(t *testing.T) { if err != nil { t.Fatal(err) } + const issuer = "https://auth.example.test" if err := store.SavePendingAuthorization("sess-1", oauth.PendingAuthorization{ + Issuer: issuer, Request: oauth.AuthorizationRequest{ClientID: "client", Scope: "patient/*.rs"}, ExpiresAt: time.Now().Add(5 * time.Minute), }); err != nil { @@ -61,7 +63,7 @@ func TestFileAuthorizationStore_PersistsPendingSessions(t *testing.T) { if err != nil { t.Fatal(err) } - entry, ok := reloaded.ConsumePendingAuthorization("", "sess-1") + entry, ok := reloaded.ConsumePendingAuthorization(issuer, "sess-1") if !ok || entry.Request.ClientID != "client" { t.Fatalf("entry = %+v ok=%v", entry, ok) } @@ -211,13 +213,14 @@ func TestOAuthServer_ConfidentialClientSecretBasic(t *testing.T) { } func TestFileAuthorizationStore_PersistsCodes(t *testing.T) { + const issuer = "https://auth.example.test" path := filepath.Join(t.TempDir(), "oauth-auth.json") store, err := oauth.NewFileAuthorizationStore(path) if err != nil { t.Fatal(err) } if err := store.SaveAuthorizationCode("code-1", oauth.AuthorizationCode{ - ClientID: "client", RedirectURI: "https://app/cb", Scope: "patient/*.rs", + Issuer: issuer, ClientID: "client", RedirectURI: "https://app/cb", Scope: "patient/*.rs", ExpiresAt: time.Now().Add(5 * time.Minute), }); err != nil { t.Fatal(err) @@ -226,7 +229,7 @@ func TestFileAuthorizationStore_PersistsCodes(t *testing.T) { if err != nil { t.Fatal(err) } - entry, ok := reloaded.ConsumeAuthorizationCode("", "code-1") + entry, ok := reloaded.ConsumeAuthorizationCode(issuer, "code-1") if !ok || entry.ClientID != "client" { t.Fatalf("entry = %+v ok=%v", entry, ok) } diff --git a/pkg/oauth/store/postgres.go b/pkg/oauth/store/postgres.go index 64385a6..32c4d60 100644 --- a/pkg/oauth/store/postgres.go +++ b/pkg/oauth/store/postgres.go @@ -31,11 +31,14 @@ func NewAuthorizationStore(pool *pgxpool.Pool) *AuthorizationStore { } func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.AuthorizationCode) error { + issuer, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode auth code: %w", err) } - issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.pool.Exec(context.Background(), ` INSERT INTO hai_oauth_auth_code (code, issuer, payload, expires_at) VALUES ($1, $2, $3::jsonb, $4) @@ -49,12 +52,15 @@ func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.Auth } func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { - now := s.now() issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.AuthorizationCode{}, false + } + now := s.now() var payload []byte err := s.pool.QueryRow(context.Background(), ` DELETE FROM hai_oauth_auth_code - WHERE code = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '') + WHERE code = $1 AND expires_at > $2 AND issuer = $3 RETURNING payload`, code, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { @@ -64,18 +70,18 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oaut if err := json.Unmarshal(payload, &entry); err != nil { return oauth.AuthorizationCode{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { - return oauth.AuthorizationCode{}, false - } return entry, true } func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshTokenEntry) error { + issuer, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode refresh token: %w", err) } - issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.pool.Exec(context.Background(), ` INSERT INTO hai_oauth_refresh_token (token, issuer, payload, expires_at) VALUES ($1, $2, $3::jsonb, $4) @@ -89,6 +95,10 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT } func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { + issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.RefreshTokenEntry{}, false + } entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return oauth.RefreshTokenEntry{}, false @@ -97,7 +107,7 @@ func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.Re issuer = oauth.NormalizeIssuerURL(issuer) tag, err := s.pool.Exec(context.Background(), ` DELETE FROM hai_oauth_refresh_token - WHERE token = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '')`, token, now, issuer) + WHERE token = $1 AND expires_at > $2 AND issuer = $3`, token, now, issuer) if err != nil || tag.RowsAffected() == 0 { return oauth.RefreshTokenEntry{}, false } @@ -105,12 +115,15 @@ func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.Re } func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { - now := s.now() issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.RefreshTokenEntry{}, false + } + now := s.now() var payload []byte err := s.pool.QueryRow(context.Background(), ` SELECT payload FROM hai_oauth_refresh_token - WHERE token = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '')`, token, now, issuer, + WHERE token = $1 AND expires_at > $2 AND issuer = $3`, token, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { return oauth.RefreshTokenEntry{}, false @@ -119,18 +132,18 @@ func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.Ref if err := json.Unmarshal(payload, &entry); err != nil { return oauth.RefreshTokenEntry{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { - return oauth.RefreshTokenEntry{}, false - } return entry, true } func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.PendingAuthorization) error { + issuer, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode pending auth: %w", err) } - issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.pool.Exec(context.Background(), ` INSERT INTO hai_oauth_pending_auth (id, issuer, payload, expires_at) VALUES ($1, $2, $3::jsonb, $4) @@ -144,12 +157,15 @@ func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.Pen } func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { - now := s.now() issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.PendingAuthorization{}, false + } + now := s.now() var payload []byte err := s.pool.QueryRow(context.Background(), ` SELECT payload FROM hai_oauth_pending_auth - WHERE id = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '')`, id, now, issuer, + WHERE id = $1 AND expires_at > $2 AND issuer = $3`, id, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { return oauth.PendingAuthorization{}, false @@ -158,9 +174,6 @@ func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.P if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { - return oauth.PendingAuthorization{}, false - } return entry, true } @@ -175,12 +188,15 @@ func (s *AuthorizationStore) PurgeExpiredPendingAuthorizations() int { } func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { - now := s.now() issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.PendingAuthorization{}, false + } + now := s.now() var payload []byte err := s.pool.QueryRow(context.Background(), ` DELETE FROM hai_oauth_pending_auth - WHERE id = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '') + WHERE id = $1 AND expires_at > $2 AND issuer = $3 RETURNING payload`, id, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { @@ -190,18 +206,18 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oau if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { - return oauth.PendingAuthorization{}, false - } return entry, true } func (s *AuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { - now := s.now() issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return false + } + now := s.now() tag, err := s.pool.Exec(context.Background(), ` DELETE FROM hai_oauth_refresh_token - WHERE token = $1 AND expires_at > $2 AND (issuer = $3 OR issuer = '') AND payload->>'clientId' = $4`, + WHERE token = $1 AND expires_at > $2 AND issuer = $3 AND payload->>'clientId' = $4`, token, now, issuer, clientID, ) if err != nil { diff --git a/pkg/oauth/store/sqlite.go b/pkg/oauth/store/sqlite.go index 7446812..f49deb5 100644 --- a/pkg/oauth/store/sqlite.go +++ b/pkg/oauth/store/sqlite.go @@ -24,11 +24,14 @@ func NewSQLiteAuthorizationStore(db *sql.DB) *SQLiteAuthorizationStore { } func (s *SQLiteAuthorizationStore) SaveAuthorizationCode(code string, entry oauth.AuthorizationCode) error { + issuer, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode auth code: %w", err) } - issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.db.ExecContext(context.Background(), ` INSERT INTO hai_oauth_auth_code (code, issuer, payload, expires_at) VALUES (?, ?, ?, ?) @@ -42,12 +45,15 @@ func (s *SQLiteAuthorizationStore) SaveAuthorizationCode(code string, entry oaut } func (s *SQLiteAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { - now := s.now().UTC().Format(time.RFC3339Nano) issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.AuthorizationCode{}, false + } + now := s.now().UTC().Format(time.RFC3339Nano) var payload string err := s.db.QueryRowContext(context.Background(), ` DELETE FROM hai_oauth_auth_code - WHERE code = ? AND expires_at > ? AND (issuer = ? OR issuer = '') + WHERE code = ? AND expires_at > ? AND issuer = ? RETURNING payload`, code, now, issuer, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { @@ -57,18 +63,18 @@ func (s *SQLiteAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) if err := json.Unmarshal([]byte(payload), &entry); err != nil { return oauth.AuthorizationCode{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { - return oauth.AuthorizationCode{}, false - } return entry, true } func (s *SQLiteAuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshTokenEntry) error { + issuer, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode refresh token: %w", err) } - issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.db.ExecContext(context.Background(), ` INSERT INTO hai_oauth_refresh_token (token, issuer, payload, expires_at) VALUES (?, ?, ?, ?) @@ -82,6 +88,10 @@ func (s *SQLiteAuthorizationStore) SaveRefreshToken(token string, entry oauth.Re } func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { + issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.RefreshTokenEntry{}, false + } entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return oauth.RefreshTokenEntry{}, false @@ -90,7 +100,7 @@ func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(issuer, token string) (oa issuer = oauth.NormalizeIssuerURL(issuer) res, err := s.db.ExecContext(context.Background(), ` DELETE FROM hai_oauth_refresh_token - WHERE token = ? AND expires_at > ? AND (issuer = ? OR issuer = '')`, token, now, issuer) + WHERE token = ? AND expires_at > ? AND issuer = ?`, token, now, issuer) if err != nil { return oauth.RefreshTokenEntry{}, false } @@ -104,10 +114,13 @@ func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(issuer, token string) (oa func (s *SQLiteAuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { now := s.now().UTC().Format(time.RFC3339Nano) issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.RefreshTokenEntry{}, false + } var payload string err := s.db.QueryRowContext(context.Background(), ` SELECT payload FROM hai_oauth_refresh_token - WHERE token = ? AND expires_at > ? AND (issuer = ? OR issuer = '')`, token, now, issuer, + WHERE token = ? AND expires_at > ? AND issuer = ?`, token, now, issuer, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { return oauth.RefreshTokenEntry{}, false @@ -116,18 +129,18 @@ func (s *SQLiteAuthorizationStore) LookupRefreshToken(issuer, token string) (oau if err := json.Unmarshal([]byte(payload), &entry); err != nil { return oauth.RefreshTokenEntry{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { - return oauth.RefreshTokenEntry{}, false - } return entry, true } func (s *SQLiteAuthorizationStore) SavePendingAuthorization(id string, entry oauth.PendingAuthorization) error { + issuer, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } payload, err := json.Marshal(entry) if err != nil { return fmt.Errorf("encode pending auth: %w", err) } - issuer := oauth.NormalizeIssuerURL(entry.Issuer) _, err = s.db.ExecContext(context.Background(), ` INSERT INTO hai_oauth_pending_auth (id, issuer, payload, expires_at) VALUES (?, ?, ?, ?) @@ -141,12 +154,15 @@ func (s *SQLiteAuthorizationStore) SavePendingAuthorization(id string, entry oau } func (s *SQLiteAuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { - now := s.now().UTC().Format(time.RFC3339Nano) issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.PendingAuthorization{}, false + } + now := s.now().UTC().Format(time.RFC3339Nano) var payload string err := s.db.QueryRowContext(context.Background(), ` SELECT payload FROM hai_oauth_pending_auth - WHERE id = ? AND expires_at > ? AND (issuer = ? OR issuer = '')`, id, now, issuer, + WHERE id = ? AND expires_at > ? AND issuer = ?`, id, now, issuer, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { return oauth.PendingAuthorization{}, false @@ -155,9 +171,6 @@ func (s *SQLiteAuthorizationStore) GetPendingAuthorization(issuer, id string) (o if err := json.Unmarshal([]byte(payload), &entry); err != nil { return oauth.PendingAuthorization{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { - return oauth.PendingAuthorization{}, false - } return entry, true } @@ -173,12 +186,15 @@ func (s *SQLiteAuthorizationStore) PurgeExpiredPendingAuthorizations() int { } func (s *SQLiteAuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { - now := s.now().UTC().Format(time.RFC3339Nano) issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.PendingAuthorization{}, false + } + now := s.now().UTC().Format(time.RFC3339Nano) var payload string err := s.db.QueryRowContext(context.Background(), ` DELETE FROM hai_oauth_pending_auth - WHERE id = ? AND expires_at > ? AND (issuer = ? OR issuer = '') + WHERE id = ? AND expires_at > ? AND issuer = ? RETURNING payload`, id, now, issuer, ).Scan(&payload) if errors.Is(err, sql.ErrNoRows) || err != nil { @@ -188,18 +204,18 @@ func (s *SQLiteAuthorizationStore) ConsumePendingAuthorization(issuer, id string if err := json.Unmarshal([]byte(payload), &entry); err != nil { return oauth.PendingAuthorization{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, issuer) { - return oauth.PendingAuthorization{}, false - } return entry, true } func (s *SQLiteAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { now := s.now().UTC().Format(time.RFC3339Nano) issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return false + } res, err := s.db.ExecContext(context.Background(), ` DELETE FROM hai_oauth_refresh_token - WHERE token = ? AND expires_at > ? AND (issuer = ? OR issuer = '') AND json_extract(payload, '$.clientId') = ?`, + WHERE token = ? AND expires_at > ? AND issuer = ? AND json_extract(payload, '$.clientId') = ?`, token, now, issuer, clientID, ) if err != nil { From 3c7dc3119010d7247332ab7525e43b3dd6549e4b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:37:27 +0000 Subject: [PATCH 08/14] refactor(oauth): remove file-backed production stores 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 --- docs/smart-auth-architecture.md | 4 +- pkg/oauth/README.md | 22 +--- pkg/oauth/atomic.go | 39 ------- pkg/oauth/client.go | 6 + pkg/oauth/client_registry.go | 87 -------------- pkg/oauth/doc.go | 5 +- pkg/oauth/issuer_binding.go | 11 ++ pkg/oauth/issuer_binding_test.go | 62 ++++++++++ pkg/oauth/persistent.go | 190 +++++++++++++++++++++++-------- pkg/oauth/production.go | 58 +--------- pkg/oauth/production_test.go | 92 --------------- pkg/oauth/redis/keys.go | 9 -- pkg/oauth/redis/scripts.go | 56 ++++++++- pkg/oauth/redis/store.go | 73 ++++++++---- pkg/oauth/redis/store_test.go | 34 ++++++ pkg/oauth/revocation.go | 87 -------------- pkg/oauth/security_test.go | 48 -------- pkg/oauth/store/postgres.go | 1 - pkg/oauth/store/sqlite.go | 1 - 19 files changed, 367 insertions(+), 518 deletions(-) delete mode 100644 pkg/oauth/atomic.go delete mode 100644 pkg/oauth/client_registry.go delete mode 100644 pkg/oauth/production_test.go diff --git a/docs/smart-auth-architecture.md b/docs/smart-auth-architecture.md index 0b37270..f62b548 100644 --- a/docs/smart-auth-architecture.md +++ b/docs/smart-auth-architecture.md @@ -105,7 +105,7 @@ and `pkg/oauth` authorization-server tests. ## Built-in OAuth server (`pkg/oauth`) -Production deployment uses `oauthstore.ApplyPostgresStores` (`pkg/oauth/store`) with Postgres-backed client, token, replay, and revocation stores. `haistack serve` wires builtin OAuth via `runtime.WithBuiltinOAuth`. Set `UserAuthenticator` for end-user consent, `LaunchResolver` for EHR launch, and persist `oauth-signing.pem` across restarts. File-backed `oauth.NewProductionServer` remains for single-node dev. See `pkg/oauth/README.md`. +Production deployment uses `oauthstore.ApplyPostgresStores` or `ApplySQLiteStores` (`pkg/oauth/store`) for clients, tokens, replay, and revocation. `haistack serve` wires builtin OAuth via `runtime.WithBuiltinOAuth`. Set `UserAuthenticator` for end-user consent, `LaunchResolver` for EHR launch, and DB-backed signing keys (or PEM fallback under the state dir). See `pkg/oauth/README.md`. ## Non-goals @@ -116,4 +116,4 @@ Production deployment uses `oauthstore.ApplyPostgresStores` (`pkg/oauth/store`) - `pkg/smart/README.md` — scope formats and v1→v2 mapping - `pkg/auth/README.md` — policy DSL - `examples/smart-authz` — runnable restricted vs unrestricted principals -- `examples/smart-oauth` — built-in OAuth server + FHIR read with consent and file-backed tokens +- `examples/smart-oauth` — built-in OAuth server + FHIR read with consent diff --git a/pkg/oauth/README.md b/pkg/oauth/README.md index ac0862b..e3c3a41 100644 --- a/pkg/oauth/README.md +++ b/pkg/oauth/README.md @@ -58,23 +58,10 @@ Schema: migrations `0017_oauth.sql` + `0018_oauth_rate_limit.sql` + `0019_oauth_ Auth codes, refresh tokens, and pending consent rows store an `issuer` column (and JSON `issuer` field) so a shared SQL store can enforce that tokens minted under `/t/{tenantId}/` are only consumed by the matching tenant issuer. -### Why file stores existed - -Early iterations used `FileAuthorizationStore` for a **zero-dependency** way to share -OAuth state across a few AS replicas on a mounted volume. That works for dev/small -deployments but is a poor fit for production: - -- No cross-host locking (NFS latency and corruption risk) -- Full-file rewrite on every token operation -- No HA failover semantics - -`NewProductionServer` (file-backed) remains for single-node and test environments. -**Postgres is the recommended production path.** - ### Redis (ephemeral token state) Use `oauthredis.NewServer` for TTL-backed auth codes, refresh tokens, replay JTIs, and -revocation denylist. **Client registration stays on Postgres or file** — pass a durable +revocation denylist. **Client registration stays on Postgres or SQLite** — pass a durable `cfg.Clients` registry; Redis does not store clients. ```go @@ -96,13 +83,6 @@ server, err := oauthredis.NewServer(oauth.Config{ `oauthredis.EphemeralStores` wires the three ephemeral interfaces with TTL-based keys. -### File-backed alternative (single node / dev) - -```go -paths := oauth.DefaultProductionPaths("/var/lib/haistack/oauth") -server, err := oauth.NewProductionServer(oauth.Config{...}, paths) -``` - ## Client authentication at the token endpoint | Method | Grants | Server | Client SDK | diff --git a/pkg/oauth/atomic.go b/pkg/oauth/atomic.go deleted file mode 100644 index 6f96b2f..0000000 --- a/pkg/oauth/atomic.go +++ /dev/null @@ -1,39 +0,0 @@ -package oauth - -import ( - "fmt" - "os" - "path/filepath" -) - -func atomicWritePrivateFile(path string, data []byte) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o700); err != nil { - return fmt.Errorf("create state directory: %w", err) - } - tmp, err := os.CreateTemp(dir, ".haistack-oauth-*") - if err != nil { - return fmt.Errorf("create state file: %w", err) - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if err := tmp.Chmod(0o600); err != nil { - _ = tmp.Close() - return fmt.Errorf("protect state file: %w", err) - } - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return fmt.Errorf("write state file: %w", err) - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return fmt.Errorf("sync state file: %w", err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close state file: %w", err) - } - if err := os.Rename(tmpName, path); err != nil { - return fmt.Errorf("replace state file: %w", err) - } - return nil -} diff --git a/pkg/oauth/client.go b/pkg/oauth/client.go index 0d9819e..77c16b1 100644 --- a/pkg/oauth/client.go +++ b/pkg/oauth/client.go @@ -6,6 +6,12 @@ import ( "github.com/degoke/health-ai-stack/pkg/smart" ) +// ClientRegistry stores registered OAuth clients. +type ClientRegistry interface { + Get(clientID string) (Client, bool) + Register(client Client) error +} + // Client describes a registered OAuth client. type Client struct { ClientID string diff --git a/pkg/oauth/client_registry.go b/pkg/oauth/client_registry.go deleted file mode 100644 index a958e7e..0000000 --- a/pkg/oauth/client_registry.go +++ /dev/null @@ -1,87 +0,0 @@ -package oauth - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "strings" - "sync" -) - -// ClientRegistry stores registered OAuth clients. -type ClientRegistry interface { - Get(clientID string) (Client, bool) - Register(client Client) error -} - -// FileClientStore persists OAuth clients as JSON. -type FileClientStore struct { - Path string - mu sync.Mutex -} - -// NewFileClientStore constructs a file-backed client registry. -func NewFileClientStore(path string) (*FileClientStore, error) { - if strings.TrimSpace(path) == "" { - return nil, fmt.Errorf("oauth: client store path required") - } - return &FileClientStore{Path: path}, nil -} - -func (s *FileClientStore) Get(clientID string) (Client, bool) { - if s == nil { - return Client{}, false - } - s.mu.Lock() - defer s.mu.Unlock() - clients, err := s.load() - if err != nil { - return Client{}, false - } - client, ok := clients[clientID] - return client, ok -} - -func (s *FileClientStore) Register(client Client) error { - if s == nil { - return fmt.Errorf("oauth: client store is nil") - } - if err := prepareClientSecret(&client); err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - clients, err := s.load() - if err != nil { - return err - } - clients[client.ClientID] = client - return s.save(clients) -} - -func (s *FileClientStore) load() (map[string]Client, error) { - data, err := os.ReadFile(s.Path) - if errors.Is(err, os.ErrNotExist) { - return make(map[string]Client), nil - } - if err != nil { - return nil, fmt.Errorf("read client store: %w", err) - } - clients := make(map[string]Client) - if len(data) == 0 { - return clients, nil - } - if err := json.Unmarshal(data, &clients); err != nil { - return nil, fmt.Errorf("decode client store: %w", err) - } - return clients, nil -} - -func (s *FileClientStore) save(clients map[string]Client) error { - data, err := json.MarshalIndent(clients, "", " ") - if err != nil { - return fmt.Errorf("encode client store: %w", err) - } - return atomicWritePrivateFile(s.Path, data) -} diff --git a/pkg/oauth/doc.go b/pkg/oauth/doc.go index 8139d9e..def57ee 100644 --- a/pkg/oauth/doc.go +++ b/pkg/oauth/doc.go @@ -10,7 +10,6 @@ // - /oauth/jwks // - /oauth/register // -// Durable state is provided by pkg/oauth/store (Postgres or SQLite JSON payload tables) -// or file-backed helpers in production.go for single-node dev. haistack serve wires -// builtin OAuth via runtime.WithBuiltinOAuth when oauth.enabled is true. +// Durable state is provided by pkg/oauth/store (Postgres or SQLite). haistack serve +// wires builtin OAuth via runtime.WithBuiltinOAuth when oauth.enabled is true. package oauth diff --git a/pkg/oauth/issuer_binding.go b/pkg/oauth/issuer_binding.go index d663f9b..eeac397 100644 --- a/pkg/oauth/issuer_binding.go +++ b/pkg/oauth/issuer_binding.go @@ -26,3 +26,14 @@ func RequireBoundIssuer(issuer string) (string, error) { } return iss, nil } + +const issuerScopedKeySep = "\x1f" + +// IssuerScopedKey returns a map key unique to one issuer and token/code/session id. +func IssuerScopedKey(issuer, id string) (string, error) { + iss, err := RequireBoundIssuer(issuer) + if err != nil { + return "", err + } + return iss + issuerScopedKeySep + id, nil +} diff --git a/pkg/oauth/issuer_binding_test.go b/pkg/oauth/issuer_binding_test.go index 328362c..c3e9747 100644 --- a/pkg/oauth/issuer_binding_test.go +++ b/pkg/oauth/issuer_binding_test.go @@ -13,6 +13,68 @@ import ( "github.com/degoke/health-ai-stack/pkg/oauth" ) +func TestRequireBoundIssuer_RejectsEmpty(t *testing.T) { + if _, err := oauth.RequireBoundIssuer(""); err == nil { + t.Fatal("expected error") + } +} + +func TestMemoryStore_RequiresIssuerOnSave(t *testing.T) { + store := oauth.NewMemoryAuthorizationStore() + if err := store.SaveAuthorizationCode("code", oauth.AuthorizationCode{ + ClientID: "client", ExpiresAt: time.Now().Add(time.Minute), + }); err == nil { + t.Fatal("expected issuer required") + } + if err := store.SaveRefreshToken("rt", oauth.RefreshTokenEntry{ + ClientID: "client", ExpiresAt: time.Now().Add(time.Minute), + }); err == nil { + t.Fatal("expected issuer required") + } + if err := store.SavePendingAuthorization("p", oauth.PendingAuthorization{ + ExpiresAt: time.Now().Add(time.Minute), + }); err == nil { + t.Fatal("expected issuer required") + } +} + +func TestMemoryStore_IsolatesSameCodeAcrossIssuers(t *testing.T) { + store := oauth.NewMemoryAuthorizationStore() + issuerA := "https://auth.example/t/clinic-a" + issuerB := "https://auth.example/t/clinic-b" + now := time.Now().Add(5 * time.Minute) + if err := store.SaveAuthorizationCode("same-code", oauth.AuthorizationCode{ + Issuer: issuerA, ClientID: "a", RedirectURI: "https://a/cb", ExpiresAt: now, + }); err != nil { + t.Fatal(err) + } + if err := store.SaveAuthorizationCode("same-code", oauth.AuthorizationCode{ + Issuer: issuerB, ClientID: "b", RedirectURI: "https://b/cb", ExpiresAt: now, + }); err != nil { + t.Fatal(err) + } + entryA, ok := store.ConsumeAuthorizationCode(issuerA, "same-code") + if !ok || entryA.ClientID != "a" { + t.Fatalf("issuer A = %+v ok=%v", entryA, ok) + } + entryB, ok := store.ConsumeAuthorizationCode(issuerB, "same-code") + if !ok || entryB.ClientID != "b" { + t.Fatalf("issuer B = %+v ok=%v", entryB, ok) + } +} + +func TestFileStore_RequiresIssuerOnSave(t *testing.T) { + store, err := oauth.NewFileAuthorizationStore(t.TempDir() + "/tokens.json") + if err != nil { + t.Fatal(err) + } + if err := store.SaveAuthorizationCode("code", oauth.AuthorizationCode{ + ClientID: "client", ExpiresAt: time.Now().Add(time.Minute), + }); err == nil { + t.Fatal("expected issuer required") + } +} + func TestEntryIssuerMatches_RequiresNonEmptyIssuer(t *testing.T) { if oauth.EntryIssuerMatches("", "https://auth.example") { t.Fatal("empty entry issuer must not match") diff --git a/pkg/oauth/persistent.go b/pkg/oauth/persistent.go index 822ba9b..02ecafc 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -1,18 +1,12 @@ package oauth import ( - "encoding/json" - "errors" - "fmt" - "os" - "strings" "sync" "time" ) // AuthorizationStore persists authorization codes, refresh tokens, and pending -// authorization sessions. File-backed implementations support multi-instance AS -// deployments that share a filesystem. +// authorization sessions. Production deployments use pkg/oauth/store (SQLite or Postgres). type AuthorizationStore interface { SaveAuthorizationCode(code string, entry AuthorizationCode) error ConsumeAuthorizationCode(issuer, code string) (AuthorizationCode, bool) @@ -84,21 +78,34 @@ func NewMemoryAuthorizationStore() *MemoryAuthorizationStore { } func (s *MemoryAuthorizationStore) SaveAuthorizationCode(code string, entry AuthorizationCode) error { + iss, err := RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := IssuerScopedKey(iss, code) + if err != nil { + return err + } s.mu.Lock() - s.codes[code] = entry + s.codes[key] = entry s.mu.Unlock() return nil } func (s *MemoryAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (AuthorizationCode, bool) { + key, err := IssuerScopedKey(issuer, code) + if err != nil { + return AuthorizationCode{}, false + } now := memoryStoreNow(s) s.mu.Lock() - entry, ok := s.codes[code] - if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt)) { + entry, ok := s.codes[key] + if ok && now.After(entry.ExpiresAt) { ok = false } if ok { - delete(s.codes, code) + delete(s.codes, key) } s.mu.Unlock() if !ok { @@ -108,61 +115,95 @@ func (s *MemoryAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) } func (s *MemoryAuthorizationStore) SaveRefreshToken(token string, entry RefreshTokenEntry) error { + iss, err := RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := IssuerScopedKey(iss, token) + if err != nil { + return err + } s.mu.Lock() - s.refreshTokens[token] = entry + s.refreshTokens[key] = entry s.mu.Unlock() return nil } func (s *MemoryAuthorizationStore) ConsumeRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { + key, err := IssuerScopedKey(issuer, token) + if err != nil { + return RefreshTokenEntry{}, false + } entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return RefreshTokenEntry{}, false } s.mu.Lock() - delete(s.refreshTokens, token) + delete(s.refreshTokens, key) s.mu.Unlock() return entry, true } func (s *MemoryAuthorizationStore) LookupRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { + key, err := IssuerScopedKey(issuer, token) + if err != nil { + return RefreshTokenEntry{}, false + } now := memoryStoreNow(s) s.mu.Lock() - entry, ok := s.refreshTokens[token] + entry, ok := s.refreshTokens[key] s.mu.Unlock() - if !ok || !EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt) { + if !ok || now.After(entry.ExpiresAt) { return RefreshTokenEntry{}, false } return entry, true } func (s *MemoryAuthorizationStore) SavePendingAuthorization(id string, entry PendingAuthorization) error { + iss, err := RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := IssuerScopedKey(iss, id) + if err != nil { + return err + } s.mu.Lock() - s.pending[id] = entry + s.pending[key] = entry s.mu.Unlock() return nil } func (s *MemoryAuthorizationStore) GetPendingAuthorization(issuer, id string) (PendingAuthorization, bool) { + key, err := IssuerScopedKey(issuer, id) + if err != nil { + return PendingAuthorization{}, false + } now := memoryStoreNow(s) s.mu.Lock() - entry, ok := s.pending[id] + entry, ok := s.pending[key] s.mu.Unlock() - if !ok || !EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt) { + if !ok || now.After(entry.ExpiresAt) { return PendingAuthorization{}, false } return entry, true } func (s *MemoryAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { + key, err := IssuerScopedKey(issuer, token) + if err != nil { + return false + } now := memoryStoreNow(s) s.mu.Lock() - entry, ok := s.refreshTokens[token] - if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || entry.ClientID != clientID || now.After(entry.ExpiresAt)) { + entry, ok := s.refreshTokens[key] + if ok && (entry.ClientID != clientID || now.After(entry.ExpiresAt)) { ok = false } if ok { - delete(s.refreshTokens, token) + delete(s.refreshTokens, key) } s.mu.Unlock() return ok @@ -183,14 +224,18 @@ func (s *MemoryAuthorizationStore) PurgeExpiredPendingAuthorizations() int { } func (s *MemoryAuthorizationStore) ConsumePendingAuthorization(issuer, id string) (PendingAuthorization, bool) { + key, err := IssuerScopedKey(issuer, id) + if err != nil { + return PendingAuthorization{}, false + } now := memoryStoreNow(s) s.mu.Lock() - entry, ok := s.pending[id] - if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt)) { + entry, ok := s.pending[key] + if ok && now.After(entry.ExpiresAt) { ok = false } if ok { - delete(s.pending, id) + delete(s.pending, key) } s.mu.Unlock() if !ok { @@ -228,25 +273,38 @@ type fileAuthorizationState struct { } func (s *FileAuthorizationStore) SaveAuthorizationCode(code string, entry AuthorizationCode) error { + iss, err := RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := IssuerScopedKey(iss, code) + if err != nil { + return err + } return s.update(func(state *fileAuthorizationState) { if state.Codes == nil { state.Codes = make(map[string]AuthorizationCode) } - state.Codes[code] = entry + state.Codes[key] = entry }) } func (s *FileAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (AuthorizationCode, bool) { + key, err := IssuerScopedKey(issuer, code) + if err != nil { + return AuthorizationCode{}, false + } now := s.now() var entry AuthorizationCode var ok bool - err := s.update(func(state *fileAuthorizationState) { - entry, ok = state.Codes[code] - if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt)) { + err = s.update(func(state *fileAuthorizationState) { + entry, ok = state.Codes[key] + if ok && now.After(entry.ExpiresAt) { ok = false } if ok { - delete(state.Codes, code) + delete(state.Codes, key) } }) if err != nil || !ok { @@ -256,21 +314,34 @@ func (s *FileAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) ( } func (s *FileAuthorizationStore) SaveRefreshToken(token string, entry RefreshTokenEntry) error { + iss, err := RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := IssuerScopedKey(iss, token) + if err != nil { + return err + } return s.update(func(state *fileAuthorizationState) { if state.Refresh == nil { state.Refresh = make(map[string]RefreshTokenEntry) } - state.Refresh[token] = entry + state.Refresh[key] = entry }) } func (s *FileAuthorizationStore) ConsumeRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { + key, err := IssuerScopedKey(issuer, token) + if err != nil { + return RefreshTokenEntry{}, false + } entry, ok := s.LookupRefreshToken(issuer, token) if !ok { return RefreshTokenEntry{}, false } - err := s.update(func(state *fileAuthorizationState) { - delete(state.Refresh, token) + err = s.update(func(state *fileAuthorizationState) { + delete(state.Refresh, key) }) if err != nil { return RefreshTokenEntry{}, false @@ -279,51 +350,72 @@ func (s *FileAuthorizationStore) ConsumeRefreshToken(issuer, token string) (Refr } func (s *FileAuthorizationStore) LookupRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { + key, err := IssuerScopedKey(issuer, token) + if err != nil { + return RefreshTokenEntry{}, false + } now := s.now() var entry RefreshTokenEntry var ok bool - err := s.update(func(state *fileAuthorizationState) { - entry, ok = state.Refresh[token] + err = s.update(func(state *fileAuthorizationState) { + entry, ok = state.Refresh[key] }) - if err != nil || !ok || !EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt) { + if err != nil || !ok || now.After(entry.ExpiresAt) { return RefreshTokenEntry{}, false } return entry, true } func (s *FileAuthorizationStore) SavePendingAuthorization(id string, entry PendingAuthorization) error { + iss, err := RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := IssuerScopedKey(iss, id) + if err != nil { + return err + } return s.update(func(state *fileAuthorizationState) { if state.Pending == nil { state.Pending = make(map[string]PendingAuthorization) } - state.Pending[id] = entry + state.Pending[key] = entry }) } func (s *FileAuthorizationStore) GetPendingAuthorization(issuer, id string) (PendingAuthorization, bool) { + key, err := IssuerScopedKey(issuer, id) + if err != nil { + return PendingAuthorization{}, false + } now := s.now() var entry PendingAuthorization var ok bool - err := s.update(func(state *fileAuthorizationState) { - entry, ok = state.Pending[id] + err = s.update(func(state *fileAuthorizationState) { + entry, ok = state.Pending[key] }) - if err != nil || !ok || !EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt) { + if err != nil || !ok || now.After(entry.ExpiresAt) { return PendingAuthorization{}, false } return entry, true } func (s *FileAuthorizationStore) ConsumePendingAuthorization(issuer, id string) (PendingAuthorization, bool) { + key, err := IssuerScopedKey(issuer, id) + if err != nil { + return PendingAuthorization{}, false + } now := s.now() var entry PendingAuthorization var ok bool - err := s.update(func(state *fileAuthorizationState) { - entry, ok = state.Pending[id] - if ok && (!EntryIssuerMatches(entry.Issuer, issuer) || now.After(entry.ExpiresAt)) { + err = s.update(func(state *fileAuthorizationState) { + entry, ok = state.Pending[key] + if ok && now.After(entry.ExpiresAt) { ok = false } if ok { - delete(state.Pending, id) + delete(state.Pending, key) } }) if err != nil || !ok { @@ -354,14 +446,18 @@ func (s *FileAuthorizationStore) PurgeExpiredPendingAuthorizations() int { } func (s *FileAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { + key, err := IssuerScopedKey(issuer, token) + if err != nil { + return false + } now := s.now() var ok bool - err := s.update(func(state *fileAuthorizationState) { - entry, found := state.Refresh[token] - if !found || !EntryIssuerMatches(entry.Issuer, issuer) || entry.ClientID != clientID || now.After(entry.ExpiresAt) { + err = s.update(func(state *fileAuthorizationState) { + entry, found := state.Refresh[key] + if !found || entry.ClientID != clientID || now.After(entry.ExpiresAt) { return } - delete(state.Refresh, token) + delete(state.Refresh, key) ok = true }) return err == nil && ok diff --git a/pkg/oauth/production.go b/pkg/oauth/production.go index 13f7fb3..f198096 100644 --- a/pkg/oauth/production.go +++ b/pkg/oauth/production.go @@ -7,28 +7,21 @@ import ( "os" "path/filepath" "strings" - - "github.com/degoke/health-ai-stack/pkg/smart" ) -// ProductionPaths names durable state files for a multi-instance authorization server. +// ProductionPaths names durable OAuth state locations on disk. +// Token, client, replay, and revocation state use pkg/oauth/store (SQLite or Postgres). type ProductionPaths struct { StateDir string - Clients string - Tokens string - Replay string SigningKey string SigningKID string } -// DefaultProductionPaths returns conventional file paths under stateDir. +// DefaultProductionPaths returns conventional paths under stateDir (PEM signing key fallback). func DefaultProductionPaths(stateDir string) ProductionPaths { stateDir = strings.TrimSpace(stateDir) return ProductionPaths{ StateDir: stateDir, - Clients: filepath.Join(stateDir, "oauth-clients.json"), - Tokens: filepath.Join(stateDir, "oauth-tokens.json"), - Replay: filepath.Join(stateDir, "oauth-replay.json"), SigningKey: filepath.Join(stateDir, "oauth-signing.pem"), } } @@ -68,28 +61,6 @@ func ValidateProductionIssuer(issuer string) error { return nil } -// ProductionStores wires file-backed stores for single-host or shared-filesystem deployments. -// Prefer store.ApplyPostgresStores for multi-instance production clusters. -func ProductionStores(paths ProductionPaths) (AuthorizationStore, ClientRegistry, smart.ReplayStore, TokenRevocationStore, error) { - authStore, err := NewFileAuthorizationStore(paths.Tokens) - if err != nil { - return nil, nil, nil, nil, err - } - clientStore, err := NewFileClientStore(paths.Clients) - if err != nil { - return nil, nil, nil, nil, err - } - replayStore, err := smart.NewFileReplayStore(paths.Replay) - if err != nil { - return nil, nil, nil, nil, err - } - revocationStore, err := NewFileTokenRevocationStore(filepath.Join(paths.StateDir, "oauth-revoked.json")) - if err != nil { - return nil, nil, nil, nil, err - } - return authStore, clientStore, replayStore, revocationStore, nil -} - // LoadSigningKey loads a persistent signing key when the PEM file exists. func LoadSigningKey(paths ProductionPaths) (*KeySet, error) { if strings.TrimSpace(paths.SigningKey) == "" { @@ -100,26 +71,3 @@ func LoadSigningKey(paths ProductionPaths) (*KeySet, error) { } return LoadKeySetFromPEM(paths.SigningKey, paths.SigningKID) } - -// NewProductionServer constructs an authorization server with durable state. -func NewProductionServer(cfg Config, paths ProductionPaths) (*Server, error) { - if strings.TrimSpace(paths.StateDir) == "" { - return nil, fmt.Errorf("oauth: production state dir required") - } - authStore, clientStore, replayStore, revocationStore, err := ProductionStores(paths) - if err != nil { - return nil, err - } - cfg.AuthorizationStore = authStore - cfg.Clients = clientStore - cfg.ReplayStore = replayStore - cfg.RevocationStore = revocationStore - if cfg.SigningKey == nil { - key, err := LoadSigningKey(paths) - if err != nil { - return nil, err - } - cfg.SigningKey = key - } - return NewServer(cfg) -} diff --git a/pkg/oauth/production_test.go b/pkg/oauth/production_test.go deleted file mode 100644 index 1966156..0000000 --- a/pkg/oauth/production_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package oauth_test - -import ( - "context" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - - "github.com/degoke/health-ai-stack/pkg/client" - "github.com/degoke/health-ai-stack/pkg/oauth" -) - -func TestNewProductionServer_FileBackedStores(t *testing.T) { - dir := t.TempDir() - paths := oauth.DefaultProductionPaths(dir) - server, err := oauth.NewProductionServer(oauth.Config{ - Issuer: "https://issuer.example", - FHIRAudience: "https://issuer.example", - AutoApprove: true, - }, paths) - if err != nil { - t.Fatal(err) - } - _ = server.RegisterClient(oauth.Client{ - ClientID: "prod-client", - ClientSecret: "prod-secret", - TokenEndpointAuthMethod: oauth.AuthMethodClientSecretPost, - RedirectURIs: []string{"https://localhost/callback"}, - Scopes: []string{"patient/Patient.rs"}, - }) - mux := http.NewServeMux() - mux.Handle("/", server.Handler()) - srv := httptest.NewServer(mux) - defer srv.Close() - base := strings.TrimSuffix(srv.URL, "/") - - restarted, err := oauth.NewProductionServer(oauth.Config{ - Issuer: base, - FHIRAudience: base, - AutoApprove: true, - }, oauth.ProductionPaths{ - StateDir: dir, - Clients: paths.Clients, - Tokens: paths.Tokens, - Replay: paths.Replay, - SigningKey: paths.SigningKey, - }) - if err != nil { - t.Fatal(err) - } - mux2 := http.NewServeMux() - mux2.Handle("/", restarted.Handler()) - srv2 := httptest.NewServer(mux2) - defer srv2.Close() - base2 := strings.TrimSuffix(srv2.URL, "/") - - httpClient, _ := client.New(client.Config{BaseURL: base2}) - cfg, _ := httpClient.SMART().Discover(context.Background(), base2) - pkce, _ := client.NewPKCEChallenge() - authURL, _ := httpClient.SMART().BuildAuthURL(client.AuthCodeRequest{ - Config: cfg, ClientID: "prod-client", RedirectURI: "https://localhost/callback", - Scope: "patient/Patient.rs", PKCE: pkce, - }) - noRedirect := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }} - authResp, err := noRedirect.Get(authURL) - if err != nil { - t.Fatal(err) - } - _ = authResp.Body.Close() - code := strings.Split(strings.Split(authResp.Header.Get("Location"), "code=")[1], "&")[0] - tokenResp, err := httpClient.SMART().ExchangeAuthCode(context.Background(), client.AuthCodeExchangeRequest{ - TokenEndpoint: cfg.TokenEndpoint, - ClientID: "prod-client", - ClientSecret: "prod-secret", - RedirectURI: "https://localhost/callback", - Code: code, - PKCE: pkce, - }) - if err != nil { - t.Fatal(err) - } - if tokenResp.AccessToken == "" { - t.Fatal("missing access token") - } - if _, err := filepath.Glob(filepath.Join(dir, "*")); err != nil { - t.Fatal(err) - } -} diff --git a/pkg/oauth/redis/keys.go b/pkg/oauth/redis/keys.go index c0565c8..9a2ab11 100644 --- a/pkg/oauth/redis/keys.go +++ b/pkg/oauth/redis/keys.go @@ -2,7 +2,6 @@ package redis import ( "encoding/base64" - "fmt" "github.com/degoke/health-ai-stack/pkg/oauth" ) @@ -38,11 +37,3 @@ func refreshRedisKey(prefix, issuer, token string) (string, error) { } return prefix + "refresh:" + seg + ":" + token, nil } - -func normalizeConsumeIssuer(issuer string) (string, error) { - iss, err := oauth.RequireBoundIssuer(issuer) - if err != nil { - return "", fmt.Errorf("oauth: issuer required") - } - return iss, nil -} diff --git a/pkg/oauth/redis/scripts.go b/pkg/oauth/redis/scripts.go index 3227d86..bcda5b5 100644 --- a/pkg/oauth/redis/scripts.go +++ b/pkg/oauth/redis/scripts.go @@ -2,16 +2,64 @@ package redis import goredis "github.com/redis/go-redis/v9" -var ( - // consumeJSONValueScript atomically reads and deletes a string value. - consumeJSONValueScript = goredis.NewScript(` +const consumeBoundJSONScript = ` +local function normalize(s) + if type(s) ~= 'string' then + return '' + end + s = string.gsub(s, '^%s+', '') + s = string.gsub(s, '%s+$', '') + s = string.gsub(s, '/+$', '') + return s +end + local payload = redis.call('GET', KEYS[1]) if not payload then return '' end +local ok, obj = pcall(cjson.decode, payload) +if not ok or type(obj) ~= 'table' then + return '' +end +if normalize(obj['issuer']) ~= ARGV[1] then + return '' +end redis.call('DEL', KEYS[1]) return payload -`) +` + +const consumeBoundRefreshScript = ` +local function normalize(s) + if type(s) ~= 'string' then + return '' + end + s = string.gsub(s, '^%s+', '') + s = string.gsub(s, '%s+$', '') + s = string.gsub(s, '/+$', '') + return s +end + +local payload = redis.call('HGET', KEYS[1], 'payload') +if not payload then + return '' +end +local ok, obj = pcall(cjson.decode, payload) +if not ok or type(obj) ~= 'table' then + return '' +end +if normalize(obj['issuer']) ~= ARGV[1] then + return '' +end +redis.call('DEL', KEYS[1]) +return payload +` + +var ( + // consumeBoundJSONValueScript deletes a JSON string key only when issuer matches. + consumeBoundJSONValueScript = goredis.NewScript(consumeBoundJSONScript) + + // consumeBoundRefreshScript deletes a refresh hash only when issuer matches. + consumeBoundRefreshTokenScript = goredis.NewScript(consumeBoundRefreshScript) saveRefreshTokenScript = goredis.NewScript(` redis.call('HSET', KEYS[1], 'clientId', ARGV[1], 'payload', ARGV[2]) diff --git a/pkg/oauth/redis/store.go b/pkg/oauth/redis/store.go index 762ca6f..ffaab9b 100644 --- a/pkg/oauth/redis/store.go +++ b/pkg/oauth/redis/store.go @@ -29,7 +29,12 @@ func NewAuthorizationStore(client goredis.Cmdable, keyPrefix string) *Authorizat } func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.AuthorizationCode) error { - key, err := authCodeRedisKey(s.prefix, entry.Issuer, code) + iss, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := authCodeRedisKey(s.prefix, iss, code) if err != nil { return err } @@ -42,7 +47,7 @@ func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.Auth } func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { - bound, err := normalizeConsumeIssuer(issuer) + bound, err := oauth.RequireBoundIssuer(issuer) if err != nil { return oauth.AuthorizationCode{}, false } @@ -50,7 +55,7 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oaut if err != nil { return oauth.AuthorizationCode{}, false } - payload, ok := consumeJSONValue(s.client, key) + payload, ok := consumeBoundJSONValue(s.client, key, bound) if !ok { return oauth.AuthorizationCode{}, false } @@ -58,14 +63,19 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oaut if err := json.Unmarshal(payload, &entry); err != nil { return oauth.AuthorizationCode{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, bound) || s.now().After(entry.ExpiresAt) { + if s.now().After(entry.ExpiresAt) { return oauth.AuthorizationCode{}, false } return entry, true } func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshTokenEntry) error { - key, err := refreshRedisKey(s.prefix, entry.Issuer, token) + iss, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := refreshRedisKey(s.prefix, iss, token) if err != nil { return err } @@ -86,11 +96,7 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT } func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { - entry, ok := s.LookupRefreshToken(issuer, token) - if !ok { - return oauth.RefreshTokenEntry{}, false - } - bound, err := normalizeConsumeIssuer(issuer) + bound, err := oauth.RequireBoundIssuer(issuer) if err != nil { return oauth.RefreshTokenEntry{}, false } @@ -98,12 +104,22 @@ func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.Re if err != nil { return oauth.RefreshTokenEntry{}, false } - _ = s.client.Del(context.Background(), key).Err() + payload, ok := consumeBoundRefreshValue(s.client, key, bound) + if !ok { + return oauth.RefreshTokenEntry{}, false + } + var entry oauth.RefreshTokenEntry + if err := json.Unmarshal(payload, &entry); err != nil { + return oauth.RefreshTokenEntry{}, false + } + if s.now().After(entry.ExpiresAt) { + return oauth.RefreshTokenEntry{}, false + } return entry, true } func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { - bound, err := normalizeConsumeIssuer(issuer) + bound, err := oauth.RequireBoundIssuer(issuer) if err != nil { return oauth.RefreshTokenEntry{}, false } @@ -119,14 +135,19 @@ func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.Ref if err := json.Unmarshal(payload, &entry); err != nil { return oauth.RefreshTokenEntry{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, bound) || s.now().After(entry.ExpiresAt) { + if s.now().After(entry.ExpiresAt) { return oauth.RefreshTokenEntry{}, false } return entry, true } func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.PendingAuthorization) error { - key, err := pendingRedisKey(s.prefix, entry.Issuer, id) + iss, err := oauth.RequireBoundIssuer(entry.Issuer) + if err != nil { + return err + } + entry.Issuer = iss + key, err := pendingRedisKey(s.prefix, iss, id) if err != nil { return err } @@ -139,7 +160,7 @@ func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.Pen } func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { - bound, err := normalizeConsumeIssuer(issuer) + bound, err := oauth.RequireBoundIssuer(issuer) if err != nil { return oauth.PendingAuthorization{}, false } @@ -155,7 +176,7 @@ func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.P if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, bound) || s.now().After(entry.ExpiresAt) { + if s.now().After(entry.ExpiresAt) { return oauth.PendingAuthorization{}, false } return entry, true @@ -166,7 +187,7 @@ func (s *AuthorizationStore) PurgeExpiredPendingAuthorizations() int { } func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { - bound, err := normalizeConsumeIssuer(issuer) + bound, err := oauth.RequireBoundIssuer(issuer) if err != nil { return oauth.PendingAuthorization{}, false } @@ -174,7 +195,7 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oau if err != nil { return oauth.PendingAuthorization{}, false } - payload, ok := consumeJSONValue(s.client, key) + payload, ok := consumeBoundJSONValue(s.client, key, bound) if !ok { return oauth.PendingAuthorization{}, false } @@ -182,14 +203,22 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oau if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if !oauth.EntryIssuerMatches(entry.Issuer, bound) || s.now().After(entry.ExpiresAt) { + if s.now().After(entry.ExpiresAt) { return oauth.PendingAuthorization{}, false } return entry, true } -func consumeJSONValue(client goredis.Cmdable, key string) ([]byte, bool) { - payload, err := consumeJSONValueScript.Run(context.Background(), client, []string{key}).Text() +func consumeBoundJSONValue(client goredis.Cmdable, key, issuer string) ([]byte, bool) { + payload, err := consumeBoundJSONValueScript.Run(context.Background(), client, []string{key}, issuer).Text() + if err != nil || payload == "" { + return nil, false + } + return []byte(payload), true +} + +func consumeBoundRefreshValue(client goredis.Cmdable, key, issuer string) ([]byte, bool) { + payload, err := consumeBoundRefreshTokenScript.Run(context.Background(), client, []string{key}, issuer).Text() if err != nil || payload == "" { return nil, false } @@ -201,7 +230,7 @@ func (s *AuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID if !ok || entry.ClientID != clientID { return false } - bound, err := normalizeConsumeIssuer(issuer) + bound, err := oauth.RequireBoundIssuer(issuer) if err != nil { return false } diff --git a/pkg/oauth/redis/store_test.go b/pkg/oauth/redis/store_test.go index 339d5f4..35dbff0 100644 --- a/pkg/oauth/redis/store_test.go +++ b/pkg/oauth/redis/store_test.go @@ -1,6 +1,9 @@ package redis_test import ( + "context" + "encoding/base64" + "encoding/json" "testing" "time" @@ -77,6 +80,37 @@ func TestEphemeralStores_RoundTrip(t *testing.T) { } } +func TestAuthorizationStore_MismatchedJSONIssuerDoesNotBurnCode(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + + client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + authStore, _, _ := oauthredis.EphemeralStores(client, "test:") + const issuer = "https://auth.example.test" + seg := base64.RawURLEncoding.EncodeToString([]byte(issuer)) + key := "test:authcode:" + seg + ":tampered" + payload, err := json.Marshal(oauth.AuthorizationCode{ + Issuer: "https://other.example", + ClientID: "redis-client", + ExpiresAt: time.Now().Add(5 * time.Minute), + }) + if err != nil { + t.Fatal(err) + } + if err := client.Set(context.Background(), key, payload, time.Minute).Err(); err != nil { + t.Fatal(err) + } + if _, ok := authStore.ConsumeAuthorizationCode(issuer, "tampered"); ok { + t.Fatal("mismatched JSON issuer must not consume") + } + if n, err := client.Exists(context.Background(), key).Result(); err != nil || n != 1 { + t.Fatalf("expected key retained n=%d err=%v", n, err) + } +} + func TestNewServer_RequiresClientRegistry(t *testing.T) { mr, err := miniredis.Run() if err != nil { diff --git a/pkg/oauth/revocation.go b/pkg/oauth/revocation.go index a5d0c14..29d94b6 100644 --- a/pkg/oauth/revocation.go +++ b/pkg/oauth/revocation.go @@ -1,10 +1,7 @@ package oauth import ( - "encoding/json" - "errors" "fmt" - "os" "sync" "time" ) @@ -55,90 +52,6 @@ func (s *MemoryTokenRevocationStore) IsRevoked(jti string) bool { return ok } -// FileTokenRevocationStore persists revoked JTIs as JSON. -type FileTokenRevocationStore struct { - Path string - Now func() time.Time - mu sync.Mutex -} - -// NewFileTokenRevocationStore constructs a file-backed revocation store. -func NewFileTokenRevocationStore(path string) (*FileTokenRevocationStore, error) { - if path == "" { - return nil, fmt.Errorf("oauth: revocation store path required") - } - return &FileTokenRevocationStore{Path: path, Now: time.Now}, nil -} - -func (s *FileTokenRevocationStore) Revoke(jti string, expiresAt time.Time) error { - return s.update(func(entries map[string]time.Time) { - entries[jti] = expiresAt - }) -} - -func (s *FileTokenRevocationStore) IsRevoked(jti string) bool { - now := revocationNow(s.Now) - var revoked bool - _ = s.update(func(entries map[string]time.Time) { - expiry, ok := entries[jti] - if ok && !expiry.IsZero() && now.After(expiry) { - delete(entries, jti) - ok = false - } - revoked = ok - }) - return revoked -} - -func (s *FileTokenRevocationStore) update(fn func(map[string]time.Time)) error { - if s == nil { - return fmt.Errorf("oauth: revocation store is nil") - } - s.mu.Lock() - defer s.mu.Unlock() - entries, err := s.load() - if err != nil { - return err - } - purgeExpiredRevocations(entries, revocationNow(s.Now)) - fn(entries) - return s.save(entries) -} - -func (s *FileTokenRevocationStore) load() (map[string]time.Time, error) { - data, err := os.ReadFile(s.Path) - if errors.Is(err, os.ErrNotExist) { - return make(map[string]time.Time), nil - } - if err != nil { - return nil, fmt.Errorf("read revocation store: %w", err) - } - entries := make(map[string]time.Time) - if len(data) == 0 { - return entries, nil - } - if err := json.Unmarshal(data, &entries); err != nil { - return nil, fmt.Errorf("decode revocation store: %w", err) - } - return entries, nil -} - -func (s *FileTokenRevocationStore) save(entries map[string]time.Time) error { - data, err := json.MarshalIndent(entries, "", " ") - if err != nil { - return fmt.Errorf("encode revocation store: %w", err) - } - return atomicWritePrivateFile(s.Path, data) -} - -func purgeExpiredRevocations(entries map[string]time.Time, now time.Time) { - for jti, expiry := range entries { - if !expiry.IsZero() && now.After(expiry) { - delete(entries, jti) - } - } -} - func revocationNow(nowFn func() time.Time) time.Time { if nowFn != nil { return nowFn() diff --git a/pkg/oauth/security_test.go b/pkg/oauth/security_test.go index 2a9e453..68f5b4f 100644 --- a/pkg/oauth/security_test.go +++ b/pkg/oauth/security_test.go @@ -4,10 +4,8 @@ import ( "context" "net/http" "net/http/httptest" - "path/filepath" "strings" "testing" - "time" "github.com/degoke/health-ai-stack/pkg/client" "github.com/degoke/health-ai-stack/pkg/oauth" @@ -45,30 +43,6 @@ func TestOAuthServer_RejectsEmptyRedirectURIList(t *testing.T) { } } -func TestFileAuthorizationStore_PersistsPendingSessions(t *testing.T) { - path := filepath.Join(t.TempDir(), "oauth-pending.json") - store, err := oauth.NewFileAuthorizationStore(path) - if err != nil { - t.Fatal(err) - } - const issuer = "https://auth.example.test" - if err := store.SavePendingAuthorization("sess-1", oauth.PendingAuthorization{ - Issuer: issuer, - Request: oauth.AuthorizationRequest{ClientID: "client", Scope: "patient/*.rs"}, - ExpiresAt: time.Now().Add(5 * time.Minute), - }); err != nil { - t.Fatal(err) - } - reloaded, err := oauth.NewFileAuthorizationStore(path) - if err != nil { - t.Fatal(err) - } - entry, ok := reloaded.ConsumePendingAuthorization(issuer, "sess-1") - if !ok || entry.Request.ClientID != "client" { - t.Fatalf("entry = %+v ok=%v", entry, ok) - } -} - func TestOAuthServer_ConfidentialClientSecretPost(t *testing.T) { mux := http.NewServeMux() srv := httptest.NewServer(mux) @@ -212,25 +186,3 @@ func TestOAuthServer_ConfidentialClientSecretBasic(t *testing.T) { } } -func TestFileAuthorizationStore_PersistsCodes(t *testing.T) { - const issuer = "https://auth.example.test" - path := filepath.Join(t.TempDir(), "oauth-auth.json") - store, err := oauth.NewFileAuthorizationStore(path) - if err != nil { - t.Fatal(err) - } - if err := store.SaveAuthorizationCode("code-1", oauth.AuthorizationCode{ - Issuer: issuer, ClientID: "client", RedirectURI: "https://app/cb", Scope: "patient/*.rs", - ExpiresAt: time.Now().Add(5 * time.Minute), - }); err != nil { - t.Fatal(err) - } - reloaded, err := oauth.NewFileAuthorizationStore(path) - if err != nil { - t.Fatal(err) - } - entry, ok := reloaded.ConsumeAuthorizationCode(issuer, "code-1") - if !ok || entry.ClientID != "client" { - t.Fatalf("entry = %+v ok=%v", entry, ok) - } -} diff --git a/pkg/oauth/store/postgres.go b/pkg/oauth/store/postgres.go index 32c4d60..6ec28d8 100644 --- a/pkg/oauth/store/postgres.go +++ b/pkg/oauth/store/postgres.go @@ -104,7 +104,6 @@ func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.Re return oauth.RefreshTokenEntry{}, false } now := s.now() - issuer = oauth.NormalizeIssuerURL(issuer) tag, err := s.pool.Exec(context.Background(), ` DELETE FROM hai_oauth_refresh_token WHERE token = $1 AND expires_at > $2 AND issuer = $3`, token, now, issuer) diff --git a/pkg/oauth/store/sqlite.go b/pkg/oauth/store/sqlite.go index f49deb5..e5463b4 100644 --- a/pkg/oauth/store/sqlite.go +++ b/pkg/oauth/store/sqlite.go @@ -97,7 +97,6 @@ func (s *SQLiteAuthorizationStore) ConsumeRefreshToken(issuer, token string) (oa return oauth.RefreshTokenEntry{}, false } now := s.now().UTC().Format(time.RFC3339Nano) - issuer = oauth.NormalizeIssuerURL(issuer) res, err := s.db.ExecContext(context.Background(), ` DELETE FROM hai_oauth_refresh_token WHERE token = ? AND expires_at > ? AND issuer = ?`, token, now, issuer) From f0770321a7eaa84185125e4d8c9656ad0838d940 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:39:03 +0000 Subject: [PATCH 09/14] fix(oauth): drop leftover file authorization store 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 --- pkg/oauth/issuer_binding_test.go | 12 -- pkg/oauth/persistent.go | 288 ------------------------------- 2 files changed, 300 deletions(-) diff --git a/pkg/oauth/issuer_binding_test.go b/pkg/oauth/issuer_binding_test.go index c3e9747..341fbcc 100644 --- a/pkg/oauth/issuer_binding_test.go +++ b/pkg/oauth/issuer_binding_test.go @@ -63,18 +63,6 @@ func TestMemoryStore_IsolatesSameCodeAcrossIssuers(t *testing.T) { } } -func TestFileStore_RequiresIssuerOnSave(t *testing.T) { - store, err := oauth.NewFileAuthorizationStore(t.TempDir() + "/tokens.json") - if err != nil { - t.Fatal(err) - } - if err := store.SaveAuthorizationCode("code", oauth.AuthorizationCode{ - ClientID: "client", ExpiresAt: time.Now().Add(time.Minute), - }); err == nil { - t.Fatal("expected issuer required") - } -} - func TestEntryIssuerMatches_RequiresNonEmptyIssuer(t *testing.T) { if oauth.EntryIssuerMatches("", "https://auth.example") { t.Fatal("empty entry issuer must not match") diff --git a/pkg/oauth/persistent.go b/pkg/oauth/persistent.go index 02ecafc..136378e 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -250,291 +250,3 @@ func memoryStoreNow(s *MemoryAuthorizationStore) time.Time { } return time.Now() } - -// FileAuthorizationStore persists authorization codes and refresh tokens as JSON. -type FileAuthorizationStore struct { - Path string - Now func() time.Time - mu sync.Mutex -} - -// NewFileAuthorizationStore constructs a file-backed authorization store. -func NewFileAuthorizationStore(path string) (*FileAuthorizationStore, error) { - if strings.TrimSpace(path) == "" { - return nil, fmt.Errorf("oauth: authorization store path required") - } - return &FileAuthorizationStore{Path: path, Now: time.Now}, nil -} - -type fileAuthorizationState struct { - Codes map[string]AuthorizationCode `json:"codes"` - Refresh map[string]RefreshTokenEntry `json:"refresh"` - Pending map[string]PendingAuthorization `json:"pending"` -} - -func (s *FileAuthorizationStore) SaveAuthorizationCode(code string, entry AuthorizationCode) error { - iss, err := RequireBoundIssuer(entry.Issuer) - if err != nil { - return err - } - entry.Issuer = iss - key, err := IssuerScopedKey(iss, code) - if err != nil { - return err - } - return s.update(func(state *fileAuthorizationState) { - if state.Codes == nil { - state.Codes = make(map[string]AuthorizationCode) - } - state.Codes[key] = entry - }) -} - -func (s *FileAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (AuthorizationCode, bool) { - key, err := IssuerScopedKey(issuer, code) - if err != nil { - return AuthorizationCode{}, false - } - now := s.now() - var entry AuthorizationCode - var ok bool - err = s.update(func(state *fileAuthorizationState) { - entry, ok = state.Codes[key] - if ok && now.After(entry.ExpiresAt) { - ok = false - } - if ok { - delete(state.Codes, key) - } - }) - if err != nil || !ok { - return AuthorizationCode{}, false - } - return entry, true -} - -func (s *FileAuthorizationStore) SaveRefreshToken(token string, entry RefreshTokenEntry) error { - iss, err := RequireBoundIssuer(entry.Issuer) - if err != nil { - return err - } - entry.Issuer = iss - key, err := IssuerScopedKey(iss, token) - if err != nil { - return err - } - return s.update(func(state *fileAuthorizationState) { - if state.Refresh == nil { - state.Refresh = make(map[string]RefreshTokenEntry) - } - state.Refresh[key] = entry - }) -} - -func (s *FileAuthorizationStore) ConsumeRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { - key, err := IssuerScopedKey(issuer, token) - if err != nil { - return RefreshTokenEntry{}, false - } - entry, ok := s.LookupRefreshToken(issuer, token) - if !ok { - return RefreshTokenEntry{}, false - } - err = s.update(func(state *fileAuthorizationState) { - delete(state.Refresh, key) - }) - if err != nil { - return RefreshTokenEntry{}, false - } - return entry, true -} - -func (s *FileAuthorizationStore) LookupRefreshToken(issuer, token string) (RefreshTokenEntry, bool) { - key, err := IssuerScopedKey(issuer, token) - if err != nil { - return RefreshTokenEntry{}, false - } - now := s.now() - var entry RefreshTokenEntry - var ok bool - err = s.update(func(state *fileAuthorizationState) { - entry, ok = state.Refresh[key] - }) - if err != nil || !ok || now.After(entry.ExpiresAt) { - return RefreshTokenEntry{}, false - } - return entry, true -} - -func (s *FileAuthorizationStore) SavePendingAuthorization(id string, entry PendingAuthorization) error { - iss, err := RequireBoundIssuer(entry.Issuer) - if err != nil { - return err - } - entry.Issuer = iss - key, err := IssuerScopedKey(iss, id) - if err != nil { - return err - } - return s.update(func(state *fileAuthorizationState) { - if state.Pending == nil { - state.Pending = make(map[string]PendingAuthorization) - } - state.Pending[key] = entry - }) -} - -func (s *FileAuthorizationStore) GetPendingAuthorization(issuer, id string) (PendingAuthorization, bool) { - key, err := IssuerScopedKey(issuer, id) - if err != nil { - return PendingAuthorization{}, false - } - now := s.now() - var entry PendingAuthorization - var ok bool - err = s.update(func(state *fileAuthorizationState) { - entry, ok = state.Pending[key] - }) - if err != nil || !ok || now.After(entry.ExpiresAt) { - return PendingAuthorization{}, false - } - return entry, true -} - -func (s *FileAuthorizationStore) ConsumePendingAuthorization(issuer, id string) (PendingAuthorization, bool) { - key, err := IssuerScopedKey(issuer, id) - if err != nil { - return PendingAuthorization{}, false - } - now := s.now() - var entry PendingAuthorization - var ok bool - err = s.update(func(state *fileAuthorizationState) { - entry, ok = state.Pending[key] - if ok && now.After(entry.ExpiresAt) { - ok = false - } - if ok { - delete(state.Pending, key) - } - }) - if err != nil || !ok { - return PendingAuthorization{}, false - } - return entry, true -} - -func (s *FileAuthorizationStore) now() time.Time { - if s.Now != nil { - return s.Now() - } - return time.Now() -} - -func (s *FileAuthorizationStore) PurgeExpiredPendingAuthorizations() int { - now := s.now() - var n int - _ = s.update(func(state *fileAuthorizationState) { - for id, entry := range state.Pending { - if now.After(entry.ExpiresAt) { - delete(state.Pending, id) - n++ - } - } - }) - return n -} - -func (s *FileAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { - key, err := IssuerScopedKey(issuer, token) - if err != nil { - return false - } - now := s.now() - var ok bool - err = s.update(func(state *fileAuthorizationState) { - entry, found := state.Refresh[key] - if !found || entry.ClientID != clientID || now.After(entry.ExpiresAt) { - return - } - delete(state.Refresh, key) - ok = true - }) - return err == nil && ok -} - -func (s *FileAuthorizationStore) update(fn func(*fileAuthorizationState)) error { - if s == nil { - return fmt.Errorf("oauth: authorization store is nil") - } - s.mu.Lock() - defer s.mu.Unlock() - state, err := s.load() - if err != nil { - return err - } - purgeExpiredAuthorizationState(&state, s.now()) - fn(&state) - return s.save(state) -} - -func purgeExpiredAuthorizationState(state *fileAuthorizationState, now time.Time) { - for code, entry := range state.Codes { - if now.After(entry.ExpiresAt) { - delete(state.Codes, code) - } - } - for token, entry := range state.Refresh { - if now.After(entry.ExpiresAt) { - delete(state.Refresh, token) - } - } - for id, entry := range state.Pending { - if now.After(entry.ExpiresAt) { - delete(state.Pending, id) - } - } -} - -func (s *FileAuthorizationStore) load() (fileAuthorizationState, error) { - data, err := os.ReadFile(s.Path) - if errors.Is(err, os.ErrNotExist) { - return fileAuthorizationState{ - Codes: make(map[string]AuthorizationCode), - Refresh: make(map[string]RefreshTokenEntry), - Pending: make(map[string]PendingAuthorization), - }, nil - } - if err != nil { - return fileAuthorizationState{}, fmt.Errorf("read authorization store: %w", err) - } - if len(data) == 0 { - return fileAuthorizationState{ - Codes: make(map[string]AuthorizationCode), - Refresh: make(map[string]RefreshTokenEntry), - Pending: make(map[string]PendingAuthorization), - }, nil - } - var state fileAuthorizationState - if err := json.Unmarshal(data, &state); err != nil { - return fileAuthorizationState{}, fmt.Errorf("decode authorization store: %w", err) - } - if state.Codes == nil { - state.Codes = make(map[string]AuthorizationCode) - } - if state.Refresh == nil { - state.Refresh = make(map[string]RefreshTokenEntry) - } - if state.Pending == nil { - state.Pending = make(map[string]PendingAuthorization) - } - return state, nil -} - -func (s *FileAuthorizationStore) save(state fileAuthorizationState) error { - data, err := json.MarshalIndent(state, "", " ") - if err != nil { - return fmt.Errorf("encode authorization store: %w", err) - } - return atomicWritePrivateFile(s.Path, data) -} From 4d8d02fcefccce59bacfce5f7bd7db0b284ae227 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:48:18 +0000 Subject: [PATCH 10/14] fix(oauth): issuer-scoped SQL keys and consume expiry 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 --- pkg/oauth/README.md | 10 +++-- pkg/oauth/issuer_binding_test.go | 22 ++++++++++ pkg/oauth/persistent.go | 23 +++++----- pkg/oauth/redis/scripts.go | 44 ++++++++++--------- pkg/oauth/redis/store.go | 33 +++++++++----- pkg/oauth/redis/store_test.go | 26 +++++++++++ pkg/oauth/store/postgres.go | 6 +-- pkg/oauth/store/postgres_test.go | 20 +++++++++ pkg/oauth/store/sqlite.go | 6 +-- pkg/oauth/store/sqlite_test.go | 20 +++++++++ .../migrations/0021_oauth_issuer_pk.sql | 10 +++++ .../migrations/0018_oauth_issuer_pk.sql | 43 ++++++++++++++++++ 12 files changed, 210 insertions(+), 53 deletions(-) create mode 100644 pkg/postgres/migrations/0021_oauth_issuer_pk.sql create mode 100644 pkg/sqlite/migrations/0018_oauth_issuer_pk.sql diff --git a/pkg/oauth/README.md b/pkg/oauth/README.md index e3c3a41..ec47bca 100644 --- a/pkg/oauth/README.md +++ b/pkg/oauth/README.md @@ -54,7 +54,7 @@ server, err := oauthstore.NewPostgresServer(oauth.Config{ - `TokenRateLimiter` / `RegisterRateLimiter` — DB-backed endpoint rate limits - DB signing keys via `ApplyPostgresSigningKey` / `ApplySQLiteSigningKey` when `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is set -Schema: migrations `0017_oauth.sql` + `0018_oauth_rate_limit.sql` + `0019_oauth_signing_key.sql` + `0020_oauth_issuer_binding.sql` (Postgres), or `0014_oauth.sql` + `0015_oauth_rate_limit.sql` + `0016_oauth_signing_key.sql` + `0017_oauth_issuer_binding.sql` (SQLite). +Schema: migrations `0017_oauth.sql` + `0018_oauth_rate_limit.sql` + `0019_oauth_signing_key.sql` + `0020_oauth_issuer_binding.sql` + `0021_oauth_issuer_pk.sql` (Postgres), or `0014_oauth.sql` + `0015_oauth_rate_limit.sql` + `0016_oauth_signing_key.sql` + `0017_oauth_issuer_binding.sql` + `0018_oauth_issuer_pk.sql` (SQLite). Auth codes, refresh tokens, and pending consent rows store an `issuer` column (and JSON `issuer` field) so a shared SQL store can enforce that tokens minted under `/t/{tenantId}/` are only consumed by the matching tenant issuer. @@ -122,11 +122,15 @@ All access tokens include a `client_id` claim; revoke rejects tokens without it. | `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` | AES key for DB-stored signing keys (required for `haistack serve` production) | | `OAUTH_SESSION_SECRET` | HMAC secret for `/oauth/login` session cookies (required for production consent) | -When `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is unset, signing keys fall back to PEM at `{state-dir}/oauth-signing.pem`. Set `OAUTH_SIGNING_KEY_ROTATE=1` before restart to rotate the active DB key. +When `OAUTH_SIGNING_KEY_ENCRYPTION_SECRET` is unset, signing keys fall back to PEM at `{state-dir}/oauth-signing.pem` (`oauth.DefaultSigningKeyPaths`). Set `OAUTH_SIGNING_KEY_ROTATE=1` before restart to rotate the active DB key. + +Embedders that previously used `oauth.NewProductionServer` should call `oauthstore.NewSQLiteServer` or `oauthstore.NewPostgresServer` instead. Those APIs persist clients, authorization codes, refresh tokens, replay JTIs, and revocation across process restarts. `oauth.NewServer` without a store is in-memory only. + +`pkg/smart` still has `FileBackendClientStore` / `FileReplayStore` for **SMART backend-service assertion** clients and `jti` replay — they are not the OAuth authorization-server stores. ## Multi-instance checklist -1. Use `oauthstore.NewPostgresServer` (recommended) or shared file stores for dev only. +1. Use `oauthstore.NewPostgresServer` (recommended) or `oauthstore.NewSQLiteServer` for single-node. 2. Persist signing keys in DB (`OAUTH_SIGNING_KEY_ENCRYPTION_SECRET`) or `oauth-signing.pem` across restarts. 3. Set `UserAuthenticator` (or use `haistack serve` production session login) for end-user consent binding. 4. Keep `AutoApprove: false` in production. diff --git a/pkg/oauth/issuer_binding_test.go b/pkg/oauth/issuer_binding_test.go index 341fbcc..7cd3a0a 100644 --- a/pkg/oauth/issuer_binding_test.go +++ b/pkg/oauth/issuer_binding_test.go @@ -63,6 +63,28 @@ func TestMemoryStore_IsolatesSameCodeAcrossIssuers(t *testing.T) { } } +func TestMemoryStore_ConsumeDeletesExpired(t *testing.T) { + store := oauth.NewMemoryAuthorizationStore() + const issuer = "https://auth.example.test" + if err := store.SaveAuthorizationCode("expired", oauth.AuthorizationCode{ + Issuer: issuer, ClientID: "c", ExpiresAt: time.Now().Add(-time.Minute), + }); err != nil { + t.Fatal(err) + } + if _, ok := store.ConsumeAuthorizationCode(issuer, "expired"); ok { + t.Fatal("expected expired consume to fail") + } + if err := store.SaveAuthorizationCode("expired", oauth.AuthorizationCode{ + Issuer: issuer, ClientID: "fresh", ExpiresAt: time.Now().Add(time.Minute), + }); err != nil { + t.Fatal(err) + } + entry, ok := store.ConsumeAuthorizationCode(issuer, "expired") + if !ok || entry.ClientID != "fresh" { + t.Fatalf("expected expired row to have been removed: %+v ok=%v", entry, ok) + } +} + func TestEntryIssuerMatches_RequiresNonEmptyIssuer(t *testing.T) { if oauth.EntryIssuerMatches("", "https://auth.example") { t.Fatal("empty entry issuer must not match") diff --git a/pkg/oauth/persistent.go b/pkg/oauth/persistent.go index 136378e..b851be9 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -101,14 +101,11 @@ func (s *MemoryAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.codes[key] - if ok && now.After(entry.ExpiresAt) { - ok = false - } if ok { delete(s.codes, key) } s.mu.Unlock() - if !ok { + if !ok || now.After(entry.ExpiresAt) { return AuthorizationCode{}, false } return entry, true @@ -135,13 +132,16 @@ func (s *MemoryAuthorizationStore) ConsumeRefreshToken(issuer, token string) (Re if err != nil { return RefreshTokenEntry{}, false } - entry, ok := s.LookupRefreshToken(issuer, token) - if !ok { - return RefreshTokenEntry{}, false - } + now := memoryStoreNow(s) s.mu.Lock() - delete(s.refreshTokens, key) + entry, ok := s.refreshTokens[key] + if ok { + delete(s.refreshTokens, key) + } s.mu.Unlock() + if !ok || now.After(entry.ExpiresAt) { + return RefreshTokenEntry{}, false + } return entry, true } @@ -231,14 +231,11 @@ func (s *MemoryAuthorizationStore) ConsumePendingAuthorization(issuer, id string now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.pending[key] - if ok && now.After(entry.ExpiresAt) { - ok = false - } if ok { delete(s.pending, key) } s.mu.Unlock() - if !ok { + if !ok || now.After(entry.ExpiresAt) { return PendingAuthorization{}, false } return entry, true diff --git a/pkg/oauth/redis/scripts.go b/pkg/oauth/redis/scripts.go index bcda5b5..e5b829b 100644 --- a/pkg/oauth/redis/scripts.go +++ b/pkg/oauth/redis/scripts.go @@ -2,7 +2,7 @@ package redis import goredis "github.com/redis/go-redis/v9" -const consumeBoundJSONScript = ` +const luaBoundHelpers = ` local function normalize(s) if type(s) ~= 'string' then return '' @@ -13,6 +13,18 @@ local function normalize(s) return s end +local function expired(obj, nowns) + local exp = tonumber(obj['exp']) + if not exp then + return true + end + return exp <= tonumber(nowns) +end +` + +var ( + // consumeBoundJSONValueScript deletes a JSON string key only when issuer matches and the entry is unexpired. + consumeBoundJSONValueScript = goredis.NewScript(luaBoundHelpers + ` local payload = redis.call('GET', KEYS[1]) if not payload then return '' @@ -24,21 +36,15 @@ end if normalize(obj['issuer']) ~= ARGV[1] then return '' end +if expired(obj, ARGV[2]) then + return '' +end redis.call('DEL', KEYS[1]) return payload -` - -const consumeBoundRefreshScript = ` -local function normalize(s) - if type(s) ~= 'string' then - return '' - end - s = string.gsub(s, '^%s+', '') - s = string.gsub(s, '%s+$', '') - s = string.gsub(s, '/+$', '') - return s -end +`) + // consumeBoundRefreshTokenScript deletes a refresh hash only when issuer matches and the entry is unexpired. + consumeBoundRefreshTokenScript = goredis.NewScript(luaBoundHelpers + ` local payload = redis.call('HGET', KEYS[1], 'payload') if not payload then return '' @@ -50,16 +56,12 @@ end if normalize(obj['issuer']) ~= ARGV[1] then return '' end +if expired(obj, ARGV[2]) then + return '' +end redis.call('DEL', KEYS[1]) return payload -` - -var ( - // consumeBoundJSONValueScript deletes a JSON string key only when issuer matches. - consumeBoundJSONValueScript = goredis.NewScript(consumeBoundJSONScript) - - // consumeBoundRefreshScript deletes a refresh hash only when issuer matches. - consumeBoundRefreshTokenScript = goredis.NewScript(consumeBoundRefreshScript) +`) saveRefreshTokenScript = goredis.NewScript(` redis.call('HSET', KEYS[1], 'clientId', ARGV[1], 'payload', ARGV[2]) diff --git a/pkg/oauth/redis/store.go b/pkg/oauth/redis/store.go index ffaab9b..0986b77 100644 --- a/pkg/oauth/redis/store.go +++ b/pkg/oauth/redis/store.go @@ -38,7 +38,7 @@ func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.Auth if err != nil { return err } - payload, err := json.Marshal(entry) + payload, err := marshalBoundJSON(entry, entry.ExpiresAt) if err != nil { return fmt.Errorf("encode auth code: %w", err) } @@ -55,7 +55,7 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oaut if err != nil { return oauth.AuthorizationCode{}, false } - payload, ok := consumeBoundJSONValue(s.client, key, bound) + payload, ok := consumeBoundJSONValue(s.client, key, bound, s.now()) if !ok { return oauth.AuthorizationCode{}, false } @@ -79,7 +79,7 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT if err != nil { return err } - payload, err := json.Marshal(entry) + payload, err := marshalBoundJSON(entry, entry.ExpiresAt) if err != nil { return fmt.Errorf("encode refresh token: %w", err) } @@ -104,7 +104,7 @@ func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.Re if err != nil { return oauth.RefreshTokenEntry{}, false } - payload, ok := consumeBoundRefreshValue(s.client, key, bound) + payload, ok := consumeBoundRefreshValue(s.client, key, bound, s.now()) if !ok { return oauth.RefreshTokenEntry{}, false } @@ -151,7 +151,7 @@ func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.Pen if err != nil { return err } - payload, err := json.Marshal(entry) + payload, err := marshalBoundJSON(entry, entry.ExpiresAt) if err != nil { return fmt.Errorf("encode pending auth: %w", err) } @@ -195,7 +195,7 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oau if err != nil { return oauth.PendingAuthorization{}, false } - payload, ok := consumeBoundJSONValue(s.client, key, bound) + payload, ok := consumeBoundJSONValue(s.client, key, bound, s.now()) if !ok { return oauth.PendingAuthorization{}, false } @@ -209,22 +209,35 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oau return entry, true } -func consumeBoundJSONValue(client goredis.Cmdable, key, issuer string) ([]byte, bool) { - payload, err := consumeBoundJSONValueScript.Run(context.Background(), client, []string{key}, issuer).Text() +func consumeBoundJSONValue(client goredis.Cmdable, key, issuer string, now time.Time) ([]byte, bool) { + payload, err := consumeBoundJSONValueScript.Run(context.Background(), client, []string{key}, issuer, now.UnixMilli()).Text() if err != nil || payload == "" { return nil, false } return []byte(payload), true } -func consumeBoundRefreshValue(client goredis.Cmdable, key, issuer string) ([]byte, bool) { - payload, err := consumeBoundRefreshTokenScript.Run(context.Background(), client, []string{key}, issuer).Text() +func consumeBoundRefreshValue(client goredis.Cmdable, key, issuer string, now time.Time) ([]byte, bool) { + payload, err := consumeBoundRefreshTokenScript.Run(context.Background(), client, []string{key}, issuer, now.UnixMilli()).Text() if err != nil || payload == "" { return nil, false } return []byte(payload), true } +func marshalBoundJSON(v any, expiresAt time.Time) ([]byte, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, err + } + obj["exp"] = expiresAt.UnixMilli() + return json.Marshal(obj) +} + func (s *AuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { entry, ok := s.LookupRefreshToken(issuer, token) if !ok || entry.ClientID != clientID { diff --git a/pkg/oauth/redis/store_test.go b/pkg/oauth/redis/store_test.go index 35dbff0..edfe843 100644 --- a/pkg/oauth/redis/store_test.go +++ b/pkg/oauth/redis/store_test.go @@ -111,6 +111,32 @@ func TestAuthorizationStore_MismatchedJSONIssuerDoesNotBurnCode(t *testing.T) { } } +func TestAuthorizationStore_ExpiredJSONDoesNotBurnCode(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + + client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + authStore, _, _ := oauthredis.EphemeralStores(client, "test:") + const issuer = "https://auth.example.test" + if err := authStore.SaveAuthorizationCode("stale", oauth.AuthorizationCode{ + Issuer: issuer, ClientID: "redis-client", RedirectURI: "https://localhost/callback", + ExpiresAt: time.Now().Add(-time.Minute), + }); err != nil { + t.Fatal(err) + } + if _, ok := authStore.ConsumeAuthorizationCode(issuer, "stale"); ok { + t.Fatal("expired code must not consume") + } + seg := base64.RawURLEncoding.EncodeToString([]byte(issuer)) + key := "test:authcode:" + seg + ":stale" + if n, err := client.Exists(context.Background(), key).Result(); err != nil || n != 1 { + t.Fatalf("expected expired key retained n=%d err=%v", n, err) + } +} + func TestNewServer_RequiresClientRegistry(t *testing.T) { mr, err := miniredis.Run() if err != nil { diff --git a/pkg/oauth/store/postgres.go b/pkg/oauth/store/postgres.go index 6ec28d8..a41bbc0 100644 --- a/pkg/oauth/store/postgres.go +++ b/pkg/oauth/store/postgres.go @@ -42,7 +42,7 @@ func (s *AuthorizationStore) SaveAuthorizationCode(code string, entry oauth.Auth _, err = s.pool.Exec(context.Background(), ` INSERT INTO hai_oauth_auth_code (code, issuer, payload, expires_at) VALUES ($1, $2, $3::jsonb, $4) - ON CONFLICT (code) DO UPDATE SET issuer = EXCLUDED.issuer, payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, + ON CONFLICT (issuer, code) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, code, issuer, payload, entry.ExpiresAt, ) if err != nil { @@ -85,7 +85,7 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT _, err = s.pool.Exec(context.Background(), ` INSERT INTO hai_oauth_refresh_token (token, issuer, payload, expires_at) VALUES ($1, $2, $3::jsonb, $4) - ON CONFLICT (token) DO UPDATE SET issuer = EXCLUDED.issuer, payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, + ON CONFLICT (issuer, token) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, token, issuer, payload, entry.ExpiresAt, ) if err != nil { @@ -146,7 +146,7 @@ func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.Pen _, err = s.pool.Exec(context.Background(), ` INSERT INTO hai_oauth_pending_auth (id, issuer, payload, expires_at) VALUES ($1, $2, $3::jsonb, $4) - ON CONFLICT (id) DO UPDATE SET issuer = EXCLUDED.issuer, payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, + ON CONFLICT (issuer, id) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, id, issuer, payload, entry.ExpiresAt, ) if err != nil { diff --git a/pkg/oauth/store/postgres_test.go b/pkg/oauth/store/postgres_test.go index 9b81c0e..ce17c11 100644 --- a/pkg/oauth/store/postgres_test.go +++ b/pkg/oauth/store/postgres_test.go @@ -42,6 +42,26 @@ func TestPostgresStores_RoundTrip(t *testing.T) { t.Fatalf("code = %+v ok=%v", entry, ok) } + const issuerB = "https://auth.example/t/clinic-b" + if err := authStore.SaveAuthorizationCode("shared-code", oauth.AuthorizationCode{ + Issuer: issuer, ClientID: "pg-client", RedirectURI: "https://localhost/callback", + Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), + }); err != nil { + t.Fatal(err) + } + if err := authStore.SaveAuthorizationCode("shared-code", oauth.AuthorizationCode{ + Issuer: issuerB, ClientID: "pg-client", RedirectURI: "https://localhost/callback", + Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), + }); err != nil { + t.Fatal(err) + } + if _, ok := authStore.ConsumeAuthorizationCode(issuer, "shared-code"); !ok { + t.Fatal("expected code for issuer A") + } + if _, ok := authStore.ConsumeAuthorizationCode(issuerB, "shared-code"); !ok { + t.Fatal("expected code for issuer B") + } + if err := replayStore.CheckAndStore("jti-1", now.Add(5*time.Minute)); err != nil { t.Fatal(err) } diff --git a/pkg/oauth/store/sqlite.go b/pkg/oauth/store/sqlite.go index e5463b4..af6e007 100644 --- a/pkg/oauth/store/sqlite.go +++ b/pkg/oauth/store/sqlite.go @@ -35,7 +35,7 @@ func (s *SQLiteAuthorizationStore) SaveAuthorizationCode(code string, entry oaut _, err = s.db.ExecContext(context.Background(), ` INSERT INTO hai_oauth_auth_code (code, issuer, payload, expires_at) VALUES (?, ?, ?, ?) - ON CONFLICT (code) DO UPDATE SET issuer = excluded.issuer, payload = excluded.payload, expires_at = excluded.expires_at`, + ON CONFLICT (issuer, code) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, code, issuer, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), ) if err != nil { @@ -78,7 +78,7 @@ func (s *SQLiteAuthorizationStore) SaveRefreshToken(token string, entry oauth.Re _, err = s.db.ExecContext(context.Background(), ` INSERT INTO hai_oauth_refresh_token (token, issuer, payload, expires_at) VALUES (?, ?, ?, ?) - ON CONFLICT (token) DO UPDATE SET issuer = excluded.issuer, payload = excluded.payload, expires_at = excluded.expires_at`, + ON CONFLICT (issuer, token) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, token, issuer, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), ) if err != nil { @@ -143,7 +143,7 @@ func (s *SQLiteAuthorizationStore) SavePendingAuthorization(id string, entry oau _, err = s.db.ExecContext(context.Background(), ` INSERT INTO hai_oauth_pending_auth (id, issuer, payload, expires_at) VALUES (?, ?, ?, ?) - ON CONFLICT (id) DO UPDATE SET issuer = excluded.issuer, payload = excluded.payload, expires_at = excluded.expires_at`, + ON CONFLICT (issuer, id) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at`, id, issuer, payload, entry.ExpiresAt.UTC().Format(time.RFC3339Nano), ) if err != nil { diff --git a/pkg/oauth/store/sqlite_test.go b/pkg/oauth/store/sqlite_test.go index 2dec053..aedae30 100644 --- a/pkg/oauth/store/sqlite_test.go +++ b/pkg/oauth/store/sqlite_test.go @@ -52,6 +52,26 @@ func TestSQLiteStores_RoundTrip(t *testing.T) { t.Fatalf("code = %+v ok=%v", entry, ok) } + const issuerB = "https://auth.example/t/clinic-b" + if err := authStore.SaveAuthorizationCode("shared-code", oauth.AuthorizationCode{ + Issuer: issuer, ClientID: "sqlite-client", RedirectURI: "https://localhost/callback", + Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), + }); err != nil { + t.Fatal(err) + } + if err := authStore.SaveAuthorizationCode("shared-code", oauth.AuthorizationCode{ + Issuer: issuerB, ClientID: "sqlite-client", RedirectURI: "https://localhost/callback", + Scope: "patient/Patient.rs", ExpiresAt: now.Add(5 * time.Minute), + }); err != nil { + t.Fatal(err) + } + if _, ok := authStore.ConsumeAuthorizationCode(issuer, "shared-code"); !ok { + t.Fatal("expected code for issuer A") + } + if _, ok := authStore.ConsumeAuthorizationCode(issuerB, "shared-code"); !ok { + t.Fatal("expected code for issuer B") + } + if err := replayStore.CheckAndStore("jti-1", now.Add(5*time.Minute)); err != nil { t.Fatal(err) } diff --git a/pkg/postgres/migrations/0021_oauth_issuer_pk.sql b/pkg/postgres/migrations/0021_oauth_issuer_pk.sql new file mode 100644 index 0000000..4805251 --- /dev/null +++ b/pkg/postgres/migrations/0021_oauth_issuer_pk.sql @@ -0,0 +1,10 @@ +-- Composite primary keys so two issuers can hold the same code/token/session id. + +ALTER TABLE hai_oauth_auth_code DROP CONSTRAINT hai_oauth_auth_code_pkey; +ALTER TABLE hai_oauth_auth_code ADD PRIMARY KEY (issuer, code); + +ALTER TABLE hai_oauth_refresh_token DROP CONSTRAINT hai_oauth_refresh_token_pkey; +ALTER TABLE hai_oauth_refresh_token ADD PRIMARY KEY (issuer, token); + +ALTER TABLE hai_oauth_pending_auth DROP CONSTRAINT hai_oauth_pending_auth_pkey; +ALTER TABLE hai_oauth_pending_auth ADD PRIMARY KEY (issuer, id); diff --git a/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql b/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql new file mode 100644 index 0000000..06325b4 --- /dev/null +++ b/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql @@ -0,0 +1,43 @@ +-- Composite primary keys so two issuers can hold the same code/token/session id. + +CREATE TABLE hai_oauth_auth_code_new ( + issuer TEXT NOT NULL, + code TEXT NOT NULL, + payload TEXT NOT NULL, + expires_at TEXT NOT NULL, + PRIMARY KEY (issuer, code) +); +INSERT INTO hai_oauth_auth_code_new (issuer, code, payload, expires_at) +SELECT issuer, code, payload, expires_at FROM hai_oauth_auth_code; +DROP TABLE hai_oauth_auth_code; +ALTER TABLE hai_oauth_auth_code_new RENAME TO hai_oauth_auth_code; +CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_expires_idx ON hai_oauth_auth_code (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); + +CREATE TABLE hai_oauth_refresh_token_new ( + issuer TEXT NOT NULL, + token TEXT NOT NULL, + payload TEXT NOT NULL, + expires_at TEXT NOT NULL, + PRIMARY KEY (issuer, token) +); +INSERT INTO hai_oauth_refresh_token_new (issuer, token, payload, expires_at) +SELECT issuer, token, payload, expires_at FROM hai_oauth_refresh_token; +DROP TABLE hai_oauth_refresh_token; +ALTER TABLE hai_oauth_refresh_token_new RENAME TO hai_oauth_refresh_token; +CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_expires_idx ON hai_oauth_refresh_token (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); + +CREATE TABLE hai_oauth_pending_auth_new ( + issuer TEXT NOT NULL, + id TEXT NOT NULL, + payload TEXT NOT NULL, + expires_at TEXT NOT NULL, + PRIMARY KEY (issuer, id) +); +INSERT INTO hai_oauth_pending_auth_new (issuer, id, payload, expires_at) +SELECT issuer, id, payload, expires_at FROM hai_oauth_pending_auth; +DROP TABLE hai_oauth_pending_auth; +ALTER TABLE hai_oauth_pending_auth_new RENAME TO hai_oauth_pending_auth; +CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_expires_idx ON hai_oauth_pending_auth (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_issuer_expires_idx ON hai_oauth_pending_auth (issuer, expires_at); From 4051c96935cb2bbb0dc13a4bd2383ac7f3a45b12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:49:22 +0000 Subject: [PATCH 11/14] refactor(oauth): rename SigningKeyPaths and restore SQLite restart test 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 --- docs/smart-auth-architecture.md | 2 +- pkg/oauth/production.go | 12 +-- pkg/oauth/production_defaults_test.go | 7 ++ pkg/oauth/store/sqlite_persist_test.go | 133 +++++++++++++++++++++++++ pkg/runtime/oauth_builtin.go | 2 +- pkg/smart/README.md | 7 +- 6 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 pkg/oauth/store/sqlite_persist_test.go diff --git a/docs/smart-auth-architecture.md b/docs/smart-auth-architecture.md index f62b548..06d78cf 100644 --- a/docs/smart-auth-architecture.md +++ b/docs/smart-auth-architecture.md @@ -105,7 +105,7 @@ and `pkg/oauth` authorization-server tests. ## Built-in OAuth server (`pkg/oauth`) -Production deployment uses `oauthstore.ApplyPostgresStores` or `ApplySQLiteStores` (`pkg/oauth/store`) for clients, tokens, replay, and revocation. `haistack serve` wires builtin OAuth via `runtime.WithBuiltinOAuth`. Set `UserAuthenticator` for end-user consent, `LaunchResolver` for EHR launch, and DB-backed signing keys (or PEM fallback under the state dir). See `pkg/oauth/README.md`. +Production deployment uses `oauthstore.ApplyPostgresStores` or `ApplySQLiteStores` (`pkg/oauth/store`) for clients, tokens, replay, and revocation. `haistack serve` wires builtin OAuth via `runtime.WithBuiltinOAuth`. Set `UserAuthenticator` for end-user consent, `LaunchResolver` for EHR launch, and DB-backed signing keys (or PEM fallback via `oauth.DefaultSigningKeyPaths`). `pkg/smart` file stores (`FileBackendClientStore`, `FileReplayStore`) are for SMART backend assertions only, not the authorization server. See `pkg/oauth/README.md`. ## Non-goals diff --git a/pkg/oauth/production.go b/pkg/oauth/production.go index f198096..801caa1 100644 --- a/pkg/oauth/production.go +++ b/pkg/oauth/production.go @@ -9,18 +9,18 @@ import ( "strings" ) -// ProductionPaths names durable OAuth state locations on disk. +// SigningKeyPaths names the PEM signing-key fallback on disk. // Token, client, replay, and revocation state use pkg/oauth/store (SQLite or Postgres). -type ProductionPaths struct { +type SigningKeyPaths struct { StateDir string SigningKey string SigningKID string } -// DefaultProductionPaths returns conventional paths under stateDir (PEM signing key fallback). -func DefaultProductionPaths(stateDir string) ProductionPaths { +// DefaultSigningKeyPaths returns the conventional PEM path under stateDir. +func DefaultSigningKeyPaths(stateDir string) SigningKeyPaths { stateDir = strings.TrimSpace(stateDir) - return ProductionPaths{ + return SigningKeyPaths{ StateDir: stateDir, SigningKey: filepath.Join(stateDir, "oauth-signing.pem"), } @@ -62,7 +62,7 @@ func ValidateProductionIssuer(issuer string) error { } // LoadSigningKey loads a persistent signing key when the PEM file exists. -func LoadSigningKey(paths ProductionPaths) (*KeySet, error) { +func LoadSigningKey(paths SigningKeyPaths) (*KeySet, error) { if strings.TrimSpace(paths.SigningKey) == "" { return nil, nil } diff --git a/pkg/oauth/production_defaults_test.go b/pkg/oauth/production_defaults_test.go index a9b6470..8e13abc 100644 --- a/pkg/oauth/production_defaults_test.go +++ b/pkg/oauth/production_defaults_test.go @@ -32,6 +32,13 @@ func TestApplyProductionDefaultsRejectsAutoApprove(t *testing.T) { } } +func TestDefaultSigningKeyPaths(t *testing.T) { + paths := oauth.DefaultSigningKeyPaths("/var/lib/haistack") + if paths.SigningKey != "/var/lib/haistack/oauth-signing.pem" { + t.Fatalf("signing key path = %q", paths.SigningKey) + } +} + func TestSigningKeyRotateOnStartupEnv(t *testing.T) { t.Setenv("OAUTH_SIGNING_KEY_ROTATE", "1") if !oauth.SigningKeyRotateOnStartup() { diff --git a/pkg/oauth/store/sqlite_persist_test.go b/pkg/oauth/store/sqlite_persist_test.go new file mode 100644 index 0000000..887653d --- /dev/null +++ b/pkg/oauth/store/sqlite_persist_test.go @@ -0,0 +1,133 @@ +package store_test + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/degoke/health-ai-stack/pkg/client" + "github.com/degoke/health-ai-stack/pkg/oauth" + oauthstore "github.com/degoke/health-ai-stack/pkg/oauth/store" + "github.com/degoke/health-ai-stack/pkg/sqlite" +) + +func TestSQLiteServer_PersistsClientsAndCodesAcrossRestart(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "oauth.db") + key, err := oauth.NewKeySet(2048) + if err != nil { + t.Fatal(err) + } + + var current http.Handler + var mu sync.Mutex + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + h := current + mu.Unlock() + if h == nil { + http.NotFound(w, r) + return + } + h.ServeHTTP(w, r) + })) + defer ts.Close() + base := strings.TrimSuffix(ts.URL, "/") + + db, err := sqlite.Open(dbPath) + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(ctx); err != nil { + _ = db.Close() + t.Fatal(err) + } + server, err := oauthstore.NewSQLiteServer(oauth.Config{ + Issuer: base, + FHIRAudience: base, + SigningKey: key, + AutoApprove: true, + }, db.SQL()) + if err != nil { + _ = db.Close() + t.Fatal(err) + } + if err := server.RegisterClient(oauth.Client{ + ClientID: "persist-client", + ClientSecret: "persist-secret", + TokenEndpointAuthMethod: oauth.AuthMethodClientSecretPost, + RedirectURIs: []string{"https://localhost/callback"}, + Scopes: []string{"patient/Patient.rs"}, + }); err != nil { + _ = db.Close() + t.Fatal(err) + } + mu.Lock() + current = server.Handler() + mu.Unlock() + + httpClient, _ := client.New(client.Config{BaseURL: base}) + cfg, _ := httpClient.SMART().Discover(context.Background(), base) + pkce, _ := client.NewPKCEChallenge() + authURL, _ := httpClient.SMART().BuildAuthURL(client.AuthCodeRequest{ + Config: cfg, ClientID: "persist-client", RedirectURI: "https://localhost/callback", + Scope: "patient/Patient.rs", PKCE: pkce, + }) + noRedirect := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }} + authResp, err := noRedirect.Get(authURL) + if err != nil { + _ = db.Close() + t.Fatal(err) + } + _ = authResp.Body.Close() + if authResp.StatusCode != http.StatusFound { + _ = db.Close() + t.Fatalf("authorize status = %d", authResp.StatusCode) + } + code := strings.Split(strings.Split(authResp.Header.Get("Location"), "code=")[1], "&")[0] + if err := db.Close(); err != nil { + t.Fatal(err) + } + + reopened, err := sqlite.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reopened.Close() }() + if err := reopened.Migrate(ctx); err != nil { + t.Fatal(err) + } + restarted, err := oauthstore.NewSQLiteServer(oauth.Config{ + Issuer: base, + FHIRAudience: base, + SigningKey: key, + AutoApprove: true, + }, reopened.SQL()) + if err != nil { + t.Fatal(err) + } + mu.Lock() + current = restarted.Handler() + mu.Unlock() + + tokenResp, err := httpClient.SMART().ExchangeAuthCode(context.Background(), client.AuthCodeExchangeRequest{ + TokenEndpoint: cfg.TokenEndpoint, + ClientID: "persist-client", + ClientSecret: "persist-secret", + RedirectURI: "https://localhost/callback", + Code: code, + PKCE: pkce, + }) + if err != nil { + t.Fatal(err) + } + if tokenResp.AccessToken == "" { + t.Fatal("missing access token") + } +} diff --git a/pkg/runtime/oauth_builtin.go b/pkg/runtime/oauth_builtin.go index 38feafe..6eaf80d 100644 --- a/pkg/runtime/oauth_builtin.go +++ b/pkg/runtime/oauth_builtin.go @@ -215,7 +215,7 @@ func (b *Builder) applyBuiltinSigningKey(cfg *oauth.Config, state *wireState, is if stateDir == "" { stateDir = b.defaultOAuthStateDir(state) } - signingKeyPath := oauth.DefaultProductionPaths(stateDir).SigningKey + signingKeyPath := oauth.DefaultSigningKeyPaths(stateDir).SigningKey keySet, err := oauth.LoadOrCreateSigningKey(signingKeyPath, "haistack") if err != nil { return err diff --git a/pkg/smart/README.md b/pkg/smart/README.md index ba3f811..6c5a51a 100644 --- a/pkg/smart/README.md +++ b/pkg/smart/README.md @@ -17,8 +17,11 @@ v1 centers on interpretation and adaptation: - `ClientRegistration` — minimal static client metadata for later expansion Hosts may use `NewFileBackendClientStore` and `NewFileReplayStore` for persisted -single-instance deployments, or inject shared transactional implementations via -`BackendClientStore` and `ReplayStore` for multi-instance deployments. +single-instance **backend-service** deployments (JWT client assertions and `jti` +replay). These are not OAuth authorization-server stores: `pkg/oauth` persists AS +clients, codes, and refresh tokens in SQLite or Postgres via `pkg/oauth/store`. +Multi-instance SMART hosts should inject transactional `BackendClientStore` and +`ReplayStore` implementations instead of JSON files. Explicitly out of v1: EHR/standalone launch orchestration, refresh-token lifecycle, SMART UI/session management, and HTTP middleware as the package center. From 325086861c9827e69b5fd5f6d5008665ad66f278 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:56:44 +0000 Subject: [PATCH 12/14] test(oauth): persist PEM keys and refresh tokens across SQLite restart 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 --- pkg/oauth/persistent.go | 24 +++- pkg/oauth/production.go | 5 +- pkg/oauth/production_defaults_test.go | 31 ++++- pkg/oauth/store/sqlite_persist_test.go | 112 ++++++++++++++---- pkg/postgres/migrations/0017_oauth.sql | 21 +++- .../migrations/0021_oauth_issuer_pk.sql | 7 +- pkg/sqlite/migrations/0014_oauth.sql | 21 +++- .../migrations/0017_oauth_issuer_binding.sql | 7 +- .../migrations/0018_oauth_issuer_pk.sql | 4 + 9 files changed, 179 insertions(+), 53 deletions(-) diff --git a/pkg/oauth/persistent.go b/pkg/oauth/persistent.go index b851be9..c1f5b6f 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -29,6 +29,7 @@ type PendingAuthorization struct { FHIRUser string `json:"fhirUser,omitempty"` CSRFToken string `json:"csrfToken,omitempty"` ExpiresAt time.Time `json:"expiresAt"` + Exp int64 `json:"exp,omitempty"` } // AuthorizationCode is a short-lived authorization code entry. @@ -44,6 +45,7 @@ type AuthorizationCode struct { Challenge string `json:"challenge,omitempty"` Method string `json:"method,omitempty"` ExpiresAt time.Time `json:"expiresAt"` + Exp int64 `json:"exp,omitempty"` } // RefreshTokenEntry stores refresh-token metadata. @@ -56,6 +58,7 @@ type RefreshTokenEntry struct { Subject string `json:"subject"` FHIRUser string `json:"fhirUser,omitempty"` ExpiresAt time.Time `json:"expiresAt"` + Exp int64 `json:"exp,omitempty"` } // MemoryAuthorizationStore is a process-local AuthorizationStore. @@ -83,6 +86,7 @@ func (s *MemoryAuthorizationStore) SaveAuthorizationCode(code string, entry Auth return err } entry.Issuer = iss + entry.Exp = entry.ExpiresAt.UnixMilli() key, err := IssuerScopedKey(iss, code) if err != nil { return err @@ -117,6 +121,7 @@ func (s *MemoryAuthorizationStore) SaveRefreshToken(token string, entry RefreshT return err } entry.Issuer = iss + entry.Exp = entry.ExpiresAt.UnixMilli() key, err := IssuerScopedKey(iss, token) if err != nil { return err @@ -153,8 +158,12 @@ func (s *MemoryAuthorizationStore) LookupRefreshToken(issuer, token string) (Ref now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.refreshTokens[key] + if ok && now.After(entry.ExpiresAt) { + delete(s.refreshTokens, key) + ok = false + } s.mu.Unlock() - if !ok || now.After(entry.ExpiresAt) { + if !ok { return RefreshTokenEntry{}, false } return entry, true @@ -166,6 +175,7 @@ func (s *MemoryAuthorizationStore) SavePendingAuthorization(id string, entry Pen return err } entry.Issuer = iss + entry.Exp = entry.ExpiresAt.UnixMilli() key, err := IssuerScopedKey(iss, id) if err != nil { return err @@ -184,8 +194,12 @@ func (s *MemoryAuthorizationStore) GetPendingAuthorization(issuer, id string) (P now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.pending[key] + if ok && now.After(entry.ExpiresAt) { + delete(s.pending, key) + ok = false + } s.mu.Unlock() - if !ok || now.After(entry.ExpiresAt) { + if !ok { return PendingAuthorization{}, false } return entry, true @@ -199,7 +213,11 @@ func (s *MemoryAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, cl now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.refreshTokens[key] - if ok && (entry.ClientID != clientID || now.After(entry.ExpiresAt)) { + if ok && now.After(entry.ExpiresAt) { + delete(s.refreshTokens, key) + ok = false + } + if ok && entry.ClientID != clientID { ok = false } if ok { diff --git a/pkg/oauth/production.go b/pkg/oauth/production.go index 801caa1..5ad954e 100644 --- a/pkg/oauth/production.go +++ b/pkg/oauth/production.go @@ -12,17 +12,16 @@ import ( // SigningKeyPaths names the PEM signing-key fallback on disk. // Token, client, replay, and revocation state use pkg/oauth/store (SQLite or Postgres). type SigningKeyPaths struct { - StateDir string SigningKey string SigningKID string } -// DefaultSigningKeyPaths returns the conventional PEM path under stateDir. +// DefaultSigningKeyPaths returns the conventional PEM path and key id under stateDir. func DefaultSigningKeyPaths(stateDir string) SigningKeyPaths { stateDir = strings.TrimSpace(stateDir) return SigningKeyPaths{ - StateDir: stateDir, SigningKey: filepath.Join(stateDir, "oauth-signing.pem"), + SigningKID: "haistack", } } diff --git a/pkg/oauth/production_defaults_test.go b/pkg/oauth/production_defaults_test.go index 8e13abc..17210e9 100644 --- a/pkg/oauth/production_defaults_test.go +++ b/pkg/oauth/production_defaults_test.go @@ -1,6 +1,7 @@ package oauth_test import ( + "path/filepath" "testing" "github.com/degoke/health-ai-stack/pkg/oauth" @@ -33,9 +34,33 @@ func TestApplyProductionDefaultsRejectsAutoApprove(t *testing.T) { } func TestDefaultSigningKeyPaths(t *testing.T) { - paths := oauth.DefaultSigningKeyPaths("/var/lib/haistack") - if paths.SigningKey != "/var/lib/haistack/oauth-signing.pem" { - t.Fatalf("signing key path = %q", paths.SigningKey) + dir := t.TempDir() + paths := oauth.DefaultSigningKeyPaths(dir) + want := filepath.Join(dir, "oauth-signing.pem") + if paths.SigningKey != want { + t.Fatalf("signing key path = %q want %q", paths.SigningKey, want) + } + if paths.SigningKID != "haistack" { + t.Fatalf("signing kid = %q", paths.SigningKID) + } +} + +func TestLoadSigningKey_RoundTripPEM(t *testing.T) { + paths := oauth.DefaultSigningKeyPaths(t.TempDir()) + created, err := oauth.LoadOrCreateSigningKey(paths.SigningKey, paths.SigningKID) + if err != nil { + t.Fatal(err) + } + loaded, err := oauth.LoadSigningKey(paths) + if err != nil { + t.Fatal(err) + } + if loaded == nil || loaded.KeyID != created.KeyID { + t.Fatalf("loaded = %+v created kid=%q", loaded, created.KeyID) + } + missing, err := oauth.LoadSigningKey(oauth.SigningKeyPaths{SigningKey: filepath.Join(t.TempDir(), "missing.pem")}) + if err != nil || missing != nil { + t.Fatalf("missing key = %v err=%v", missing, err) } } diff --git a/pkg/oauth/store/sqlite_persist_test.go b/pkg/oauth/store/sqlite_persist_test.go index 887653d..28b192e 100644 --- a/pkg/oauth/store/sqlite_persist_test.go +++ b/pkg/oauth/store/sqlite_persist_test.go @@ -15,26 +15,40 @@ import ( "github.com/degoke/health-ai-stack/pkg/sqlite" ) -func TestSQLiteServer_PersistsClientsAndCodesAcrossRestart(t *testing.T) { +type swappingHandler struct { + mu sync.Mutex + current http.Handler +} + +func (h *swappingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + cur := h.current + h.mu.Unlock() + if cur == nil { + http.NotFound(w, r) + return + } + cur.ServeHTTP(w, r) +} + +func (h *swappingHandler) set(next http.Handler) { + h.mu.Lock() + h.current = next + h.mu.Unlock() +} + +func TestSQLiteServer_PersistsAcrossRestart(t *testing.T) { ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "oauth.db") - key, err := oauth.NewKeySet(2048) + dir := t.TempDir() + dbPath := filepath.Join(dir, "oauth.db") + keyPaths := oauth.DefaultSigningKeyPaths(filepath.Join(dir, "keys")) + created, err := oauth.LoadOrCreateSigningKey(keyPaths.SigningKey, keyPaths.SigningKID) if err != nil { t.Fatal(err) } - var current http.Handler - var mu sync.Mutex - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - h := current - mu.Unlock() - if h == nil { - http.NotFound(w, r) - return - } - h.ServeHTTP(w, r) - })) + handler := &swappingHandler{} + ts := httptest.NewServer(handler) defer ts.Close() base := strings.TrimSuffix(ts.URL, "/") @@ -49,7 +63,7 @@ func TestSQLiteServer_PersistsClientsAndCodesAcrossRestart(t *testing.T) { server, err := oauthstore.NewSQLiteServer(oauth.Config{ Issuer: base, FHIRAudience: base, - SigningKey: key, + SigningKey: created, AutoApprove: true, }, db.SQL()) if err != nil { @@ -66,9 +80,7 @@ func TestSQLiteServer_PersistsClientsAndCodesAcrossRestart(t *testing.T) { _ = db.Close() t.Fatal(err) } - mu.Lock() - current = server.Handler() - mu.Unlock() + handler.set(server.Handler()) httpClient, _ := client.New(client.Config{BaseURL: base}) cfg, _ := httpClient.SMART().Discover(context.Background(), base) @@ -95,26 +107,32 @@ func TestSQLiteServer_PersistsClientsAndCodesAcrossRestart(t *testing.T) { t.Fatal(err) } + loaded, err := oauth.LoadSigningKey(keyPaths) + if err != nil { + t.Fatal(err) + } + if loaded == nil || loaded.KeyID != created.KeyID { + t.Fatalf("pem reload kid = %v want %q", loaded, created.KeyID) + } + reopened, err := sqlite.Open(dbPath) if err != nil { t.Fatal(err) } - defer func() { _ = reopened.Close() }() if err := reopened.Migrate(ctx); err != nil { + _ = reopened.Close() t.Fatal(err) } restarted, err := oauthstore.NewSQLiteServer(oauth.Config{ Issuer: base, FHIRAudience: base, - SigningKey: key, + SigningKey: loaded, AutoApprove: true, }, reopened.SQL()) if err != nil { t.Fatal(err) } - mu.Lock() - current = restarted.Handler() - mu.Unlock() + handler.set(restarted.Handler()) tokenResp, err := httpClient.SMART().ExchangeAuthCode(context.Background(), client.AuthCodeExchangeRequest{ TokenEndpoint: cfg.TokenEndpoint, @@ -127,7 +145,49 @@ func TestSQLiteServer_PersistsClientsAndCodesAcrossRestart(t *testing.T) { if err != nil { t.Fatal(err) } - if tokenResp.AccessToken == "" { - t.Fatal("missing access token") + if tokenResp.AccessToken == "" || tokenResp.RefreshToken == "" { + t.Fatalf("token resp missing access or refresh: %+v", tokenResp) + } + + if err := reopened.Close(); err != nil { + t.Fatal(err) + } + reloadedKey, err := oauth.LoadSigningKey(keyPaths) + if err != nil { + t.Fatal(err) + } + if reloadedKey == nil || reloadedKey.KeyID != created.KeyID { + t.Fatalf("second pem reload kid = %v want %q", reloadedKey, created.KeyID) + } + again, err := sqlite.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer func() { _ = again.Close() }() + if err := again.Migrate(ctx); err != nil { + t.Fatal(err) + } + third, err := oauthstore.NewSQLiteServer(oauth.Config{ + Issuer: base, + FHIRAudience: base, + SigningKey: reloadedKey, + AutoApprove: true, + }, again.SQL()) + if err != nil { + t.Fatal(err) + } + handler.set(third.Handler()) + + refreshed, err := httpClient.SMART().RefreshToken(context.Background(), client.RefreshTokenRequest{ + TokenEndpoint: cfg.TokenEndpoint, + ClientID: "persist-client", + ClientSecret: "persist-secret", + RefreshToken: tokenResp.RefreshToken, + }) + if err != nil { + t.Fatal(err) + } + if refreshed.AccessToken == "" { + t.Fatal("missing access token after refresh") } } diff --git a/pkg/postgres/migrations/0017_oauth.sql b/pkg/postgres/migrations/0017_oauth.sql index 14af6d3..6b75033 100644 --- a/pkg/postgres/migrations/0017_oauth.sql +++ b/pkg/postgres/migrations/0017_oauth.sql @@ -5,28 +5,37 @@ CREATE TABLE IF NOT EXISTS hai_oauth_client ( ); CREATE TABLE IF NOT EXISTS hai_oauth_auth_code ( - code TEXT PRIMARY KEY, + issuer TEXT NOT NULL DEFAULT '', + code TEXT NOT NULL, payload JSONB NOT NULL, - expires_at TIMESTAMPTZ NOT NULL + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (issuer, code) ); CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_expires_idx ON hai_oauth_auth_code (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_refresh_token ( - token TEXT PRIMARY KEY, + issuer TEXT NOT NULL DEFAULT '', + token TEXT NOT NULL, payload JSONB NOT NULL, - expires_at TIMESTAMPTZ NOT NULL + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (issuer, token) ); CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_expires_idx ON hai_oauth_refresh_token (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_pending_auth ( - id TEXT PRIMARY KEY, + issuer TEXT NOT NULL DEFAULT '', + id TEXT NOT NULL, payload JSONB NOT NULL, - expires_at TIMESTAMPTZ NOT NULL + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (issuer, id) ); CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_expires_idx ON hai_oauth_pending_auth (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_issuer_expires_idx ON hai_oauth_pending_auth (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_replay_jti ( jti TEXT PRIMARY KEY, diff --git a/pkg/postgres/migrations/0021_oauth_issuer_pk.sql b/pkg/postgres/migrations/0021_oauth_issuer_pk.sql index 4805251..ae0905a 100644 --- a/pkg/postgres/migrations/0021_oauth_issuer_pk.sql +++ b/pkg/postgres/migrations/0021_oauth_issuer_pk.sql @@ -1,10 +1,11 @@ -- Composite primary keys so two issuers can hold the same code/token/session id. +-- Idempotent: drop existing primary keys, then recreate as (issuer, id). -ALTER TABLE hai_oauth_auth_code DROP CONSTRAINT hai_oauth_auth_code_pkey; +ALTER TABLE hai_oauth_auth_code DROP CONSTRAINT IF EXISTS hai_oauth_auth_code_pkey; ALTER TABLE hai_oauth_auth_code ADD PRIMARY KEY (issuer, code); -ALTER TABLE hai_oauth_refresh_token DROP CONSTRAINT hai_oauth_refresh_token_pkey; +ALTER TABLE hai_oauth_refresh_token DROP CONSTRAINT IF EXISTS hai_oauth_refresh_token_pkey; ALTER TABLE hai_oauth_refresh_token ADD PRIMARY KEY (issuer, token); -ALTER TABLE hai_oauth_pending_auth DROP CONSTRAINT hai_oauth_pending_auth_pkey; +ALTER TABLE hai_oauth_pending_auth DROP CONSTRAINT IF EXISTS hai_oauth_pending_auth_pkey; ALTER TABLE hai_oauth_pending_auth ADD PRIMARY KEY (issuer, id); diff --git a/pkg/sqlite/migrations/0014_oauth.sql b/pkg/sqlite/migrations/0014_oauth.sql index 4877de8..6ddac9d 100644 --- a/pkg/sqlite/migrations/0014_oauth.sql +++ b/pkg/sqlite/migrations/0014_oauth.sql @@ -7,28 +7,37 @@ CREATE TABLE IF NOT EXISTS hai_oauth_client ( ); CREATE TABLE IF NOT EXISTS hai_oauth_auth_code ( - code TEXT PRIMARY KEY, + issuer TEXT NOT NULL, + code TEXT NOT NULL, payload TEXT NOT NULL, - expires_at TEXT NOT NULL + expires_at TEXT NOT NULL, + PRIMARY KEY (issuer, code) ); CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_expires_idx ON hai_oauth_auth_code (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_refresh_token ( - token TEXT PRIMARY KEY, + issuer TEXT NOT NULL, + token TEXT NOT NULL, payload TEXT NOT NULL, - expires_at TEXT NOT NULL + expires_at TEXT NOT NULL, + PRIMARY KEY (issuer, token) ); CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_expires_idx ON hai_oauth_refresh_token (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_pending_auth ( - id TEXT PRIMARY KEY, + issuer TEXT NOT NULL, + id TEXT NOT NULL, payload TEXT NOT NULL, - expires_at TEXT NOT NULL + expires_at TEXT NOT NULL, + PRIMARY KEY (issuer, id) ); CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_expires_idx ON hai_oauth_pending_auth (expires_at); +CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_issuer_expires_idx ON hai_oauth_pending_auth (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_replay_jti ( jti TEXT PRIMARY KEY, diff --git a/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql b/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql index b2994bf..3e10946 100644 --- a/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql +++ b/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql @@ -1,8 +1,9 @@ -- Bind OAuth ephemeral rows to the issuer URL that created them (multi-tenant shared store). +-- Idempotent: greenfield 0014 already has issuer; upgrades from older 0014 add the column. -ALTER TABLE hai_oauth_auth_code ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; -ALTER TABLE hai_oauth_refresh_token ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; -ALTER TABLE hai_oauth_pending_auth ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_auth_code ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_refresh_token ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_pending_auth ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); diff --git a/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql b/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql index 06325b4..79e3f84 100644 --- a/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql +++ b/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql @@ -1,5 +1,7 @@ -- Composite primary keys so two issuers can hold the same code/token/session id. +-- Idempotent: DROP leftover rebuild tables, then replace. +DROP TABLE IF EXISTS hai_oauth_auth_code_new; CREATE TABLE hai_oauth_auth_code_new ( issuer TEXT NOT NULL, code TEXT NOT NULL, @@ -14,6 +16,7 @@ ALTER TABLE hai_oauth_auth_code_new RENAME TO hai_oauth_auth_code; CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_expires_idx ON hai_oauth_auth_code (expires_at); CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); +DROP TABLE IF EXISTS hai_oauth_refresh_token_new; CREATE TABLE hai_oauth_refresh_token_new ( issuer TEXT NOT NULL, token TEXT NOT NULL, @@ -28,6 +31,7 @@ ALTER TABLE hai_oauth_refresh_token_new RENAME TO hai_oauth_refresh_token; CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_expires_idx ON hai_oauth_refresh_token (expires_at); CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); +DROP TABLE IF EXISTS hai_oauth_pending_auth_new; CREATE TABLE hai_oauth_pending_auth_new ( issuer TEXT NOT NULL, id TEXT NOT NULL, From 329b684ef83ae38f4fae21dba5f451a08ef992bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:57:07 +0000 Subject: [PATCH 13/14] chore: drop unrelated issuer-PK edits from persist-test commit Restore memory-store and SQL migration files that were staged with the PEM/refresh restart test by accident. Co-authored-by: Adegoke Adewoye --- pkg/oauth/persistent.go | 24 +++---------------- pkg/postgres/migrations/0017_oauth.sql | 21 +++++----------- .../migrations/0021_oauth_issuer_pk.sql | 7 +++--- pkg/sqlite/migrations/0014_oauth.sql | 21 +++++----------- .../migrations/0017_oauth_issuer_binding.sql | 7 +++--- .../migrations/0018_oauth_issuer_pk.sql | 4 ---- 6 files changed, 21 insertions(+), 63 deletions(-) diff --git a/pkg/oauth/persistent.go b/pkg/oauth/persistent.go index c1f5b6f..b851be9 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -29,7 +29,6 @@ type PendingAuthorization struct { FHIRUser string `json:"fhirUser,omitempty"` CSRFToken string `json:"csrfToken,omitempty"` ExpiresAt time.Time `json:"expiresAt"` - Exp int64 `json:"exp,omitempty"` } // AuthorizationCode is a short-lived authorization code entry. @@ -45,7 +44,6 @@ type AuthorizationCode struct { Challenge string `json:"challenge,omitempty"` Method string `json:"method,omitempty"` ExpiresAt time.Time `json:"expiresAt"` - Exp int64 `json:"exp,omitempty"` } // RefreshTokenEntry stores refresh-token metadata. @@ -58,7 +56,6 @@ type RefreshTokenEntry struct { Subject string `json:"subject"` FHIRUser string `json:"fhirUser,omitempty"` ExpiresAt time.Time `json:"expiresAt"` - Exp int64 `json:"exp,omitempty"` } // MemoryAuthorizationStore is a process-local AuthorizationStore. @@ -86,7 +83,6 @@ func (s *MemoryAuthorizationStore) SaveAuthorizationCode(code string, entry Auth return err } entry.Issuer = iss - entry.Exp = entry.ExpiresAt.UnixMilli() key, err := IssuerScopedKey(iss, code) if err != nil { return err @@ -121,7 +117,6 @@ func (s *MemoryAuthorizationStore) SaveRefreshToken(token string, entry RefreshT return err } entry.Issuer = iss - entry.Exp = entry.ExpiresAt.UnixMilli() key, err := IssuerScopedKey(iss, token) if err != nil { return err @@ -158,12 +153,8 @@ func (s *MemoryAuthorizationStore) LookupRefreshToken(issuer, token string) (Ref now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.refreshTokens[key] - if ok && now.After(entry.ExpiresAt) { - delete(s.refreshTokens, key) - ok = false - } s.mu.Unlock() - if !ok { + if !ok || now.After(entry.ExpiresAt) { return RefreshTokenEntry{}, false } return entry, true @@ -175,7 +166,6 @@ func (s *MemoryAuthorizationStore) SavePendingAuthorization(id string, entry Pen return err } entry.Issuer = iss - entry.Exp = entry.ExpiresAt.UnixMilli() key, err := IssuerScopedKey(iss, id) if err != nil { return err @@ -194,12 +184,8 @@ func (s *MemoryAuthorizationStore) GetPendingAuthorization(issuer, id string) (P now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.pending[key] - if ok && now.After(entry.ExpiresAt) { - delete(s.pending, key) - ok = false - } s.mu.Unlock() - if !ok { + if !ok || now.After(entry.ExpiresAt) { return PendingAuthorization{}, false } return entry, true @@ -213,11 +199,7 @@ func (s *MemoryAuthorizationStore) DeleteRefreshTokenForClient(issuer, token, cl now := memoryStoreNow(s) s.mu.Lock() entry, ok := s.refreshTokens[key] - if ok && now.After(entry.ExpiresAt) { - delete(s.refreshTokens, key) - ok = false - } - if ok && entry.ClientID != clientID { + if ok && (entry.ClientID != clientID || now.After(entry.ExpiresAt)) { ok = false } if ok { diff --git a/pkg/postgres/migrations/0017_oauth.sql b/pkg/postgres/migrations/0017_oauth.sql index 6b75033..14af6d3 100644 --- a/pkg/postgres/migrations/0017_oauth.sql +++ b/pkg/postgres/migrations/0017_oauth.sql @@ -5,37 +5,28 @@ CREATE TABLE IF NOT EXISTS hai_oauth_client ( ); CREATE TABLE IF NOT EXISTS hai_oauth_auth_code ( - issuer TEXT NOT NULL DEFAULT '', - code TEXT NOT NULL, + code TEXT PRIMARY KEY, payload JSONB NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (issuer, code) + expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_expires_idx ON hai_oauth_auth_code (expires_at); -CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_refresh_token ( - issuer TEXT NOT NULL DEFAULT '', - token TEXT NOT NULL, + token TEXT PRIMARY KEY, payload JSONB NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (issuer, token) + expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_expires_idx ON hai_oauth_refresh_token (expires_at); -CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_pending_auth ( - issuer TEXT NOT NULL DEFAULT '', - id TEXT NOT NULL, + id TEXT PRIMARY KEY, payload JSONB NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (issuer, id) + expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_expires_idx ON hai_oauth_pending_auth (expires_at); -CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_issuer_expires_idx ON hai_oauth_pending_auth (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_replay_jti ( jti TEXT PRIMARY KEY, diff --git a/pkg/postgres/migrations/0021_oauth_issuer_pk.sql b/pkg/postgres/migrations/0021_oauth_issuer_pk.sql index ae0905a..4805251 100644 --- a/pkg/postgres/migrations/0021_oauth_issuer_pk.sql +++ b/pkg/postgres/migrations/0021_oauth_issuer_pk.sql @@ -1,11 +1,10 @@ -- Composite primary keys so two issuers can hold the same code/token/session id. --- Idempotent: drop existing primary keys, then recreate as (issuer, id). -ALTER TABLE hai_oauth_auth_code DROP CONSTRAINT IF EXISTS hai_oauth_auth_code_pkey; +ALTER TABLE hai_oauth_auth_code DROP CONSTRAINT hai_oauth_auth_code_pkey; ALTER TABLE hai_oauth_auth_code ADD PRIMARY KEY (issuer, code); -ALTER TABLE hai_oauth_refresh_token DROP CONSTRAINT IF EXISTS hai_oauth_refresh_token_pkey; +ALTER TABLE hai_oauth_refresh_token DROP CONSTRAINT hai_oauth_refresh_token_pkey; ALTER TABLE hai_oauth_refresh_token ADD PRIMARY KEY (issuer, token); -ALTER TABLE hai_oauth_pending_auth DROP CONSTRAINT IF EXISTS hai_oauth_pending_auth_pkey; +ALTER TABLE hai_oauth_pending_auth DROP CONSTRAINT hai_oauth_pending_auth_pkey; ALTER TABLE hai_oauth_pending_auth ADD PRIMARY KEY (issuer, id); diff --git a/pkg/sqlite/migrations/0014_oauth.sql b/pkg/sqlite/migrations/0014_oauth.sql index 6ddac9d..4877de8 100644 --- a/pkg/sqlite/migrations/0014_oauth.sql +++ b/pkg/sqlite/migrations/0014_oauth.sql @@ -7,37 +7,28 @@ CREATE TABLE IF NOT EXISTS hai_oauth_client ( ); CREATE TABLE IF NOT EXISTS hai_oauth_auth_code ( - issuer TEXT NOT NULL, - code TEXT NOT NULL, + code TEXT PRIMARY KEY, payload TEXT NOT NULL, - expires_at TEXT NOT NULL, - PRIMARY KEY (issuer, code) + expires_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_expires_idx ON hai_oauth_auth_code (expires_at); -CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_refresh_token ( - issuer TEXT NOT NULL, - token TEXT NOT NULL, + token TEXT PRIMARY KEY, payload TEXT NOT NULL, - expires_at TEXT NOT NULL, - PRIMARY KEY (issuer, token) + expires_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_expires_idx ON hai_oauth_refresh_token (expires_at); -CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_pending_auth ( - issuer TEXT NOT NULL, - id TEXT NOT NULL, + id TEXT PRIMARY KEY, payload TEXT NOT NULL, - expires_at TEXT NOT NULL, - PRIMARY KEY (issuer, id) + expires_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_expires_idx ON hai_oauth_pending_auth (expires_at); -CREATE INDEX IF NOT EXISTS hai_oauth_pending_auth_issuer_expires_idx ON hai_oauth_pending_auth (issuer, expires_at); CREATE TABLE IF NOT EXISTS hai_oauth_replay_jti ( jti TEXT PRIMARY KEY, diff --git a/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql b/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql index 3e10946..b2994bf 100644 --- a/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql +++ b/pkg/sqlite/migrations/0017_oauth_issuer_binding.sql @@ -1,9 +1,8 @@ -- Bind OAuth ephemeral rows to the issuer URL that created them (multi-tenant shared store). --- Idempotent: greenfield 0014 already has issuer; upgrades from older 0014 add the column. -ALTER TABLE hai_oauth_auth_code ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; -ALTER TABLE hai_oauth_refresh_token ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; -ALTER TABLE hai_oauth_pending_auth ADD COLUMN IF NOT EXISTS issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_auth_code ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_refresh_token ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; +ALTER TABLE hai_oauth_pending_auth ADD COLUMN issuer TEXT NOT NULL DEFAULT ''; CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); diff --git a/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql b/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql index 79e3f84..06325b4 100644 --- a/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql +++ b/pkg/sqlite/migrations/0018_oauth_issuer_pk.sql @@ -1,7 +1,5 @@ -- Composite primary keys so two issuers can hold the same code/token/session id. --- Idempotent: DROP leftover rebuild tables, then replace. -DROP TABLE IF EXISTS hai_oauth_auth_code_new; CREATE TABLE hai_oauth_auth_code_new ( issuer TEXT NOT NULL, code TEXT NOT NULL, @@ -16,7 +14,6 @@ ALTER TABLE hai_oauth_auth_code_new RENAME TO hai_oauth_auth_code; CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_expires_idx ON hai_oauth_auth_code (expires_at); CREATE INDEX IF NOT EXISTS hai_oauth_auth_code_issuer_expires_idx ON hai_oauth_auth_code (issuer, expires_at); -DROP TABLE IF EXISTS hai_oauth_refresh_token_new; CREATE TABLE hai_oauth_refresh_token_new ( issuer TEXT NOT NULL, token TEXT NOT NULL, @@ -31,7 +28,6 @@ ALTER TABLE hai_oauth_refresh_token_new RENAME TO hai_oauth_refresh_token; CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_expires_idx ON hai_oauth_refresh_token (expires_at); CREATE INDEX IF NOT EXISTS hai_oauth_refresh_token_issuer_expires_idx ON hai_oauth_refresh_token (issuer, expires_at); -DROP TABLE IF EXISTS hai_oauth_pending_auth_new; CREATE TABLE hai_oauth_pending_auth_new ( issuer TEXT NOT NULL, id TEXT NOT NULL, From 236d0aa13401ea504dc16c78e535939b6511a7b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 11:01:24 +0000 Subject: [PATCH 14/14] fix(oauth): trust Redis Lua expiry after consume 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 --- pkg/oauth/redis/store.go | 9 --------- pkg/oauth/redis/store_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/pkg/oauth/redis/store.go b/pkg/oauth/redis/store.go index 0986b77..0fb552c 100644 --- a/pkg/oauth/redis/store.go +++ b/pkg/oauth/redis/store.go @@ -63,9 +63,6 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oaut if err := json.Unmarshal(payload, &entry); err != nil { return oauth.AuthorizationCode{}, false } - if s.now().After(entry.ExpiresAt) { - return oauth.AuthorizationCode{}, false - } return entry, true } @@ -112,9 +109,6 @@ func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.Re if err := json.Unmarshal(payload, &entry); err != nil { return oauth.RefreshTokenEntry{}, false } - if s.now().After(entry.ExpiresAt) { - return oauth.RefreshTokenEntry{}, false - } return entry, true } @@ -203,9 +197,6 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oau if err := json.Unmarshal(payload, &entry); err != nil { return oauth.PendingAuthorization{}, false } - if s.now().After(entry.ExpiresAt) { - return oauth.PendingAuthorization{}, false - } return entry, true } diff --git a/pkg/oauth/redis/store_test.go b/pkg/oauth/redis/store_test.go index edfe843..0605fcb 100644 --- a/pkg/oauth/redis/store_test.go +++ b/pkg/oauth/redis/store_test.go @@ -137,6 +137,39 @@ func TestAuthorizationStore_ExpiredJSONDoesNotBurnCode(t *testing.T) { } } +func TestAuthorizationStore_LuaExpiryWinsOverExpiresAt(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + + client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + authStore, _, _ := oauthredis.EphemeralStores(client, "test:") + const issuer = "https://auth.example.test" + seg := base64.RawURLEncoding.EncodeToString([]byte(issuer)) + key := "test:authcode:" + seg + ":skew" + raw, err := json.Marshal(map[string]any{ + "issuer": issuer, + "clientId": "redis-client", + "expiresAt": time.Now().Add(-time.Minute), + "exp": time.Now().Add(time.Minute).UnixMilli(), + }) + if err != nil { + t.Fatal(err) + } + if err := client.Set(context.Background(), key, raw, time.Minute).Err(); err != nil { + t.Fatal(err) + } + entry, ok := authStore.ConsumeAuthorizationCode(issuer, "skew") + if !ok || entry.ClientID != "redis-client" { + t.Fatalf("Lua-unexpired payload must consume even if expiresAt is past: %+v ok=%v", entry, ok) + } + if n, err := client.Exists(context.Background(), key).Result(); err != nil || n != 0 { + t.Fatalf("expected key deleted after consume n=%d err=%v", n, err) + } +} + func TestNewServer_RequiresClientRegistry(t *testing.T) { mr, err := miniredis.Run() if err != nil {