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)
}