332 lines
9.4 KiB
Go
332 lines
9.4 KiB
Go
package authn
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
|
|
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
|
fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake"
|
|
)
|
|
|
|
// stubAuthentik signs ES256 ID tokens and returns them through a real
|
|
// httptest.Server, exercising the full Login path end-to-end without
|
|
// touching a live Authentik instance.
|
|
type stubAuthentik struct {
|
|
srv *httptest.Server
|
|
signer jose.Signer
|
|
pubJWK jose.JSONWebKey
|
|
verifyCalled bool
|
|
mu sync.Mutex
|
|
codeToPKCE map[string]string
|
|
}
|
|
|
|
func newStubAuthentik(t *testing.T) *stubAuthentik {
|
|
t.Helper()
|
|
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: priv},
|
|
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stub := &stubAuthentik{
|
|
signer: signer,
|
|
pubJWK: jose.JSONWebKey{
|
|
Key: priv.Public(),
|
|
KeyID: "test",
|
|
Algorithm: "ES256",
|
|
Use: "sig",
|
|
},
|
|
codeToPKCE: map[string]string{},
|
|
}
|
|
mux := http.NewServeMux()
|
|
stub.srv = httptest.NewServer(mux)
|
|
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"issuer": stub.srv.URL,
|
|
"authorization_endpoint": stub.srv.URL + "/authorize",
|
|
"token_endpoint": stub.srv.URL + "/token",
|
|
"jwks_uri": stub.srv.URL + "/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) {
|
|
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{stub.pubJWK}})
|
|
})
|
|
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
stub.mu.Lock()
|
|
stub.codeToPKCE["code-"+q.Get("state")] = q.Get("code_challenge")
|
|
stub.mu.Unlock()
|
|
// Redirect to the loopback redirect URI with the code+state.
|
|
ru, _ := url.Parse(q.Get("redirect_uri"))
|
|
rq := ru.Query()
|
|
rq.Set("code", "code-"+q.Get("state"))
|
|
rq.Set("state", q.Get("state"))
|
|
ru.RawQuery = rq.Encode()
|
|
http.Redirect(w, r, ru.String(), http.StatusFound)
|
|
})
|
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
|
|
// Refresh grant: hand back a rotated access token (and a fresh
|
|
// id_token) without re-running the PKCE dance. Used by the
|
|
// TokenSource renewal tests.
|
|
if r.Form.Get("grant_type") == "refresh_token" {
|
|
clientID := r.Form.Get("client_id")
|
|
if clientID == "" {
|
|
if user, _, ok := r.BasicAuth(); ok {
|
|
clientID = user
|
|
}
|
|
}
|
|
if clientID == "" {
|
|
clientID = "test-client"
|
|
}
|
|
idToken := stub.signIDToken(t, clientID, map[string]any{
|
|
"sub": "user-1",
|
|
"email": "ops@example.com",
|
|
"name": "Test Operator",
|
|
})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": "at-refreshed",
|
|
"refresh_token": "rt-2",
|
|
"id_token": idToken,
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_expires_in": 86400,
|
|
})
|
|
return
|
|
}
|
|
|
|
code := r.Form.Get("code")
|
|
verifier := r.Form.Get("code_verifier")
|
|
stub.mu.Lock()
|
|
challenge, ok := stub.codeToPKCE[code]
|
|
stub.mu.Unlock()
|
|
if !ok {
|
|
http.Error(w, "unknown code", http.StatusBadRequest)
|
|
return
|
|
}
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
|
if want != challenge {
|
|
http.Error(w, "bad code_verifier", http.StatusBadRequest)
|
|
return
|
|
}
|
|
stub.verifyCalled = true
|
|
// Public PKCE clients don't always send client_id in the body
|
|
// (oauth2 prefers Basic-Auth when AuthStyle defaults to header).
|
|
// Pull it from Authorization or fall back to the form/test value.
|
|
clientID := r.Form.Get("client_id")
|
|
if clientID == "" {
|
|
if user, _, ok := r.BasicAuth(); ok {
|
|
clientID = user
|
|
}
|
|
}
|
|
if clientID == "" {
|
|
clientID = "test-client"
|
|
}
|
|
idToken := stub.signIDToken(t, clientID, map[string]any{
|
|
"sub": "user-1",
|
|
"email": "ops@example.com",
|
|
"name": "Test Operator",
|
|
})
|
|
resp := map[string]any{
|
|
"access_token": "at-1",
|
|
"refresh_token": "rt-1",
|
|
"id_token": idToken,
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_expires_in": 86400,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
t.Cleanup(stub.srv.Close)
|
|
return stub
|
|
}
|
|
|
|
func (s *stubAuthentik) signIDToken(t *testing.T, audience string, claims map[string]any) string {
|
|
t.Helper()
|
|
now := time.Now()
|
|
all := map[string]any{
|
|
"iss": s.srv.URL,
|
|
"aud": audience,
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(time.Hour).Unix(),
|
|
}
|
|
for k, v := range claims {
|
|
all[k] = v
|
|
}
|
|
tok, err := jwt.Signed(s.signer).Claims(all).Serialize()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tok
|
|
}
|
|
|
|
func TestLogin_HappyPath(t *testing.T) {
|
|
stub := newStubAuthentik(t)
|
|
cfg := Config{
|
|
Issuer: stub.srv.URL,
|
|
ClientID: "test-client",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
LoopbackAddrOverride: "127.0.0.1:0",
|
|
}
|
|
|
|
// The browser-open hook drives the flow programmatically.
|
|
var browserErr error
|
|
opts := LoginOptions{
|
|
OnAuthURL: func(u string) {
|
|
resp, err := (&http.Client{
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}).Get(u)
|
|
if err != nil {
|
|
browserErr = err
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
loc := resp.Header.Get("Location")
|
|
if loc == "" {
|
|
browserErr = fmt.Errorf("expected redirect, got %d", resp.StatusCode)
|
|
return
|
|
}
|
|
// Follow the redirect into the loopback listener.
|
|
cb, err := http.Get(loc)
|
|
if err != nil {
|
|
browserErr = err
|
|
return
|
|
}
|
|
_, _ = io.Copy(io.Discard, cb.Body)
|
|
_ = cb.Body.Close()
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
res, err := Login(ctx, cfg, opts)
|
|
if err != nil {
|
|
t.Fatalf("Login: %v", err)
|
|
}
|
|
if browserErr != nil {
|
|
t.Fatalf("browser stub: %v", browserErr)
|
|
}
|
|
if res.Tokens.IDToken == "" || res.Tokens.RefreshToken == "" {
|
|
t.Fatalf("missing tokens: %+v", res.Tokens)
|
|
}
|
|
if res.Tokens.Subject != "user-1" || res.Tokens.Email != "ops@example.com" {
|
|
t.Fatalf("claims wrong: %+v", res.Tokens)
|
|
}
|
|
if res.Tokens.ClientID != "test-client" {
|
|
t.Fatalf("ClientID not persisted: %q", res.Tokens.ClientID)
|
|
}
|
|
if !strings.HasPrefix(res.Tokens.Issuer, "http://127.0.0.1:") {
|
|
t.Fatalf("Issuer = %q", res.Tokens.Issuer)
|
|
}
|
|
}
|
|
|
|
func TestLogin_NotConfigured(t *testing.T) {
|
|
_, err := Login(context.Background(), Config{}, LoginOptions{})
|
|
if err != ErrNotConfigured {
|
|
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
|
}
|
|
}
|
|
|
|
func TestLogin_ContextCancel(t *testing.T) {
|
|
stub := newStubAuthentik(t)
|
|
cfg := Config{
|
|
Issuer: stub.srv.URL,
|
|
ClientID: "test-client",
|
|
Scopes: []string{"openid"},
|
|
LoopbackAddrOverride: "127.0.0.1:0",
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
_, err := Login(ctx, cfg, LoginOptions{OnAuthURL: func(string) {}})
|
|
if err == nil {
|
|
t.Fatal("expected error on cancel")
|
|
}
|
|
}
|
|
|
|
// Sanity check that the verifier built off the stub provider works
|
|
// outside Login too — guards against accidental client-ID drift.
|
|
func TestIDTokenVerifies_AgainstStub(t *testing.T) {
|
|
stub := newStubAuthentik(t)
|
|
provider, err := oidc.NewProvider(context.Background(), stub.srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw := stub.signIDToken(t, "client-x", map[string]any{"sub": "u"})
|
|
if _, err := provider.Verifier(&oidc.Config{ClientID: "client-x"}).Verify(context.Background(), raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureFresh_FastPath(t *testing.T) {
|
|
store := fakekeyring.New()
|
|
_ = store.Set("test", keyring.TokenSet{
|
|
IDToken: "ok",
|
|
IDExpiresAt: time.Now().Add(time.Hour),
|
|
Issuer: "x",
|
|
ClientID: "y",
|
|
})
|
|
ts, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
|
|
if err != nil {
|
|
t.Fatalf("EnsureFresh: %v", err)
|
|
}
|
|
if ts.IDToken != "ok" {
|
|
t.Fatal("fast path should return current token")
|
|
}
|
|
}
|
|
|
|
func TestEnsureFresh_NoTokens(t *testing.T) {
|
|
store := fakekeyring.New()
|
|
_, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
|
|
if err != keyring.ErrNoTokens {
|
|
t.Fatalf("err = %v, want ErrNoTokens", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureFresh_NeedsRefreshButNoRefreshToken(t *testing.T) {
|
|
store := fakekeyring.New()
|
|
_ = store.Set("test", keyring.TokenSet{
|
|
IDToken: "stale",
|
|
IDExpiresAt: time.Now().Add(-time.Minute),
|
|
Issuer: "x",
|
|
ClientID: "y",
|
|
})
|
|
_, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
|
|
if err != ErrNoRefreshToken {
|
|
t.Fatalf("err = %v, want ErrNoRefreshToken", err)
|
|
}
|
|
}
|