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) }