Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/inferno.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
26 changes: 26 additions & 0 deletions cmd/haistack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
18 changes: 17 additions & 1 deletion cmd/haistack/command/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package command_test
import (
"context"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions cmd/haistack/command/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions cmd/haistack/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
107 changes: 107 additions & 0 deletions cmd/haistack/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strconv"
"strings"

"github.com/degoke/health-ai-stack/pkg/oauth"
"gopkg.in/yaml.v3"
)

Expand All @@ -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"`
}

Expand All @@ -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"`
Expand Down Expand Up @@ -76,6 +92,9 @@ func Defaults() Config {
EnableSearch: true,
ModulePaths: []string{},
},
OAuth: OAuthConfig{
Enabled: boolPtr(true),
},
Sync: SyncConfig{
NodeID: DefaultSyncNodeID,
},
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -235,6 +284,11 @@ runtime:
modulePaths: []
packages: []
preExpandValueSets: false
oauth:
enabled: true
production: false
registrationAccessToken: ""
issuerURL: ""
sync:
hubURL: ""
nodeID: runtime-node
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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"
}
Loading
Loading