223 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|