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/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..26eceac 100644 --- a/cmd/haistack/README.md +++ b/cmd/haistack/README.md @@ -107,11 +107,27 @@ 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` 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_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 1. Built-in defaults @@ -138,6 +154,16 @@ 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` | +| `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` | ### Persistent flags 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/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/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..2c38659 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,36 @@ 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") + } + 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 } @@ -235,6 +284,11 @@ runtime: modulePaths: [] packages: [] preExpandValueSets: false +oauth: + enabled: true + production: false + registrationAccessToken: "" + issuerURL: "" sync: hubURL: "" nodeID: runtime-node @@ -286,6 +340,39 @@ 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 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) + } return nil } @@ -336,3 +423,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..6a79825 100644 --- a/cmd/haistack/internal/config/config_test.go +++ b/cmd/haistack/internal/config/config_test.go @@ -215,3 +215,82 @@ func TestPostgresRequiresTenant(t *testing.T) { t.Fatal("expected tenant validation error") } } + +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") + 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") + 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 { + 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") + 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) + } + if !cfg.OAuthProduction() { + t.Fatal("expected production config to load") + } +} diff --git a/cmd/haistack/internal/config/doc.go b/cmd/haistack/internal/config/doc.go index 673f478..803a3c0 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,10 @@ // # 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, +// OAUTH_SIGNING_KEY_ENCRYPTION_SECRET, OAUTH_SESSION_SECRET) when +// oauth.production is enabled. // // # Environment variables // @@ -45,6 +48,16 @@ // - 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 +// - 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. package config 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..06d78cf 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 @@ -94,7 +105,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` 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 @@ -105,4 +116,4 @@ Production deployment uses `oauthpostgres.NewServer` (`pkg/oauth/postgres`) with - `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/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..ce89549 100644 --- a/examples/smart-oauth/main.go +++ b/examples/smart-oauth/main.go @@ -16,8 +16,10 @@ 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/smart" + "github.com/degoke/health-ai-stack/pkg/sqlite" ) func main() { @@ -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/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..3ed3543 100644 --- a/pkg/http/sync.go +++ b/pkg/http/sync.go @@ -174,7 +174,16 @@ 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) + mux.Handle("/t/", 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 +201,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..4c150aa --- /dev/null +++ b/pkg/oauth/OPERATIONS.md @@ -0,0 +1,45 @@ +# 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 `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 | +| 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`. + +## 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_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`). +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`. 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 eed5f30..ec47bca 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) | @@ -19,20 +22,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,44 +45,35 @@ 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 - `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 `0015_oauth.sql`. +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). -### 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.** +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. ### 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 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", @@ -88,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 | @@ -126,12 +114,27 @@ 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` (`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 `oauthpostgres.NewServer` (recommended) or shared file stores for dev only. -2. Persist `oauth-signing.pem` across restarts (`LoadKeySetFromPEM`). -3. Set `UserAuthenticator` for end-user consent binding. +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. 5. Enable `AllowDynamicRegistration` only when required. +6. Mount tenant routes at `/t/{tenantId}/` when using `MultiTenantServer`. -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/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_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/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/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/doc.go b/pkg/oauth/doc.go index 47625ae..def57ee 100644 --- a/pkg/oauth/doc.go +++ b/pkg/oauth/doc.go @@ -9,4 +9,7 @@ // - /oauth/token // - /oauth/jwks // - /oauth/register +// +// 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/errors.go b/pkg/oauth/errors.go index bd6f1c3..31eec10 100644 --- a/pkg/oauth/errors.go +++ b/pkg/oauth/errors.go @@ -2,9 +2,25 @@ package oauth import ( "encoding/json" + "errors" "net/http" + "strings" ) +// 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 { + 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 f9e9131..79bcb88 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) { @@ -52,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, @@ -78,12 +110,18 @@ 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 } 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, @@ -112,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 @@ -128,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 @@ -137,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 @@ -175,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, @@ -244,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 @@ -317,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 @@ -339,6 +378,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"` @@ -356,6 +401,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) @@ -366,7 +420,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 { @@ -380,7 +434,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, " "), }) } @@ -420,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, @@ -474,6 +529,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"}, @@ -545,3 +601,11 @@ 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 + } + auth := strings.TrimSpace(r.Header.Get("Authorization")) + return strings.HasPrefix(auth, "Bearer ") && strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == expected +} diff --git a/pkg/oauth/introspect.go b/pkg/oauth/introspect.go new file mode 100644 index 0000000..5146a71 --- /dev/null +++ b/pkg/oauth/introspect.go @@ -0,0 +1,162 @@ +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(s.cfg.Issuer, token) + if !ok || !EntryIssuerMatches(record.Issuer, s.cfg.Issuer) { + return IntrospectionResponse{}, false + } + iss := NormalizeIssuerURL(record.Issuer) + return IntrospectionResponse{ + Active: true, + Scope: record.Scope, + ClientID: record.ClientID, + Username: record.Subject, + TokenType: "refresh_token", + Exp: record.ExpiresAt.Unix(), + Sub: record.Subject, + Iss: iss, + 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..3d43a9f --- /dev/null +++ b/pkg/oauth/introspect_test.go @@ -0,0 +1,98 @@ +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) + } +} + +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/issuer_binding.go b/pkg/oauth/issuer_binding.go new file mode 100644 index 0000000..eeac397 --- /dev/null +++ b/pkg/oauth/issuer_binding.go @@ -0,0 +1,39 @@ +package oauth + +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 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) + 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 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 new file mode 100644 index 0000000..7cd3a0a --- /dev/null +++ b/pkg/oauth/issuer_binding_test.go @@ -0,0 +1,324 @@ +package oauth_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/degoke/health-ai-stack/pkg/client" + "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 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") + } + 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" + 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_RejectsCrossIssuerRefreshToken(t *testing.T) { + store := oauth.NewMemoryAuthorizationStore() + 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" + + key, err := oauth.NewKeySet(2048) + if err != nil { + t.Fatal(err) + } + registry := oauth.NewTenantRegistry() + for _, spec := range []struct{ id, iss string }{ + {"clinic-a", issuerA}, + {"clinic-b", issuerB}, + } { + if err := registry.Register(oauth.TenantIssuerConfig{ + TenantID: spec.id, + Issuer: spec.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) + } + mux.Handle("/t/", multi.Handler()) + + 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) + } + + 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 { + t.Fatal(err) + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "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") + 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()) + } + + 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("issuer A consent GET status = %d", rec2.Code) + } +} diff --git a/pkg/oauth/keys.go b/pkg/oauth/keys.go index ea2cae5..b5fe966 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,62 @@ 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) + } + 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 +} + +// 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/persistent.go b/pkg/oauth/persistent.go index de68447..b851be9 100644 --- a/pkg/oauth/persistent.go +++ b/pkg/oauth/persistent.go @@ -1,31 +1,29 @@ 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(code string) (AuthorizationCode, bool) + ConsumeAuthorizationCode(issuer, code string) (AuthorizationCode, bool) SaveRefreshToken(token string, entry RefreshTokenEntry) error - ConsumeRefreshToken(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"` @@ -35,6 +33,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"` @@ -49,6 +48,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"` @@ -78,18 +78,31 @@ 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(code string) (AuthorizationCode, bool) { +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] + entry, ok := s.codes[key] if ok { - delete(s.codes, code) + delete(s.codes, key) } s.mu.Unlock() if !ok || now.After(entry.ExpiresAt) { @@ -99,19 +112,47 @@ func (s *MemoryAuthorizationStore) ConsumeAuthorizationCode(code string) (Author } 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(token string) (RefreshTokenEntry, bool) { +func (s *MemoryAuthorizationStore) ConsumeRefreshToken(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] if ok { - delete(s.refreshTokens, token) + delete(s.refreshTokens, key) + } + s.mu.Unlock() + if !ok || now.After(entry.ExpiresAt) { + return RefreshTokenEntry{}, false + } + 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[key] s.mu.Unlock() if !ok || now.After(entry.ExpiresAt) { return RefreshTokenEntry{}, false @@ -120,16 +161,29 @@ func (s *MemoryAuthorizationStore) ConsumeRefreshToken(token string) (RefreshTok } 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(id string) (PendingAuthorization, bool) { +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 || now.After(entry.ExpiresAt) { return PendingAuthorization{}, false @@ -137,243 +191,59 @@ func (s *MemoryAuthorizationStore) GetPendingAuthorization(id string) (PendingAu return entry, true } -func (s *MemoryAuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { +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] + 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 } -func (s *MemoryAuthorizationStore) ConsumePendingAuthorization(id string) (PendingAuthorization, bool) { +func (s *MemoryAuthorizationStore) PurgeExpiredPendingAuthorizations() int { now := memoryStoreNow(s) s.mu.Lock() - entry, ok := s.pending[id] - if ok { - delete(s.pending, id) - } - s.mu.Unlock() - if !ok || now.After(entry.ExpiresAt) { - return PendingAuthorization{}, false - } - return entry, true -} - -func memoryStoreNow(s *MemoryAuthorizationStore) time.Time { - if s != nil && s.Now != nil { - return s.Now() - } - 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 { - return s.update(func(state *fileAuthorizationState) { - if state.Codes == nil { - state.Codes = make(map[string]AuthorizationCode) - } - state.Codes[code] = entry - }) -} - -func (s *FileAuthorizationStore) ConsumeAuthorizationCode(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 { - delete(state.Codes, code) - } - }) - if err != nil || !ok || now.After(entry.ExpiresAt) { - return AuthorizationCode{}, false - } - return entry, true -} - -func (s *FileAuthorizationStore) SaveRefreshToken(token string, entry RefreshTokenEntry) error { - return s.update(func(state *fileAuthorizationState) { - if state.Refresh == nil { - state.Refresh = make(map[string]RefreshTokenEntry) - } - state.Refresh[token] = entry - }) -} - -func (s *FileAuthorizationStore) ConsumeRefreshToken(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) + n := 0 + for id, entry := range s.pending { + if now.After(entry.ExpiresAt) { + delete(s.pending, id) + n++ } - }) - if err != nil || !ok || now.After(entry.ExpiresAt) { - return RefreshTokenEntry{}, false } - return entry, true -} - -func (s *FileAuthorizationStore) SavePendingAuthorization(id string, entry PendingAuthorization) error { - return s.update(func(state *fileAuthorizationState) { - if state.Pending == nil { - state.Pending = make(map[string]PendingAuthorization) - } - state.Pending[id] = entry - }) + s.mu.Unlock() + return n } -func (s *FileAuthorizationStore) GetPendingAuthorization(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) { +func (s *MemoryAuthorizationStore) ConsumePendingAuthorization(issuer, id string) (PendingAuthorization, bool) { + key, err := IssuerScopedKey(issuer, id) + if err != nil { return PendingAuthorization{}, false } - return entry, true -} - -func (s *FileAuthorizationStore) ConsumePendingAuthorization(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 { - delete(state.Pending, id) - } - }) - if err != nil || !ok || now.After(entry.ExpiresAt) { + now := memoryStoreNow(s) + s.mu.Lock() + entry, ok := s.pending[key] + if ok { + delete(s.pending, key) + } + s.mu.Unlock() + if !ok || now.After(entry.ExpiresAt) { return PendingAuthorization{}, false } return entry, true } -func (s *FileAuthorizationStore) now() time.Time { - if s.Now != nil { +func memoryStoreNow(s *MemoryAuthorizationStore) time.Time { + if s != nil && s.Now != nil { return s.Now() } return time.Now() } - -func (s *FileAuthorizationStore) DeleteRefreshTokenForClient(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) { - return - } - delete(state.Refresh, token) - 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) -} 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..5ad954e 100644 --- a/pkg/oauth/production.go +++ b/pkg/oauth/production.go @@ -3,59 +3,65 @@ package oauth import ( "errors" "fmt" + "net/url" "os" "path/filepath" "strings" - - "github.com/degoke/health-ai-stack/pkg/smart" ) -// ProductionPaths names durable state files for a multi-instance authorization server. -type ProductionPaths struct { - StateDir string - Clients string - Tokens string - Replay string +// 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 { SigningKey string SigningKID string } -// DefaultProductionPaths returns conventional file paths under stateDir. -func DefaultProductionPaths(stateDir string) ProductionPaths { +// DefaultSigningKeyPaths returns the conventional PEM path and key id under stateDir. +func DefaultSigningKeyPaths(stateDir string) SigningKeyPaths { 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"), + return SigningKeyPaths{ SigningKey: filepath.Join(stateDir, "oauth-signing.pem"), + SigningKID: "haistack", } } -// ProductionStores wires file-backed stores for single-host or shared-filesystem deployments. -// Prefer oauthpostgres.NewServer 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 +// 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) } - clientStore, err := NewFileClientStore(paths.Clients) - if err != nil { - return nil, nil, nil, nil, err + if err := RequireSigningKeyEncryptionSecret(); err != nil { + return err } - replayStore, err := smart.NewFileReplayStore(paths.Replay) - if err != nil { - return nil, nil, nil, nil, 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), "/") + if issuer == "" { + return fmt.Errorf("oauth: issuer URL is required for production") } - revocationStore, err := NewFileTokenRevocationStore(filepath.Join(paths.StateDir, "oauth-revoked.json")) - if err != nil { - return nil, nil, nil, nil, err + 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 authStore, clientStore, replayStore, revocationStore, nil + return nil } // 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 } @@ -64,26 +70,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_defaults_test.go b/pkg/oauth/production_defaults_test.go new file mode 100644 index 0000000..17210e9 --- /dev/null +++ b/pkg/oauth/production_defaults_test.go @@ -0,0 +1,76 @@ +package oauth_test + +import ( + "path/filepath" + "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 TestDefaultSigningKeyPaths(t *testing.T) { + 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) + } +} + +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/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/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/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/keys.go b/pkg/oauth/redis/keys.go new file mode 100644 index 0000000..9a2ab11 --- /dev/null +++ b/pkg/oauth/redis/keys.go @@ -0,0 +1,39 @@ +package redis + +import ( + "encoding/base64" + + "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 +} diff --git a/pkg/oauth/redis/scripts.go b/pkg/oauth/redis/scripts.go index aa0efe5..e5b829b 100644 --- a/pkg/oauth/redis/scripts.go +++ b/pkg/oauth/redis/scripts.go @@ -2,30 +2,71 @@ package redis import goredis "github.com/redis/go-redis/v9" +const luaBoundHelpers = ` +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 function expired(obj, nowns) + local exp = tonumber(obj['exp']) + if not exp then + return true + end + return exp <= tonumber(nowns) +end +` + var ( - // consumeJSONValueScript atomically reads and deletes a string value. - consumeJSONValueScript = goredis.NewScript(` + // 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 '' 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 +if expired(obj, ARGV[2]) then + return '' +end redis.call('DEL', KEYS[1]) return payload `) - saveRefreshTokenScript = goredis.NewScript(` -redis.call('HSET', KEYS[1], 'clientId', ARGV[1], 'payload', ARGV[2]) -redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) -return 1 -`) - - consumeRefreshTokenScript = goredis.NewScript(` + // 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 '' 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 +if expired(obj, ARGV[2]) then + return '' +end redis.call('DEL', KEYS[1]) return payload +`) + + saveRefreshTokenScript = goredis.NewScript(` +redis.call('HSET', KEYS[1], 'clientId', ARGV[1], 'payload', ARGV[2]) +redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) +return 1 `) deleteRefreshTokenForClientScript = goredis.NewScript(` diff --git a/pkg/oauth/redis/store.go b/pkg/oauth/redis/store.go index 8d826b7..0fb552c 100644 --- a/pkg/oauth/redis/store.go +++ b/pkg/oauth/redis/store.go @@ -28,21 +28,34 @@ 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 { - payload, err := json.Marshal(entry) + 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 + } + payload, err := marshalBoundJSON(entry, entry.ExpiresAt) 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(code string) (oauth.AuthorizationCode, bool) { - payload, ok := consumeJSONValue(s.client, s.key("authcode:"+code)) +func (s *AuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { + bound, err := oauth.RequireBoundIssuer(issuer) + if err != nil { + return oauth.AuthorizationCode{}, false + } + key, err := authCodeRedisKey(s.prefix, bound, code) + if err != nil { + return oauth.AuthorizationCode{}, false + } + payload, ok := consumeBoundJSONValue(s.client, key, bound, s.now()) if !ok { return oauth.AuthorizationCode{}, false } @@ -50,18 +63,23 @@ 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) { - return oauth.AuthorizationCode{}, false - } return entry, true } func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshTokenEntry) error { - payload, err := json.Marshal(entry) + 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 + } + payload, err := marshalBoundJSON(entry, entry.ExpiresAt) 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(), @@ -74,14 +92,41 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT return err } -func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { - key := s.key("refresh:" + token) - payload, err := consumeRefreshTokenScript.Run(context.Background(), s.client, []string{key}).Text() - if err != nil || payload == "" { +func (s *AuthorizationStore) ConsumeRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { + bound, err := oauth.RequireBoundIssuer(issuer) + if err != nil { + return oauth.RefreshTokenEntry{}, false + } + key, err := refreshRedisKey(s.prefix, bound, token) + if err != nil { + return oauth.RefreshTokenEntry{}, false + } + payload, ok := consumeBoundRefreshValue(s.client, key, bound, s.now()) + if !ok { 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 + } + return entry, true +} + +func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { + bound, err := oauth.RequireBoundIssuer(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 + } + var entry oauth.RefreshTokenEntry + if err := json.Unmarshal(payload, &entry); err != nil { return oauth.RefreshTokenEntry{}, false } if s.now().After(entry.ExpiresAt) { @@ -91,16 +136,33 @@ func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTok } func (s *AuthorizationStore) SavePendingAuthorization(id string, entry oauth.PendingAuthorization) error { - payload, err := json.Marshal(entry) + 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 + } + payload, err := marshalBoundJSON(entry, entry.ExpiresAt) 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(id string) (oauth.PendingAuthorization, bool) { - payload, err := s.client.Get(context.Background(), s.key("pending:"+id)).Bytes() +func (s *AuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { + bound, err := oauth.RequireBoundIssuer(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 } @@ -114,8 +176,20 @@ func (s *AuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAu return entry, true } -func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { - payload, ok := consumeJSONValue(s.client, s.key("pending:"+id)) +func (s *AuthorizationStore) PurgeExpiredPendingAuthorizations() int { + return 0 +} + +func (s *AuthorizationStore) ConsumePendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { + bound, err := oauth.RequireBoundIssuer(issuer) + if err != nil { + return oauth.PendingAuthorization{}, false + } + key, err := pendingRedisKey(s.prefix, bound, id) + if err != nil { + return oauth.PendingAuthorization{}, false + } + payload, ok := consumeBoundJSONValue(s.client, key, bound, s.now()) if !ok { return oauth.PendingAuthorization{}, false } @@ -123,22 +197,51 @@ 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) { - 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, 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, 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 (s *AuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { - key := s.key("refresh:" + token) +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 { + return false + } + bound, err := oauth.RequireBoundIssuer(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 5678701..0605fcb 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" @@ -21,30 +24,44 @@ 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) } + 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{ - 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") } @@ -63,6 +80,96 @@ 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 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 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 { 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/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/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/security_test.go b/pkg/oauth/security_test.go index 051ce40..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,28 +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) - } - if err := store.SavePendingAuthorization("sess-1", oauth.PendingAuthorization{ - 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("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) @@ -210,24 +186,3 @@ func TestOAuthServer_ConfidentialClientSecretBasic(t *testing.T) { } } -func TestFileAuthorizationStore_PersistsCodes(t *testing.T) { - 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", - 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("code-1") - if !ok || entry.ClientID != "client" { - t.Fatalf("entry = %+v ok=%v", entry, ok) - } -} diff --git a/pkg/oauth/server.go b/pkg/oauth/server.go index aec3d40..8a3cc62 100644 --- a/pkg/oauth/server.go +++ b/pkg/oauth/server.go @@ -33,10 +33,26 @@ 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. 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 + // 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. @@ -46,6 +62,8 @@ type Server struct { replayStore smart.ReplayStore revocationStore TokenRevocationStore backendAuth *smart.BackendServiceAuth + tokenLimiter RateLimitStore + registerLimiter RateLimitStore } // NewServer constructs an authorization server. @@ -99,13 +117,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. @@ -190,16 +210,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/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 new file mode 100644 index 0000000..338288f --- /dev/null +++ b/pkg/oauth/signing_key_crypto.go @@ -0,0 +1,101 @@ +package oauth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "os" + "strings" +) + +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() == "" { + 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/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/apply.go b/pkg/oauth/store/apply.go new file mode 100644 index 0000000..8cfe404 --- /dev/null +++ b/pkg/oauth/store/apply.go @@ -0,0 +1,108 @@ +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 + cfg.TokenRateLimiter = &PostgresRateLimitStore{Pool: pool} + cfg.RegisterRateLimiter = &PostgresRateLimitStore{Pool: pool} + 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 + 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) +} + +// 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) +} + +// 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/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 66% rename from pkg/oauth/postgres/store.go rename to pkg/oauth/store/postgres.go index 9fc9e58..a41bbc0 100644 --- a/pkg/oauth/postgres/store.go +++ b/pkg/oauth/store/postgres.go @@ -1,4 +1,4 @@ -package postgres +package store import ( "context" @@ -31,15 +31,19 @@ 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) } _, 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 (issuer, code) DO UPDATE SET 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 +51,17 @@ 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) { + 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 - RETURNING payload`, code, now, + 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 { return oauth.AuthorizationCode{}, false @@ -66,15 +74,19 @@ func (s *AuthorizationStore) ConsumeAuthorizationCode(code string) (oauth.Author } 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) } _, 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 (issuer, token) DO UPDATE SET 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,13 +94,35 @@ func (s *AuthorizationStore) SaveRefreshToken(token string, entry oauth.RefreshT return nil } -func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTokenEntry, bool) { +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 + } + 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`, token, now, issuer) + if err != nil || tag.RowsAffected() == 0 { + return oauth.RefreshTokenEntry{}, false + } + return entry, true +} + +func (s *AuthorizationStore) LookupRefreshToken(issuer, token string) (oauth.RefreshTokenEntry, bool) { + issuer = oauth.NormalizeIssuerURL(issuer) + if issuer == "" { + return oauth.RefreshTokenEntry{}, false + } 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 AND issuer = $3`, token, now, issuer, ).Scan(&payload) if errors.Is(err, pgx.ErrNoRows) || err != nil { return oauth.RefreshTokenEntry{}, false @@ -101,15 +135,19 @@ func (s *AuthorizationStore) ConsumeRefreshToken(token string) (oauth.RefreshTok } 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) } _, 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 (issuer, id) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at`, + id, issuer, payload, entry.ExpiresAt, ) if err != nil { return fmt.Errorf("save pending auth: %w", err) @@ -117,12 +155,16 @@ 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) { + 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`, id, now, + 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 @@ -134,13 +176,27 @@ func (s *AuthorizationStore) GetPendingAuthorization(id string) (oauth.PendingAu return entry, true } -func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.PendingAuthorization, bool) { +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(issuer, id string) (oauth.PendingAuthorization, bool) { + 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 - RETURNING payload`, id, now, + 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 { return oauth.PendingAuthorization{}, false @@ -152,12 +208,16 @@ func (s *AuthorizationStore) ConsumePendingAuthorization(id string) (oauth.Pendi return entry, true } -func (s *AuthorizationStore) DeleteRefreshTokenForClient(token, clientID string) bool { +func (s *AuthorizationStore) DeleteRefreshTokenForClient(issuer, token, clientID string) bool { + 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 payload->>'clientId' = $3`, - token, now, clientID, + WHERE token = $1 AND expires_at > $2 AND issuer = $3 AND payload->>'clientId' = $4`, + token, now, issuer, clientID, ) if err != nil { return false @@ -283,8 +343,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/postgres_test.go b/pkg/oauth/store/postgres_test.go new file mode 100644 index 0000000..ce17c11 --- /dev/null +++ b/pkg/oauth/store/postgres_test.go @@ -0,0 +1,78 @@ +package store_test + +import ( + "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/testkit/postgrestest" +) + +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() + if err := clientStore.Register(oauth.Client{ + ClientID: "pg-client", + ClientSecret: "pg-secret", + RedirectURIs: []string{"https://localhost/callback"}, + Scopes: []string{"patient/Patient.rs"}, + }); err != nil { + t.Fatal(err) + } + client, ok := clientStore.Get("pg-client") + if !ok || client.ClientID != "pg-client" || client.ClientSecretHash == "" { + t.Fatalf("client = %+v ok=%v", client, ok) + } + + const issuer = "https://auth.example.test" + if err := authStore.SaveAuthorizationCode("code-1", 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) + } + entry, ok := authStore.ConsumeAuthorizationCode(issuer, "code-1") + if !ok || entry.ClientID != "pg-client" { + 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) + } + 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/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..6785fcb --- /dev/null +++ b/pkg/oauth/store/signing_key.go @@ -0,0 +1,337 @@ +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 + RotateOnStartup bool +} + +// 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) + 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 + } + 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) + 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 + } + 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 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 { + 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 new file mode 100644 index 0000000..af6e007 --- /dev/null +++ b/pkg/oauth/store/sqlite.go @@ -0,0 +1,355 @@ +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 { + 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) + } + _, err = s.db.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_auth_code (code, issuer, payload, expires_at) + VALUES (?, ?, ?, ?) + 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 { + return fmt.Errorf("save auth code: %w", err) + } + return nil +} + +func (s *SQLiteAuthorizationStore) ConsumeAuthorizationCode(issuer, code string) (oauth.AuthorizationCode, bool) { + 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 = ? + RETURNING payload`, code, now, issuer, + ).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 { + 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) + } + _, err = s.db.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_refresh_token (token, issuer, payload, expires_at) + VALUES (?, ?, ?, ?) + 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 { + return fmt.Errorf("save refresh token: %w", err) + } + return nil +} + +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 + } + 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 issuer = ?`, token, now, issuer) + if err != nil { + return oauth.RefreshTokenEntry{}, false + } + n, _ := res.RowsAffected() + if n == 0 { + return oauth.RefreshTokenEntry{}, false + } + return entry, true +} + +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 = ?`, token, now, issuer, + ).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 { + 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) + } + _, err = s.db.ExecContext(context.Background(), ` + INSERT INTO hai_oauth_pending_auth (id, issuer, payload, expires_at) + VALUES (?, ?, ?, ?) + 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 { + return fmt.Errorf("save pending auth: %w", err) + } + return nil +} + +func (s *SQLiteAuthorizationStore) GetPendingAuthorization(issuer, id string) (oauth.PendingAuthorization, bool) { + 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 = ?`, id, now, issuer, + ).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) 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(issuer, id string) (oauth.PendingAuthorization, bool) { + 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 = ? + RETURNING payload`, id, now, issuer, + ).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(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 = ? AND json_extract(payload, '$.clientId') = ?`, + token, now, issuer, 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_persist_test.go b/pkg/oauth/store/sqlite_persist_test.go new file mode 100644 index 0000000..28b192e --- /dev/null +++ b/pkg/oauth/store/sqlite_persist_test.go @@ -0,0 +1,193 @@ +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" +) + +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() + 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) + } + + handler := &swappingHandler{} + ts := httptest.NewServer(handler) + 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: created, + 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) + } + handler.set(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: "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) + } + + 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) + } + if err := reopened.Migrate(ctx); err != nil { + _ = reopened.Close() + t.Fatal(err) + } + restarted, err := oauthstore.NewSQLiteServer(oauth.Config{ + Issuer: base, + FHIRAudience: base, + SigningKey: loaded, + AutoApprove: true, + }, reopened.SQL()) + if err != nil { + t.Fatal(err) + } + handler.set(restarted.Handler()) + + 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 == "" || 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/oauth/store/sqlite_test.go b/pkg/oauth/store/sqlite_test.go new file mode 100644 index 0000000..aedae30 --- /dev/null +++ b/pkg/oauth/store/sqlite_test.go @@ -0,0 +1,88 @@ +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) + } + + const issuer = "https://auth.example.test" + if err := authStore.SaveAuthorizationCode("code-1", 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 _, 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) + } + + 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) + } + 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/oauth/tenant.go b/pkg/oauth/tenant.go new file mode 100644 index 0000000..acd2e0d --- /dev/null +++ b/pkg/oauth/tenant.go @@ -0,0 +1,269 @@ +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 +} + +// 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 { + 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/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/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/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/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/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/postgres/oauth_store_test.go b/pkg/postgres/oauth_store_test.go deleted file mode 100644 index 1c8d17d..0000000 --- a/pkg/postgres/oauth_store_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package postgres_test - -import ( - "testing" - "time" - - "github.com/degoke/health-ai-stack/pkg/oauth" - oauthpostgres "github.com/degoke/health-ai-stack/pkg/oauth/postgres" -) - -func TestOAuthPostgresStores_RoundTrip(t *testing.T) { - db, cleanup := openTestDB(t) - defer cleanup() - - authStore, clientStore, replayStore, revocationStore := oauthpostgres.Stores(db.Pool()) - now := time.Now() - if err := clientStore.Register(oauth.Client{ - ClientID: "pg-client", - ClientSecret: "pg-secret", - RedirectURIs: []string{"https://localhost/callback"}, - Scopes: []string{"patient/Patient.rs"}, - }); err != nil { - t.Fatal(err) - } - client, ok := clientStore.Get("pg-client") - if !ok || client.ClientID != "pg-client" || client.ClientSecretHash == "" { - t.Fatalf("client = %+v ok=%v", client, ok) - } - - if err := authStore.SaveAuthorizationCode("code-1", oauth.AuthorizationCode{ - 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") - if !ok || entry.ClientID != "pg-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/runtime/README.md b/pkg/runtime/README.md index 9d01412..2ed8de2 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/*`, 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(). + 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 755bcbb..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" ) @@ -40,6 +41,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 @@ -57,6 +59,11 @@ type Builder struct { preExpandValueSets bool maxExpansion int + + builtinOAuth *BuiltinOAuthConfig + oauthHandler http.Handler + oauthIssuerURL string + oauthAuthStore oauth.AuthorizationStore } // New returns a new runtime builder. @@ -323,6 +330,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/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/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..6eaf80d --- /dev/null +++ b/pkg/runtime/oauth_builtin.go @@ -0,0 +1,269 @@ +package runtime + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "github.com/degoke/health-ai-stack/pkg/auth" + "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") + } + 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 +} + +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 + } + + oauthCfg := oauth.Config{ + Issuer: issuer, + FHIRAudience: issuer, + } + 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") + } + + 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 + 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 + 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 + } + + 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) + } + + 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"}, + }) + 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(`{ + "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 = oauth.CombineHandlers(srv.Handler(), multiTenant.Handler()) + b.oauthAuthStore = oauthCfg.AuthorizationStore + b.oauthIssuerURL = issuer + b.httpPrincipalResolver = mtBearer.PrincipalResolver() + b.httpAuthBundleResolver = mtBearer.BundleResolver() + b.httpAuthChecker = smart.ScopePolicyAuthChecker{Engine: engine, Adapter: adapter} + return nil +} + +func (b *Builder) applyBuiltinSigningKey(cfg *oauth.Config, state *wireState, issuer string) error { + opts := oauthstore.SigningKeyOptions{ + ActiveKeyID: "haistack", + EncryptionSecret: oauth.SigningKeyEncryptionSecret(), + RotateOnStartup: oauth.SigningKeyRotateOnStartup(), + } + 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.DefaultSigningKeyPaths(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") + } + 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..f232698 --- /dev/null +++ b/pkg/runtime/oauth_builtin_test.go @@ -0,0 +1,188 @@ +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 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") + 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/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 a52fb0d..926018f 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 @@ -100,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 @@ -709,6 +714,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, @@ -739,6 +749,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, @@ -762,6 +773,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/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. diff --git a/pkg/sqlite/migrations/0014_oauth.sql b/pkg/sqlite/migrations/0014_oauth.sql new file mode 100644 index 0000000..4877de8 --- /dev/null +++ b/pkg/sqlite/migrations/0014_oauth.sql @@ -0,0 +1,45 @@ +-- OAuth authorization server state (JSON payloads mirror Postgres 0017_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/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 = ''; 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); 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); 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**: 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..c408540 --- /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", + ) +}