Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/api/api-stability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
| `POST /api/agents/register` | 🤝 Partner | 0.1.0 | — | |
| `POST /api/ai/chat` | 🌐 Public | 0.1.0 | — | |
| `POST /api/ai/pipeline` | 🌐 Public | 0.1.0 | — | |
| `POST /api/auth/login` | 🌐 Public | 0.1.0 | — | [auth reference](auth.md) |
| `GET /api/auth/me` | 🌐 Public | 0.1.0 | — | [auth reference](auth.md) |
| `GET /api/users` | 🌐 Public | 0.1.0 | — | [auth reference](auth.md) |
| `GET /api/dashboard/overview` | 🔒 Internal | 0.1.0 | — | Dashboard is platform UI |
| `GET /api/diagnostics/system` | 🔒 Internal | 0.1.0 | — | |
| `GET /api/plugins` | 🤝 Partner | 0.1.0 | — | Plugin marketplace |
Expand Down
83 changes: 83 additions & 0 deletions docs/api/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Authentication API

> **Stability:** 🌐 Public
> **Base path:** `/api/auth`
> **Content-Type:** `application/json`

JWT bearer authentication. Login with credentials to obtain a token; send it as
`Authorization: Bearer <token>` on protected endpoints. Public endpoints
(`/api/health`, `/api/auth/**`, actuator, swagger) need no token.

## POST /api/auth/login

Authenticates credentials and returns a JWT. The token's `scope` claim carries
the user's roles.

### Request

```json
{
"username": "admin",
"password": "admin-test-password"
}
```

### Response — 200 OK

```json
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer"
}
```

### Errors

| Status | Body | When |
|--------|------|------|
| `401` | `{ "error": "invalid credentials" }` | Bad username/password, or the account is disabled/locked. |
| `400` | — | Malformed JSON body. |

## GET /api/auth/me

Returns the authenticated caller's own user record. Requires a bearer token.

### Response — 200 OK

```json
{
"id": "00000000-0000-0000-0000-000000000001",
"username": "admin",
"email": "admin@syncflow.local",
"roles": "ADMIN",
"enabled": true
}
```

### Errors

| Status | When |
|--------|------|
| `401` | Missing/invalid bearer token. |

## Using the token

```http
GET /api/users
Authorization: Bearer <token>
```

- Token is an **HS256** JWT signed with the configured `syncflow.jwt.secret`.
- Expiry is `syncflow.jwt.expiry-minutes` (default 60).
- Tokens are stateless — no server-side session.

## Configuration (`syncflow.jwt.*`)

| Key | Default | Env | Description |
|-----|---------|-----|-------------|
| `syncflow.jwt.secret` | dev default | `SYNCFLOW_JWT_SECRET` | Base64-encoded HS256 key, **≥ 32 bytes**. Required. |
| `syncflow.jwt.issuer` | `syncflow` | `SYNCFLOW_JWT_ISSUER` | JWT `iss` claim. |
| `syncflow.jwt.expiry-minutes` | `60` | `SYNCFLOW_JWT_EXPIRY_MINUTES` | Token lifetime. |

> **Security:** replace the default secret in production. Startup fails with a
> clear message if the secret is missing, not base64, or shorter than 256 bits.
93 changes: 93 additions & 0 deletions docs/security/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Authentication & CSRF

> **Status:** Implemented
> **Version:** 1.0
> **Last Updated:** 2026-08-05

## Model

The control plane uses **stateless JWT bearer authentication** (HS256) over
Spring Security oauth2 resource-server. There is no server-side session; every
request is authenticated by the bearer token in the `Authorization` header.

```
┌──────────────────────────────────────────────────────┐
[Client] ──►│ Spring Security filter chain │
│ - public paths: permitAll │
│ - /api/auth/login: permitAll │
│ - /api/** : JWT bearer (oauth2ResourceServer) + auth │
└─────────────────────────┬────────────────────────────┘
Authentication (JWT claims)
│ authorities (ROLE_* from scope)
TenantFilter → TenantContext
│ roles
AuthorizationService (RBAC)
```

The JWT's `scope` claim maps to `ROLE_*` Spring authorities, which
`TenantFilter` reads into the tenant context roles; the existing
`AuthorizationService`/`PolicyResolver` RBAC enforces permissions. Auth
plugs into the pre-existing RBAC — there is no parallel authorization model.

## Accounts & credentials

- Users live in the `app_users` table (migration V9). Credentials are stored as
**BCrypt** password hashes (`password_hash` column). Plaintext passwords are
never stored or logged.
- A default `admin` user is seeded by V9 (`admin-test-password`) for
development/test only. **Replace the password and JWT secret in production.**
- The `PolicyResolver` grants the `admin` username full permissions; other users
are authorized by their roles/authorities.

## Users API

