255 lines
8.7 KiB
Go
255 lines
8.7 KiB
Go
|
|
package authclient
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/rsa"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"keycape/internal/domain"
|
||
|
|
"keycape/internal/server/oidc"
|
||
|
|
"keycape/internal/server/telemetry"
|
||
|
|
)
|
||
|
|
|
||
|
|
type users struct{}
|
||
|
|
|
||
|
|
func (users) LookupUser(context.Context, string) (*domain.User, error) {
|
||
|
|
return &domain.User{ID: "user:test", Username: "test"}, nil
|
||
|
|
}
|
||
|
|
func (users) LookupGroups(context.Context, string) ([]domain.Group, error) { return nil, nil }
|
||
|
|
func (users) ValidatePassword(context.Context, string, string) (bool, error) { return true, nil }
|
||
|
|
func (users) ListUsers(context.Context) ([]domain.User, error) { return nil, nil }
|
||
|
|
|
||
|
|
func provider(t *testing.T) (*Client, Discovery, *oidc.TokenHandler) {
|
||
|
|
t.Helper()
|
||
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
server := httptest.NewTLSServer(mux)
|
||
|
|
t.Cleanup(server.Close)
|
||
|
|
sessions := oidc.NewSessionStore()
|
||
|
|
h := &oidc.TokenHandler{Issuer: server.URL, SigningKey: key, TokenLifetime: 15 * time.Minute, Sessions: sessions, Users: users{}, Emitter: telemetry.NoopEmitter{}, ClientConfig: map[string]*domain.Client{
|
||
|
|
"service:consumer": {ClientID: "service:consumer", ClientType: "confidential", ClientSecret: "special+%: secret", GrantTypes: []string{"client_credentials"}, AllowedScopes: []string{"approval:read"}, Audience: "approval-engine", ServiceSubject: "service:test", Tenant: "tenant:test"},
|
||
|
|
"human": {ClientID: "human", AllowedScopes: []string{"openid", "approval:approve"}, Audience: "approval-engine"},
|
||
|
|
}}
|
||
|
|
mux.Handle("/token", h)
|
||
|
|
keys := oidc.NewKeySet()
|
||
|
|
keys.AddKey("key-1", &key.PublicKey)
|
||
|
|
mux.Handle("/jwks", oidc.NewJWKSHandler(keys))
|
||
|
|
d := Discovery{Issuer: server.URL, Authorization: server.URL + "/authorize", Token: server.URL + "/token", JWKS: server.URL + "/jwks"}
|
||
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(d) })
|
||
|
|
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
q := r.URL.Query()
|
||
|
|
code := sessions.Create(&oidc.PKCESession{ClientID: q.Get("client_id"), Username: "test", Nonce: q.Get("nonce"), Scopes: strings.Fields(q.Get("scope")), PKCEChallenge: q.Get("code_challenge"), ExpiresAt: time.Now().Add(time.Minute)})
|
||
|
|
target, _ := url.Parse(q.Get("redirect_uri"))
|
||
|
|
params := target.Query()
|
||
|
|
params.Set("state", q.Get("state"))
|
||
|
|
params.Set("code", code)
|
||
|
|
target.RawQuery = params.Encode()
|
||
|
|
http.Redirect(w, r, target.String(), 302)
|
||
|
|
})
|
||
|
|
c, err := New(server.URL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
c.HTTP.Transport = server.Client().Transport
|
||
|
|
return c, d, h
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestServiceExchangeAndClaimValidation(t *testing.T) {
|
||
|
|
c, _, h := provider(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
d, err := c.Discover(ctx)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
|
||
|
|
token, err := c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", "")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = c.Verify(ctx, d, token.AccessToken, "other", ""); err == nil {
|
||
|
|
t.Fatal("wrong audience accepted")
|
||
|
|
}
|
||
|
|
if _, err = c.Verify(ctx, d, token.AccessToken+"tampered", "approval-engine", ""); err == nil {
|
||
|
|
t.Fatal("tampering accepted")
|
||
|
|
}
|
||
|
|
if _, err = c.Exchange(ctx, d, form, "service:consumer", "wrong", "approval-engine", ""); err == nil || strings.Contains(err.Error(), "special") {
|
||
|
|
t.Fatal("wrong secret not safely rejected")
|
||
|
|
}
|
||
|
|
form.Set("scope", "approval:consume")
|
||
|
|
if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil {
|
||
|
|
t.Fatal("excess scope accepted")
|
||
|
|
}
|
||
|
|
form.Set("scope", "approval:read")
|
||
|
|
h.TokenLifetime = -time.Minute
|
||
|
|
if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil {
|
||
|
|
t.Fatal("expired response accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type urlWriter struct{ urls chan string }
|
||
|
|
|
||
|
|
func (w urlWriter) Write(p []byte) (int, error) {
|
||
|
|
for _, line := range strings.Split(string(p), "\n") {
|
||
|
|
if strings.HasPrefix(line, "https://") {
|
||
|
|
w.urls <- line
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return len(p), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBrowserLoginPKCEAndState(t *testing.T) {
|
||
|
|
c, d, _ := provider(t)
|
||
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
redirect := "http://" + listener.Addr().String() + "/callback"
|
||
|
|
listener.Close()
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
urls := make(chan string, 1)
|
||
|
|
completed := make(chan error, 1)
|
||
|
|
go func() {
|
||
|
|
_, err := c.Login(ctx, d, "human", "approval-engine", "openid approval:approve", redirect, urlWriter{urls})
|
||
|
|
completed <- err
|
||
|
|
}()
|
||
|
|
var address string
|
||
|
|
select {
|
||
|
|
case address = <-urls:
|
||
|
|
case err := <-completed:
|
||
|
|
t.Fatal(err)
|
||
|
|
case <-ctx.Done():
|
||
|
|
t.Fatal("no login URL")
|
||
|
|
}
|
||
|
|
// A forged callback must not consume the real login attempt.
|
||
|
|
res, err := http.Get(redirect + "?state=forged&code=forged")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
res.Body.Close()
|
||
|
|
if res.StatusCode != 400 {
|
||
|
|
t.Fatal("forged state accepted")
|
||
|
|
}
|
||
|
|
browser := &http.Client{Transport: c.HTTP.Transport, Timeout: 5 * time.Second}
|
||
|
|
res, err = browser.Get(address)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
io.Copy(io.Discard, res.Body)
|
||
|
|
res.Body.Close()
|
||
|
|
select {
|
||
|
|
case err := <-completed:
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
case <-ctx.Done():
|
||
|
|
t.Fatal("login did not finish")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoginRejectsUnsafeCallbacks(t *testing.T) {
|
||
|
|
c, d, _ := provider(t)
|
||
|
|
for _, callback := range []string{"http://example.com:8000/callback", "http://localhost:8000/callback", "http://127.0.0.1:0/callback", "http://127.0.0.1:8000/callback?extra=yes"} {
|
||
|
|
if _, err := c.Login(context.Background(), d, "human", "approval-engine", "openid", callback, io.Discard); err == nil {
|
||
|
|
t.Fatalf("accepted %s", callback)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOutputProtection(t *testing.T) {
|
||
|
|
dir := t.TempDir()
|
||
|
|
path := filepath.Join(dir, "token.json")
|
||
|
|
file, err := reserveOutput(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("reserve %s: %v", path, err)
|
||
|
|
}
|
||
|
|
file.Close()
|
||
|
|
info, _ := os.Stat(path)
|
||
|
|
if info.Mode().Perm() != 0600 {
|
||
|
|
t.Fatal("file not private")
|
||
|
|
}
|
||
|
|
if _, err = reserveOutput(path); err == nil {
|
||
|
|
t.Fatal("overwrote existing file")
|
||
|
|
}
|
||
|
|
link := filepath.Join(dir, "link")
|
||
|
|
if err = os.Symlink(path, link); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = reserveOutput(link); err == nil {
|
||
|
|
t.Fatal("followed output symlink")
|
||
|
|
}
|
||
|
|
repo := filepath.Join(dir, "repo")
|
||
|
|
os.Mkdir(repo, 0700)
|
||
|
|
os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: elsewhere"), 0600)
|
||
|
|
if _, err = reserveOutput(filepath.Join(repo, "token")); err == nil {
|
||
|
|
t.Fatal("allowed token in worktree")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDiscoveryAndRedirectBoundaries(t *testing.T) {
|
||
|
|
for _, issuer := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?secret=value"} {
|
||
|
|
if _, err := New(issuer); err == nil {
|
||
|
|
t.Fatal("unsafe issuer accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
server := httptest.NewTLSServer(mux)
|
||
|
|
defer server.Close()
|
||
|
|
c, _ := New(server.URL)
|
||
|
|
c.HTTP.Transport = server.Client().Transport
|
||
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
fmt.Fprintf(w, `{"issuer":%q,"authorization_endpoint":"https://evil.example/a","token_endpoint":"https://evil.example/t","jwks_uri":"https://evil.example/j"}`, server.URL)
|
||
|
|
})
|
||
|
|
if _, err := c.Discover(context.Background()); err == nil {
|
||
|
|
t.Fatal("cross-origin discovery accepted")
|
||
|
|
}
|
||
|
|
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://evil.example", 307) })
|
||
|
|
var out any
|
||
|
|
if err := c.request(context.Background(), "POST", server.URL+"/redirect", nil, "id", "secret", &out); err == nil {
|
||
|
|
t.Fatal("followed credential redirect")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestNonceMismatchAndCancelledLogin(t *testing.T) {
|
||
|
|
c, d, _ := provider(t)
|
||
|
|
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
|
||
|
|
token, err := c.Exchange(context.Background(), d, form, "service:consumer", "special+%: secret", "approval-engine", "")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = c.Verify(context.Background(), d, token.AccessToken, "approval-engine", "required-nonce"); err == nil {
|
||
|
|
t.Fatal("missing nonce accepted")
|
||
|
|
}
|
||
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
redirect := "http://" + listener.Addr().String() + "/callback"
|
||
|
|
listener.Close()
|
||
|
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
|
cancel()
|
||
|
|
if _, err = c.Login(ctx, d, "human", "approval-engine", "openid", redirect, io.Discard); err == nil {
|
||
|
|
t.Fatal("cancelled login succeeded")
|
||
|
|
}
|
||
|
|
listener, err = net.Listen("tcp", strings.TrimPrefix(strings.TrimSuffix(redirect, "/callback"), "http://"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal("listener not released")
|
||
|
|
}
|
||
|
|
listener.Close()
|
||
|
|
}
|