Skip to content

Commit 7156ab6

Browse files
authored
Phase 2: secure OIDC auth and PostgreSQL task persistence (#2)
* feat: add OIDC auth and PostgreSQL task persistence * fix: advertise enforced OIDC scopes * fix: harden durable identity and error boundaries * fix: resolve phase 2 security review * refactor: share OIDC loopback validation
1 parent e51d6bf commit 7156ab6

29 files changed

Lines changed: 3998 additions & 31 deletions

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,19 @@ A2A_KEEP_ALIVE_INTERVAL=15s
77
A2A_AGENT_INACTIVITY_TIMEOUT=5m
88
HTTP_READ_TIMEOUT=30s
99
MAX_REQUEST_BODY_BYTES=1048576
10+
11+
OIDC_ISSUER=
12+
OIDC_AUDIENCE=
13+
OIDC_TENANT_CLAIM=tenant_id
14+
OIDC_REQUIRED_SCOPES=a2a
15+
OIDC_ALLOWED_ALGORITHMS=RS256
16+
OIDC_CLOCK_SKEW=1m
17+
OIDC_HTTP_TIMEOUT=10s
18+
19+
DATABASE_URL=
20+
DATABASE_PASSWORD_FILE=
21+
DATABASE_MAX_CONNECTIONS=20
22+
DATABASE_MIN_CONNECTIONS=2
23+
DATABASE_MAX_CONNECTION_LIFETIME=1h
24+
DATABASE_MAX_CONNECTION_IDLE=15m
25+
DATABASE_HEALTH_CHECK_PERIOD=30s

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,22 @@ for the SDK boundary and planned production adapters.
2626

2727
`.env.example` documents variables loaded by the container runtime. When
2828
running the binary directly, export overrides in the shell environment.
29+
30+
## OIDC and PostgreSQL development wiring
31+
32+
Set `OIDC_ISSUER`, `OIDC_AUDIENCE`, and `DATABASE_URL` to exercise the durable,
33+
authenticated path. Credentials are read from mounted files such as
34+
`DATABASE_PASSWORD_FILE`; passwords, passfiles, service files, and password
35+
environment variables outside that explicit path are rejected. OIDC policy
36+
variables without a complete issuer/audience pair also fail startup.
37+
Apply schema migrations separately before starting the server:
38+
39+
```powershell
40+
go run ./cmd/migrate
41+
go run ./cmd/server
42+
```
43+
44+
The local in-memory task store remains available only when no database is
45+
configured in development or tests. Staging and production require explicit
46+
authentication and durable storage. See [docs/architecture.md](docs/architecture.md)
47+
for the token and tenant-isolation contract.

cmd/migrate/main.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"os"
8+
"os/signal"
9+
"strings"
10+
"syscall"
11+
12+
"github.com/KB01111/A2A-RedPandaServer-Container/internal/storage/postgres"
13+
)
14+
15+
func main() {
16+
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
17+
if err := run(); err != nil {
18+
logger.Error("database migration failed", "error", err)
19+
os.Exit(1)
20+
}
21+
logger.Info("database schema is current", "version", postgres.CurrentSchemaVersion())
22+
}
23+
24+
func run() error {
25+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
26+
defer stop()
27+
28+
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
29+
if databaseURL == "" {
30+
return fmt.Errorf("DATABASE_URL is required")
31+
}
32+
pool, err := postgres.OpenPool(ctx, postgres.PoolConfig{
33+
DatabaseURL: databaseURL,
34+
PasswordFile: strings.TrimSpace(os.Getenv("DATABASE_PASSWORD_FILE")),
35+
ApplicationName: "bridge-a2a-migrate",
36+
MaxConns: 1,
37+
})
38+
if err != nil {
39+
return err
40+
}
41+
defer pool.Close()
42+
43+
if err := postgres.Migrate(ctx, pool); err != nil {
44+
return err
45+
}
46+
return postgres.VerifySchema(ctx, pool)
47+
}

cmd/server/main.go

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,86 @@ package main
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"log/slog"
78
"net/http"
89
"os"
910
"os/signal"
1011
"syscall"
1112
"time"
1213

14+
"github.com/KB01111/A2A-RedPandaServer-Container/internal/auth"
1315
"github.com/KB01111/A2A-RedPandaServer-Container/internal/config"
1416
"github.com/KB01111/A2A-RedPandaServer-Container/internal/orchestrator"
1517
appserver "github.com/KB01111/A2A-RedPandaServer-Container/internal/server"
18+
"github.com/KB01111/A2A-RedPandaServer-Container/internal/storage/postgres"
1619
)
1720

