package oidcstub_test import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" gooidc "github.com/coreos/go-oidc/v3/oidc" "sourcecraft.dev/bigbes/lethe/internal/testutil/oidcstub" ) // TestNew_RequiresIssuer asserts that New returns an error when Issuer is empty. func TestNew_RequiresIssuer(t *testing.T) { _, err := oidcstub.New(oidcstub.Options{}) if err == nil { t.Fatal("New(Options{}): expected error when Issuer is empty, got nil") } } // TestSignToken_RoundtripsThroughDiscovery mounts the stub Handler on a real // httptest server, signs a token, then verifies it using coreos/go-oidc. The // audience and sub claims must survive the round-trip. func TestSignToken_RoundtripsThroughDiscovery(t *testing.T) { // Bootstrap: create stub with placeholder issuer, start server, fix issuer. stub, err := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"}) if err != nil { t.Fatalf("New: %v", err) } srv := httptest.NewServer(stub.Handler()) t.Cleanup(srv.Close) stub.SetIssuer(srv.URL) const aud = "myapp" tok, err := stub.SignToken(map[string]any{ "sub": "user-42", "aud": aud, }) if err != nil { t.Fatalf("SignToken: %v", err) } // Verify via go-oidc. ctx := context.Background() provider, err := gooidc.NewProvider(ctx, srv.URL) if err != nil { t.Fatalf("oidc.NewProvider: %v", err) } verifier := provider.Verifier(&gooidc.Config{ClientID: aud}) idToken, err := verifier.Verify(ctx, tok) if err != nil { t.Fatalf("verifier.Verify: %v", err) } var claims struct { Aud string `json:"aud"` Sub string `json:"sub"` } if err := idToken.Claims(&claims); err != nil { t.Fatalf("Claims: %v", err) } if claims.Aud != aud { t.Errorf("aud = %q; want %q", claims.Aud, aud) } if claims.Sub != "user-42" { t.Errorf("sub = %q; want user-42", claims.Sub) } } // TestMint_SetsUsernameClaim asserts that Mint sets the configured // username_claim to the sub argument. func TestMint_SetsUsernameClaim(t *testing.T) { stub, err := oidcstub.New(oidcstub.Options{ Issuer: "http://placeholder", UsernameClaim: "preferred_username", }) if err != nil { t.Fatalf("New: %v", err) } srv := httptest.NewServer(stub.Handler()) t.Cleanup(srv.Close) stub.SetIssuer(srv.URL) tok, _, err := stub.Mint("alice", time.Hour, nil) if err != nil { t.Fatalf("Mint: %v", err) } // Decode claims from the token without full OIDC verification — we just // want to inspect the payload. var raw map[string]any if err := decodeJWTPayload(tok, &raw); err != nil { t.Fatalf("decodeJWTPayload: %v", err) } got, ok := raw["preferred_username"] if !ok { t.Fatal("preferred_username claim is absent") } if got != "alice" { t.Errorf("preferred_username = %v; want alice", got) } if sub, _ := raw["sub"].(string); sub != "alice" { t.Errorf("sub = %q; want alice", sub) } } // TestDevToken_HTTP_ReturnsBearer checks that GET /dev/token?sub=alice&exp=15m // returns 200 with parseable JSON, and that the token verifies under the same // Stub. func TestDevToken_HTTP_ReturnsBearer(t *testing.T) { stub, err := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"}) if err != nil { t.Fatalf("New: %v", err) } srv := httptest.NewServer(stub.Handler()) t.Cleanup(srv.Close) stub.SetIssuer(srv.URL) resp, err := http.Get(srv.URL + "/dev/token?sub=alice&exp=15m") //nolint:noctx if err != nil { t.Fatalf("GET /dev/token: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d; want 200; body=%s", resp.StatusCode, body) } var payload struct { Token string `json:"token"` ExpiresAt int64 `json:"expires_at"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } if payload.Token == "" { t.Fatal("token is empty") } if payload.ExpiresAt == 0 { t.Fatal("expires_at is zero") } // Verify the token decodes correctly (basic payload check). var claims map[string]any if err := decodeJWTPayload(payload.Token, &claims); err != nil { t.Fatalf("decodeJWTPayload: %v", err) } if claims["sub"] != "alice" { t.Errorf("sub = %v; want alice", claims["sub"]) } } // TestDevToken_HTTP_400OnMissingSub asserts that GET /dev/token (no ?sub=) // returns 400. func TestDevToken_HTTP_400OnMissingSub(t *testing.T) { stub, err := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"}) if err != nil { t.Fatalf("New: %v", err) } srv := httptest.NewServer(stub.Handler()) t.Cleanup(srv.Close) stub.SetIssuer(srv.URL) resp, err := http.Get(srv.URL + "/dev/token") //nolint:noctx if err != nil { t.Fatalf("GET /dev/token: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d; want 400", resp.StatusCode) } } // decodeJWTPayload base64-decodes the payload segment of a JWT without // verifying the signature. Only for test assertions. func decodeJWTPayload(token string, dst any) error { parts := strings.SplitN(token, ".", 3) if len(parts) != 3 { return fmt.Errorf("expected 3 JWT parts, got %d", len(parts)) } b, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return err } return json.Unmarshal(b, dst) }