Files
foundry-core/internal/auth/slack_test.go
T
codex-bot fbe0910638
Foundry Go checks / check (push) Failing after 1m59s
Foundry Go checks / check (pull_request) Failing after 1m56s
Guard legacy Google accounts through provider login expansion
2026-11-05 18:51:56 +08:00

223 lines
8.2 KiB
Go

package auth
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func signIdentity(t *testing.T, key *rsa.PrivateKey, claims slackClaims) string {
t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"fixture"}`))
body, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
value := header + "." + base64.RawURLEncoding.EncodeToString(body)
hash := sha256.Sum256([]byte(value))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
if err != nil {
t.Fatal(err)
}
return value + "." + base64.RawURLEncoding.EncodeToString(sig)
}
func TestSlackIdentityVerification(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
s := &service{slack: &SlackConfig{ClientID: "fixture", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()}}
good := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: "nonce"}
for _, name := range []string{"valid", "issuer", "audience", "expired", "nonce", "signature", "algorithm"} {
t.Run(name, func(t *testing.T) {
claims := good
switch name {
case "issuer":
claims.Issuer = "https://attacker.invalid"
case "audience":
claims.Audience = "other-client"
case "expired":
claims.Expires = time.Now().Add(-time.Minute).Unix()
case "nonce":
claims.Nonce = "wrong"
}
value := signIdentity(t, key, claims)
if name == "signature" {
parts := strings.Split(value, ".")
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sig[0] ^= 1
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
value = strings.Join(parts, ".")
}
if name == "algorithm" {
parts := strings.Split(value, ".")
parts[0] = base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","kid":"fixture"}`))
value = strings.Join(parts, ".")
}
_, err := s.verifySlack(context.Background(), value, "nonce")
if (err == nil) != (name == "valid") {
t.Fatalf("unexpected verification result %v", err)
}
})
}
}
func TestSlackCallbackAndExchange(t *testing.T) {
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires isolated integration database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
var value [12]byte
rand.Read(value[:])
project := "slack-" + hex.EncodeToString(value[:])
defer db.Exec("DELETE FROM project_accounts WHERE project=$1", project)
frontend, _ := url.Parse("https://app.example.com/auth/callback")
config := &SlackConfig{ClientID: "fixture", Secret: "fixture-secret", RedirectURI: "https://api.example.com/auth/slack/callback", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()}
verifier := token()
var nonce string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Error("unexpected Slack request method")
}
_ = r.ParseForm()
if r.Form.Get("client_id") != "fixture" || r.Form.Get("client_secret") != "fixture-secret" {
t.Error("incorrect Slack client credentials")
}
claims := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: nonce, Name: "Slack User"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id_token": signIdentity(t, key, claims)})
}))
defer upstream.Close()
s := &service{db: db, projects: map[string]*url.URL{project: frontend}, slack: config, google: &http.Client{Transport: rewriteTransport{destination: upstream.URL}}}
start := httptest.NewRequest("GET", "https://api.example.com/projects/"+project+"/auth/slack?challenge="+digest(verifier), nil)
out := httptest.NewRecorder()
if err = s.startSlack(out, start, project); err != nil {
t.Fatal(err)
}
if out.Code != 302 {
t.Fatalf("start status %d", out.Code)
}
location, _ := url.Parse(out.Header().Get("Location"))
nonce = location.Query().Get("nonce")
state := location.Query().Get("state")
if nonce == "" || state == "" {
t.Fatal("missing state or nonce")
}
cookies := out.Result().Cookies()
if len(cookies) != 1 || cookies[0].SameSite != http.SameSiteNoneMode || !cookies[0].Secure || !cookies[0].HttpOnly {
t.Fatal("incorrect Slack browser binding cookie")
}
body := url.Values{"state": {state}, "code": {"fixture-code"}}
callback := func() *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", config.RedirectURI, strings.NewReader(body.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookies[0])
w := httptest.NewRecorder()
if err := s.slackCallback(w, r); err != nil {
t.Fatal(err)
}
return w
}
success := callback()
if success.Code != 302 {
t.Fatalf("callback status %d %s", success.Code, success.Body.String())
}
destination, _ := url.Parse(success.Header().Get("Location"))
fragment, _ := url.ParseQuery(destination.Fragment)
grant := fragment.Get("code")
if !tokenPattern.MatchString(grant) {
t.Fatal("callback missing one-time exchange code")
}
if replay := callback(); replay.Code != 400 {
t.Fatal("Slack callback replay accepted")
}
request := httptest.NewRequest("POST", "https://api.example.com/projects/"+project+"/auth/exchange", nil)
request.Header.Set("X-Foundry-Code", grant)
request.Header.Set("X-Foundry-Verifier", verifier)
exchange := httptest.NewRecorder()
if err = s.exchange(exchange, request, project); err != nil {
t.Fatal(err)
}
if exchange.Code != 200 {
t.Fatalf("exchange status %d", exchange.Code)
}
var account, username, name string
err = db.QueryRow("SELECT a.id::text,a.username,a.display_name FROM project_accounts a JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) WHERE a.project=$1 AND i.provider='slack' AND i.subject='T123:U123'", project).Scan(&account, &username, &name)
if err != nil || username == "" || name != "Slack User" {
t.Fatalf("Slack profile prefill failed %v", err)
}
_, err = db.Exec("UPDATE project_accounts SET username='chosen-name',display_name='Chosen name' WHERE project=$1 AND id=$2", project, account)
if err != nil {
t.Fatal(err)
}
if _, err = s.identityGrant(context.Background(), project, "slack", "T123:U123", nil, "New Slack Name", "", digest(verifier)); err != nil {
t.Fatal(err)
}
if err = db.QueryRow("SELECT username,display_name FROM project_accounts WHERE project=$1 AND id=$2", project, account).Scan(&username, &name); err != nil {
t.Fatal(err)
}
if username != "chosen-name" || name != "Chosen name" {
t.Fatal("provider login overwrote chosen profile")
}
}
type rewriteTransport struct{ destination string }
func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) {
cloned := r.Clone(r.Context())
target, _ := url.Parse(t.destination)
cloned.URL.Scheme, cloned.URL.Host = target.Scheme, target.Host
return http.DefaultTransport.RoundTrip(cloned)
}
func TestGoogleIdentityKeepsLegacyAccount(t *testing.T) {
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires isolated integration database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := &service{db: db}
grant, err := s.identityGrant(context.Background(), "fixture", "google", "existing-google", nil, "Existing Google User", "", digest(token()))
if err != nil {
t.Fatal(err)
}
var account string
if err = db.QueryRow("SELECT account_id::text FROM login_grants WHERE project='fixture' AND token_hash=$1", digest(grant)).Scan(&account); err != nil {
t.Fatal(err)
}
if account != "00000000-0000-0000-0000-000000000001" {
t.Fatal("legacy Google identity changed account ID")
}
var count int
if err = db.QueryRow("SELECT count(*) FROM project_sessions WHERE project='fixture' AND token_hash='existing-session'").Scan(&count); err != nil || count != 1 {
t.Fatalf("existing Google session was lost %v", err)
}
}