200 lines
6.0 KiB
Go
200 lines
6.0 KiB
Go
package authn_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
|
|
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
|
|
)
|
|
|
|
// stubAuthentik is a minimal OIDC provider sufficient to drive Flow
|
|
// end-to-end: it implements discovery, an authorize endpoint that
|
|
// immediately redirects with a code, and a token endpoint that returns
|
|
// a signed id_token.
|
|
type stubAuthentik struct {
|
|
server *httptest.Server
|
|
signer jose.Signer
|
|
pubJWK jose.JSONWebKey
|
|
issuer string
|
|
clientID string
|
|
|
|
// State remembered between authorize and token calls.
|
|
expectedCodeChallenge string
|
|
}
|
|
|
|
func newStub(t *testing.T, clientID string) *stubAuthentik {
|
|
t.Helper()
|
|
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("genkey: %v", err)
|
|
}
|
|
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key},
|
|
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test"))
|
|
if err != nil {
|
|
t.Fatalf("signer: %v", err)
|
|
}
|
|
pub := jose.JSONWebKey{Key: &key.PublicKey, KeyID: "test", Algorithm: "ES256", Use: "sig"}
|
|
|
|
s := &stubAuthentik{signer: signer, pubJWK: pub, clientID: clientID}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("content-type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"issuer": s.issuer,
|
|
"authorization_endpoint": s.issuer + "/auth",
|
|
"token_endpoint": s.issuer + "/token",
|
|
"jwks_uri": s.issuer + "/jwks",
|
|
"response_types_supported": []string{"code"},
|
|
"subject_types_supported": []string{"public"},
|
|
"id_token_signing_alg_values_supported": []string{"ES256"},
|
|
})
|
|
})
|
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("content-type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{pub}})
|
|
})
|
|
mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) {
|
|
// Capture code_challenge so we can demand the right verifier
|
|
// on the token call. Redirect to the redirect_uri with state+code.
|
|
s.expectedCodeChallenge = r.URL.Query().Get("code_challenge")
|
|
redirect := r.URL.Query().Get("redirect_uri")
|
|
state := r.URL.Query().Get("state")
|
|
u, _ := url.Parse(redirect)
|
|
q := u.Query()
|
|
q.Set("code", "stub-code")
|
|
q.Set("state", state)
|
|
u.RawQuery = q.Encode()
|
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
|
})
|
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
verifier := r.PostForm.Get("code_verifier")
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
got := base64.RawURLEncoding.EncodeToString(sum[:])
|
|
if got != s.expectedCodeChallenge {
|
|
http.Error(w, "verifier mismatch", http.StatusBadRequest)
|
|
return
|
|
}
|
|
now := time.Now()
|
|
claims := struct {
|
|
jwt.Claims
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
}{
|
|
Claims: jwt.Claims{
|
|
Issuer: s.issuer,
|
|
Subject: "stub-user",
|
|
Audience: jwt.Audience{s.clientID},
|
|
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
},
|
|
Email: "stub@example",
|
|
Name: "Stub User",
|
|
}
|
|
idToken, err := jwt.Signed(s.signer).Claims(claims).Serialize()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
w.Header().Set("content-type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": "access-1",
|
|
"token_type": "Bearer",
|
|
"refresh_token": "refresh-1",
|
|
"expires_in": 3600,
|
|
"refresh_expires_in": 30 * 24 * 3600,
|
|
"id_token": idToken,
|
|
})
|
|
})
|
|
|
|
s.server = httptest.NewServer(mux)
|
|
s.issuer = s.server.URL
|
|
t.Cleanup(s.server.Close)
|
|
return s
|
|
}
|
|
|
|
// ensure big.Int is "imported" (referenced) so future tweaks don't
|
|
// require touching imports.
|
|
var _ = big.NewInt
|
|
|
|
func TestFlow_EndToEnd(t *testing.T) {
|
|
stub := newStub(t, "sherlock-client")
|
|
|
|
flow := authn.NewFlow(authn.Config{
|
|
Issuer: stub.issuer,
|
|
ClientID: "sherlock-client",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
}, nil)
|
|
defer func() { _ = flow.Close() }()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
start, err := flow.StartFlow(ctx)
|
|
if err != nil {
|
|
t.Fatalf("StartFlow: %v", err)
|
|
}
|
|
if start.State == "" || !strings.HasPrefix(start.AuthURL, stub.issuer+"/auth?") {
|
|
t.Fatalf("bad start: %+v", start)
|
|
}
|
|
|
|
// Simulate the browser: follow the redirect chain. We DON'T want
|
|
// the http.Client to follow into our loopback callback as part of
|
|
// the same client (it would try and the cb would 200), so just let
|
|
// the chain go.
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := client.Get(start.AuthURL)
|
|
if err != nil {
|
|
t.Fatalf("GET auth: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
t.Fatalf("auth chain ended at %s", resp.Status)
|
|
}
|
|
|
|
res, err := flow.AwaitCallback(ctx, start.State, 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("AwaitCallback: %v", err)
|
|
}
|
|
if res.Subject != "stub-user" || res.Email != "stub@example" {
|
|
t.Fatalf("bad claims: %+v", res)
|
|
}
|
|
if res.IDToken == "" || res.RefreshToken != "refresh-1" {
|
|
t.Fatalf("bad tokens: %+v", res)
|
|
}
|
|
if time.Until(res.IDExpiresAt) < 30*time.Minute {
|
|
t.Fatalf("id token expiry too soon: %v", res.IDExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestFlow_NotConfigured(t *testing.T) {
|
|
flow := authn.NewFlow(authn.Config{}, nil)
|
|
defer func() { _ = flow.Close() }()
|
|
_, err := flow.StartFlow(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "not configured") {
|
|
t.Fatalf("expected not-configured error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Sanity check that fmt is referenced by tests so an unused import
|
|
// linter doesn't complain after future edits.
|
|
var _ = fmt.Sprintf
|