package authclient import ( "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "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() // Record the redirect URI as the real /authorize does: the token // endpoint binds the exchange to it (KEY-WP-0016-T02). 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"), RedirectURI: q.Get("redirect_uri"), 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() } // The caller-side verifier moved onto internal/jose (KEY-WP-0019-T05). The // existing tamper case proves the signature is checked at all; these two prove // the parts of the move that a byte-level tamper cannot reach. A token signed // by an unpublished key under a kid the provider does publish fails only if key // selection is bound to the published set, not merely if the bytes are intact. // A key set carrying an undersized modulus must deny the login rather than fall // through to the claims, which is ParseJWKS strictness being applied on this // path and not only in the adapter. func TestVerifyRejectsForeignSignatureAndUnusableKeySet(t *testing.T) { c, d, _ := provider(t) ctx := context.Background() foreign, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } now := time.Now() forged := signToken(t, foreign, "key-1", map[string]any{ "iss": c.Issuer, "aud": "approval-engine", "sub": "service:test", "iat": now.Unix(), "exp": now.Add(time.Minute).Unix(), }) if _, err := c.Verify(ctx, d, forged, "approval-engine", ""); err == nil { t.Fatal("token signed by an unpublished key accepted") } // A key set the client cannot use must deny, not fall through to the // claims. The token here is otherwise genuine. genuine := signToken(t, foreign, "key-1", map[string]any{ "iss": c.Issuer, "aud": "approval-engine", "sub": "service:test", "iat": now.Unix(), "exp": now.Add(time.Minute).Unix(), }) broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, `{"keys":[{"kty":"RSA","kid":"key-1","n":"AQAB","e":"AQAB"}]}`) })) t.Cleanup(broken.Close) if _, err := c.Verify(ctx, Discovery{Issuer: d.Issuer, JWKS: broken.URL}, genuine, "approval-engine", ""); err == nil { t.Fatal("undersized key in the published set accepted") } } // signToken builds an RS256 JWT with the given kid and claims. func signToken(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string { t.Helper() enc := func(v any) string { raw, err := json.Marshal(v) if err != nil { t.Fatal(err) } return base64.RawURLEncoding.EncodeToString(raw) } input := enc(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}) + "." + enc(claims) digest := sha256.Sum256([]byte(input)) signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) if err != nil { t.Fatal(err) } return input + "." + base64.RawURLEncoding.EncodeToString(signature) }