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(` +