mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 03:22:09 +00:00
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid, at_hash, signed userinfo, RP-initiated and back-channel logout), managed refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens, DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592 registration, RFC 9207 iss, signing keyring with rotation. State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct backends, four dialect DDLs, conformance cases). Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL. PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse. Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
181 lines
6.0 KiB
Go
181 lines
6.0 KiB
Go
package backends
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
)
|
|
|
|
// Container tests start a throwaway database server with podman or docker (whichever is
|
|
// installed, podman first) and run the conformance suite against it. They pull an image, so
|
|
// they only run when RESOLVESPEC_TEST_CONTAINERS=1 and not with -short. The container is
|
|
// removed when the test ends.
|
|
|
|
const containerPassword = "Resolve_Spec_1"
|
|
|
|
func containerRuntime(t *testing.T) string {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("container tests are skipped with -short")
|
|
}
|
|
if os.Getenv("RESOLVESPEC_TEST_CONTAINERS") != "1" {
|
|
t.Skip("set RESOLVESPEC_TEST_CONTAINERS=1 to run tests that start a podman/docker container")
|
|
}
|
|
for _, rt := range []string{"podman", "docker"} {
|
|
if p, err := exec.LookPath(rt); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
t.Skip("neither podman nor docker found in PATH")
|
|
return ""
|
|
}
|
|
|
|
func run(t *testing.T, timeout time.Duration, name string, args ...string) string {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
var out, errb bytes.Buffer
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Stdout, cmd.Stderr = &out, &errb
|
|
if err := cmd.Run(); err != nil {
|
|
t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, errb.String())
|
|
}
|
|
return strings.TrimSpace(out.String())
|
|
}
|
|
|
|
// startContainer runs image publishing containerPort on a random localhost port and returns
|
|
// the host port. The container is force-removed on cleanup.
|
|
func startContainer(t *testing.T, rt, image, containerPort string, env map[string]string) string {
|
|
t.Helper()
|
|
args := []string{"run", "-d", "--rm", "-p", "127.0.0.1::" + containerPort}
|
|
for k, v := range env {
|
|
args = append(args, "-e", k+"="+v)
|
|
}
|
|
args = append(args, image)
|
|
id := run(t, 10*time.Minute, rt, args...) // first run may pull the image
|
|
t.Cleanup(func() { _ = exec.Command(rt, "rm", "-f", id).Run() })
|
|
|
|
// "127.0.0.1:49153" (docker may print one line per address family)
|
|
out := run(t, 30*time.Second, rt, "port", id, containerPort)
|
|
line := strings.Fields(out)[len(strings.Fields(out))-1]
|
|
for _, l := range strings.Split(out, "\n") {
|
|
if strings.HasPrefix(strings.TrimSpace(l), "127.0.0.1") || strings.Contains(l, " 127.0.0.1:") {
|
|
line = l[strings.LastIndex(l, " ")+1:]
|
|
break
|
|
}
|
|
}
|
|
_, port, err := net.SplitHostPort(line)
|
|
if err != nil {
|
|
t.Fatalf("cannot parse published port %q: %v", out, err)
|
|
}
|
|
return port
|
|
}
|
|
|
|
// waitReady retries until the server accepts queries or the deadline passes.
|
|
func waitReady(t *testing.T, driver, dsn string, d time.Duration) *sql.DB {
|
|
t.Helper()
|
|
deadline := time.Now().Add(d)
|
|
var last error
|
|
for time.Now().Before(deadline) {
|
|
db, err := sql.Open(driver, dsn)
|
|
if err == nil {
|
|
if last = db.Ping(); last == nil {
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db
|
|
}
|
|
_ = db.Close()
|
|
} else {
|
|
last = err
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
t.Fatalf("database did not become ready within %s: %v", d, last)
|
|
return nil
|
|
}
|
|
|
|
func TestConformancePostgresContainer(t *testing.T) {
|
|
rt := containerRuntime(t)
|
|
port := startContainer(t, rt, "docker.io/library/postgres:16-alpine", "5432", map[string]string{"POSTGRES_PASSWORD": containerPassword})
|
|
dsn := func(db string) string {
|
|
return fmt.Sprintf("postgres://postgres:%s@127.0.0.1:%s/%s?sslmode=disable", containerPassword, port, db)
|
|
}
|
|
admin := waitReady(t, "pgx", dsn("postgres"), 90*time.Second)
|
|
// The official image restarts once during init: make sure the second start is the one we use.
|
|
time.Sleep(2 * time.Second)
|
|
admin = waitReady(t, "pgx", dsn("postgres"), 60*time.Second)
|
|
for _, name := range []string{"cf_proc", "cf_direct"} {
|
|
if _, err := admin.Exec("CREATE DATABASE " + name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
t.Run("procedure", func(t *testing.T) {
|
|
db, err := sql.Open("pgx", dsn("cf_proc"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
for _, f := range []string{"../database_schema.sql", "../keystore_schema.sql"} {
|
|
b, err := os.ReadFile(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Exec(string(b)); err != nil {
|
|
t.Fatalf("apply %s: %v", f, err)
|
|
}
|
|
}
|
|
runConformance(t, db, "postgres", lookup.Config{Mode: lookup.ModeProcedure}, true)
|
|
})
|
|
t.Run("direct", func(t *testing.T) {
|
|
runOnServer(t, "pgx", dsn("cf_direct"), "postgres", lookup.Config{Mode: lookup.ModeDirect}, true)
|
|
})
|
|
}
|
|
|
|
func TestConformanceMSSQLContainer(t *testing.T) {
|
|
rt := containerRuntime(t)
|
|
port := startContainer(t, rt, "mcr.microsoft.com/mssql/server:2022-latest", "1433", map[string]string{
|
|
"ACCEPT_EULA": "Y", "MSSQL_SA_PASSWORD": containerPassword,
|
|
})
|
|
dsn := func(db string) string {
|
|
return fmt.Sprintf("sqlserver://sa:%s@127.0.0.1:%s?database=%s&encrypt=disable", containerPassword, port, db)
|
|
}
|
|
admin := waitReady(t, "sqlserver", dsn("master"), 3*time.Minute)
|
|
if _, err := admin.Exec("CREATE DATABASE cf_direct"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runOnServer(t, "sqlserver", dsn("cf_direct"), "mssql", lookup.Config{}, true)
|
|
}
|
|
|
|
// TestContainerLifecycle checks the start/stop plumbing the container tests rely on: the
|
|
// container comes up and accepts connections, and after stop it is gone (it runs with --rm).
|
|
func TestContainerLifecycle(t *testing.T) {
|
|
rt := containerRuntime(t)
|
|
port := startContainer(t, rt, "docker.io/library/postgres:16-alpine", "5432", map[string]string{"POSTGRES_PASSWORD": containerPassword})
|
|
waitReady(t, "pgx", fmt.Sprintf("postgres://postgres:%s@127.0.0.1:%s/postgres?sslmode=disable", containerPassword, port), 90*time.Second)
|
|
|
|
listed := func() string {
|
|
return run(t, 30*time.Second, rt, "ps", "-q", "--filter", "ancestor=docker.io/library/postgres:16-alpine")
|
|
}
|
|
id := listed()
|
|
if id == "" {
|
|
t.Fatal("container is not running after start")
|
|
}
|
|
run(t, time.Minute, rt, "stop", "-t", "2", id)
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for listed() != "" {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("container still present after stop")
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
}
|