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
9 changes: 9 additions & 0 deletions cmd/hyperfleet-api/environments/e_integration_testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ func (e *integrationTestingEnvImpl) OverrideConfig(c *config.ApplicationConfig)
c.Database.SSL.Mode = SSLModeDisable
}

// JWT issuer config for integration tests.
// JWKCertURL is filled in later by StartJWKCertServerMock once the mock server is running.
c.Server.JWT.Enabled = true
c.Server.JWT.Configs = []config.JWTIssuerConfig{{
IssuerURL: "https://hyperfleet.test/auth",
IdentityClaim: "email",
Header: "X-HyperFleet-Identity",
}}

return nil
}

Expand Down
18 changes: 14 additions & 4 deletions cmd/hyperfleet-api/server/api_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,21 @@ func NewAPIServer(tracingEnabled bool) Server {
var mainHandler http.Handler = mainRouter

if env().Config.Server.JWT.Enabled {
cfgs := env().Config.Server.JWT.Configs
issuers := make([]auth.JWTIssuerHandlerConfig, len(cfgs))
for i, c := range cfgs {
issuers[i] = auth.JWTIssuerHandlerConfig{
IssuerURL: c.IssuerURL,
Audience: c.Audience,
KeysFile: c.JWKCertFile,
KeysURL: c.JWKCertURL,
IdentityClaim: c.IdentityClaim,
IdentityClaimPattern: c.IdentityClaimPattern,
Header: c.Header,
}
}
jwtHandler, err := auth.NewJWTHandler(context.Background(), auth.JWTHandlerConfig{
KeysFile: env().Config.Server.JWK.CertFile,
KeysURL: env().Config.Server.JWK.CertURL,
IssuerURL: env().Config.Server.JWT.IssuerURL,
Audience: env().Config.Server.JWT.Audience,
Issuers: issuers,
PublicPaths: []string{
"^/api/hyperfleet/?$",
"^/api/hyperfleet/v1/?$",
Expand Down
17 changes: 6 additions & 11 deletions cmd/hyperfleet-api/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,12 @@ func (s *apiServer) routes(tracingEnabled bool) *mux.Router {
err = registerAPIMiddleware(apiV1Router)
check(err, "Failed to initialize API middleware")

identityCfg := auth.CallerIdentityConfig{
HeaderName: env().Config.Server.IdentityHeader,
}
if env().Config.Server.JWT.Enabled && env().Config.Server.JWT.IdentityClaim != "" {
identityCfg.JWTIdentityClaim = env().Config.Server.JWT.IdentityClaim
}
if identityCfg.JWTIdentityClaim != "" || identityCfg.HeaderName != "" {
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(identityCfg)
check(mwErr, "Unable to create caller identity middleware")
apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
}
// Identity config is resolved per-request from context when JWT is enabled
// (JWTHandler stores the matched issuer's config). An empty static config is used
// as the fallback for non-JWT paths (dev/no-auth mode).
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
check(mwErr, "Unable to create caller identity middleware")
apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
Comment on lines +89 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not install an empty identity resolver in no-auth mode.

With JWT disabled, no matched identity config is ever placed in context. This middleware then falls back to the empty config on Line 92, cannot resolve an identity, and returns 401 for every mutating request. That breaks run-no-auth. CWE/CVE: not applicable.

Gate the middleware unless a real identity source exists
-	// Identity config is resolved per-request from context when JWT is enabled
-	// (JWTHandler stores the matched issuer's config). An empty static config is used
-	// as the fallback for non-JWT paths (dev/no-auth mode).
-	callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
-	check(mwErr, "Unable to create caller identity middleware")
-	apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
+	if env().Config.Server.JWT.Enabled {
+		// Identity config is resolved per-request from context when JWTHandler stores
+		// the matched issuer's config.
+		callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
+		check(mwErr, "Unable to create caller identity middleware")
+		apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
+	}

As per coding guidelines, make run-no-auth must “Start server without auth.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Identity config is resolved per-request from context when JWT is enabled
// (JWTHandler stores the matched issuer's config). An empty static config is used
// as the fallback for non-JWT paths (dev/no-auth mode).
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
check(mwErr, "Unable to create caller identity middleware")
apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
if env().Config.Server.JWT.Enabled {
// Identity config is resolved per-request from context when JWTHandler stores
// the matched issuer's config.
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
check(mwErr, "Unable to create caller identity middleware")
apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/hyperfleet-api/server/routes.go` around lines 89 - 94, The
callerIdentityMW middleware with an empty CallerIdentityConfig is being
installed unconditionally, which breaks no-auth mode by returning 401 errors on
all mutating requests when no identity config exists in context. Only install
the NewCallerIdentityMiddleware and the callerIdentityMW.ResolveCallerIdentity
middleware if JWT is actually enabled or a real identity source exists. Gate the
middleware creation and installation behind a condition that checks whether JWT
is configured, rather than unconditionally installing it with a fallback empty
config.

Source: Coding guidelines


// Auto-discovered routes (no manual editing needed)
LoadDiscoveredRoutes(apiV1Router, services)
Expand Down
178 changes: 103 additions & 75 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,30 +33,7 @@ export HYPERFLEET_SERVER_JWT_ENABLED=false

### Caller identity in development mode

When JWT is disabled and no `identity_header` is configured, caller identity resolution is inactive. Audit fields (`created_by`, `updated_by`, `deleted_by`) fall back to `system@hyperfleet.local`.

To get proper caller attribution without JWT, configure an identity header:

```bash
./bin/hyperfleet-api serve \
--server-jwt-enabled=false \
--server-identity-header=X-HyperFleet-Identity
```

Then pass the header in requests:

```bash
# Create with attribution
curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \
-H "Content-Type: application/json" \
-H "X-HyperFleet-Identity: dev-user@local" \
-d '{"kind":"Cluster","name":"my-cluster","spec":{}}'

# Read requests work without the header
curl http://localhost:8000/api/hyperfleet/v1/clusters | jq
```

When `identity_header` or `identity_claim` is configured, mutating requests (POST, PATCH, PUT, DELETE) that cannot resolve a caller identity are rejected with `401 Unauthorized`. Read requests (GET, LIST) are allowed without identity.
When JWT is disabled, caller identity resolution is inactive. Audit fields (`created_by`, `updated_by`, `deleted_by`) fall back to `system@hyperfleet.local`. Read requests (GET, LIST) are always allowed without identity.

**Important**: Never disable authentication in production environments.

Expand All @@ -69,21 +46,33 @@ For local development with real JWT validation, you can use Google Cloud identit
- [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) installed
- Authenticated with `gcloud auth login`

### Start the server
### Create a config file

```bash
./bin/hyperfleet-api serve \
--server-jwt-enabled=true \
--server-jwt-issuer-url="https://accounts.google.com" \
--server-jwk-cert-url="https://www.googleapis.com/oauth2/v3/certs" \
--server-jwt-audience="32555940559.apps.googleusercontent.com" \
--server-jwt-identity-claim="email" \
--server-identity-header=X-HyperFleet-Identity \
--db-host localhost --db-port 5432 --db-name hyperfleet --db-username hyperfleet
```yaml
# dev-config.yaml
server:
jwt:
enabled: true
configs:
- issuer_url: "https://accounts.google.com"
jwk_cert_url: "https://www.googleapis.com/oauth2/v3/certs"
audience: "32555940559.apps.googleusercontent.com"
identity_claim: email

database:
host: localhost
port: 5432
name: hyperfleet
username: hyperfleet
password: devpassword
```

The audience `32555940559.apps.googleusercontent.com` is the default gcloud CLI OAuth client ID. It matches the `aud` claim in tokens generated by `gcloud auth print-identity-token`.

```bash
./bin/hyperfleet-api serve --config=dev-config.yaml
```

### Generate a token and make requests

```bash
Expand All @@ -99,13 +88,6 @@ curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"Cluster","name":"my-cluster","spec":{}}'

# Override identity via header (header takes precedence over JWT)
curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-HyperFleet-Identity: gateway-user@corp.com" \
-d '{"kind":"Cluster","name":"my-cluster-2","spec":{}}'
```

### How it works
Expand Down Expand Up @@ -141,14 +123,14 @@ curl -H "Authorization: Bearer ${TOKEN}" \

### JWT Authentication

HyperFleet API validates JWT tokens using RS256 signature verification.
HyperFleet API validates JWT tokens using RS256 signature verification. With multiple issuers configured, the server tries each issuer in order and accepts the request as soon as any issuer validates the token.

**Token validation checks:**
1. Signature - Token signed by trusted issuer
2. Issuer - Matches configured `HYPERFLEET_SERVER_JWT_ISSUER_URL`
3. Audience - Matches configured `HYPERFLEET_SERVER_JWT_AUDIENCE`
4. Expiration - Token not expired
5. Claims - Required claims present
**Token validation checks (per issuer):**
1. Signature — token signed by the issuer's JWKS keys
2. Issuer — `iss` claim matches the configured `issuer_url`
3. Audience — `aud` claim matches the configured `audience` (skipped if not configured)
4. Expiration — token not expired
5. Identity — configured `identity_claim` is present and non-empty

**Token format:**
```text
Expand All @@ -163,72 +145,118 @@ curl -H "Authorization: Bearer ${TOKEN}" \

## Caller identity for audit

Authentication (JWT validation) and caller identity (audit attribution) are separate concerns. Identity resolution is enabled by setting `identity_claim` (in the JWT config) and/or `identity_header`. When neither is set, no identity middleware is registered and audit fields fall back to `system@hyperfleet.local`.
Authentication (JWT validation) and caller identity (audit attribution) are separate concerns. When JWT is enabled, the matched issuer's `identity_claim`, `identity_claim_pattern`, and `header` settings determine how identity is resolved for that request.

| Layer | Component | Responsibility |
|-------|-----------|----------------|
| Outer | `JWTHandler` | Validates `Authorization: Bearer` token |
| Inner | `ResolveCallerIdentity` middleware | Resolves who is recorded as the actor |
| Outer | `JWTHandler` | Validates `Authorization: Bearer` token; stores matched issuer config in context |
| Inner | `ResolveCallerIdentity` middleware | Reads the per-request issuer config from context; resolves audit identity |

The resolved identity is written to `created_by` on create, `updated_by` on update, and `deleted_by` on delete. Precedence: identity header > JWT claim.
The resolved identity is written to `created_by` on create, `updated_by` on update, and `deleted_by` on delete. When identity resolution is configured, mutating requests (POST, PATCH, PUT, DELETE) that cannot resolve a caller identity are rejected with `401 Unauthorized`. Read requests (GET, LIST) are allowed without identity.

When identity resolution is configured, mutating requests (POST, PATCH, PUT, DELETE) that cannot resolve a caller identity are rejected with `401 Unauthorized`. Read requests (GET, LIST) are allowed without identity.
### Per-issuer identity claim

### JWT claim

Configure which JWT claim is used as the caller identity:
Each issuer config defines which JWT claim is used as the caller identity for tokens from that issuer:

```yaml
server:
jwt:
identity_claim: email # or preferred_username, sub, etc.
configs:
- issuer_url: https://idp-a.example.com/realms/hyperfleet
jwk_cert_url: https://idp-a.example.com/realms/hyperfleet/protocol/openid-connect/certs
identity_claim: email # or preferred_username, sub, etc.

- issuer_url: https://accounts.google.com
jwk_cert_url: https://www.googleapis.com/oauth2/v3/certs
identity_claim: sub
```

### HTTP identity header (optional)
If the configured claim is absent from the token the request is rejected with `401 Unauthorized`.

### Identity claim pattern (optional, per issuer)

When set, a trusted gateway can set the caller identity via HTTP header. **If the header is present and non-empty, it overrides the JWT claim** for audit fields. JWT validation is still required when `jwt.enabled=true`.
When set, the resolved identity value must match the configured regex. Requests whose identity does not match are rejected with `401 Unauthorized`. An invalid regex prevents the server from starting.

```yaml
server:
identity_header: X-HyperFleet-Identity
jwt:
configs:
- issuer_url: https://idp-a.example.com/realms/hyperfleet
jwk_cert_url: https://idp-a.example.com/realms/hyperfleet/protocol/openid-connect/certs
identity_claim: email
identity_claim_pattern: '^[^@]+@[^@]+$' # require email-like values
```

**Security:** Clients must not be able to set this header directly. Configure your ingress/gateway to strip the header from external requests and set it from the authenticated upstream user.
Use regex alternation to allow a fixed set of values (e.g., service accounts):

```bash
export HYPERFLEET_SERVER_IDENTITY_HEADER=X-HyperFleet-Identity
```yaml
- issuer_url: https://sa-issuer.example.com
jwk_cert_url: https://sa-issuer.example.com/certs
identity_claim: sub
identity_claim_pattern: '^(sentinel|adapter-namespace|adapter-locator)$'
```

The `^` and `$` anchors ensure full-string matching; without them a value like `evil-sentinel` would also pass. When no pattern is set, any non-empty string that passes length and character validation is accepted.

### HTTP identity header (optional, per issuer)

When set, a trusted gateway can pass the caller identity via HTTP header. **If the header is present and non-empty, it overrides the JWT claim** for audit fields. JWT validation is still required when `jwt.enabled=true`.

```yaml
server:
jwt:
configs:
- issuer_url: https://idp.example.com/realms/hyperfleet
jwk_cert_url: https://idp.example.com/realms/hyperfleet/protocol/openid-connect/certs
identity_claim: email
header: X-HyperFleet-Identity # optional header override
```

**Security:** Clients must not be able to set this header directly. Configure your ingress/gateway to strip the header from external requests and set it from the authenticated upstream user.

Identity values from both sources are validated: trimmed of whitespace, limited to 256 characters, and rejected if they contain control characters.

## Configuration

### Environment Variables
### Config file (required for JWT issuers)

```bash
# Development (no auth)
export HYPERFLEET_SERVER_JWT_ENABLED=false
The `server.jwt.configs` array cannot be expressed as individual environment variables. Use a YAML config file or a JSON environment variable:

# Production (with auth)
**YAML config file (recommended):**
```yaml
server:
jwt:
enabled: true
configs:
- issuer_url: https://your-idp.example.com/auth/realms/your-realm
jwk_cert_url: https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs
audience: https://your-api.example.com
identity_claim: email
```

**JSON environment variable (for environments without a config file mount):**
```bash
export HYPERFLEET_SERVER_JWT_ENABLED=true
export HYPERFLEET_SERVER_JWT_ISSUER_URL=https://your-idp.example.com/auth/realms/your-realm
export HYPERFLEET_SERVER_JWT_AUDIENCE=https://your-api.example.com
export HYPERFLEET_SERVER_JWT_CONFIGS='[{"issuer_url":"https://your-idp.example.com/auth/realms/your-realm","jwk_cert_url":"https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs","audience":"https://your-api.example.com","identity_claim":"email"}]'
```

See [Deployment](deployment.md) for complete configuration options.

### Kubernetes Deployment

Configure via Helm values:
Configure via Helm values (mounts a ConfigMap with the YAML config):

```yaml
# values.yaml
config:
server:
jwt:
enabled: true
issuer_url: https://your-idp.example.com/auth/realms/your-realm
audience: https://your-api.example.com
configs:
- issuer_url: https://your-idp.example.com/auth/realms/your-realm
jwk_cert_url: https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs
audience: https://your-api.example.com
identity_claim: email
```

Deploy:
Expand All @@ -242,8 +270,8 @@ helm install hyperfleet-api ./charts/ --values values.yaml

**401 Unauthorized**
- Check token is valid and not expired
- Verify `HYPERFLEET_SERVER_JWT_ISSUER_URL` and `HYPERFLEET_SERVER_JWT_AUDIENCE` match token claims
- Ensure `Authorization` header is correctly formatted
- Verify the token's `iss` and `aud` claims match an entry in `server.jwt.configs`
- Ensure `Authorization: Bearer <token>` header is correctly formatted

**Token debugging**
```bash
Expand Down
Loading