1821
func main() {
1922
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
23+
if err := run(logger); err != nil {
24+
logger.Error("server failed", "error", err)
25+
os.Exit(1)
26+
}
27+
}
28+
29+
func run(logger *slog.Logger) error {
2030
cfg, err := config.Load()
2131
if err != nil {
22-
logger.Error("invalid configuration", "error", err)
23-
os.Exit(1)
32+
return fmt.Errorf("invalid configuration: %w", err)
2433
}
2534
if cfg.Environment != "development" && cfg.Environment != "test" {
26-
logger.Error("production dependencies are not configured", "environment", cfg.Environment)
27-
os.Exit(1)
35+
return fmt.Errorf("redpanda dispatcher is not configured for %s", cfg.Environment)
2836
}
2937

30-
handler, err := appserver.New(cfg, appserver.Dependencies{
38+
shutdownSignals, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
39+
defer stop()
40+
41+
dependencies := appserver.Dependencies{
3142
Dispatcher: orchestrator.LoopbackDispatcher{},
3243
Logger: logger,
33-
})
44+
}
45+
if cfg.OIDC.Enabled() {
46+
verifier, err := auth.NewOIDCVerifier(shutdownSignals, cfg.OIDC)
47+
if err != nil {
48+
return fmt.Errorf("initialize OIDC verifier: %w", err)
49+
}
50+
dependencies.Authentication, err = auth.NewAuthenticator(verifier, cfg.OIDC.Issuer, cfg.OIDC.RequiredScopes)
51+
if err != nil {
52+
return fmt.Errorf("initialize authentication: %w", err)
53+
}
54+
}
55+
56+
if cfg.Database.URL != "" {
57+
if dependencies.Authentication == nil {
58+
return fmt.Errorf("DATABASE_URL requires OIDC authentication")
59+
}
60+
pool, err := postgres.OpenPool(shutdownSignals, postgres.PoolConfig{
61+
DatabaseURL: cfg.Database.URL,
62+
PasswordFile: cfg.Database.PasswordFile,
63+
ApplicationName: "bridge-a2a-server",
64+
MaxConns: cfg.Database.MaxConnections,
65+
MinConns: cfg.Database.MinConnections,
66+
MaxConnLifetime: cfg.Database.MaxConnectionLife,
67+
MaxConnIdleTime: cfg.Database.MaxConnectionIdle,
68+
HealthCheckPeriod: cfg.Database.HealthCheckPeriod,
69+
})
70+
if err != nil {
71+
return err
72+
}
73+
defer pool.Close()
74+
if err := postgres.VerifySchema(shutdownSignals, pool); err != nil {
75+
return fmt.Errorf("verify database schema: %w", err)
76+
}
77+
dependencies.TaskStore, err = postgres.NewStore(pool)
78+
if err != nil {
79+
return err
80+
}
81+
}
82+
83+
handler, err := appserver.New(cfg, dependencies)
3484
if err != nil {
35-
logger.Error("build server", "error", err)
36-
os.Exit(1)
85+
return fmt.Errorf("build server: %w", err)
3786
}
3887

3988
server := &http.Server{
@@ -45,8 +94,6 @@ func main() {
4594
MaxHeaderBytes: 1 << 20,
4695
}
4796

48-
shutdownSignals, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
49-
defer stop()
5097
go func() {
5198
<-shutdownSignals.Done()
5299
ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
@@ -58,7 +105,7 @@ func main() {
58105

59106
logger.Info("starting A2A server", "address", server.Addr, "environment", cfg.Environment)
60107
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
61-
logger.Error("server stopped", "error", err)
62-
os.Exit(1)
108+
return fmt.Errorf("serve HTTP: %w", err)
63109
}
110+
return nil
64111
}

docs/architecture.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,53 @@ The SDK does not itself enforce the `A2A-Version` service parameter. The HTTP
2626
stack therefore requires `A2A-Version: 1.0` for protocol calls and normalizes
2727
comma-separated `A2A-Extensions` values before handing requests to `a2asrv`.
2828

29+
## Authentication and tenant boundary
30+
31+
OIDC authentication wraps the A2A transport rather than running only as an SDK
32+
interceptor. This is deliberate: the SDK snapshots HTTP headers into request
33+
metadata before calling the executor. The outer middleware verifies a signed
34+
JWT, removes `Authorization`, `Proxy-Authorization`, and `Cookie`, and places a
35+
small verified identity in the request context. An SDK interceptor then sets
36+
the authenticated A2A user and injects the authoritative tenant claim into the
37+
typed request. A client-supplied tenant that differs from the signed claim is
38+
rejected.
39+
40+
The accepted token profile is a signed JWT access token with the configured
41+
issuer, audience, asymmetric signing algorithm, `sub`, tenant, `exp`, and
42+
required scopes. Opaque tokens are not accepted. Discovery is performed once
43+
at startup and the long-lived verifier uses the provider's cached remote key
44+
set. An identity-provider outage therefore does not make liveness dependent on
45+
a network call for every request, although a previously unseen signing key
46+
still fails closed while the key endpoint is unavailable.
47+
48+
Discovery redirects and the discovered JWKS endpoint must remain on the exact
49+
issuer origin (scheme, host, and effective port). In staging and production the
50+
OIDC dialer also rejects private, loopback, link-local, and special-use IP
51+
destinations at connection time. Cleartext loopback issuers and private
52+
destinations are enabled only for development and test environments.
53+
54+
## PostgreSQL task ownership
55+
56+
The PostgreSQL adapter implements the SDK's `taskstore.Store` contract without
57+
changing A2A wire behavior. Every read and write is scoped by the verified OIDC
58+
issuer, tenant, and subject from `context.Context`; request JSON is never an
59+
authority source. Cross-scope lookups return not-found so they cannot be used
60+
as an existence oracle. Task IDs remain globally unique because the SDK's
61+
in-process event, work, and push stores are keyed only by task ID.
62+
63+
The full canonical task is stored as JSONB with indexed projections for tenant,
64+
owner, context, state, status time, and update time. Updates use row locking and
65+
the SDK task version for optimistic concurrency. List operations use a
66+
repeatable-read snapshot and opaque keyset cursors.
67+
68+
Schema changes are forward-only, checksummed, and protected by a PostgreSQL
69+
advisory lock. `cmd/migrate` owns DDL. The server only verifies that the schema
70+
is current and fails startup when it is behind; production deployments should
71+
use separate migration and runtime database roles and must not run migrations
72+
concurrently with application processes. Database passwords are accepted only
73+
through the explicit bounded secret file; URI parameters and PostgreSQL
74+
password/service environment fallbacks are rejected.
75+
2976
## Runtime boundary
3077

3178
The server is a stateless protocol and orchestration process. Agent workers,

go.mod

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@ module github.com/KB01111/A2A-RedPandaServer-Container
22

33
go 1.25.0
44

5-
require github.com/a2aproject/a2a-go/v2 v2.4.0
5+
require (
6+
github.com/a2aproject/a2a-go/v2 v2.4.0
7+
github.com/coreos/go-oidc/v3 v3.20.0
8+
github.com/jackc/pgx/v5 v5.10.0
9+
)
610

711
require (
12+
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
813
github.com/google/uuid v1.6.0 // indirect
9-
golang.org/x/sync v0.20.0 // indirect
14+
github.com/jackc/pgpassfile v1.0.0 // indirect
15+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
16+
github.com/jackc/puddle/v2 v2.2.2 // indirect
17+
golang.org/x/oauth2 v0.36.0 // indirect
18+
golang.org/x/sync v0.22.0 // indirect
19+
golang.org/x/text v0.40.0 // indirect
1020
)

go.sum

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
11
github.com/a2aproject/a2a-go/v2 v2.4.0 h1:da0iwA6voxLhXIw1B3GSAdrhs5UIa2v6m+v/Fix5qTw=
22
github.com/a2aproject/a2a-go/v2 v2.4.0/go.mod h1:EghJ/rY9OCC6jme1z+otOBf+1YCVVCpMSPZU31t3hKY=
3+
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
4+
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
5+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8+
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
9+
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
310
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
411
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
512
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
613
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7-
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
8-
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
9-
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
10-
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
14+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
15+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
16+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
17+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
18+
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
19+
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
20+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
21+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
22+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
23+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
24+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
25+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
26+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
27+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
28+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
29+
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
30+
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
31+
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
32+
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
33+
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
34+
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
35+
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
36+
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
37+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
38+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
39+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
40+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)