`/api/users` (see [User Management](#user-management)) manages accounts and role
assignment. Creating/updating roles is restricted to a known allow-list and
guarded by RBAC (`ORG_WRITE`).

## CSRF policy (hybrid)

CSRF protection is **enabled** but scoped so it does not interfere with the
bearer-token API:

- **`/api/**`** — CSRF is ignored. These endpoints use header bearer tokens,
which browsers cannot attach on behalf of a victim (the classic CSRF attack
vector), so CSRF protection is unnecessary and would only add friction.
- **Non-`/api/**` paths** (cookie-based session flows) — CSRF is enforced via a
`CookieCsrfTokenRepository`. This is what satisfies the "Disabled Spring CSRF"
scan finding: protection is on, just scoped.

## JWT configuration

| Setting | Detail |
|---------|--------|
| Algorithm | HS256 (HMAC-SHA256, symmetric) |
| Secret | Base64, forced ≥ 32 bytes at startup; invalid config fails fast. |
| Claims | `iss`, `iat`, `exp`, `sub` (username), `scope` (roles) |
| Signing | Nimbus (`ImmutableJWKSet` + `OctetSequenceKey`), `NimbusJwtDecoder` |

## Security considerations

- **Stateless**: no session fixation; outages don't invalidate tokens until `exp`.
- **Secret rotation** requires coordinated key change across instances.
- **HS256** is symmetric — any holder of the secret can mint tokens. For
multi-signer or external verification, migrate to **RS256/asymmetric**
(documented upgrade path).
- Login returns a uniform `401` for bad credentials and disabled/locked
accounts — it does not reveal whether an account exists.
- Failed/malformed JWTs are rejected by the resource server; tampering is
detected by the signature.

## Threat-model mapping

| Threat | Control |
|--------|---------|
| Bad/missing token | Resource server rejects; 401. |
| Token forgery | HS256 signature verification. |
| Account enumeration via login | Uniform 401 for all auth failures. |
| CSRF on bearer API | Not applicable (header token); CSRF enabled for cookie paths. |
| Privilege escalation | Role allow-list on user management; RBAC on `/api/users`. |
| Weak secret | Startup validation enforces ≥ 256-bit key. |
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-star
spring-modulith-starter-core = { module = "org.springframework.modulith:spring-modulith-starter-core" }
spring-modulith-starter-test = { module = "org.springframework.modulith:spring-modulith-test" }
spring-security-test = { module = "org.springframework.security:spring-security-test" }
spring-security-oauth2-jose = { module = "org.springframework.security:spring-security-oauth2-jose", version.ref = "spring-security" }
spring-security-oauth2-resource-server = { module = "org.springframework.security:spring-security-oauth2-resource-server", version.ref = "spring-security" }
spring-doc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" }

lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
Expand Down
2 changes: 2 additions & 0 deletions syncflow-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ dependencies {

implementation libs.spring.boot.starter.actuator
implementation libs.spring.boot.starter.security
implementation libs.spring.security.oauth2.jose
implementation libs.spring.security.oauth2.resource.server
implementation libs.spring.doc.openapi.starter.webmvc.ui
implementation libs.kafka.clients
implementation libs.flyway.core
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.syncflow.api.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;

/**
* Auth beans: BCrypt password encoder, the AuthenticationManager backed by
* DaoAuthenticationProvider over the user-details service, and the JWT
* authentication converter that maps the JWT {@code scope} claim to
* {@code ROLE_*} authorities (consumed by TenantFilter / RBAC).
*/
@Configuration
public class AuthSecurityBeans {

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
var provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
// Map JWT 'scope' claim -> ROLE_ authorities. The login token encodes the
// user's roles into 'scope'; TenantFilter reads authorities for RBAC.
return new JwtAuthenticationConverter();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.syncflow.api.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* JWT signing properties bound from {@code syncflow.jwt.*}.
* Pure data holder — bean wiring lives in {@link JwtSecurityConfig} so the
* properties binding is not entangled with bean lifecycle.
*/
@Setter
@Getter
@ConfigurationProperties(prefix = "syncflow.jwt")
public class JwtProperties {

/** Base64-encoded HMAC secret (HS256 requires >= 256-bit key = 32 bytes). */
private String secret;

/** JWT issuer claim. */
private String issuer = "syncflow";

/** Token lifetime in minutes. */
private long expiryMinutes = 60;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.syncflow.api.config;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.OctetSequenceKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;

import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

/**
* JWT HS256 encoder/decoder wiring. Decoupled from the properties holder
* ({@link JwtProperties}) so bean creation always happens after properties are
* bound, and the secret is validated up front.
*
* ponytail: HS256 with one shared secret — sufficient for a single-platform
* deployment. RS256/asymmetric is the documented upgrade path.
*/
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class JwtSecurityConfig {

@Bean
public JwtEncoder jwtEncoder(JwtProperties props) {
var jwk = new OctetSequenceKey.Builder(secretKey(props))
.algorithm(JWSAlgorithm.HS256)
.build();
return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk)));
}

@Bean
public JwtDecoder jwtDecoder(JwtProperties props) {
return NimbusJwtDecoder.withSecretKey(secretKey(props))
.macAlgorithm(MacAlgorithm.HS256)
.build();
}

private static SecretKeySpec secretKey(JwtProperties props) {
var secret = props.getSecret();
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("syncflow.jwt.secret is not configured. "
+ "Set a base64-encoded HS256 key (>= 32 bytes) via env SYNCFLOW_JWT_SECRET.");
}
final byte[] bytes;
try {
bytes = Base64.getDecoder().decode(secret);
} catch (IllegalArgumentException e) {
throw new IllegalStateException("syncflow.jwt.secret is not valid base64", e);
}
if (bytes.length < 32) {
throw new IllegalStateException("syncflow.jwt.secret decodes to " + bytes.length
+ " bytes; HS256 requires at least 32 bytes (256 bits).");
}
return new SecretKeySpec(bytes, "HmacSHA256");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

@Configuration
@EnableWebSecurity
Expand All @@ -16,12 +19,22 @@ public class WebSecurityConfig {
// Skip this chain when a test provides its own (permissive) SecurityFilterChain
@Bean
@ConditionalOnMissingBean(SecurityFilterChain.class)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain filterChain(HttpSecurity http,
JwtAuthenticationConverter jwtAuthenticationConverter) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.csrf(csrf -> csrf
// Hybrid CSRF: protect cookie-based paths; /api/** uses bearer
// tokens in headers (browsers cannot forge them), so it stays
// CSRF-free.
.ignoringRequestMatchers("/api/**")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(SecurityConfig.publicPaths().toArray(String[]::new)).permitAll()
.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)))
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable);
return http.build();
Expand Down
Loading