~bigbes/lethe

ref: f903c872196ad6f6bf020e59637993f119438d7f lethe/internal/server/auth/oidctestserver_test.go -rw-r--r-- 4.5 KiB
f903c872 — Eugene Blikh web: scaffold vite/react/ts project, port design tokens and primitives a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
package auth_test

import (
	"context"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"math/big"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)

// oidcTestServer hosts a minimal OIDC discovery + JWKS endpoint backed by
// an in-memory RSA key. It is not a full OP — just enough surface that the
// coreos/go-oidc verifier can fetch the JWKS and validate RS256 signatures
// produced by signToken.
type oidcTestServer struct {
	srv    *httptest.Server
	key    *rsa.PrivateKey
	keyID  string
	issuer string
}

// newOIDCTestServer builds an httptest.Server serving discovery and JWKS
// keyed off a fresh 2048-bit RSA key. The caller must call Close (via
// t.Cleanup) when done.
func newOIDCTestServer(t *testing.T) *oidcTestServer {
	t.Helper()

	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatalf("rsa.GenerateKey: %v", err)
	}
	o := &oidcTestServer{key: key, keyID: "test-key-1"}

	mux := http.NewServeMux()
	mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		body := map[string]any{
			"issuer":                                o.issuer,
			"jwks_uri":                              o.issuer + "/jwks",
			"authorization_endpoint":                o.issuer + "/auth",
			"token_endpoint":                        o.issuer + "/token",
			"id_token_signing_alg_values_supported": []string{"RS256"},
			"response_types_supported":              []string{"id_token"},
			"subject_types_supported":               []string{"public"},
		}
		_ = json.NewEncoder(w).Encode(body)
	})
	mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		jwk := map[string]any{
			"kty": "RSA",
			"alg": "RS256",
			"use": "sig",
			"kid": o.keyID,
			"n":   base64URLUint(key.N),
			"e":   base64URLUintFromInt(key.E),
		}
		_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{jwk}})
	})

	o.srv = httptest.NewServer(mux)
	o.issuer = o.srv.URL
	t.Cleanup(o.srv.Close)
	return o
}

// signToken produces a signed RS256 JWT with the supplied claims merged with
// issuer/aud/iat/exp defaults. Claims supplied by the caller win.
func (o *oidcTestServer) signToken(t *testing.T, claims map[string]any) string {
	t.Helper()

	header := map[string]any{
		"alg": "RS256",
		"typ": "JWT",
		"kid": o.keyID,
	}

	// Merge defaults under what the caller provided so tests can override
	// e.g. exp or aud explicitly.
	merged := map[string]any{
		"iss": o.issuer,
		"iat": time.Now().Unix(),
		"exp": time.Now().Add(time.Hour).Unix(),
	}
	for k, v := range claims {
		merged[k] = v
	}

	headerBytes, err := json.Marshal(header)
	if err != nil {
		t.Fatalf("marshal header: %v", err)
	}
	payloadBytes, err := json.Marshal(merged)
	if err != nil {
		t.Fatalf("marshal payload: %v", err)
	}

	signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) +
		"." + base64.RawURLEncoding.EncodeToString(payloadBytes)
	digest := sha256.Sum256([]byte(signingInput))
	sig, err := rsa.SignPKCS1v15(rand.Reader, o.key, crypto.SHA256, digest[:])
	if err != nil {
		t.Fatalf("rsa.SignPKCS1v15: %v", err)
	}
	return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}

// newVerifier constructs a real OIDCVerifier pointed at this test server,
// audience-bound to aud.
func (o *oidcTestServer) newVerifier(t *testing.T, aud string) *auth.OIDCVerifier {
	t.Helper()
	v := &auth.OIDCVerifier{
		Cfg: config.OIDCConfig{
			Enabled:       true,
			Issuer:        o.issuer,
			Audience:      aud,
			UsernameClaim: "preferred_username",
		},
	}
	if err := v.Init(context.Background()); err != nil {
		t.Fatalf("OIDCVerifier.Init: %v", err)
	}
	return v
}

// base64URLUint encodes a big.Int as base64url with no padding, per JWA
// (RFC 7518) §6.3.1.1.
func base64URLUint(n *big.Int) string {
	return base64.RawURLEncoding.EncodeToString(n.Bytes())
}

// base64URLUintFromInt encodes the public exponent (typically 65537) as the
// minimal big-endian byte representation, per JWA §6.3.1.2.
func base64URLUintFromInt(e int) string {
	if e <= 0 {
		panic(fmt.Sprintf("invalid RSA exponent: %d", e))
	}
	var buf [8]byte
	binary.BigEndian.PutUint64(buf[:], uint64(e))
	// Strip leading zero bytes so the encoding is minimal.
	i := 0
	for i < len(buf)-1 && buf[i] == 0 {
		i++
	}
	return base64.RawURLEncoding.EncodeToString(buf[i:])
}