textmachine/platform/internal/login/issuer_test.go

171 lines
5.4 KiB
Go

package login
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
)
// fakeIssuer is a real OIDC provider, small enough to read: discovery, a JWKS, and a token endpoint
// that signs an identity token. Everything the flow verifies — signature, issuer, audience, expiry,
// nonce, PKCE — is verified against THIS, so the test exercises go-oidc rather than a stub of it.
type fakeIssuer struct {
srv *httptest.Server
key *rsa.PrivateKey
// what the token endpoint will mint
sub string
email string
emailVerified bool
nonce string
audience string
expiresIn time.Duration
// expectChallenge, when set, is the PKCE challenge the exchange must present a verifier for.
expectChallenge string
// issSupported is what the discovery document advertises for RFC 9207. Google advertises true
// (checked live, 05.08), so that is the default here.
issSupported bool
// stall, when set, makes the endpoint named by stallOn accept the request and never answer it
// until the channel is closed. It is how "the provider is up but hung" is expressed.
stall chan struct{}
stallOn string
// stallOnce stalls only the FIRST request, so a test can show that the endpoint recovering is
// enough — or that it is not.
stallOnce bool
// Read from one handler goroutine while another is still blocked in the stall, so it is atomic.
stallsDone atomic.Bool
tokenCalls int
}
func newIssuer(t *testing.T) *fakeIssuer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
f := &fakeIssuer{key: key, sub: "sub-A", email: "reader@example.org", emailVerified: true,
expiresIn: time.Hour, issSupported: true}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{
"issuer": f.srv.URL,
"authorization_endpoint": f.srv.URL + "/authorize",
"token_endpoint": f.srv.URL + "/token",
"jwks_uri": f.srv.URL + "/keys",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
"authorization_response_iss_parameter_supported": f.issSupported,
})
})
mux.HandleFunc("GET /keys", func(w http.ResponseWriter, _ *http.Request) {
if f.stall != nil && f.stallOn == "keys" && !f.stallsDone.Swap(f.stallOnce) {
<-f.stall
return
}
pub := f.key.Public().(*rsa.PublicKey)
writeJSON(w, map[string]any{"keys": []map[string]string{{
"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test",
"n": b64(pub.N.Bytes()),
"e": b64(big.NewInt(int64(pub.E)).Bytes()),
}}})
})
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
f.tokenCalls++
if f.stall != nil && f.stallOn == "token" {
<-f.stall
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
// PKCE, verified for real: the verifier presented here must hash to the challenge the
// authorization request carried.
if f.expectChallenge != "" {
sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier")))
if b64(sum[:]) != f.expectChallenge {
http.Error(w, "invalid_grant", http.StatusBadRequest)
return
}
}
aud := f.audience
if aud == "" {
aud = "test-client"
}
writeJSON(w, map[string]any{
"access_token": "opaque-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": f.idToken(map[string]any{
"iss": f.srv.URL,
"aud": aud,
"sub": f.sub,
"exp": time.Now().Add(f.expiresIn).Unix(),
"iat": time.Now().Unix(),
"nonce": f.nonce,
"email": f.email,
"email_verified": f.emailVerified,
}),
})
})
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
// idToken signs a JWT with RS256 by hand: twenty lines, no extra dependency, and it makes the
// signature the test controls rather than a library's.
func (f *fakeIssuer) idToken(claims map[string]any) string {
header := b64(mustJSON(map[string]string{"alg": "RS256", "kid": "test", "typ": "JWT"}))
payload := b64(mustJSON(claims))
signing := header + "." + payload
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:])
if err != nil {
panic(err)
}
return signing + "." + b64(sig)
}
// challengeFrom reads the PKCE challenge out of the authorization redirect, so the token endpoint
// can hold the exchange to it.
func challengeFrom(t *testing.T, location string) (state, challenge, nonce string) {
t.Helper()
u, err := url.Parse(location)
if err != nil {
t.Fatal(err)
}
q := u.Query()
if q.Get("code_challenge_method") != "S256" {
t.Fatalf("authorization request must use S256, got %q", q.Get("code_challenge_method"))
}
return q.Get("state"), q.Get("code_challenge"), q.Get("nonce")
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(mustJSON(v))
}