diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..a2723be --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,182 @@ +# Conformance: backendkit against a real Socrate (#101). +# +# Builds Socrate from ovander/go-oauth2 (main by default), starts it on a +# PostgreSQL 16 service container, seeds three clients and one user per suite +# package (conformance/testdata/socrate-seed, throwaway secrets generated in +# the job), and runs the live tests behind the `conformance` build tag: the +# fixtures in conformance/ against real tokens and documents, and jwtauth, +# socrate, bff and pep against the live server. scripts/conformance-local.sh +# does all of it, here and on a workstation alike. +# +# Informational: it is not a required check and never gates a backendkit pull +# request. It runs nightly, on demand, and on pull requests that change the +# suite itself. A failed nightly or manual run opens an issue labelled +# `conformance` (or comments on the open one). +name: Conformance + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + inputs: + socrate_ref: + description: ovander/go-oauth2 ref to build (branch, tag or commit) + required: false + default: main + pull_request: + paths: + - .github/workflows/conformance.yml + - conformance/** + - scripts/conformance-local.sh + - "**/*conformance*_test.go" + +permissions: + contents: read + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +jobs: + conformance: + name: Socrate conformance + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: socrate + POSTGRES_PASSWORD: ci-only + POSTGRES_DB: socrate_conformance + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U socrate" --health-interval 5s + --health-timeout 5s --health-retries 10 + env: + CONFORMANCE_DATABASE_URL: postgres://socrate:ci-only@localhost:5432/socrate_conformance?sslmode=disable + CONFORMANCE_WORKDIR: ${{ github.workspace }}/conformance-work + SOCRATE_REF: ${{ inputs.socrate_ref || 'main' }} + + steps: + - uses: actions/checkout@v4 + with: + path: backendkit + + - uses: actions/checkout@v4 + with: + repository: ovander/go-oauth2 + ref: ${{ env.SOCRATE_REF }} + path: socrate + + # Each module is built with the Go its go.mod names: its toolchain line, + # or its go line when there is none (the rule both repositories' CI use). + - name: Read the Go versions from go.mod + id: go + run: | + gover() { awk '/^toolchain /{t=$2} /^go /{g="go"$2} END{print (t != "" ? t : g)}' "$1" | sed 's/^go//'; } + echo "socrate=$(gover socrate/go.mod)" >> "$GITHUB_OUTPUT" + echo "backendkit=$(gover backendkit/go.mod)" >> "$GITHUB_OUTPUT" + echo "Socrate $(git -C socrate rev-parse --short HEAD) ($SOCRATE_REF)" >> "$GITHUB_STEP_SUMMARY" + + - name: Set up Go ${{ steps.go.outputs.socrate }} (Socrate) + uses: actions/setup-go@v5 + with: + go-version: ${{ steps.go.outputs.socrate }} + cache: true + cache-dependency-path: | + socrate/go.sum + backendkit/go.sum + + - name: Build Socrate + working-directory: socrate + env: + WANT_GO: ${{ steps.go.outputs.socrate }} + run: | + test "$(go env GOVERSION)" = "go$WANT_GO" + go build -o "$RUNNER_TEMP/socrate" ./cmd/server + + - name: Set up Go ${{ steps.go.outputs.backendkit }} (backendkit) + uses: actions/setup-go@v5 + with: + go-version: ${{ steps.go.outputs.backendkit }} + cache: false + + # Boots the binary on loopback (migrating the empty database), seeds it, + # and runs `go test -tags conformance -run '^TestConformance' ./...`. + - name: Run the conformance suite + working-directory: backendkit + env: + SOCRATE_SRC: ${{ github.workspace }}/socrate + SOCRATE_BIN: ${{ runner.temp }}/socrate + WANT_GO: ${{ steps.go.outputs.backendkit }} + run: | + test "$(go env GOVERSION)" = "go$WANT_GO" + scripts/conformance-local.sh + + - name: Socrate log to the job summary + if: failure() + run: | + { + echo "### Socrate server log (last 200 lines)" + echo '```' + tail -200 "$CONFORMANCE_WORKDIR/server.log" 2>/dev/null || echo "(no log: the server did not start)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the Socrate log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: socrate-server-log + path: ${{ env.CONFORMANCE_WORKDIR }}/server.log + if-no-files-found: ignore + retention-days: 14 + + # Separate job so only this one holds issues: write. + report: + name: Report a failed run + needs: conformance + if: failure() && github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + env: + SOCRATE_REF: ${{ inputs.socrate_ref || 'main' }} + with: + script: | + const label = 'conformance'; + const repo = context.repo; + const run = `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`; + const body = [ + `The Socrate conformance run failed (${context.eventName}, ovander/go-oauth2 \`${process.env.SOCRATE_REF}\`).`, + '', + `Run: ${run}`, + '', + 'Either Socrate changed a shape backendkit or its fixtures rely on (update conformance/fixtures, ' + + 'and the affected package, in a pull request), or the suite itself broke. ' + + 'Reproduce with `scripts/conformance-local.sh`.', + ].join('\n'); + try { + await github.rest.issues.getLabel({ ...repo, name: label }); + } catch (e) { + if (e.status !== 404) throw e; + await github.rest.issues.createLabel({ + ...repo, name: label, color: 'b60205', + description: 'Nightly Socrate conformance run (#101)', + }); + } + const { data: open } = await github.rest.issues.listForRepo({ + ...repo, state: 'open', labels: label, per_page: 1, + }); + if (open.length > 0) { + await github.rest.issues.createComment({ ...repo, issue_number: open[0].number, body }); + core.info(`Commented on #${open[0].number}`); + } else { + const { data: issue } = await github.rest.issues.create({ + ...repo, title: 'Socrate conformance run failed', labels: [label], body, + }); + core.info(`Opened #${issue.number}`); + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 258f35e..233648a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,24 @@ All notable changes to backendkit are documented here. Format: writer, before any identity is injected; with no non-empty scope it rejects every token. A malformed `scope`/`scp` claim only matters with `RequireScopes` (it is then a 403): callers that do not require scopes see no change. +- **`conformance`: what Socrate really issues, as fixtures for your Socrate mock, checked nightly + against a live Socrate** (#101). Each consumer built its own Socrate mock and found the + differences in production. The new package (no other backendkit import) embeds the claim sets + of Socrate's access, ID and refresh tokens (authorization code with and without MFA, refresh, + `client_credentials`, `AUDIENCE_MODE=dual`, namespaced custom claims, DPoP-bound), its token, + userinfo and introspection responses, and its discovery and JWKS documents, with placeholders + for volatile values. + - `Names`, `Raw`, `Load(name, Params)`, `Sign(name, Params, Key)`, `JWKS`, `Discovery`, + `NewKey`, and `Match(name, doc, Params)`, which checks a document against a fixture (same + keys, values by rule). + - Live tests behind the `conformance` build tag check every fixture against a running Socrate, + and test `jwtauth`, `socrate`, `bff` (authorize → callback → refresh → logout) and `pep` + against it. `go test ./...` and CI are unchanged. + - `scripts/conformance-local.sh` builds Socrate from a checkout, starts and seeds it on a + scratch database (`conformance/testdata/socrate-seed`), and runs them. The new **Conformance** + workflow does the same nightly, on demand, and on pull requests that change the suite. It is + informational, not a required check. A failed nightly or manual run opens or updates an issue + labelled `conformance`. ### Fixed - **`WithPendingLoginManagedSchema` now also checks the `UPDATE` privilege.** `Put` is an diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7a6b91..642e54f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,14 @@ govulncheck ./... ``` - Tests sit next to the code (`*_test.go`), table-driven. +- Live tests against a real Socrate sit in `*_conformance_test.go` files behind the `conformance` + build tag, named `TestConformance…`, and import only what their package already may. They are + not part of the required checks: the **Conformance** workflow runs them nightly (and on a pull + request that touches them) against Socrate built from `ovander/go-oauth2` `main`, and opens an + issue labelled `conformance` when they fail. Run them locally with + `scripts/conformance-local.sh` (it needs a scratch PostgreSQL database and a go-oauth2 + checkout), and check they still build with `go vet -tags conformance ./...`. When Socrate + changes a shape on purpose, update `conformance/fixtures` in the same pull request. - Do not weaken a check to get green: no skipped or deleted tests, and no `//nolint` or `t.Skip` without a one-line reason. diff --git a/README.md b/README.md index 914191f..db7d7ef 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ Go developers writing an API or a BFF in the Socrate suite. [apierror](#apierror) · [ctxutil](#ctxutil) · [httpware](#httpware) · [gormlogger](#gormlogger) · [socrate](#socrate) · [jwtauth](#jwtauth) · [bff](#bff) · [pep](#pep) · [tiering](#tiering) · [aigateway](#aigateway) · [ailang](#ailang) · - [ainarration](#ainarration) · [pagination](#pagination) · [buildinfo](#buildinfo) + [ainarration](#ainarration) · [pagination](#pagination) · [buildinfo](#buildinfo) · + [conformance](#conformance) - [Environment variables](#environment-variables) - [Testing](#testing) - [Troubleshooting](#troubleshooting) @@ -87,6 +88,7 @@ backendkit puts that foundation in one versioned dependency: | [`ainarration`](#ainarration) | Generic LRU+TTL narration cache and `CacheKey` helper | | [`pagination`](#pagination) | Query-param parsing and `PagedResponse` | | [`buildinfo`](#buildinfo) | Build-time version metadata (`-ldflags`) and a `/version` HTTP handler | +| [`conformance`](#conformance) | Fixtures of what Socrate really issues (token claim sets, token, userinfo and introspection responses, discovery, JWKS) and helpers to sign Socrate-shaped tokens and check documents against them, for your Socrate mock; verified nightly against a live Socrate | `go get` pulls the whole module, but importing one package brings in only what it builds on: the shared `ctxutil` and `apierror` primitives, plus `socrate` for the two packages that exist to talk @@ -114,6 +116,7 @@ to Socrate (`bff`, `pep`) and `ailang` for `aigateway`. | Parse `?page`/`?per_page` and return paged lists | [`pagination`](#pagination) | | Log GORM queries through logrus / flag slow queries | [`gormlogger`](#gormlogger) | | Expose build/version info on a `/version` endpoint | [`buildinfo`](#buildinfo) | +| Test against Socrate-shaped tokens and documents instead of a hand-written mock | [`conformance`](#conformance) | --- @@ -1047,6 +1050,49 @@ info := buildinfo.Get() Full API: [pkg.go.dev/…/buildinfo](https://pkg.go.dev/github.com/ovander/backendkit/buildinfo). +### conformance + +What Socrate really issues, as fixtures a consumer's Socrate mock loads instead of hand-writing +claims: the claim sets of its access, ID and refresh tokens (authorization code with and without +MFA, refresh, `client_credentials`, `AUDIENCE_MODE=dual`, namespaced custom claims, DPoP-bound), +its token, userinfo and introspection responses, and its discovery and JWKS documents. Volatile +and deployment-specific values are placeholders (``, ``, ``, `tenant_id`, +…) that `Params` fills. The package imports nothing else from backendkit. + +```go +key, _ := conformance.NewKey() // RSA-2048, UUID kid, like Socrate's key ring +jwks, _ := conformance.JWKS(key) // serve at /.well-known/jwks.json +disc, _ := conformance.Discovery(mock.URL) // serve at /.well-known/openid-configuration + +tok, _ := conformance.Sign("access/authorization_code", conformance.Params{ + Issuer: mock.URL, ClientID: "my-app", Subject: "42", Role: "editor", +}, key) // the token Socrate would issue + +claims, _ := conformance.Load("access/client_credentials", conformance.Params{AppID: 12}) +// claims["sub"] == "app:12", claims["aud"] == ["my-app"], claims["scope"] == "api" + +err := conformance.Match("access/authorization_code", myMockClaims, conformance.Params{}) +// nil only if the document has exactly the fixture's keys and every value fits +``` + +| Function | Purpose | +|---|---| +| `Names()` | Every fixture name, e.g. `access/client_credentials_custom_claims`, `discovery` | +| `Raw(name)` | The fixture JSON, placeholders unresolved | +| `Load(name, Params)` | The fixture with every placeholder filled (defaults: the `Default…` constants) | +| `Sign(name, Params, Key)` | A token fixture as an RS256 JWS under Socrate's JOSE header | +| `JWKS(keys…)` / `Discovery(issuer)` / `NewKey()` | The documents a mock serves, and a signing key | +| `Match(name, doc, Params)` | Check a document against a fixture: same keys, literal values equal, placeholders by rule (exactly, when the `Params` field is set) | + +The fixtures stay honest because a nightly workflow (`.github/workflows/conformance.yml`) builds +Socrate from `ovander/go-oauth2` `main`, seeds it, and checks every fixture against the real +tokens and documents; the same run tests `jwtauth`, `socrate`, `bff` and `pep` against the live +server. Those tests sit behind the `conformance` build tag, so `go test ./...` never needs a +server; `scripts/conformance-local.sh` runs them locally (see [Testing](#testing)). A failed +nightly run opens an issue labelled `conformance`. + +Full API: [pkg.go.dev/…/conformance](https://pkg.go.dev/github.com/ovander/backendkit/conformance). + --- ## Environment variables @@ -1109,6 +1155,21 @@ takes any `bff.TokenRefresher`; `bff.Gateway.Now` fixes the clock. runnable `Example*` functions (visible on [pkg.go.dev](https://pkg.go.dev/github.com/ovander/backendkit)) that double as usage docs. +When a test needs Socrate itself — a JWKS, a discovery document, a token with the claims a real +server issues — build the mock from [`conformance`](#conformance) rather than writing claims by +hand: the fixtures are checked against a live Socrate every night. + +backendkit's own live suite runs the same way locally. It wipes the given scratch database, +builds Socrate from a checkout, starts and seeds it on loopback, and runs the tests behind the +`conformance` build tag (they read `SOCRATE_*` variables, listed in the `conformance` package +documentation, and fail without them): + +```bash +SOCRATE_SRC=../go-oauth2 \ +CONFORMANCE_DATABASE_URL='postgres://user:pass@127.0.0.1:5432/socrate_conf?sslmode=disable' \ +scripts/conformance-local.sh +``` + --- ## Troubleshooting diff --git a/bff/bff_conformance_test.go b/bff/bff_conformance_test.go new file mode 100644 index 0000000..ee3e48d --- /dev/null +++ b/bff/bff_conformance_test.go @@ -0,0 +1,326 @@ +//go:build conformance + +// Live conformance: a minimal BFF built from this package runs the whole +// session lifecycle against a running Socrate — authorize (hosted sign-in and +// consent), callback and code exchange, proxying with the session's bearer, +// refresh, logout. Run with scripts/conformance-local.sh (see package +// conformance for the environment). Without that environment the tests fail: +// the build tag is the opt-in. + +package bff_test + +import ( + "context" + "encoding/json" + "html" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/ovander/backendkit/bff" + "github.com/ovander/backendkit/socrate" +) + +const liveUser = "conf-bff@example.test" + +func liveEnv(t *testing.T, name string) string { + t.Helper() + v := os.Getenv(name) + if v == "" { + t.Fatalf("%s is not set: the conformance tests need a running Socrate (scripts/conformance-local.sh)", name) + } + return v +} + +// liveBFF is the smallest BFF this package supports: /login, /callback, +// /logout and the session→bearer proxy for /oauth/userinfo. +type liveBFF struct { + issuer, clientID, redirect string + client *socrate.Client + store *bff.MemoryStore + pending *bff.MemoryPendingLoginStore + binding bff.LoginBinding + gw *bff.Gateway +} + +func newLiveBFF(t *testing.T) (*liveBFF, *httptest.Server) { + t.Helper() + b := &liveBFF{ + issuer: liveEnv(t, "SOCRATE_ISSUER"), + clientID: liveEnv(t, "SOCRATE_PLAIN_CLIENT_ID"), + redirect: liveEnv(t, "SOCRATE_REDIRECT_URI"), + store: bff.NewMemoryStore(time.Hour, 8*time.Hour), + pending: bff.NewMemoryPendingLoginStore(bff.DefaultPendingLoginTTL, 100), + binding: bff.LoginBinding{Cookie: bff.CookieConfig{Name: "conformance_login"}}, + } + c, err := socrate.NewClient(socrate.ClientConfig{ + BaseURL: b.issuer, AdminBaseURL: liveEnv(t, "SOCRATE_ADMIN_URL"), + ClientID: b.clientID, ClientSecret: liveEnv(t, "SOCRATE_PLAIN_CLIENT_SECRET"), Timeout: 30 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + b.client = c + b.gw = &bff.Gateway{Store: b.store, Cookie: bff.CookieConfig{Name: "conformance_session"}, Refresher: c} + upstream, _ := url.Parse(b.issuer) + + mux := http.NewServeMux() + mux.HandleFunc("GET /login", b.login) + mux.HandleFunc("GET /callback", b.callback) + mux.HandleFunc("POST /logout", b.logout) + mux.Handle("/oauth/userinfo", b.gw.ProxyWithSession(bff.NewSingleHostProxy(upstream))) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return b, srv +} + +func (b *liveBFF) login(w http.ResponseWriter, r *http.Request) { + pkce := bff.NewPKCE() + state := bff.RandomToken(24) + nonce := b.binding.Begin(w) + if err := b.pending.Put(r.Context(), state, bff.PendingLogin{ + Verifier: pkce.Verifier, Nonce: nonce, ReturnTo: bff.SanitizeReturnTo("/"), Created: time.Now(), + }); err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + q := url.Values{ + "response_type": {"code"}, "client_id": {b.clientID}, "redirect_uri": {b.redirect}, + "scope": {"openid profile email"}, "state": {state}, "nonce": {bff.RandomToken(16)}, + "code_challenge": {pkce.Challenge}, "code_challenge_method": {"S256"}, + } + http.Redirect(w, r, b.issuer+"/oauth/authorize?"+q.Encode(), http.StatusFound) +} + +func (b *liveBFF) callback(w http.ResponseWriter, r *http.Request) { + pending, ok := b.pending.Take(r.Context(), r.URL.Query().Get("state")) + if !ok || !b.binding.Verify(w, r, pending.Nonce) { + http.Error(w, "unknown login", http.StatusBadRequest) + return + } + if iss := r.URL.Query().Get("iss"); iss != b.issuer { + http.Error(w, "unexpected issuer", http.StatusBadRequest) + return + } + ts, err := b.client.ExchangeCode(r.Context(), r.URL.Query().Get("code"), b.redirect, pending.Verifier) + if err != nil { + http.Error(w, "code exchange: "+err.Error(), http.StatusBadGateway) + return + } + profile, err := b.client.GetCurrentUserProfile(socrate.WithJWT(r.Context(), ts.AccessToken)) + if err != nil || profile == nil { + http.Error(w, "userinfo", http.StatusBadGateway) + return + } + s := bff.NewSession(bff.RandomToken(32), bff.RandomToken(32), ts, + bff.UserInfo{Sub: profile.Sub, Email: profile.Email, Name: profile.Name, Roles: ts.Roles}, time.Now()) + b.store.Put(s) + b.gw.Cookie.SetSession(w, s.ID()) + http.Redirect(w, r, pending.ReturnTo, http.StatusFound) +} + +func (b *liveBFF) logout(w http.ResponseWriter, r *http.Request) { + s, ok := b.gw.SessionFromRequest(r) + if !ok || !b.gw.CheckCSRF(r, s) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + // Revoke the refresh token, end the user's Socrate session, drop ours. + if err := b.client.RevokeToken(r.Context(), s.RefreshToken()); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + if err := b.client.Logout(socrate.WithJWT(r.Context(), s.AccessToken())); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + b.store.Delete(s.ID()) + b.gw.Cookie.ClearSession(w) + w.WriteHeader(http.StatusNoContent) +} + +var ( + liveHidden = regexp.MustCompile(`, without a trailing slash + ClientID string // + Audiences []string // : the client's registered audiences (AUDIENCE_MODE=dual) + Subject string // : the user's numeric id + AppID uint // is "app:" + Role string // and the client's entry in + Email string // + Name string // + Scope string // ; empty uses the fixture's default + Nonce string // + TenantID string // + // Attributes holds the user attributes projects; Load's + // default is {"department": "engineering"}. + Attributes map[string]any + ClaimsNamespace string // ; default DefaultClaimsNamespace + TokenVersion int // ; default 1 + JKT string // + + Now time.Time // , ; default time.Now() + AuthTime time.Time // ; default Now + TTL time.Duration // = Now+TTL, ; default DefaultTTL + RefreshTTL time.Duration // = Now+RefreshTTL; default DefaultRefreshTTL + + // AccessToken, RefreshToken and IDToken fill the token_response fixtures; + // AccessToken also gives an ID token its real at_hash. + AccessToken string + RefreshToken string + IDToken string + + // Keys fill the jwks fixture (one entry each) and . + Keys []Key +} + +// Key is an RSA signing key and the kid Socrate would publish it under. +type Key struct { + ID string + Private *rsa.PrivateKey +} + +// NewKey returns a fresh 2048-bit RSA key with a UUID kid, as Socrate's key +// ring generates them. +func NewKey() (Key, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return Key{}, fmt.Errorf("conformance: generate key: %w", err) + } + return Key{ID: uuid.NewString(), Private: priv}, nil +} + +// Names returns the name of every fixture, sorted, for example +// "access/authorization_code" or "discovery". +func Names() []string { + var names []string + _ = fs.WalkDir(fixtureFS, "fixtures", func(path string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() && strings.HasSuffix(path, ".json") { + names = append(names, strings.TrimSuffix(strings.TrimPrefix(path, "fixtures/"), ".json")) + } + return nil + }) + sort.Strings(names) + return names +} + +// Raw returns the fixture's JSON with its placeholders unresolved. +func Raw(name string) ([]byte, error) { + if name == "" || strings.Contains(name, "..") { + return nil, fmt.Errorf("conformance: unknown fixture %q", name) + } + b, err := fixtureFS.ReadFile("fixtures/" + name + ".json") + if err != nil { + return nil, fmt.Errorf("conformance: unknown fixture %q", name) + } + return b, nil +} + +func parse(name string) (map[string]any, error) { + b, err := Raw(name) + if err != nil { + return nil, err + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("conformance: fixture %s: %w", name, err) + } + return doc, nil +} + +// Load returns the fixture with every placeholder resolved from p. The result +// is a fresh map the caller may change, e.g. to add a claim of its own. +func Load(name string, p Params) (map[string]any, error) { + doc, err := parse(name) + if err != nil { + return nil, err + } + r := newResolver(p) + var out any + if name == "jwks" { + out, err = r.resolveJWKS(doc) + } else { + out, err = r.resolve(doc) + } + if err != nil { + return nil, fmt.Errorf("conformance: fixture %s: %w", name, err) + } + return out.(map[string]any), nil +} + +// Discovery returns the discovery document of a Socrate whose issuer is +// issuer (DPoP off, the server default; load "discovery_dpop" for the other). +func Discovery(issuer string) (map[string]any, error) { + return Load("discovery", Params{Issuer: issuer}) +} + +// JWKS returns the JWKS document Socrate serves for keys. +func JWKS(keys ...Key) (map[string]any, error) { + if len(keys) == 0 { + return nil, errors.New("conformance: JWKS needs at least one key") + } + return Load("jwks", Params{Keys: keys}) +} + +// Sign returns the fixture — an access/, id_token/ or refresh_token/ claim set, +// resolved from p — as a compact JWS signed RS256 with key, under Socrate's +// JOSE header (alg, kid, typ). A resource server validating it against +// JWKS(key) sees exactly what it would see from Socrate. +func Sign(name string, p Params, key Key) (string, error) { + if !strings.HasPrefix(name, "access/") && !strings.HasPrefix(name, "id_token/") && + !strings.HasPrefix(name, "refresh_token/") { + return "", fmt.Errorf("conformance: %s is not a token fixture", name) + } + if key.Private == nil || key.ID == "" { + return "", errors.New("conformance: Sign needs a key with an ID and a private key") + } + claims, err := Load(name, p) + if err != nil { + return "", err + } + header, err := Load("jose_header", Params{Keys: []Key{key}}) + if err != nil { + return "", err + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims)) + tok.Header = header + s, err := tok.SignedString(key.Private) + if err != nil { + return "", fmt.Errorf("conformance: sign %s: %w", name, err) + } + return s, nil +} + +// placeholder reports whether s is a whole placeholder and returns its kind +// and argument ("scope", "api" for ""). +func placeholder(s string) (kind, arg string, ok bool) { + if len(s) < 3 || s[0] != '<' || s[len(s)-1] != '>' || strings.ContainsAny(s[1:len(s)-1], "<>") { + return "", "", false + } + inner := s[1 : len(s)-1] + kind, arg, _ = strings.Cut(inner, ":") + return kind, arg, true +} + +const ( + issuerToken = "" + nsToken = "" +) + +type resolver struct { + p Params + now time.Time +} + +func newResolver(p Params) *resolver { + now := p.Now + if now.IsZero() { + now = time.Now() + } + return &resolver{p: p, now: now} +} + +func or(v, def string) string { + if v != "" { + return v + } + return def +} + +func (r *resolver) clientID() string { return or(r.p.ClientID, DefaultClientID) } +func (r *resolver) role() string { return or(r.p.Role, DefaultRole) } +func (r *resolver) ns() string { return or(r.p.ClaimsNamespace, DefaultClaimsNamespace) } +func (r *resolver) issuer() string { return or(r.p.Issuer, DefaultIssuer) } + +func (r *resolver) ttl() time.Duration { + if r.p.TTL > 0 { + return r.p.TTL + } + return DefaultTTL +} + +func randomB64(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// atHash is the OIDC Core §3.1.3.6 at_hash of an RS256-signed token: the +// left half of its SHA-256, base64url-encoded. +func atHash(accessToken string) string { + sum := sha256.Sum256([]byte(accessToken)) + return base64.RawURLEncoding.EncodeToString(sum[:len(sum)/2]) +} + +func (r *resolver) resolve(v any) (any, error) { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + rv, err := r.resolve(val) + if err != nil { + return nil, err + } + out[strings.ReplaceAll(k, nsToken, r.ns())] = rv + } + return out, nil + case []any: + out := make([]any, 0, len(t)) + for _, el := range t { + if s, ok := el.(string); ok && s == "" { + auds := r.p.Audiences + if len(auds) == 0 { + auds = []string{DefaultAudience} + } + for _, a := range auds { + out = append(out, a) + } + continue + } + rv, err := r.resolve(el) + if err != nil { + return nil, err + } + out = append(out, rv) + } + return out, nil + case string: + kind, arg, ok := placeholder(t) + if !ok { + return strings.ReplaceAll(t, issuerToken, r.issuer()), nil + } + return r.value(kind, arg) + default: + return v, nil + } +} + +// resolveJWKS expands the jwks fixture's single key template once per key. +func (r *resolver) resolveJWKS(doc map[string]any) (any, error) { + tmpls, _ := doc["keys"].([]any) + if len(tmpls) != 1 { + return nil, errors.New("the jwks fixture must hold one key template") + } + tmpl, _ := tmpls[0].(map[string]any) + if len(r.p.Keys) == 0 { + return nil, errors.New("the jwks fixture needs Params.Keys (or use JWKS)") + } + keys := make([]any, 0, len(r.p.Keys)) + for _, k := range r.p.Keys { + if k.Private == nil || k.ID == "" { + return nil, errors.New("every key needs an ID and a private key") + } + entry := make(map[string]any, len(tmpl)) + for name, val := range tmpl { + switch val { + case "": + entry[name] = k.ID + case "": + entry[name] = base64.RawURLEncoding.EncodeToString(k.Private.N.Bytes()) + default: + entry[name] = val + } + } + if e := big.NewInt(int64(k.Private.E)).Bytes(); base64.RawURLEncoding.EncodeToString(e) != entry["e"] { + return nil, fmt.Errorf("key %s: exponent %d is not Socrate's (65537)", k.ID, k.Private.E) + } + keys = append(keys, entry) + } + return map[string]any{"keys": keys}, nil +} + +func (r *resolver) value(kind, arg string) (any, error) { + p := r.p + switch kind { + case "issuer": + return r.issuer(), nil + case "client_id": + return r.clientID(), nil + case "sub": + return or(p.Subject, DefaultSubject), nil + case "app_sub": + id := p.AppID + if id == 0 { + id = DefaultAppID + } + return fmt.Sprintf("app:%d", id), nil + case "role": + return r.role(), nil + case "app_roles": + return map[string]any{r.clientID(): r.role()}, nil + case "email": + return or(p.Email, DefaultEmail), nil + case "name": + return or(p.Name, DefaultName), nil + case "nonce": + return or(p.Nonce, randomB64(12)), nil + case "tenant_id": + return or(p.TenantID, DefaultTenantID), nil + case "attribute": + attrs := p.Attributes + if attrs == nil { + attrs = map[string]any{"department": "engineering"} + } + v, ok := attrs[arg] + if !ok { + return nil, fmt.Errorf("no attribute %q in Params.Attributes", arg) + } + return v, nil + case "scope": + return or(p.Scope, arg), nil + case "iat", "nbf": + return r.now.Unix(), nil + case "auth_time": + if !p.AuthTime.IsZero() { + return p.AuthTime.Unix(), nil + } + return r.now.Unix(), nil + case "exp": + return r.now.Add(r.ttl()).Unix(), nil + case "refresh_exp": + ttl := p.RefreshTTL + if ttl <= 0 { + ttl = DefaultRefreshTTL + } + return r.now.Add(ttl).Unix(), nil + case "expires_in": + return int64(r.ttl().Seconds()), nil + case "jti": + return uuid.NewString(), nil + case "kid": + if len(p.Keys) > 0 && p.Keys[0].ID != "" { + return p.Keys[0].ID, nil + } + return uuid.NewString(), nil + case "token_version": + if p.TokenVersion > 0 { + return int64(p.TokenVersion), nil + } + return int64(1), nil + case "at_hash": + if p.AccessToken != "" { + return atHash(p.AccessToken), nil + } + return randomB64(16), nil + case "jkt": + return or(p.JKT, randomB64(32)), nil + case "access_token", "refresh_token", "id_token": + v := map[string]string{"access_token": p.AccessToken, "refresh_token": p.RefreshToken, "id_token": p.IDToken}[kind] + if v == "" { + return nil, fmt.Errorf("<%s> needs the token in Params", kind) + } + return v, nil + } + return nil, fmt.Errorf("unknown placeholder <%s>", kind) +} diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go new file mode 100644 index 0000000..9639780 --- /dev/null +++ b/conformance/conformance_test.go @@ -0,0 +1,393 @@ +package conformance + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "slices" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// testKey is generated once: RSA generation dominates the test time otherwise. +var testKey = func() Key { + k, err := NewKey() + if err != nil { + panic(err) + } + return k +}() + +func fullParams() Params { + return Params{ + Issuer: "https://socrate.test", ClientID: "app-1", Audiences: []string{"https://api.test", "urn:svc"}, + Subject: "1234", AppID: 99, Role: "admin", Email: "ada@example.test", Name: "Ada", + Nonce: "nonce-1", TenantID: "11111111-2222-4333-8444-555555555555", + Attributes: map[string]any{"department": "research"}, + TokenVersion: 3, JKT: strings.Repeat("A", 43), + Now: time.Unix(1_800_000_000, 0), AuthTime: time.Unix(1_799_999_000, 0), + TTL: 10 * time.Minute, RefreshTTL: 24 * time.Hour, + AccessToken: "aaa.bbb.ccc", RefreshToken: "ddd.eee.fff", IDToken: "ggg.hhh.iii", + Keys: []Key{testKey}, + } +} + +func TestNames(t *testing.T) { + names := Names() + want := []string{ + "access/authorization_code", "access/authorization_code_mfa", "access/refresh", + "access/audience_dual", "access/custom_claims", "access/client_credentials", + "access/client_credentials_audience_dual", "access/client_credentials_custom_claims", + "access/client_credentials_dpop", "id_token/authorization_code", "id_token/authorization_code_mfa", + "id_token/refresh", "id_token/custom_claims", "refresh_token/authorization_code", + "token_response/authorization_code", "token_response/refresh", "token_response/client_credentials", + "userinfo", "introspection/access", "introspection/client_credentials", "introspection/inactive", + "discovery", "discovery_dpop", "jwks", "jose_header", + } + for _, n := range want { + if !slices.Contains(names, n) { + t.Errorf("Names() lacks %q", n) + } + } + if len(names) != len(want) { + t.Errorf("Names() = %d fixtures, want %d: %v", len(names), len(want), names) + } + if !slices.IsSorted(names) { + t.Error("Names() is not sorted") + } +} + +func TestRawUnknown(t *testing.T) { + for _, n := range []string{"", "nope", "../conformance", "access/../discovery", "fixtures/discovery"} { + if _, err := Raw(n); err == nil { + t.Errorf("Raw(%q): want an error", n) + } + if _, err := Load(n, Params{}); err == nil { + t.Errorf("Load(%q): want an error", n) + } + } +} + +func TestLoadResolvesEveryPlaceholder(t *testing.T) { + for _, n := range Names() { + doc, err := Load(n, fullParams()) + if err != nil { + t.Errorf("Load(%s): %v", n, err) + continue + } + raw, _ := json.Marshal(doc) + if strings.Contains(string(raw), "<") || strings.Contains(string(raw), `<`) { + t.Errorf("Load(%s) left a placeholder: %s", n, raw) + } + } +} + +func TestLoadDefaults(t *testing.T) { + claims, err := Load("access/authorization_code", Params{}) + if err != nil { + t.Fatal(err) + } + if claims["iss"] != DefaultIssuer || claims["sub"] != DefaultSubject || claims["role"] != DefaultRole { + t.Errorf("defaults not applied: %v", claims) + } + if aud, _ := claims["aud"].([]any); len(aud) != 1 || aud[0] != DefaultClientID { + t.Errorf("aud = %v, want [%s]", claims["aud"], DefaultClientID) + } + iat, exp := claims["iat"].(int64), claims["exp"].(int64) + if exp-iat != int64(DefaultTTL.Seconds()) { + t.Errorf("exp-iat = %d, want %v", exp-iat, DefaultTTL) + } + cc, err := Load("access/client_credentials", Params{}) + if err != nil { + t.Fatal(err) + } + if cc["sub"] != "app:7" || cc["scope"] != "api" { + t.Errorf("client_credentials defaults: sub=%v scope=%v", cc["sub"], cc["scope"]) + } + dual, _ := Load("access/audience_dual", Params{ClientID: "c"}) + if aud, _ := dual["aud"].([]any); len(aud) != 2 || aud[0] != "c" || aud[1] != DefaultAudience { + t.Errorf("dual aud = %v", dual["aud"]) + } + custom, _ := Load("access/custom_claims", Params{ClaimsNamespace: "urn:x:"}) + if custom["urn:x:tenant_id"] != DefaultTenantID || custom["urn:x:department"] != "engineering" { + t.Errorf("custom claims not namespaced: %v", custom) + } + if _, err := Load("access/custom_claims", Params{Attributes: map[string]any{"other": 1}}); err == nil { + t.Error("want an error for a missing attribute") + } + if _, err := Load("token_response/authorization_code", Params{}); err == nil { + t.Error("want an error when the tokens are not given") + } + if _, err := Load("jwks", Params{}); err == nil { + t.Error("want an error for jwks without keys") + } +} + +func TestLoadReturnsFreshMaps(t *testing.T) { + a, _ := Load("userinfo", Params{}) + a["sub"] = "changed" + b, _ := Load("userinfo", Params{}) + if b["sub"] == "changed" { + t.Error("Load shares state between calls") + } +} + +// Every fixture, filled by Load, matches itself — with the Params pinned and +// with no Params at all. +func TestMatchSelf(t *testing.T) { + for _, n := range Names() { + p := fullParams() + doc, err := Load(n, p) + if err != nil { + t.Fatalf("Load(%s): %v", n, err) + } + if err := Match(n, doc, p); err != nil { + t.Errorf("Match(%s) with pinned params: %v", n, err) + } + p.Issuer, p.ClientID, p.Audiences, p.Subject, p.AppID, p.Role, p.Email, p.Name = "", "", nil, "", 0, "", "", "" + p.Nonce, p.TenantID, p.Attributes, p.TokenVersion, p.JKT = "", "", nil, 0, "" + p.AccessToken, p.RefreshToken, p.IDToken = "", "", "" + if n == "discovery" || n == "discovery_dpop" { + p.Issuer = "https://socrate.test" // its URLs are built from the issuer + } + if err := Match(n, doc, p); err != nil { + t.Errorf("Match(%s) with rules only: %v", n, err) + } + } +} + +func TestMatchDetects(t *testing.T) { + p := Params{Issuer: "https://socrate.test", ClientID: "app-1", Audiences: []string{"https://api.test"}} + base := func(name string) map[string]any { + doc, err := Load(name, p) + if err != nil { + t.Fatal(err) + } + return doc + } + tests := []struct { + name string + fixture string + mutate func(map[string]any) + want string + }{ + {"missing claim", "access/authorization_code", func(c map[string]any) { delete(c, "token_version") }, "token_version: missing"}, + {"extra claim", "access/authorization_code", func(c map[string]any) { c["tenant_id"] = "x" }, "tenant_id: not in the fixture"}, + {"aud as a string", "access/authorization_code", func(c map[string]any) { c["aud"] = "app-1" }, "aud: want an array"}, + {"wrong client", "access/authorization_code", func(c map[string]any) { c["aud"] = []any{"other"} }, `want "app-1"`}, + {"non-numeric sub", "access/authorization_code", func(c map[string]any) { c["sub"] = "user-42" }, "numeric user id"}, + {"uuid sub", "access/authorization_code", func(c map[string]any) { c["sub"] = "6f1c0f3e-0000-4000-8000-000000000000" }, "numeric user id"}, + {"exp before iat", "access/authorization_code", func(c map[string]any) { c["exp"] = c["iat"].(int64) - 1 }, "is not after iat"}, + {"string exp", "access/authorization_code", func(c map[string]any) { c["exp"] = "soon" }, "exp: want an integer"}, + {"wrong acr", "access/authorization_code_mfa", func(c map[string]any) { c["acr"] = "pwd" }, `acr: want "mfa"`}, + {"missing audience", "access/audience_dual", func(c map[string]any) { c["aud"] = []any{"app-1"} }, "registered audiences"}, + {"bare custom claim", "access/custom_claims", func(c map[string]any) { + c["tenant_id"] = c["https://socrate/tenant_id"] + delete(c, "https://socrate/tenant_id") + }, "https://socrate/tenant_id: missing"}, + {"service sub", "access/client_credentials", func(c map[string]any) { c["sub"] = "7" }, `"app:"`}, + {"user claims on a service token", "access/client_credentials", func(c map[string]any) { c["role"] = "user" }, "role: not in the fixture"}, + {"jkt shape", "access/client_credentials_dpop", func(c map[string]any) { c["cnf"] = map[string]any{"jkt": "short"} }, "thumbprint"}, + {"app_roles without the client", "userinfo", func(c map[string]any) { c["app_roles"] = map[string]any{"x": "user"} }, `no entry for the client "app-1"`}, + {"wrong endpoint", "discovery", func(c map[string]any) { c["token_endpoint"] = "https://socrate.test/token" }, "token_endpoint"}, + {"plain PKCE", "discovery", func(c map[string]any) { c["code_challenge_methods_supported"] = []any{"S256", "plain"} }, "want 1 entries"}, + {"jwks alg", "jwks", func(c map[string]any) { c["keys"].([]any)[0].(map[string]any)["alg"] = "RS512" }, "keys[0].alg"}, + {"token type", "token_response/client_credentials", func(c map[string]any) { c["token_type"] = "DPoP" }, `want "Bearer"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := p + q.Keys = []Key{testKey} + q.AccessToken = "a.b.c" + var doc map[string]any + if strings.HasPrefix(tt.fixture, "token_response/") || tt.fixture == "jwks" { + d, err := Load(tt.fixture, q) + if err != nil { + t.Fatal(err) + } + doc = d + } else { + doc = base(tt.fixture) + } + tt.mutate(doc) + err := Match(tt.fixture, doc, p) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("Match = %v, want an error containing %q", err, tt.want) + } + }) + } +} + +func TestMatchNeverEchoesTokens(t *testing.T) { + doc, _ := Load("token_response/client_credentials", Params{AccessToken: "x.y.z"}) + doc["access_token"] = "secret-not-a-jws" + err := Match("token_response/client_credentials", doc, Params{}) + if err == nil || strings.Contains(err.Error(), "secret-not-a-jws") { + t.Errorf("Match = %v: want an error that does not echo the token", err) + } +} + +func TestMatchAcceptsStructsAndMapClaims(t *testing.T) { + claims, _ := Load("access/client_credentials", Params{}) + if err := Match("access/client_credentials", jwt.MapClaims(claims), Params{}); err != nil { + t.Errorf("jwt.MapClaims: %v", err) + } + type inactive struct { + Active bool `json:"active"` + } + if err := Match("introspection/inactive", inactive{}, Params{}); err != nil { + t.Errorf("struct: %v", err) + } + if err := Match("introspection/inactive", []int{1}, Params{}); err == nil { + t.Error("an array is not an object") + } +} + +func publicKeyFromJWK(t *testing.T, jwk map[string]any) *rsa.PublicKey { + t.Helper() + n, err := base64.RawURLEncoding.DecodeString(jwk["n"].(string)) + if err != nil { + t.Fatal(err) + } + e, err := base64.RawURLEncoding.DecodeString(jwk["e"].(string)) + if err != nil { + t.Fatal(err) + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())} +} + +func TestSignVerifiesAgainstJWKS(t *testing.T) { + other, err := NewKey() + if err != nil { + t.Fatal(err) + } + set, err := JWKS(testKey, other) + if err != nil { + t.Fatal(err) + } + if err := Match("jwks", set, Params{}); err != nil { + t.Fatalf("JWKS does not match its fixture: %v", err) + } + byKid := map[string]*rsa.PublicKey{} + for _, k := range set["keys"].([]any) { + jwk := k.(map[string]any) + byKid[jwk["kid"].(string)] = publicKeyFromJWK(t, jwk) + } + + p := Params{Issuer: "https://socrate.test", ClientID: "app-1", Subject: "5"} + for _, n := range Names() { + if !strings.HasPrefix(n, "access/") && !strings.HasPrefix(n, "id_token/") && !strings.HasPrefix(n, "refresh_token/") { + if _, err := Sign(n, p, testKey); err == nil { + t.Errorf("Sign(%s): want an error for a non-token fixture", n) + } + continue + } + tok, err := Sign(n, p, testKey) + if err != nil { + t.Fatalf("Sign(%s): %v", n, err) + } + parsed, err := jwt.Parse(tok, func(tk *jwt.Token) (any, error) { + return byKid[tk.Header["kid"].(string)], nil + }, jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer("https://socrate.test"), jwt.WithAudience("app-1")) + if err != nil { + t.Fatalf("Sign(%s): the token does not verify: %v", n, err) + } + if err := Match("jose_header", parsed.Header, Params{}); err != nil { + t.Errorf("Sign(%s) header: %v", n, err) + } + if parsed.Header["kid"] != testKey.ID { + t.Errorf("Sign(%s) kid = %v, want %s", n, parsed.Header["kid"], testKey.ID) + } + if err := Match(n, parsed.Claims, p); err != nil { + t.Errorf("Sign(%s) claims: %v", n, err) + } + } + if _, err := Sign("access/authorization_code", p, Key{}); err == nil { + t.Error("Sign without a key: want an error") + } +} + +func TestIDTokenAtHash(t *testing.T) { + at, err := Sign("access/authorization_code", Params{}, testKey) + if err != nil { + t.Fatal(err) + } + claims, _ := Load("id_token/authorization_code", Params{AccessToken: at}) + if claims["at_hash"] != atHash(at) || len(claims["at_hash"].(string)) != 22 { + t.Errorf("at_hash = %v, want %s", claims["at_hash"], atHash(at)) + } +} + +func TestDiscovery(t *testing.T) { + d, err := Discovery("https://id.example.org") + if err != nil { + t.Fatal(err) + } + if d["jwks_uri"] != "https://id.example.org/.well-known/jwks.json" { + t.Errorf("jwks_uri = %v", d["jwks_uri"]) + } + // discovery_dpop is discovery plus the DPoP algorithms, nothing else: the + // live suite checks discovery_dpop, so this keeps discovery honest too. + withDPoP, _ := Load("discovery_dpop", Params{Issuer: "https://id.example.org"}) + if got := withDPoP["dpop_signing_alg_values_supported"]; !slices.Equal(toStrings(got), []string{"ES256"}) { + t.Errorf("dpop_signing_alg_values_supported = %v", got) + } + delete(withDPoP, "dpop_signing_alg_values_supported") + a, _ := json.Marshal(d) + b, _ := json.Marshal(withDPoP) + if string(a) != string(b) { + t.Errorf("discovery and discovery_dpop differ beyond the DPoP key:\n%s\n%s", a, b) + } +} + +func toStrings(v any) []string { + var out []string + for _, el := range v.([]any) { + out = append(out, el.(string)) + } + return out +} + +// The plain fixtures of a grant are the dual and custom-claims ones without +// what those add, so a change to one is made to all. +func TestFixtureFamiliesAgree(t *testing.T) { + strip := func(name string, drop ...string) map[string]any { + var doc map[string]any + raw, err := Raw(name) + if err != nil { + t.Fatal(err) + } + _ = json.Unmarshal(raw, &doc) + for _, k := range drop { + delete(doc, k) + } + return doc + } + same := func(a, b map[string]any) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) + } + pairs := [][2]map[string]any{ + {strip("access/authorization_code"), strip("access/refresh")}, + {strip("access/authorization_code", "aud"), strip("access/audience_dual", "aud")}, + {strip("access/authorization_code"), strip("access/custom_claims", "client", "department", "tenant_id")}, + {strip("access/authorization_code", "amr", "acr"), strip("access/authorization_code_mfa", "amr", "acr")}, + {strip("access/client_credentials", "aud"), strip("access/client_credentials_audience_dual", "aud")}, + {strip("access/client_credentials"), strip("access/client_credentials_custom_claims", "client", "tenant_id")}, + {strip("access/client_credentials"), strip("access/client_credentials_dpop", "cnf")}, + {strip("id_token/authorization_code", "nonce"), strip("id_token/refresh")}, + {strip("id_token/authorization_code"), strip("id_token/custom_claims", "tenant_id")}, + {strip("id_token/authorization_code", "amr", "acr"), strip("id_token/authorization_code_mfa", "amr", "acr")}, + {strip("token_response/authorization_code"), strip("token_response/refresh")}, + } + for i, pr := range pairs { + if !same(pr[0], pr[1]) { + t.Errorf("pair %d differs", i) + } + } +} diff --git a/conformance/doc.go b/conformance/doc.go new file mode 100644 index 0000000..7854db7 --- /dev/null +++ b/conformance/doc.go @@ -0,0 +1,103 @@ +// Package conformance holds the shapes Socrate really produces — the claim sets +// of its tokens, its token, userinfo and introspection responses, and its +// discovery and JWKS documents — as fixtures a consumer's Socrate mock loads +// instead of hand-writing claims, plus the helpers to sign Socrate-shaped +// tokens with a test key and to check a document against a fixture. +// +// A mock written from memory drifts from the server: a claim spelt +// differently, aud as a string instead of an array, a custom claim without +// its namespace. Such differences surface in production. The fixtures here are +// checked nightly against a Socrate built from ovander/go-oauth2 main (the +// Conformance workflow, live tests behind the conformance build tag), so a +// mock built on them fails in CI when the server changes, not after a deploy. +// +// # Fixtures +// +// [Names] lists them. Each is a JSON document whose volatile or +// deployment-specific values are placeholders: +// +// access/authorization_code access token, authorization-code grant (password sign-in) +// access/authorization_code_mfa the same after a TOTP or recovery code: amr ["pwd","otp","mfa"], acr "mfa" +// access/refresh access token from the refresh_token grant (auth_time, amr, acr kept) +// access/audience_dual user token, AUDIENCE_MODE=dual, client with a registered audience +// access/custom_claims user token of a client with claim mappings (namespaced custom claims) +// access/client_credentials service-account token: sub "app:", scope "api" by default +// access/client_credentials_audience_dual the same, AUDIENCE_MODE=dual, client with a registered audience +// access/client_credentials_custom_claims the same, client with claim mappings (app and literal sources only) +// access/client_credentials_dpop the same, requested with a DPoP proof: cnf.jkt +// id_token/authorization_code ID token (nonce echoed), and …_mfa, refresh (no nonce), custom_claims +// refresh_token/authorization_code refresh token (Socrate's are JWTs; treat them as opaque) +// token_response/authorization_code /oauth/token response, and …/refresh, …/client_credentials +// userinfo /oauth/userinfo response +// introspection/access /oauth/introspect, active user token, and …/client_credentials, …/inactive +// discovery /.well-known/openid-configuration with DPOP_MODE=off (the default) +// discovery_dpop the same with DPoP enabled (dpop_signing_alg_values_supported) +// jwks /.well-known/jwks.json (one entry per signing key) +// jose_header the JOSE header of every Socrate token +// +// Without a registered audience, or with AUDIENCE_MODE=off (the server default), +// aud is [""]: the plain fixtures. Socrate's audience mode, claim +// namespace and DPoP mode are server-wide settings; the fixtures name the one +// they assume. +// +// # Placeholders +// +// A string value that is a whole placeholder stands for one value; [Load] +// fills it from [Params] (or a default), and [Match] checks it by rule: +// +// Params.Issuer — also inside strings: "/oauth/token" +// Params.ClientID +// in an array only: Params.Audiences, appended after the client_id +// Params.Subject: the user's numeric id as a decimal string +// "app:" + Params.AppID +// Params.Role, the user's role in the client +// {client_id: role} — the user's role in every client they belong to +// , Params.Email, Params.Name +// Params.Nonce, echoed from the authorization request +// Params.TenantID (a UUID) — the value of a literal claim mapping +// Params.Attributes[K] — a user attribute projected by a claim mapping +// Params.Scope, or D, the scope Socrate grants when none is asked for +// , Params.Now, as seconds since the epoch +// Params.AuthTime (default Params.Now) +// Params.Now + Params.TTL; : + Params.RefreshTTL +// Params.TTL in seconds +// , a UUID (kid: the signing key's ID) +// Params.TokenVersion +// the OIDC at_hash of Params.AccessToken (random without it) +// Params.JKT, a JWK SHA-256 thumbprint +// , , Params.AccessToken, … +// the RSA modulus of each key in Params.Keys +// +// A key may contain , the claim namespace (Params.ClaimsNamespace, default +// "https://socrate/", Socrate's CLAIMS_NAMESPACE default): "tenant_id" is +// the claim "https://socrate/tenant_id". +// +// # Using the fixtures in a mock +// +// key, _ := conformance.NewKey() +// jwks, _ := conformance.JWKS(key) // serve at /.well-known/jwks.json +// disc, _ := conformance.Discovery(srv.URL) // serve at /.well-known/openid-configuration +// tok, _ := conformance.Sign("access/authorization_code", conformance.Params{ +// Issuer: srv.URL, ClientID: "my-app", Subject: "42", +// }, key) +// +// [Match] checks a document — a token's claims, a response body — against a +// fixture: same keys, nothing missing or extra, and every value equal to the +// fixture's or satisfying its placeholder's rule. A placeholder whose Params +// field is set must equal it exactly. +// +// # Live checks +// +// Behind the conformance build tag, this package's tests obtain the real +// documents from a running Socrate over raw HTTP (hosted sign-in and consent, +// TOTP enrolment, a DPoP proof) and Match every fixture against them; the jwtauth, socrate, bff +// and pep packages each test themselves against the same server. They read +// SOCRATE_ISSUER, SOCRATE_ADMIN_URL, SOCRATE_REDIRECT_URI, SOCRATE_AUDIENCE, +// SOCRATE_TENANT_ID, SOCRATE_USER_PASSWORD, SOCRATE_POLICY_MODE and, for each +// of the plain, dual and claims clients, SOCRATE__CLIENT_ID +// and _SECRET, and expect a server with AUDIENCE_MODE=dual and DPOP_MODE=observe. +// Every fixture is matched against the live server except discovery, which a +// unit test ties to the live-checked discovery_dpop. +// scripts/conformance-local.sh builds, starts and seeds a Socrate and runs +// them; the Conformance workflow does the same nightly. +package conformance diff --git a/conformance/example_test.go b/conformance/example_test.go new file mode 100644 index 0000000..894f4f5 --- /dev/null +++ b/conformance/example_test.go @@ -0,0 +1,68 @@ +package conformance_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/ovander/backendkit/conformance" +) + +// A Socrate mock for a resource server's tests: its JWKS and discovery +// documents, and access tokens with the claims Socrate really issues. +func Example_mock() { + key, err := conformance.NewKey() + if err != nil { + panic(err) + } + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + + serve := func(doc map[string]any) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + } + } + jwks, _ := conformance.JWKS(key) + disc, _ := conformance.Discovery(srv.URL) + mux.Handle("/.well-known/jwks.json", serve(jwks)) + mux.Handle("/.well-known/openid-configuration", serve(disc)) + + // A user's access token for the client "my-app"… + userToken, _ := conformance.Sign("access/authorization_code", conformance.Params{ + Issuer: srv.URL, ClientID: "my-app", Subject: "42", Role: "editor", + }, key) + // …and a service account's, carrying a tenant through a literal claim mapping. + serviceToken, _ := conformance.Sign("access/client_credentials_custom_claims", conformance.Params{ + Issuer: srv.URL, ClientID: "billing-worker", AppID: 12, + TenantID: "5b0c7a52-3e0c-4d2a-9f8e-0d6f1a2b3c4d", + }, key) + + fmt.Println(userToken != "" && serviceToken != "") + // Output: true +} + +func ExampleLoad() { + claims, err := conformance.Load("access/client_credentials", conformance.Params{ + Issuer: "https://socrate.example.com", ClientID: "billing-worker", AppID: 12, + }) + if err != nil { + panic(err) + } + fmt.Println(claims["sub"], claims["aud"], claims["scope"], claims["type"]) + // Output: app:12 [billing-worker] api access +} + +func ExampleMatch() { + // A hand-written mock token: aud as a bare string, no type, no jti. + handWritten := map[string]any{ + "iss": "https://socrate.example.com", "sub": "42", "aud": "my-app", + "iat": 1700000000, "exp": 1700000900, + } + err := conformance.Match("access/client_credentials", handWritten, conformance.Params{}) + fmt.Println(err != nil) + // Output: true +} diff --git a/conformance/fixtures/access/audience_dual.json b/conformance/fixtures/access/audience_dual.json new file mode 100644 index 0000000..ae48921 --- /dev/null +++ b/conformance/fixtures/access/audience_dual.json @@ -0,0 +1,25 @@ +{ + "acr": "pwd", + "amr": [ + "pwd" + ], + "app_roles": "", + "aud": [ + "", + "" + ], + "auth_time": "", + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "role": "", + "roles": [ + "" + ], + "scope": "", + "sub": "", + "token_version": "", + "type": "access" +} diff --git a/conformance/fixtures/access/authorization_code.json b/conformance/fixtures/access/authorization_code.json new file mode 100644 index 0000000..9430bb2 --- /dev/null +++ b/conformance/fixtures/access/authorization_code.json @@ -0,0 +1,18 @@ +{ + "acr": "pwd", + "amr": ["pwd"], + "app_roles": "", + "aud": [""], + "auth_time": "", + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "role": "", + "roles": [""], + "scope": "", + "sub": "", + "token_version": "", + "type": "access" +} diff --git a/conformance/fixtures/access/authorization_code_mfa.json b/conformance/fixtures/access/authorization_code_mfa.json new file mode 100644 index 0000000..6c2adfc --- /dev/null +++ b/conformance/fixtures/access/authorization_code_mfa.json @@ -0,0 +1,26 @@ +{ + "acr": "mfa", + "amr": [ + "pwd", + "otp", + "mfa" + ], + "app_roles": "", + "aud": [ + "" + ], + "auth_time": "", + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "role": "", + "roles": [ + "" + ], + "scope": "", + "sub": "", + "token_version": "", + "type": "access" +} diff --git a/conformance/fixtures/access/client_credentials.json b/conformance/fixtures/access/client_credentials.json new file mode 100644 index 0000000..88d89a8 --- /dev/null +++ b/conformance/fixtures/access/client_credentials.json @@ -0,0 +1,13 @@ +{ + "aud": [ + "" + ], + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "scope": "", + "sub": "", + "type": "access" +} diff --git a/conformance/fixtures/access/client_credentials_audience_dual.json b/conformance/fixtures/access/client_credentials_audience_dual.json new file mode 100644 index 0000000..59da170 --- /dev/null +++ b/conformance/fixtures/access/client_credentials_audience_dual.json @@ -0,0 +1,14 @@ +{ + "aud": [ + "", + "" + ], + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "scope": "", + "sub": "", + "type": "access" +} diff --git a/conformance/fixtures/access/client_credentials_custom_claims.json b/conformance/fixtures/access/client_credentials_custom_claims.json new file mode 100644 index 0000000..c597a55 --- /dev/null +++ b/conformance/fixtures/access/client_credentials_custom_claims.json @@ -0,0 +1,15 @@ +{ + "client": "", + "tenant_id": "", + "aud": [ + "" + ], + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "scope": "", + "sub": "", + "type": "access" +} diff --git a/conformance/fixtures/access/client_credentials_dpop.json b/conformance/fixtures/access/client_credentials_dpop.json new file mode 100644 index 0000000..d1232c7 --- /dev/null +++ b/conformance/fixtures/access/client_credentials_dpop.json @@ -0,0 +1,16 @@ +{ + "aud": [ + "" + ], + "cnf": { + "jkt": "" + }, + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "scope": "", + "sub": "", + "type": "access" +} diff --git a/conformance/fixtures/access/custom_claims.json b/conformance/fixtures/access/custom_claims.json new file mode 100644 index 0000000..1cb46be --- /dev/null +++ b/conformance/fixtures/access/custom_claims.json @@ -0,0 +1,27 @@ +{ + "client": "", + "department": "", + "tenant_id": "", + "acr": "pwd", + "amr": [ + "pwd" + ], + "app_roles": "", + "aud": [ + "" + ], + "auth_time": "", + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "role": "", + "roles": [ + "" + ], + "scope": "", + "sub": "", + "token_version": "", + "type": "access" +} diff --git a/conformance/fixtures/access/refresh.json b/conformance/fixtures/access/refresh.json new file mode 100644 index 0000000..fad31fa --- /dev/null +++ b/conformance/fixtures/access/refresh.json @@ -0,0 +1,24 @@ +{ + "acr": "pwd", + "amr": [ + "pwd" + ], + "app_roles": "", + "aud": [ + "" + ], + "auth_time": "", + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "role": "", + "roles": [ + "" + ], + "scope": "", + "sub": "", + "token_version": "", + "type": "access" +} diff --git a/conformance/fixtures/discovery.json b/conformance/fixtures/discovery.json new file mode 100644 index 0000000..f6f5a26 --- /dev/null +++ b/conformance/fixtures/discovery.json @@ -0,0 +1,67 @@ +{ + "issuer": "", + "authorization_endpoint": "/oauth/authorize", + "token_endpoint": "/oauth/token", + "userinfo_endpoint": "/oauth/userinfo", + "jwks_uri": "/.well-known/jwks.json", + "introspection_endpoint": "/oauth/introspect", + "revocation_endpoint": "/oauth/revoke", + "response_types_supported": [ + "code" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "client_credentials" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile", + "offline_access", + "api" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post" + ], + "claims_supported": [ + "sub", + "iss", + "aud", + "exp", + "iat", + "nbf", + "email", + "email_verified", + "name", + "preferred_username", + "picture", + "role", + "app_roles", + "token_version", + "auth_time", + "acr", + "amr", + "act", + "cnf" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "prompt_values_supported": [ + "none", + "login" + ], + "acr_values_supported": [ + "pwd", + "mfa" + ], + "authorization_response_iss_parameter_supported": true +} diff --git a/conformance/fixtures/discovery_dpop.json b/conformance/fixtures/discovery_dpop.json new file mode 100644 index 0000000..c3bc891 --- /dev/null +++ b/conformance/fixtures/discovery_dpop.json @@ -0,0 +1,70 @@ +{ + "issuer": "", + "authorization_endpoint": "/oauth/authorize", + "token_endpoint": "/oauth/token", + "userinfo_endpoint": "/oauth/userinfo", + "jwks_uri": "/.well-known/jwks.json", + "introspection_endpoint": "/oauth/introspect", + "revocation_endpoint": "/oauth/revoke", + "response_types_supported": [ + "code" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "client_credentials" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile", + "offline_access", + "api" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post" + ], + "claims_supported": [ + "sub", + "iss", + "aud", + "exp", + "iat", + "nbf", + "email", + "email_verified", + "name", + "preferred_username", + "picture", + "role", + "app_roles", + "token_version", + "auth_time", + "acr", + "amr", + "act", + "cnf" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "prompt_values_supported": [ + "none", + "login" + ], + "acr_values_supported": [ + "pwd", + "mfa" + ], + "dpop_signing_alg_values_supported": [ + "ES256" + ], + "authorization_response_iss_parameter_supported": true +} diff --git a/conformance/fixtures/id_token/authorization_code.json b/conformance/fixtures/id_token/authorization_code.json new file mode 100644 index 0000000..d948c1a --- /dev/null +++ b/conformance/fixtures/id_token/authorization_code.json @@ -0,0 +1,25 @@ +{ + "acr": "pwd", + "amr": [ + "pwd" + ], + "app_roles": "", + "at_hash": "", + "aud": [ + "" + ], + "auth_time": "", + "email": "", + "email_verified": true, + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "name": "", + "nbf": "", + "nonce": "", + "preferred_username": "", + "role": "", + "sub": "", + "type": "id_token" +} diff --git a/conformance/fixtures/id_token/authorization_code_mfa.json b/conformance/fixtures/id_token/authorization_code_mfa.json new file mode 100644 index 0000000..ffc15e2 --- /dev/null +++ b/conformance/fixtures/id_token/authorization_code_mfa.json @@ -0,0 +1,27 @@ +{ + "acr": "mfa", + "amr": [ + "pwd", + "otp", + "mfa" + ], + "app_roles": "", + "at_hash": "", + "aud": [ + "" + ], + "auth_time": "", + "email": "", + "email_verified": true, + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "name": "", + "nbf": "", + "nonce": "", + "preferred_username": "", + "role": "", + "sub": "", + "type": "id_token" +} diff --git a/conformance/fixtures/id_token/custom_claims.json b/conformance/fixtures/id_token/custom_claims.json new file mode 100644 index 0000000..78bc7a5 --- /dev/null +++ b/conformance/fixtures/id_token/custom_claims.json @@ -0,0 +1,26 @@ +{ + "tenant_id": "", + "acr": "pwd", + "amr": [ + "pwd" + ], + "app_roles": "", + "at_hash": "", + "aud": [ + "" + ], + "auth_time": "", + "email": "", + "email_verified": true, + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "name": "", + "nbf": "", + "nonce": "", + "preferred_username": "", + "role": "", + "sub": "", + "type": "id_token" +} diff --git a/conformance/fixtures/id_token/refresh.json b/conformance/fixtures/id_token/refresh.json new file mode 100644 index 0000000..e153de5 --- /dev/null +++ b/conformance/fixtures/id_token/refresh.json @@ -0,0 +1,24 @@ +{ + "acr": "pwd", + "amr": [ + "pwd" + ], + "app_roles": "", + "at_hash": "", + "aud": [ + "" + ], + "auth_time": "", + "email": "", + "email_verified": true, + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "name": "", + "nbf": "", + "preferred_username": "", + "role": "", + "sub": "", + "type": "id_token" +} diff --git a/conformance/fixtures/introspection/access.json b/conformance/fixtures/introspection/access.json new file mode 100644 index 0000000..266c606 --- /dev/null +++ b/conformance/fixtures/introspection/access.json @@ -0,0 +1,18 @@ +{ + "acr": "pwd", + "active": true, + "amr": [ + "pwd" + ], + "aud": [ + "" + ], + "auth_time": "", + "client_id": "", + "exp": "", + "iat": "", + "scope": "", + "sub": "", + "token_type": "Bearer", + "username": "" +} diff --git a/conformance/fixtures/introspection/client_credentials.json b/conformance/fixtures/introspection/client_credentials.json new file mode 100644 index 0000000..08c06f3 --- /dev/null +++ b/conformance/fixtures/introspection/client_credentials.json @@ -0,0 +1,12 @@ +{ + "active": true, + "aud": [ + "" + ], + "client_id": "", + "exp": "", + "iat": "", + "scope": "", + "sub": "", + "token_type": "Bearer" +} diff --git a/conformance/fixtures/introspection/inactive.json b/conformance/fixtures/introspection/inactive.json new file mode 100644 index 0000000..156c781 --- /dev/null +++ b/conformance/fixtures/introspection/inactive.json @@ -0,0 +1,3 @@ +{ + "active": false +} diff --git a/conformance/fixtures/jose_header.json b/conformance/fixtures/jose_header.json new file mode 100644 index 0000000..1c9ca93 --- /dev/null +++ b/conformance/fixtures/jose_header.json @@ -0,0 +1,5 @@ +{ + "alg": "RS256", + "kid": "", + "typ": "JWT" +} diff --git a/conformance/fixtures/jwks.json b/conformance/fixtures/jwks.json new file mode 100644 index 0000000..628915c --- /dev/null +++ b/conformance/fixtures/jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "kid": "", + "alg": "RS256", + "n": "", + "e": "AQAB" + } + ] +} diff --git a/conformance/fixtures/refresh_token/authorization_code.json b/conformance/fixtures/refresh_token/authorization_code.json new file mode 100644 index 0000000..26d304f --- /dev/null +++ b/conformance/fixtures/refresh_token/authorization_code.json @@ -0,0 +1,24 @@ +{ + "acr": "pwd", + "amr": [ + "pwd" + ], + "app_roles": "", + "aud": [ + "" + ], + "auth_time": "", + "exp": "", + "iat": "", + "iss": "", + "jti": "", + "nbf": "", + "role": "", + "roles": [ + "" + ], + "scope": "", + "sub": "", + "type": "refresh", + "ver": "" +} diff --git a/conformance/fixtures/token_response/authorization_code.json b/conformance/fixtures/token_response/authorization_code.json new file mode 100644 index 0000000..f0e5255 --- /dev/null +++ b/conformance/fixtures/token_response/authorization_code.json @@ -0,0 +1,12 @@ +{ + "access_token": "", + "app_roles": "", + "expires_in": "", + "id_token": "", + "refresh_token": "", + "roles": [ + "" + ], + "scope": "", + "token_type": "Bearer" +} diff --git a/conformance/fixtures/token_response/client_credentials.json b/conformance/fixtures/token_response/client_credentials.json new file mode 100644 index 0000000..827b76b --- /dev/null +++ b/conformance/fixtures/token_response/client_credentials.json @@ -0,0 +1,6 @@ +{ + "access_token": "", + "expires_in": "", + "scope": "", + "token_type": "Bearer" +} diff --git a/conformance/fixtures/token_response/refresh.json b/conformance/fixtures/token_response/refresh.json new file mode 100644 index 0000000..f0e5255 --- /dev/null +++ b/conformance/fixtures/token_response/refresh.json @@ -0,0 +1,12 @@ +{ + "access_token": "", + "app_roles": "", + "expires_in": "", + "id_token": "", + "refresh_token": "", + "roles": [ + "" + ], + "scope": "", + "token_type": "Bearer" +} diff --git a/conformance/fixtures/userinfo.json b/conformance/fixtures/userinfo.json new file mode 100644 index 0000000..da33b29 --- /dev/null +++ b/conformance/fixtures/userinfo.json @@ -0,0 +1,9 @@ +{ + "app_roles": "", + "email": "", + "email_verified": true, + "name": "", + "preferred_username": "", + "role": "", + "sub": "" +} diff --git a/conformance/live_conformance_test.go b/conformance/live_conformance_test.go new file mode 100644 index 0000000..74521f3 --- /dev/null +++ b/conformance/live_conformance_test.go @@ -0,0 +1,473 @@ +//go:build conformance + +// Live checks: every fixture against what a running Socrate really returns. +// Run with scripts/conformance-local.sh, or see the package documentation for +// the environment they read. Without it they fail: the conformance build tag +// is the opt-in. + +package conformance + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "html" + "io" + "math/big" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const userScope = "openid profile email" + +type liveClient struct{ id, secret string } + +type live struct { + issuer, redirect, audience, tenant, password string + plain, dual, claims liveClient + keys map[string]*rsa.PublicKey +} + +func liveEnv(t *testing.T) *live { + t.Helper() + get := func(name string) string { + v := os.Getenv(name) + if v == "" { + t.Fatalf("%s is not set: the conformance tests need a running Socrate (scripts/conformance-local.sh)", name) + } + return v + } + l := &live{ + issuer: strings.TrimSuffix(get("SOCRATE_ISSUER"), "/"), + redirect: get("SOCRATE_REDIRECT_URI"), + audience: get("SOCRATE_AUDIENCE"), + tenant: get("SOCRATE_TENANT_ID"), + password: get("SOCRATE_USER_PASSWORD"), + plain: liveClient{get("SOCRATE_PLAIN_CLIENT_ID"), get("SOCRATE_PLAIN_CLIENT_SECRET")}, + dual: liveClient{get("SOCRATE_DUAL_CLIENT_ID"), get("SOCRATE_DUAL_CLIENT_SECRET")}, + claims: liveClient{get("SOCRATE_CLAIMS_CLIENT_ID"), get("SOCRATE_CLAIMS_CLIENT_SECRET")}, + keys: map[string]*rsa.PublicKey{}, + } + jwks := l.getJSON(t, l.issuer+"/.well-known/jwks.json") + keys, _ := jwks["keys"].([]any) + for _, k := range keys { + jwk, _ := k.(map[string]any) + n, err1 := base64.RawURLEncoding.DecodeString(fmt.Sprint(jwk["n"])) + e, err2 := base64.RawURLEncoding.DecodeString(fmt.Sprint(jwk["e"])) + if err1 != nil || err2 != nil { + t.Fatalf("JWKS key %v: undecodable n or e", jwk["kid"]) + } + l.keys[fmt.Sprint(jwk["kid"])] = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())} + } + if len(l.keys) == 0 { + t.Fatal("the live JWKS has no key") + } + return l +} + +func email(name string) string { return name + "@example.test" } + +func (l *live) getJSON(t *testing.T, u string) map[string]any { + t.Helper() + resp, err := http.Get(u) + if err != nil { + t.Fatalf("GET %s: %v", u, err) + } + return decodeBody(t, resp, http.StatusOK) +} + +func decodeBody(t *testing.T, resp *http.Response, want int) map[string]any { + t.Helper() + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != want { + t.Fatalf("%s %s: HTTP %d, want %d: %.300s", resp.Request.Method, resp.Request.URL.Path, resp.StatusCode, want, b) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("%s: not a JSON object: %v", resp.Request.URL.Path, err) + } + return doc +} + +// verify checks tok's RS256 signature against the live JWKS and returns its +// header and claims. +func (l *live) verify(t *testing.T, tok string) (map[string]any, map[string]any) { + t.Helper() + parsed, err := jwt.Parse(tok, func(tk *jwt.Token) (any, error) { + k, ok := l.keys[fmt.Sprint(tk.Header["kid"])] + if !ok { + return nil, fmt.Errorf("kid %v is not in the JWKS", tk.Header["kid"]) + } + return k, nil + }, jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer(l.issuer)) + if err != nil { + t.Fatalf("token does not verify against the live JWKS: %v", err) + } + if err := Match("jose_header", parsed.Header, Params{}); err != nil { + t.Errorf("JOSE header: %v", err) + } + return parsed.Header, map[string]any(parsed.Claims.(jwt.MapClaims)) +} + +func check(t *testing.T, name string, got any, p Params) { + t.Helper() + if err := Match(name, got, p); err != nil { + t.Errorf("live %v", err) + } +} + +// signIn runs the hosted Authorization Code + PKCE flow — sign-in form, +// consent, callback — and returns the token response. +func (l *live) signIn(t *testing.T, c liveClient, user, nonce, mfaCode string) map[string]any { + t.Helper() + jar, _ := cookiejar.New(nil) + hc := &http.Client{Jar: jar, Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + + verifier := base64.RawURLEncoding.EncodeToString(randomBytes(32)) + sum := sha256.Sum256([]byte(verifier)) + state := uuid.NewString() + q := url.Values{ + "response_type": {"code"}, "client_id": {c.id}, "redirect_uri": {l.redirect}, + "scope": {userScope}, "state": {state}, "nonce": {nonce}, + "code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])}, "code_challenge_method": {"S256"}, + } + page := htmlPage(t, hc, http.MethodGet, l.issuer+"/oauth/authorize?"+q.Encode(), nil) + form := hiddenInputs(page) + form.Set("email", email(user)) + form.Set("password", l.password) + if mfaCode != "" { + form.Set("mfa_code", mfaCode) + } + page = htmlPage(t, hc, http.MethodPost, l.issuer+"/oauth/authorize", form) + form = hiddenInputs(page) + if form.Get("consent_token") == "" { + t.Fatalf("sign-in of %s did not reach the consent page", user) + } + form.Set("authorized", "true") + page = htmlPage(t, hc, http.MethodPost, l.issuer+"/oauth/authorize", form) + m := regexp.MustCompile(`http-equiv="refresh" content="0;url=([^"]+)"`).FindStringSubmatch(page) + if m == nil { + t.Fatalf("consent did not redirect to the client") + } + cb, err := url.Parse(html.UnescapeString(m[1])) + if err != nil { + t.Fatalf("callback URL: %v", err) + } + if got := cb.Query().Get("state"); got != state { + t.Fatalf("callback state = %q, want %q", got, state) + } + if got := cb.Query().Get("iss"); got != l.issuer { + t.Errorf("callback iss = %q, want %q (RFC 9207)", got, l.issuer) + } + code := cb.Query().Get("code") + if code == "" { + t.Fatalf("callback has no code (error=%q)", cb.Query().Get("error")) + } + return l.token(t, c, url.Values{ + "grant_type": {"authorization_code"}, "code": {code}, + "redirect_uri": {l.redirect}, "code_verifier": {verifier}, + }, "") +} + +func htmlPage(t *testing.T, hc *http.Client, method, u string, form url.Values) string { + t.Helper() + var body io.Reader + if form != nil { + body = strings.NewReader(form.Encode()) + } + req, _ := http.NewRequestWithContext(context.Background(), method, u, body) + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + resp, err := hc.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, req.URL.Path, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s %s: HTTP %d", method, req.URL.Path, resp.StatusCode) + } + if m := regexp.MustCompile(`(?s)class="alert alert-error">\s*(.*?)\s*`).FindStringSubmatch(string(b)); m != nil { + t.Fatalf("%s %s: the page shows an error: %s", method, req.URL.Path, html.UnescapeString(m[1])) + } + return string(b) +} + +var reHidden = regexp.MustCompile(` keys use Params.ClaimsNamespace or the default. +// got may be any value that marshals to a JSON object (jwt.MapClaims, a +// struct, a map). The error lists every difference found. +func Match(name string, got any, p Params) error { + want, err := parse(name) + if err != nil { + return err + } + b, err := json.Marshal(got) + if err != nil { + return fmt.Errorf("conformance: marshal the document: %w", err) + } + var doc any + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.UseNumber() + if err := dec.Decode(&doc); err != nil { + return fmt.Errorf("conformance: decode the document: %w", err) + } + m := &matcher{p: p} + if name == "jwks" { + m.jwks = true + } + m.match("", want, doc) + if len(m.errs) > 0 { + return fmt.Errorf("conformance: %s: %w", name, errors.Join(m.errs...)) + } + return nil +} + +type matcher struct { + p Params + jwks bool + errs []error +} + +func (m *matcher) fail(path, format string, args ...any) { + if path == "" { + path = "(document)" + } + m.errs = append(m.errs, fmt.Errorf("%s: %s", path, fmt.Sprintf(format, args...))) +} + +func join(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} + +func (m *matcher) ns() string { return or(m.p.ClaimsNamespace, DefaultClaimsNamespace) } + +func (m *matcher) match(path string, want, got any) { + switch w := want.(type) { + case map[string]any: + g, ok := got.(map[string]any) + if !ok { + m.fail(path, "want an object, got %s", describe(got)) + return + } + m.matchObject(path, w, g) + case []any: + g, ok := got.([]any) + if !ok { + m.fail(path, "want an array, got %s", describe(got)) + return + } + m.matchArray(path, w, g) + case string: + if kind, arg, ok := placeholder(w); ok { + m.rule(path, kind, arg, got) + return + } + g, ok := got.(string) + if !ok { + m.fail(path, "want the string %q, got %s", w, describe(got)) + return + } + if strings.Contains(w, issuerToken) { + rest := strings.SplitN(w, issuerToken, 2) + if m.p.Issuer != "" { + w = strings.ReplaceAll(w, issuerToken, m.p.Issuer) + } else if strings.HasPrefix(g, rest[0]) && strings.HasSuffix(g, rest[1]) && len(g) > len(rest[0])+len(rest[1]) { + return + } + } + if g != w { + m.fail(path, "want %q, got %q", w, g) + } + case bool: + if g, ok := got.(bool); !ok || g != w { + m.fail(path, "want %v, got %s", w, describe(got)) + } + case float64: + g, ok := number(got) + if !ok || g != w { + m.fail(path, "want %v, got %s", w, describe(got)) + } + case nil: + if got != nil { + m.fail(path, "want null, got %s", describe(got)) + } + } +} + +func (m *matcher) matchObject(path string, w, g map[string]any) { + expected := make(map[string]any, len(w)) + for k, v := range w { + expected[strings.ReplaceAll(k, nsToken, m.ns())] = v + } + for _, k := range sortedKeys(expected) { + gv, ok := g[k] + if !ok { + m.fail(join(path, k), "missing") + continue + } + m.match(join(path, k), expected[k], gv) + } + for _, k := range sortedKeys(g) { + if _, ok := expected[k]; !ok { + m.fail(join(path, k), "not in the fixture (value %s)", describe(g[k])) + } + } + // Time ordering: a token expires after it is issued. + if iat, ok := number(g["iat"]); ok { + if exp, ok := number(g["exp"]); ok && exp <= iat { + m.fail(join(path, "exp"), "exp %v is not after iat %v", exp, iat) + } + } +} + +func (m *matcher) matchArray(path string, w, g []any) { + // The jwks fixture holds one key template that every published key matches. + if m.jwks && path == "keys" && len(w) == 1 { + if len(g) == 0 { + m.fail(path, "no key") + } + for i, el := range g { + m.match(fmt.Sprintf("%s[%d]", path, i), w[0], el) + } + return + } + // stands for the registered audiences, after the fixed entries. + for i, el := range w { + if el != "" { + continue + } + if i != len(w)-1 { + m.fail(path, "fixture error: must be the last entry") + return + } + if len(g) < i+1 { + m.fail(path, "want %d fixed entries followed by the registered audiences, got %s", i, describe(g)) + return + } + for j := 0; j < i; j++ { + m.match(fmt.Sprintf("%s[%d]", path, j), w[j], g[j]) + } + rest := g[i:] + if len(m.p.Audiences) > 0 { + if len(rest) != len(m.p.Audiences) { + m.fail(path, "want the audiences %q after the fixed entries, got %s", m.p.Audiences, describe(rest)) + return + } + for j, a := range m.p.Audiences { + if s, _ := rest[j].(string); s != a { + m.fail(fmt.Sprintf("%s[%d]", path, i+j), "want %q, got %s", a, describe(rest[j])) + } + } + return + } + for j, el := range rest { + if s, ok := el.(string); !ok || s == "" { + m.fail(fmt.Sprintf("%s[%d]", path, i+j), "want an audience string, got %s", describe(el)) + } + } + return + } + if len(g) != len(w) { + m.fail(path, "want %d entries, got %d: %s", len(w), len(g), describe(g)) + return + } + for i := range w { + m.match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i]) + } +} + +// rule checks got against the placeholder . +func (m *matcher) rule(path, kind, arg string, got any) { + p := m.p + str := func(re *regexp.Regexp, desc, exact string) { + s, ok := got.(string) + switch { + case !ok: + m.fail(path, "want %s, got %s", desc, describe(got)) + case exact != "" && s != exact: + m.fail(path, "want %q, got %q", exact, s) + case exact == "" && re != nil && !re.MatchString(s): + m.fail(path, "want %s, got %q", desc, s) + case exact == "" && re == nil && s == "": + m.fail(path, "want %s, got an empty string", desc) + } + } + integer := func(min float64) { + n, ok := number(got) + if !ok || n != math.Trunc(n) || n < min { + m.fail(path, "want an integer of at least %v, got %s", min, describe(got)) + } + } + switch kind { + case "issuer": + str(nil, "the issuer", p.Issuer) + case "client_id": + str(nil, "a client_id", p.ClientID) + case "sub": + str(reDecimal, "a numeric user id as a decimal string", p.Subject) + case "app_sub": + exact := "" + if p.AppID != 0 { + exact = fmt.Sprintf("app:%d", p.AppID) + } + str(reAppSub, `"app:"`, exact) + case "role": + str(nil, "a role", p.Role) + case "email": + str(reEmail, "an email address", p.Email) + case "name": + str(nil, "a name", p.Name) + case "nonce": + str(nil, "the nonce", p.Nonce) + case "tenant_id": + str(reUUID, "a UUID", p.TenantID) + case "jti", "kid": + str(reUUID, "a UUID", "") + case "at_hash", "n": + str(reB64URL, "a base64url string", "") + case "jkt": + if p.JKT != "" { + str(nil, "the JWK thumbprint", p.JKT) + } else if s, ok := got.(string); !ok || len(s) != 43 || !reB64URL.MatchString(s) { + m.fail(path, "want a base64url SHA-256 thumbprint (43 characters), got %s", describe(got)) + } + case "access_token", "refresh_token", "id_token": + exact := map[string]string{"access_token": p.AccessToken, "refresh_token": p.RefreshToken, "id_token": p.IDToken}[kind] + // Never echo a token into an error message. + if s, ok := got.(string); !ok || !reJWS.MatchString(s) { + m.fail(path, "want a compact JWS (header.payload.signature)") + } else if exact != "" && s != exact { + m.fail(path, "not the token given in Params") + } + case "scope": + str(nil, "a scope", or(p.Scope, arg)) + case "attribute": + if got == nil { + m.fail(path, "want the attribute %q, got null", arg) + } else if v, ok := p.Attributes[arg]; ok && !sameJSON(v, got) { + m.fail(path, "want %s, got %s", describe(v), describe(got)) + } + case "iat", "nbf", "auth_time", "exp", "refresh_exp": + integer(1) + case "expires_in": + integer(1) + case "token_version": + if p.TokenVersion > 0 { + if n, ok := number(got); !ok || n != float64(p.TokenVersion) { + m.fail(path, "want %d, got %s", p.TokenVersion, describe(got)) + } + return + } + integer(1) + case "app_roles": + obj, ok := got.(map[string]any) + if !ok || len(obj) == 0 { + m.fail(path, "want an object of client_id → role, got %s", describe(got)) + return + } + for _, k := range sortedKeys(obj) { + if s, ok := obj[k].(string); !ok || s == "" { + m.fail(join(path, k), "want a role, got %s", describe(obj[k])) + } + } + if p.ClientID != "" { + role, ok := obj[p.ClientID] + switch { + case !ok: + m.fail(path, "no entry for the client %q", p.ClientID) + case p.Role != "" && role != p.Role: + m.fail(join(path, p.ClientID), "want %q, got %s", p.Role, describe(role)) + } + } + default: + m.fail(path, "fixture error: unknown placeholder <%s>", kind) + } +} + +// number reads a JSON number (json.Number or float64) as a float64. +func number(v any) (float64, bool) { + switch n := v.(type) { + case json.Number: + f, err := strconv.ParseFloat(string(n), 64) + return f, err == nil + case float64: + return n, true + } + return 0, false +} + +func sameJSON(a, b any) bool { + norm := func(v any) any { + raw, err := json.Marshal(v) + if err != nil { + return v + } + var out any + _ = json.Unmarshal(raw, &out) + return out + } + return reflect.DeepEqual(norm(a), norm(b)) +} + +func describe(v any) string { + if v == nil { + return "null" + } + raw, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + s := string(raw) + if len(s) > 120 { + s = s[:117] + "..." + } + return s +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/conformance/testdata/socrate-seed/main.go b/conformance/testdata/socrate-seed/main.go new file mode 100644 index 0000000..69cc257 --- /dev/null +++ b/conformance/testdata/socrate-seed/main.go @@ -0,0 +1,145 @@ +// Command socrate-seed writes the fixtures the backendkit conformance suite +// needs into a scratch Socrate database: three confidential clients and one +// verified user per suite package, a member of every client. +// +// It is not part of the backendkit module (it sits under testdata, which the go +// tool ignores). scripts/conformance-local.sh and the Conformance workflow copy +// it into a Socrate source tree and run it there, because it builds on +// Socrate's own model and hashing (internal packages), exactly like Socrate's +// deploy/perf/seed: a client secret or password hash written by Socrate's code +// is one Socrate accepts. +// +// Every secret comes from the environment, generated per run by the caller; +// nothing here is a credential. Point it only at a throwaway database: it +// refuses to run when the fixture clients already exist. +// +// Environment: +// +// DATABASE_URL the scratch Socrate database (migrated) +// CONFORMANCE_PLAIN_CLIENT_ID no registered audience, no claim mapping +// CONFORMANCE_PLAIN_CLIENT_SECRET +// CONFORMANCE_DUAL_CLIENT_ID one registered audience, no claim mapping +// CONFORMANCE_DUAL_CLIENT_SECRET +// CONFORMANCE_CLAIMS_CLIENT_ID claim mappings, no registered audience +// CONFORMANCE_CLAIMS_CLIENT_SECRET +// CONFORMANCE_REDIRECT_URI the redirect URI every client registers +// CONFORMANCE_AUDIENCE the audience registered on the dual client +// CONFORMANCE_TENANT_ID the UUID the claims client's literal mapping issues +// CONFORMANCE_USER_PASSWORD password of every fixture user +// CONFORMANCE_USERS comma-separated user names; each becomes +// @example.test +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/ovander/go-oauth2/internal/model" + "github.com/ovander/go-oauth2/internal/shared/auth" + "gorm.io/driver/postgres" + "gorm.io/gorm" + glogger "gorm.io/gorm/logger" +) + +func mustEnv(name string) string { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + log.Fatalf("%s is required", name) + } + return v +} + +func main() { + db, err := gorm.Open(postgres.Open(mustEnv("DATABASE_URL")), &gorm.Config{ + Logger: glogger.Default.LogMode(glogger.Silent), + }) + if err != nil { + log.Fatalf("connect: %v", err) + } + + plainID := mustEnv("CONFORMANCE_PLAIN_CLIENT_ID") + dualID := mustEnv("CONFORMANCE_DUAL_CLIENT_ID") + claimsID := mustEnv("CONFORMANCE_CLAIMS_CLIENT_ID") + ids := []string{plainID, dualID, claimsID} + var existing int64 + if err := db.Model(&model.App{}).Where("client_id IN ?", ids).Count(&existing).Error; err != nil { + log.Fatalf("check existing clients: %v", err) + } + if existing > 0 { + log.Fatalf("the conformance clients already exist: seed a fresh database") + } + + now := time.Now() + redirect := mustEnv("CONFORMANCE_REDIRECT_URI") + newClient := func(name, clientID, secretEnv string) *model.App { + return &model.App{ + Name: name, ClientID: clientID, ClientSecretHash: hashSecret(mustEnv(secretEnv)), + Active: true, RequirePKCE: true, RedirectURIs: model.StringArray{redirect}, + CreatedAt: now, UpdatedAt: now, + } + } + + plain := newClient("backendkit conformance (plain)", plainID, "CONFORMANCE_PLAIN_CLIENT_SECRET") + dual := newClient("backendkit conformance (audience)", dualID, "CONFORMANCE_DUAL_CLIENT_SECRET") + dual.Audiences = model.StringArray{mustEnv("CONFORMANCE_AUDIENCE")} + claims := newClient("backendkit conformance (claims)", claimsID, "CONFORMANCE_CLAIMS_CLIENT_SECRET") + claims.ClaimMappings = model.ClaimMappings{ + // A literal (tenant) claim in both tokens, as the OP contract + // recommends for per-tenant service accounts. + "tenant_id": {Source: model.ClaimSourceLiteralPrefix + mustEnv("CONFORMANCE_TENANT_ID"), Target: model.ClaimTargetBoth}, + // A user attribute, in the access token only. + "department": {Source: model.ClaimSourceUserAttrPrefix + "department", Target: model.ClaimTargetAccess}, + // An app-sourced claim, which client_credentials tokens carry too. + "client": {Source: model.ClaimSourceAppClientID, Target: model.ClaimTargetAccess}, + } + apps := []*model.App{plain, dual, claims} + for _, app := range apps { + if err := db.Create(app).Error; err != nil { + log.Fatalf("create client %s: %v", app.ClientID, err) + } + } + + passwordHash, err := auth.HashPassword(mustEnv("CONFORMANCE_USER_PASSWORD")) + if err != nil { + log.Fatalf("hash password: %v", err) + } + var emails []string + for _, name := range strings.Split(mustEnv("CONFORMANCE_USERS"), ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + user := &model.User{ + Name: "Conformance " + name, Email: name + "@example.test", HashedPassword: passwordHash, + IsVerified: true, Role: model.UserRoleUser, TokenVersion: 1, Source: "conformance-seed", + Attributes: model.JSONMap{"department": "research"}, + ConfirmedAt: &now, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(user).Error; err != nil { + log.Fatalf("create user %s: %v", user.Email, err) + } + for _, app := range apps { + role := &model.UserAppRole{UserID: user.ID, AppID: app.ID, Role: "user", CreatedAt: now, UpdatedAt: now} + if err := db.Create(role).Error; err != nil { + log.Fatalf("grant %s a role in %s: %v", user.Email, app.ClientID, err) + } + } + emails = append(emails, user.Email) + } + + for _, app := range apps { + fmt.Printf("seeded client %s (app:%d)\n", app.ClientID, app.ID) + } + fmt.Printf("seeded users %s\n", strings.Join(emails, ", ")) +} + +func hashSecret(secret string) string { + h, err := auth.HashClientSecret(secret) + if err != nil { + log.Fatalf("hash client secret: %v", err) + } + return h +} diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index c433476..cc1449d 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -49,6 +49,7 @@ It is written for two audiences working on the same product: - [11. Recipes](#11-recipes) - [12. Quick reference](#12-quick-reference) - [13. Gotchas & FAQ](#13-gotchas--faq) +- [14. Testing against Socrate](#14-testing-against-socrate) --- @@ -1372,6 +1373,83 @@ user token in context. Mount `auth.Handler` first (§10). **Is the client safe to share across goroutines?** Yes. Construct one at startup and reuse it; the service-account token cache is mutex-guarded. +## 14. Testing against Socrate + +Your tests should not need a running Socrate, but the Socrate they fake must be the real one. +A mock written from memory drifts: `aud` as a string instead of an array, a `sub` that is a UUID +instead of the numeric user id, a `tenant_id` without its `https://socrate/` namespace, a +`client_credentials` token carrying a `role` Socrate never puts there. Such differences pass every +test and break in production. + +The [`conformance`](../README.md#conformance) package holds what Socrate really issues, as +fixtures: the claim sets of its tokens (authorization code with and without MFA, refresh, +`client_credentials`, `AUDIENCE_MODE=dual`, claim-mapped custom claims, DPoP-bound), its token, +userinfo and introspection responses, and its discovery and JWKS documents. A nightly backendkit +workflow checks every fixture against a Socrate built from `ovander/go-oauth2` `main`, so when the +server changes, the fixtures change with it and your mock follows on the next backendkit upgrade. + +Build the mock from the fixtures instead of hand-writing claims: + +```go +func newSocrateMock(t *testing.T) (*httptest.Server, conformance.Key) { + t.Helper() + key, err := conformance.NewKey() + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + serve := func(doc map[string]any) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + } + } + jwks, _ := conformance.JWKS(key) + disc, _ := conformance.Discovery(srv.URL) + mux.Handle("GET /.well-known/jwks.json", serve(jwks)) + mux.Handle("GET /.well-known/openid-configuration", serve(disc)) + return srv, key +} + +func TestInvoices(t *testing.T) { + socrate, key := newSocrateMock(t) + // Users sign in to "invoices"; with AUDIENCE_MODE=off a calling service's + // token carries its own client_id, so the API lists each caller too. + auth := jwtauth.New(socrate.URL+"/.well-known/jwks.json", socrate.URL, logger, + jwtauth.WithAudiences("invoices", "billing-worker"), + jwtauth.WithTenantClaim("https://socrate/tenant_id")) + + // A user of the tenant, exactly as Socrate would sign them in… + user, _ := conformance.Sign("access/custom_claims", conformance.Params{ + Issuer: socrate.URL, ClientID: "invoices", Subject: "42", Role: "editor", + TenantID: "5b0c7a52-3e0c-4d2a-9f8e-0d6f1a2b3c4d", + }, key) + // …and the calling service, sub "app:12", aud ["billing-worker"]. + worker, _ := conformance.Sign("access/client_credentials", conformance.Params{ + Issuer: socrate.URL, ClientID: "billing-worker", AppID: 12, + }, key) + // serve requests with "Authorization: Bearer "+user / worker through auth.Handler … +} +``` + +- `conformance.Names()` lists every fixture; `Load(name, params)` returns one as a map you may + extend, `Sign` returns a token fixture as a signed JWS. Unset `Params` fields get documented + defaults (`Default…` constants), times start at `time.Now()`. +- If you keep a hand-written mock (an HTTP stub of `/oauth/token`, `/oauth/userinfo`, + `/oauth/introspect`), assert its output with `conformance.Match("token_response/authorization_code", + body, conformance.Params{})` and friends: it fails on a missing or extra key or a value of the + wrong shape. +- Pick the fixture for your server's settings: `access/audience_dual` only applies with + `AUDIENCE_MODE=dual` and a registered audience, the `custom_claims` fixtures only to a client with + claim mappings, `discovery_dpop` only with DPoP enabled. + +To test your service against a real Socrate instead, do what backendkit's suite does: +`scripts/conformance-local.sh` (in this repository) builds Socrate from a checkout, starts it on a +scratch PostgreSQL database, seeds clients and users from `conformance/testdata/socrate-seed`, and +runs the tests behind the `conformance` build tag. + --- ### See also diff --git a/jwtauth/jwtauth_conformance_test.go b/jwtauth/jwtauth_conformance_test.go new file mode 100644 index 0000000..b00b506 --- /dev/null +++ b/jwtauth/jwtauth_conformance_test.go @@ -0,0 +1,207 @@ +//go:build conformance + +// Live conformance: jwtauth validates real Socrate tokens against the real +// JWKS. Run with scripts/conformance-local.sh (see package conformance for the +// environment). Without that environment the tests fail: the build tag is the +// opt-in. + +package jwtauth_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "slices" + "strings" + "testing" + + "github.com/ovander/backendkit/ctxutil" + "github.com/ovander/backendkit/jwtauth" +) + +const liveUser = "conf-jwtauth@example.test" + +func liveEnv(t *testing.T, name string) string { + t.Helper() + v := os.Getenv(name) + if v == "" { + t.Fatalf("%s is not set: the conformance tests need a running Socrate (scripts/conformance-local.sh)", name) + } + return v +} + +func livePostJSON(t *testing.T, req *http.Request) map[string]any { + t.Helper() + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s %s: HTTP %d: %.200s", req.Method, req.URL.Path, resp.StatusCode, b) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("%s: %v", req.URL.Path, err) + } + return out +} + +// liveUserToken signs the test user in to clientID through Socrate's direct +// login API and returns the access token. +func liveUserToken(t *testing.T, clientID string) string { + t.Helper() + body, _ := json.Marshal(map[string]string{ + "email": liveUser, "password": liveEnv(t, "SOCRATE_USER_PASSWORD"), "app_client_id": clientID, + }) + req, _ := http.NewRequest(http.MethodPost, liveEnv(t, "SOCRATE_ISSUER")+"/api/auth/login", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + tok, _ := livePostJSON(t, req)["access_token"].(string) + if tok == "" { + t.Fatal("login returned no access token") + } + return tok +} + +// liveServiceToken returns a client_credentials access token for the client +// named by the SOCRATE__CLIENT_ID/_SECRET variables. +func liveServiceToken(t *testing.T, kind string) string { + t.Helper() + req, _ := http.NewRequest(http.MethodPost, liveEnv(t, "SOCRATE_ISSUER")+"/oauth/token", + strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(liveEnv(t, "SOCRATE_"+kind+"_CLIENT_ID"), liveEnv(t, "SOCRATE_"+kind+"_CLIENT_SECRET")) + tok, _ := livePostJSON(t, req)["access_token"].(string) + if tok == "" { + t.Fatal("client_credentials returned no access token") + } + return tok +} + +type liveSeen struct { + sub, role, tenant string + authTime int64 + amr, aud []string + appRoles map[string]string +} + +// liveServe runs token through a Middleware on the live JWKS and issuer. +func liveServe(t *testing.T, token string, opts ...jwtauth.Option) (int, liveSeen) { + t.Helper() + issuer := liveEnv(t, "SOCRATE_ISSUER") + m := jwtauth.New(issuer+"/.well-known/jwks.json", issuer, testLogger(), opts...) + var seen liveSeen + h := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + seen = liveSeen{ + sub: ctxutil.GetUserSub(ctx), role: ctxutil.GetUserRole(ctx), tenant: ctxutil.GetTenantIDStr(ctx), + authTime: ctxutil.GetAuthTime(ctx), amr: ctxutil.GetAMR(ctx), aud: ctxutil.GetAudiences(ctx), + appRoles: ctxutil.GetAppRoles(ctx), + } + w.WriteHeader(http.StatusOK) + })) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code, seen +} + +func TestConformanceUserToken(t *testing.T) { + plain := liveEnv(t, "SOCRATE_PLAIN_CLIENT_ID") + tok := liveUserToken(t, plain) + + code, seen := liveServe(t, tok, jwtauth.WithAudience(plain)) + if code != http.StatusOK { + t.Fatalf("a real user token for %s: HTTP %d, want 200", plain, code) + } + if seen.sub == "" || strings.Trim(seen.sub, "0123456789") != "" { + t.Errorf("sub = %q, want the numeric user id", seen.sub) + } + if seen.role != "user" || seen.appRoles[plain] != "user" { + t.Errorf("role = %q, app_roles = %v, want user in %s", seen.role, seen.appRoles, plain) + } + if seen.authTime == 0 || !slices.Equal(seen.amr, []string{"pwd"}) { + t.Errorf("auth_time = %d, amr = %v, want a sign-in time and [pwd]", seen.authTime, seen.amr) + } + if !slices.Contains(seen.aud, plain) { + t.Errorf("audiences = %v, want %s", seen.aud, plain) + } + + // Fail closed: another application's audience, the wrong issuer, a + // tampered signature. + if code, _ := liveServe(t, tok, jwtauth.WithAudience("some-other-app")); code != http.StatusUnauthorized { + t.Errorf("wrong audience: HTTP %d, want 401", code) + } + issuer := liveEnv(t, "SOCRATE_ISSUER") + wrongIss := jwtauth.New(issuer+"/.well-known/jwks.json", "https://not-socrate.example", testLogger(), jwtauth.WithAudience(plain)) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer "+tok) + wrongIss.Handler(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Errorf("wrong issuer: HTTP %d, want 401", w.Code) + } + parts := strings.Split(tok, ".") + sig := []byte(parts[2]) + sig[len(sig)/2] ^= 0x01 + if code, _ := liveServe(t, parts[0]+"."+parts[1]+"."+string(sig), jwtauth.WithAudience(plain)); code != http.StatusUnauthorized { + t.Errorf("tampered signature: HTTP %d, want 401", code) + } +} + +// With AUDIENCE_MODE=dual a resource server checks only its own identifier, +// registered as an audience of the calling client, for user and service tokens. +func TestConformanceAudienceDual(t *testing.T) { + dual := liveEnv(t, "SOCRATE_DUAL_CLIENT_ID") + resource := liveEnv(t, "SOCRATE_AUDIENCE") + for name, tok := range map[string]string{ + "user token": liveUserToken(t, dual), + "service token": liveServiceToken(t, "DUAL"), + } { + t.Run(name, func(t *testing.T) { + code, seen := liveServe(t, tok, jwtauth.WithAudience(resource)) + if code != http.StatusOK { + t.Fatalf("HTTP %d, want 200 for the registered audience %s", code, resource) + } + if !slices.Contains(seen.aud, resource) { + t.Errorf("audiences = %v, want %s", seen.aud, resource) + } + if code, _ := liveServe(t, tok, jwtauth.WithAudiences("unrelated", dual)); code != http.StatusOK { + t.Errorf("WithAudiences(…, client_id): HTTP %d, want 200 (aud[0] is the client_id)", code) + } + if code, _ := liveServe(t, tok, jwtauth.WithAudience("https://other.example")); code != http.StatusUnauthorized { + t.Errorf("unregistered audience: HTTP %d, want 401", code) + } + }) + } +} + +// A literal claim mapping issues the tenant under the claims namespace, in +// user and service tokens alike; WithTenantClaim reads it. +func TestConformanceTenantClaim(t *testing.T) { + claimsClient := liveEnv(t, "SOCRATE_CLAIMS_CLIENT_ID") + tenant := liveEnv(t, "SOCRATE_TENANT_ID") + for name, tok := range map[string]string{ + "user token": liveUserToken(t, claimsClient), + "service token": liveServiceToken(t, "CLAIMS"), + } { + t.Run(name, func(t *testing.T) { + code, seen := liveServe(t, tok, jwtauth.WithAudience(claimsClient), jwtauth.WithTenantClaim("https://socrate/tenant_id")) + if code != http.StatusOK { + t.Fatalf("HTTP %d, want 200", code) + } + if seen.tenant != tenant { + t.Errorf("tenant = %q, want %q", seen.tenant, tenant) + } + // The bare name is not what Socrate issues. + if _, seen := liveServe(t, tok, jwtauth.WithAudience(claimsClient)); seen.tenant != "" { + t.Errorf("default tenant_id claim = %q: Socrate never issues a bare tenant_id", seen.tenant) + } + }) + } +} diff --git a/pep/pep_conformance_test.go b/pep/pep_conformance_test.go new file mode 100644 index 0000000..7cb90de --- /dev/null +++ b/pep/pep_conformance_test.go @@ -0,0 +1,180 @@ +//go:build conformance + +// Live conformance: the enforcement point against Socrate's real decision +// point. Run with scripts/conformance-local.sh (see package conformance for the +// environment; SOCRATE_POLICY_MODE is the server's POLICY_MODE). Without that +// environment the tests fail: the build tag is the opt-in. + +package pep_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/ovander/backendkit/ctxutil" + "github.com/ovander/backendkit/pep" + "github.com/ovander/backendkit/socrate" +) + +const liveUser = "conf-pep@example.test" + +func liveEnv(t *testing.T, name string) string { + t.Helper() + v := os.Getenv(name) + if v == "" { + t.Fatalf("%s is not set: the conformance tests need a running Socrate (scripts/conformance-local.sh)", name) + } + return v +} + +// liveClient returns a client for the plain conformance client, with the app +// id taken from its service token's sub ("app:"). +func liveClient(t *testing.T) *socrate.Client { + t.Helper() + cfg := socrate.ClientConfig{ + BaseURL: liveEnv(t, "SOCRATE_ISSUER"), + AdminBaseURL: liveEnv(t, "SOCRATE_ADMIN_URL"), + ClientID: liveEnv(t, "SOCRATE_PLAIN_CLIENT_ID"), + ClientSecret: liveEnv(t, "SOCRATE_PLAIN_CLIENT_SECRET"), + Timeout: 30 * time.Second, + } + c, err := socrate.NewClient(cfg) + if err != nil { + t.Fatal(err) + } + tok, _, err := c.ServiceToken(context.Background()) + if err != nil { + t.Fatalf("ServiceToken: %v", err) + } + in, err := c.IntrospectToken(context.Background(), tok) + if err != nil || !strings.HasPrefix(in.Sub, "app:") { + t.Fatalf("introspect the service token: %+v, %v", in, err) + } + cfg.AppID = strings.TrimPrefix(in.Sub, "app:") + if c, err = socrate.NewClient(cfg); err != nil { + t.Fatal(err) + } + return c +} + +// liveUserToken signs the test user in through the direct login API. +func liveUserToken(t *testing.T) string { + t.Helper() + body, _ := json.Marshal(map[string]string{ + "email": liveUser, "password": liveEnv(t, "SOCRATE_USER_PASSWORD"), + "app_client_id": liveEnv(t, "SOCRATE_PLAIN_CLIENT_ID"), + }) + resp, err := http.Post(liveEnv(t, "SOCRATE_ISSUER")+"/api/auth/login", "application/json", strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + var out struct { + AccessToken string `json:"access_token"` + } + if resp.StatusCode != http.StatusOK || json.Unmarshal(b, &out) != nil || out.AccessToken == "" { + t.Fatalf("login: HTTP %d", resp.StatusCode) + } + return out.AccessToken +} + +// No rule in Socrate's baseline covers an application action, so every +// decision below is a deny with reason no_applicable_rule — a real decision, +// reported with the server's mode. +const ( + liveAction = "conformance.read" + liveNoRule = "no_applicable_rule" + liveResource = "conformance" +) + +func TestConformanceDecide(t *testing.T) { + c := liveClient(t) + mode := liveEnv(t, "SOCRATE_POLICY_MODE") + tok := liveUserToken(t) + ctx := context.Background() + + for name, subject := range map[string]*socrate.PolicySubject{ + "user token": {Token: tok}, + "application": nil, + } { + t.Run(name, func(t *testing.T) { + d, err := c.Decide(ctx, socrate.DecideRequest{ + Subject: subject, Action: liveAction, + Resource: socrate.PolicyResource{Type: liveResource, ID: "1"}, + Context: socrate.PolicyContext{IP: "203.0.113.7"}, + }) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if d.Mode != mode || d.Allow || d.Reason != liveNoRule || d.PolicyVersion < 1 || !d.PEPModeAccepted { + t.Errorf("decision = %+v, want mode %s, deny %s, a policy version, pep_mode_accepted", d, mode, liveNoRule) + } + }) + } + + // pep_mode is accepted (v1.13.0+), and the admin namespace is refused. + if _, err := c.Decide(ctx, socrate.DecideRequest{Action: liveAction, PEPMode: socrate.PolicyModeEnforce}); err != nil { + t.Errorf("Decide with pep_mode: %v", err) + } + if _, err := c.Decide(ctx, socrate.DecideRequest{Action: "GET /api/admin/apps"}); !errors.Is(err, socrate.ErrPolicyRequestRejected) { + t.Errorf("Decide on an admin action: %v, want ErrPolicyRequestRejected", err) + } +} + +func TestConformanceEnforcer(t *testing.T) { + c := liveClient(t) + if mode := liveEnv(t, "SOCRATE_POLICY_MODE"); mode != socrate.PolicyModeOff { + t.Fatalf("this test expects POLICY_MODE=off on the server, got %s", mode) + } + userCtx := ctxutil.WithRawJWT(context.Background(), liveUserToken(t)) + res := socrate.PolicyResource{Type: liveResource, ID: "1"} + + // Following the server (off), the deny is ignored. + follow, err := pep.New(pep.Config{Decider: c}) + if err != nil { + t.Fatal(err) + } + if err := follow.Check(userCtx, liveAction, res, socrate.PolicyContext{}); err != nil { + t.Errorf("Check in off mode: %v, want nil", err) + } + if err := follow.CheckAsApp(context.Background(), liveAction, res); err != nil { + t.Errorf("CheckAsApp in off mode: %v, want nil", err) + } + + // An enforcement point stricter than the server enforces the same deny. + var decisions int + strict, err := pep.New(pep.Config{Decider: c, MinimumMode: socrate.PolicyModeEnforce, + OnDecision: func(context.Context, string, *socrate.Decision, bool, error) { decisions++ }}) + if err != nil { + t.Fatal(err) + } + err = strict.Check(userCtx, liveAction, res, socrate.PolicyContext{}) + var denial *pep.Denial + if !errors.As(err, &denial) || denial.Code != pep.CodeDenied || denial.Status != http.StatusForbidden || + denial.Decision == nil || denial.Decision.Reason != liveNoRule { + t.Fatalf("strict Check = %v, want a 403 policy_denied for %s", err, liveNoRule) + } + + // The middleware refuses the request before the handler. + reached := false + h := strict.Middleware(func(*http.Request) (string, socrate.PolicyResource, bool) { + return liveAction, res, true + })(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil).WithContext(userCtx)) + if w.Code != http.StatusForbidden || reached { + t.Errorf("strict middleware: HTTP %d, handler reached %v, want 403 and not reached", w.Code, reached) + } + if decisions != 2 { + t.Errorf("OnDecision called %d times, want 2", decisions) + } +} diff --git a/scripts/conformance-local.sh b/scripts/conformance-local.sh new file mode 100755 index 0000000..33fd83e --- /dev/null +++ b/scripts/conformance-local.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# conformance-local.sh — run the backendkit conformance suite against a Socrate +# built from source, the same way the Conformance workflow does. +# +# SOCRATE_SRC=../go-oauth2 \ +# CONFORMANCE_DATABASE_URL=postgres://user:pass@127.0.0.1:5432/socrate_conf?sslmode=disable \ +# scripts/conformance-local.sh [packages…] +# +# It wipes the database's public schema, builds Socrate from SOCRATE_SRC, +# starts it on loopback (migrating the schema), seeds three clients and one user +# per suite package with conformance/testdata/socrate-seed, then runs +# the live tests (`go test -tags conformance -run '^TestConformance'`, default +# packages ./...) with the SOCRATE_* variables the suite reads. Every secret is generated for the run; nothing is written to +# either repository except a temporary seed directory in SOCRATE_SRC, removed on +# exit. The server log is kept in $CONFORMANCE_WORKDIR (default: a temp dir). +# +# Variables: +# SOCRATE_SRC Socrate source tree (ovander/go-oauth2) — required +# CONFORMANCE_DATABASE_URL scratch PostgreSQL database — required, WIPED +# CONFORMANCE_PORT public port (default 18080); admin port is +1 +# CONFORMANCE_WORKDIR where the binary, keys and server.log go +# SOCRATE_BIN a Socrate server binary already built from +# SOCRATE_SRC (skips the build; the workflow builds +# it with Socrate's own Go) +# CONFORMANCE_SERVE=1 boot and seed, print the environment, and keep the +# server running until interrupted (no tests) +set -euo pipefail + +die() { echo "conformance-local: $*" >&2; exit 1; } + +[ -n "${SOCRATE_SRC:-}" ] || die "SOCRATE_SRC (a go-oauth2 checkout) is required" +[ -f "$SOCRATE_SRC/go.mod" ] || die "SOCRATE_SRC=$SOCRATE_SRC is not a Go module" +grep -q '^module github.com/ovander/go-oauth2$' "$SOCRATE_SRC/go.mod" || + die "SOCRATE_SRC=$SOCRATE_SRC is not github.com/ovander/go-oauth2" +[ -n "${CONFORMANCE_DATABASE_URL:-}" ] || die "CONFORMANCE_DATABASE_URL (a scratch database) is required" +command -v psql >/dev/null || die "psql is required" +command -v openssl >/dev/null || die "openssl is required" + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +SOCRATE_SRC=$(cd "$SOCRATE_SRC" && pwd) +PORT=${CONFORMANCE_PORT:-18080} +ADMIN_PORT=$((PORT + 1)) +WORKDIR=${CONFORMANCE_WORKDIR:-$(mktemp -d)} +mkdir -p "$WORKDIR/keys" +SEED_DIR="$SOCRATE_SRC/.conformance-seed-$$" +SERVER_PID="" + +cleanup() { + status=$? + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + for _ in $(seq 1 20); do kill -0 "$SERVER_PID" 2>/dev/null || break; sleep 0.5; done + kill -9 "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$SEED_DIR" + if [ "$status" -ne 0 ] && [ -f "$WORKDIR/server.log" ]; then + echo "--- last 80 lines of the Socrate log ($WORKDIR/server.log) ---" >&2 + tail -80 "$WORKDIR/server.log" >&2 || true + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +rand() { openssl rand -hex "$1"; } + +# --- the run's configuration (throwaway values) ------------------------------ +export SOCRATE_ISSUER="http://127.0.0.1:$PORT" +export SOCRATE_ADMIN_URL="http://127.0.0.1:$ADMIN_PORT" +export SOCRATE_PLAIN_CLIENT_ID="conformance-plain" +export SOCRATE_PLAIN_CLIENT_SECRET="cs-$(rand 24)" +export SOCRATE_DUAL_CLIENT_ID="conformance-dual" +export SOCRATE_DUAL_CLIENT_SECRET="cs-$(rand 24)" +export SOCRATE_CLAIMS_CLIENT_ID="conformance-claims" +export SOCRATE_CLAIMS_CLIENT_SECRET="cs-$(rand 24)" +export SOCRATE_REDIRECT_URI="http://127.0.0.1:9/callback" +export SOCRATE_AUDIENCE="https://api.conformance.test" +SOCRATE_TENANT_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr 'A-Z' 'a-z') +export SOCRATE_TENANT_ID +# The suite asserts the policy decision point runs in this mode. +export SOCRATE_POLICY_MODE=off +# 12–72 characters with every class the password policy asks for. +export SOCRATE_USER_PASSWORD="Pw-$(rand 12)-aZ9!" + +# --- a clean schema ------------------------------------------------------------ +echo "conformance-local: wiping the public schema of the scratch database" +psql "$CONFORMANCE_DATABASE_URL" -q -v ON_ERROR_STOP=1 -c 'SET client_min_messages TO warning' \ + -c 'DROP SCHEMA IF EXISTS public CASCADE' -c 'CREATE SCHEMA public' >/dev/null + +# --- build and start Socrate ----------------------------------------------------- +REV=$(git -C "$SOCRATE_SRC" rev-parse --short HEAD 2>/dev/null || echo 'not a git tree') +if [ -n "${SOCRATE_BIN:-}" ]; then + [ -x "$SOCRATE_BIN" ] || die "SOCRATE_BIN=$SOCRATE_BIN is not executable" + echo "conformance-local: using $SOCRATE_BIN (source $SOCRATE_SRC at $REV)" + [ "$SOCRATE_BIN" -ef "$WORKDIR/socrate" ] || cp "$SOCRATE_BIN" "$WORKDIR/socrate" +else + echo "conformance-local: building Socrate from $SOCRATE_SRC ($REV)" + (cd "$SOCRATE_SRC" && go build -o "$WORKDIR/socrate" ./cmd/server) +fi + +echo "conformance-local: starting Socrate on $SOCRATE_ISSUER (admin $SOCRATE_ADMIN_URL)" +( + cd "$WORKDIR" + # The suite asserts AUDIENCE_MODE=dual (aud = client_id + registered + # audiences) and DPOP_MODE=observe (a valid proof binds the token, no proof + # is still accepted). Rate limits are lifted: the suite signs in many times. + # exec: SERVER_PID is the server itself, so the cleanup stops it. + exec env ENV=development PORT="$PORT" ADMIN_PORT="$ADMIN_PORT" ADMIN_BIND_HOST=127.0.0.1 \ + DATABASE_URL="$CONFORMANCE_DATABASE_URL" OAUTH_ISSUER="$SOCRATE_ISSUER" \ + SECRET_KEY_BASE="$(rand 32)" KEYS_PATH="$WORKDIR/keys" AUTO_MIGRATE=true \ + AUDIENCE_MODE=dual DPOP_MODE=observe POLICY_MODE="$SOCRATE_POLICY_MODE" LOG_LEVEL=info \ + RATE_LIMIT_LOGIN=1000000 RATE_LIMIT_SIGNUP=1000000 RATE_LIMIT_TOKEN=1000000 \ + "$WORKDIR/socrate" >"$WORKDIR/server.log" 2>&1 +) & +SERVER_PID=$! +for i in $(seq 1 60); do + if curl -fs -o /dev/null "$SOCRATE_ISSUER/health"; then + echo "conformance-local: Socrate up after ${i}s" + break + fi + kill -0 "$SERVER_PID" 2>/dev/null || die "Socrate exited during start-up" + [ "$i" -lt 60 ] || die "Socrate did not become healthy" + sleep 1 +done + +# --- seed ------------------------------------------------------------------------ +mkdir -p "$SEED_DIR" +cp "$ROOT/conformance/testdata/socrate-seed/main.go" "$SEED_DIR/main.go" +( + cd "$SOCRATE_SRC" + env DATABASE_URL="$CONFORMANCE_DATABASE_URL" \ + CONFORMANCE_PLAIN_CLIENT_ID="$SOCRATE_PLAIN_CLIENT_ID" CONFORMANCE_PLAIN_CLIENT_SECRET="$SOCRATE_PLAIN_CLIENT_SECRET" \ + CONFORMANCE_DUAL_CLIENT_ID="$SOCRATE_DUAL_CLIENT_ID" CONFORMANCE_DUAL_CLIENT_SECRET="$SOCRATE_DUAL_CLIENT_SECRET" \ + CONFORMANCE_CLAIMS_CLIENT_ID="$SOCRATE_CLAIMS_CLIENT_ID" CONFORMANCE_CLAIMS_CLIENT_SECRET="$SOCRATE_CLAIMS_CLIENT_SECRET" \ + CONFORMANCE_REDIRECT_URI="$SOCRATE_REDIRECT_URI" CONFORMANCE_AUDIENCE="$SOCRATE_AUDIENCE" \ + CONFORMANCE_TENANT_ID="$SOCRATE_TENANT_ID" CONFORMANCE_USER_PASSWORD="$SOCRATE_USER_PASSWORD" \ + CONFORMANCE_USERS="conf-fixtures,conf-mfa,conf-jwtauth,conf-socrate,conf-bff,conf-pep" \ + go run "./$(basename "$SEED_DIR")" +) +rm -rf "$SEED_DIR" + +if [ "${CONFORMANCE_SERVE:-}" = "1" ]; then + echo "conformance-local: serving; export these to run the suite yourself:" + env | grep '^SOCRATE_' | grep -v '^SOCRATE_SRC=' | sed 's/^/ export /' + echo "conformance-local: Ctrl-C to stop" + wait "$SERVER_PID" + exit 0 +fi + +# --- the suite --------------------------------------------------------------------- +cd "$ROOT" +if [ "$#" -eq 0 ]; then set -- ./...; fi +echo "conformance-local: go test -tags conformance -run '^TestConformance' $*" +go test -tags conformance -count=1 -race -timeout=300s -run '^TestConformance' "$@" diff --git a/socrate/socrate_conformance_test.go b/socrate/socrate_conformance_test.go new file mode 100644 index 0000000..3bbbbd9 --- /dev/null +++ b/socrate/socrate_conformance_test.go @@ -0,0 +1,194 @@ +//go:build conformance + +// Live conformance: the socrate client against a running Socrate. Run with +// scripts/conformance-local.sh (see package conformance for the environment). +// Without that environment the tests fail: the build tag is the opt-in. + +package socrate_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/ovander/backendkit/socrate" +) + +const liveUser = "conf-socrate@example.test" + +func liveEnv(t *testing.T, name string) string { + t.Helper() + v := os.Getenv(name) + if v == "" { + t.Fatalf("%s is not set: the conformance tests need a running Socrate (scripts/conformance-local.sh)", name) + } + return v +} + +// liveClient returns a client for the plain conformance client. Service-account +// calls need its numeric app id, which it learns as an application would: from +// the sub ("app:") of its own service token. +func liveClient(t *testing.T) *socrate.Client { + t.Helper() + cfg := socrate.ClientConfig{ + BaseURL: liveEnv(t, "SOCRATE_ISSUER"), + AdminBaseURL: liveEnv(t, "SOCRATE_ADMIN_URL"), + ClientID: liveEnv(t, "SOCRATE_PLAIN_CLIENT_ID"), + ClientSecret: liveEnv(t, "SOCRATE_PLAIN_CLIENT_SECRET"), + Timeout: 30 * time.Second, + } + c, err := socrate.NewClient(cfg) + if err != nil { + t.Fatal(err) + } + tok, _, err := c.ServiceToken(context.Background()) + if err != nil { + t.Fatalf("ServiceToken: %v", err) + } + in, err := c.IntrospectToken(context.Background(), tok) + if err != nil || !strings.HasPrefix(in.Sub, "app:") { + t.Fatalf("introspect the service token: %+v, %v", in, err) + } + cfg.AppID = strings.TrimPrefix(in.Sub, "app:") + if c, err = socrate.NewClient(cfg); err != nil { + t.Fatal(err) + } + return c +} + +// liveLogin signs the test user in through Socrate's direct login API (the +// client wraps the hosted flow's token calls, not a password login) and +// returns the token set. +func liveLogin(t *testing.T) *socrate.TokenSet { + t.Helper() + body, _ := json.Marshal(map[string]string{ + "email": liveUser, "password": liveEnv(t, "SOCRATE_USER_PASSWORD"), + "app_client_id": liveEnv(t, "SOCRATE_PLAIN_CLIENT_ID"), + }) + resp, err := http.Post(liveEnv(t, "SOCRATE_ISSUER")+"/api/auth/login", "application/json", strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login: HTTP %d: %.200s", resp.StatusCode, b) + } + var ts socrate.TokenSet + if err := json.Unmarshal(b, &ts); err != nil || ts.AccessToken == "" || ts.RefreshToken == "" { + t.Fatalf("login: no token set (%v)", err) + } + return &ts +} + +func TestConformanceServiceAccount(t *testing.T) { + c := liveClient(t) + ctx := context.Background() + tok, exp, err := c.ServiceToken(ctx) + if err != nil { + t.Fatalf("ServiceToken: %v", err) + } + if tok == "" || !exp.After(time.Now()) { + t.Fatalf("ServiceToken: empty token or expiry %v in the past", exp) + } + in, err := c.IntrospectToken(ctx, tok) + if err != nil { + t.Fatalf("IntrospectToken: %v", err) + } + if !in.Active || !strings.HasPrefix(in.Sub, "app:") || in.ClientID != liveEnv(t, "SOCRATE_PLAIN_CLIENT_ID") || in.Scope != "api" { + t.Errorf("service token introspection = %+v, want active, sub app:, the client, scope api", in) + } +} + +func TestConformanceUserCalls(t *testing.T) { + c := liveClient(t) + ts := liveLogin(t) + ctx := socrate.WithJWT(context.Background(), ts.AccessToken) + + profile, err := c.GetCurrentUserProfile(ctx) + if err != nil || profile == nil { + t.Fatalf("GetCurrentUserProfile = %v, %v", profile, err) + } + if profile.Email != liveUser || !profile.EmailVerified { + t.Errorf("userinfo = %+v, want %s, verified", profile, liveUser) + } + id, err := strconv.ParseUint(profile.Sub, 10, 64) + if err != nil { + t.Fatalf("userinfo sub %q is not the numeric user id", profile.Sub) + } + + full, err := c.GetProfile(ctx) + if err != nil || full == nil { + t.Fatalf("GetProfile = %v, %v", full, err) + } + + in, err := c.IntrospectToken(context.Background(), ts.AccessToken) + if err != nil { + t.Fatalf("IntrospectToken: %v", err) + } + if !in.Active || in.Sub != profile.Sub || in.ClientID != liveEnv(t, "SOCRATE_PLAIN_CLIENT_ID") { + t.Errorf("introspection = %+v, want active for sub %s", in, profile.Sub) + } + + // The application looks its member up by the token's sub. + member, err := c.GetUserAsService(context.Background(), profile.Sub) + if err != nil || member == nil { + t.Fatalf("GetUserAsService(%s) = %v, %v", profile.Sub, member, err) + } + if member.ID != uint(id) || member.Email != liveUser || member.Role != "user" { + t.Errorf("member = %+v, want id %d, %s, role user", member, id, liveUser) + } + if member.TokenVersion == nil || member.Locked == nil { + t.Errorf("member lacks token_version or locked (Socrate ≥ v1.8.0 sends both)") + } + if none, err := c.GetUserAsService(context.Background(), "999999999"); err != nil || none != nil { + t.Errorf("GetUserAsService(non-member) = %v, %v, want nil, nil", none, err) + } +} + +func TestConformanceRefreshRevokeLogout(t *testing.T) { + c := liveClient(t) + ctx := context.Background() + first := liveLogin(t) + + next, err := c.RefreshToken(ctx, first.RefreshToken) + if err != nil { + t.Fatalf("RefreshToken: %v", err) + } + if next.AccessToken == "" || next.RefreshToken == "" || next.RefreshToken == first.RefreshToken || next.TokenType != "Bearer" { + t.Fatalf("RefreshToken: want a rotated Bearer token set, got type %q", next.TokenType) + } + + // A revoked refresh token is refused with invalid_grant. + if err := c.RevokeToken(ctx, next.RefreshToken); err != nil { + t.Fatalf("RevokeToken: %v", err) + } + _, err = c.RefreshToken(ctx, next.RefreshToken) + var oe *socrate.OAuthError + if !errors.As(err, &oe) || oe.Code != "invalid_grant" { + t.Errorf("refresh with a revoked token: %v, want an OAuthError invalid_grant", err) + } + if in, err := c.IntrospectToken(ctx, next.RefreshToken); err != nil || in.Active { + t.Errorf("introspection of a revoked refresh token = %+v, %v, want inactive", in, err) + } + + // Logout ends the user's tokens. + other := liveLogin(t) + userCtx := socrate.WithJWT(ctx, other.AccessToken) + if err := c.Logout(userCtx); err != nil { + t.Fatalf("Logout: %v", err) + } + if p, err := c.GetCurrentUserProfile(userCtx); err != nil || p != nil { + t.Errorf("userinfo after logout = %v, %v, want nil, nil (401)", p, err) + } + if _, err := c.RefreshToken(ctx, other.RefreshToken); !errors.As(err, &oe) { + t.Errorf("refresh after logout: %v, want an OAuthError", err) + } +}