171 lines
5.4 KiB
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))
|
|
}
|