From 5c0ad30a7a057e2a73e011ab2768a23b2a22dcfc Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:49:41 +0800 Subject: [PATCH] Add Slack OpenID login with verified identities and callback replay protection --- .env.example | 4 + cmd/foundry/main.go | 6 +- db/002-platform.sql | 4 + deploy/check-schema.sh | 14 +- deploy/release.sh | 6 +- internal/auth/auth.go | 39 ++--- internal/auth/identity.go | 67 ++++++++ internal/auth/slack.go | 226 ++++++++++++++++++++++++++ internal/auth/slack_test.go | 194 ++++++++++++++++++++++ internal/platform/integration_test.go | 2 +- 10 files changed, 524 insertions(+), 38 deletions(-) create mode 100644 internal/auth/identity.go create mode 100644 internal/auth/slack.go create mode 100644 internal/auth/slack_test.go diff --git a/.env.example b/.env.example index c752c26..c6447fe 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,7 @@ SMTP_PORT=465 SMTP_USERNAME=noreply@notice.entroia.com SMTP_PASSWORD=replace-me SMTP_FROM=noreply@notice.entroia.com + +# Optional Sign in with Slack. +SLACK_CLIENT_ID=replace-me +SLACK_CLIENT_SECRET=replace-me diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 6b25a5e..7feed0e 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -52,7 +52,11 @@ func main() { log.Fatal(err) } go email.Run(context.Background(), db) - handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email) + slack, err := auth.SlackConfigFromEnv(origin.ResolveReference(&url.URL{Path: "/auth/slack/callback"}).String()) + if err != nil { + log.Fatal(err) + } + handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email, slack) if err != nil { log.Fatal(err) } diff --git a/db/002-platform.sql b/db/002-platform.sql index 3721b0d..87f9def 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -11,6 +11,10 @@ ALTER TABLE project_accounts CREATE UNIQUE INDEX project_account_username ON project_accounts(project, lower(username)) WHERE username IS NOT NULL; +ALTER TABLE oauth_attempts + ADD COLUMN provider text NOT NULL DEFAULT 'google' CHECK (provider IN ('google','slack')), + ADD COLUMN oidc_nonce text; + CREATE TABLE account_identities ( project text NOT NULL, provider text NOT NULL CHECK (provider IN ('google', 'slack')), diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index adec116..32756c5 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -60,9 +60,11 @@ END $$; ROLLBACK; SQL printf 'Migration preserved existing logins and enforced project isolation.\n' -CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform -docker run --rm --network "container:$container" --user 1000:1000 \ - --read-only --cap-drop ALL --security-opt no-new-privileges \ - --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/platform-tests,readonly" \ - -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ - --entrypoint /opt/platform-tests postgres:17-alpine -test.v +for package in platform auth; do + CGO_ENABLED=0 go test -mod=readonly -c -o "bin/$package-tests" "./internal/$package" + docker run --rm --network "container:$container" --user 1000:1000 \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$(pwd)/bin/$package-tests,target=/opt/integration-tests,readonly" \ + -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ + --entrypoint /opt/integration-tests postgres:17-alpine -test.v +done diff --git a/deploy/release.sh b/deploy/release.sh index d9ca918..39433be 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -49,6 +49,10 @@ mail_env=() if [[ -s "$foundry/mail.env" ]]; then mail_env=(--env-file "$foundry/mail.env") fi +slack_env=() +if [[ -s "$foundry/slack.env" ]]; then + slack_env=(--env-file "$foundry/slack.env") +fi codex_mounts=() codex_dir=$foundry/codex if [[ -d "$codex_dir" ]]; then @@ -69,7 +73,7 @@ if [[ -d "$codex_dir" ]]; then ) fi -docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" --network "$network" \ +docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" "${slack_env[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e2d0428..e2e5137 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -30,14 +30,15 @@ type service struct { email *EmailConfig passwordSlots chan struct{} dummyPassword []byte + slack *SlackConfig } -func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig) (http.Handler, error) { +func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig, slack *SlackConfig) (http.Handler, error) { dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost) if err != nil { return nil, err } - s := &service{db: db, email: email, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, + s := &service{db: db, email: email, slack: slack, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} for name, raw := range callbacks { u, err := url.Parse(raw) @@ -60,6 +61,7 @@ func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string reply(w, 200, payload{"status": "ready"}) return nil })) + mux.HandleFunc("/auth/slack/callback", s.wrap(s.slackCallback)) mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback)) mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -114,6 +116,9 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error { if frontend == nil { return reject(w, 404, "not_found") } + if r.Method == http.MethodGet && action == "slack" { + return s.startSlack(w, r, project) + } if r.Method == http.MethodGet && action == "google" { return s.start(w, r, project) } @@ -156,7 +161,7 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string) } state, browser, verifier := token(), token(), token() _, err := s.db.ExecContext(r.Context(), ` - INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') + INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at) VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') `, digest(state), digest(browser), project, challenge, verifier) if err != nil { return err @@ -211,7 +216,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { var project, challenge, verifier string err = s.db.QueryRowContext(ctx, ` DELETE FROM oauth_attempts - WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() + WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() AND provider='google' RETURNING project, frontend_challenge, google_verifier `, digest(state), digest(browser.Value)).Scan(&project, &challenge, &verifier) if errors.Is(err, sql.ErrNoRows) { @@ -263,31 +268,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { user.Email = nil } - grant := token() - _, err = s.db.ExecContext(ctx, ` - WITH identity AS ( - SELECT gen_random_uuid() AS id - ), account AS ( - INSERT INTO project_accounts (project, id, google_subject, email, username, display_name, avatar_url) - SELECT $1, id, $2, $3, - left(COALESCE(NULLIF(regexp_replace(lower($6), '[^a-z0-9]', '', 'g'), ''), 'user'),7) - || '_' || replace(id::text,'-',''), - $6, NULLIF($7,'') FROM identity - ON CONFLICT (project, google_subject) DO UPDATE SET - email = EXCLUDED.email, - username = COALESCE(project_accounts.username, EXCLUDED.username), - display_name = CASE WHEN project_accounts.display_name='' THEN EXCLUDED.display_name ELSE project_accounts.display_name END, - avatar_url = COALESCE(project_accounts.avatar_url, EXCLUDED.avatar_url), - updated_at = now() - RETURNING id - ), provider AS ( - INSERT INTO account_identities(project,provider,subject,account_id) - SELECT $1,'google',$2,id FROM account - ON CONFLICT (project,provider,subject) DO NOTHING - ) - INSERT INTO login_grants - SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account - `, project, user.Subject, user.Email, digest(grant), challenge, user.Name, user.Picture) + grant, err := s.identityGrant(ctx, project, "google", user.Subject, user.Email, user.Name, user.Picture, challenge) if err != nil { return err } diff --git a/internal/auth/identity.go b/internal/auth/identity.go new file mode 100644 index 0000000..d4ef1e9 --- /dev/null +++ b/internal/auth/identity.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "net/url" + "strings" +) + +func (s *service) identityGrant(ctx context.Context, project, provider, subject string, email *string, name, picture, challenge string) (string, error) { + name = strings.TrimSpace(name) + if len(name) > 160 { + name = "" + } + if u, err := url.Parse(picture); err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(picture) > 2048 { + picture = "" + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() + // Serialize first registration for the same provider identity. + if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", project+":"+provider+":"+subject); err != nil { + return "", err + } + var account string + err = tx.QueryRowContext(ctx, "SELECT account_id::text FROM account_identities WHERE project=$1 AND provider=$2 AND subject=$3", project, provider, subject).Scan(&account) + if errors.Is(err, sql.ErrNoRows) { + var googleSubject *string + if provider == "google" { + googleSubject = &subject + } + err = tx.QueryRowContext(ctx, ` +WITH identity AS(SELECT gen_random_uuid() AS id) +INSERT INTO project_accounts(project,id,google_subject,email,username,display_name,avatar_url) +SELECT $1,id,$2,$3, + left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-',''), + $4,NULLIF($5,'') FROM identity RETURNING id::text +`, project, googleSubject, email, name, picture).Scan(&account) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, "INSERT INTO account_identities(project,provider,subject,account_id) VALUES($1,$2,$3,$4)", project, provider, subject, account) + } else if err == nil { + _, err = tx.ExecContext(ctx, ` +UPDATE project_accounts SET email=COALESCE($3,email), + display_name=CASE WHEN display_name='' THEN $4 ELSE display_name END, + avatar_url=COALESCE(avatar_url,NULLIF($5,'')), + username=COALESCE(username,left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-','')), + updated_at=now() WHERE project=$1 AND id=$2 +`, project, account, email, name, picture) + } + if err != nil { + return "", err + } + grant := token() + _, err = tx.ExecContext(ctx, "INSERT INTO login_grants VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(grant), project, account, challenge) + if err != nil { + return "", err + } + if err = tx.Commit(); err != nil { + return "", err + } + return grant, nil +} diff --git a/internal/auth/slack.go b/internal/auth/slack.go new file mode 100644 index 0000000..1a94da6 --- /dev/null +++ b/internal/auth/slack.go @@ -0,0 +1,226 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +type SlackConfig struct { + ClientID, Secret, RedirectURI string + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +func SlackConfigFromEnv(redirect string) (*SlackConfig, error) { + id, secret := os.Getenv("SLACK_CLIENT_ID"), os.Getenv("SLACK_CLIENT_SECRET") + if id == "" && secret == "" { + return nil, nil + } + if id == "" || secret == "" || strings.ContainsAny(id+secret, "\r\n") { + return nil, errors.New("Slack requires client ID and secret") + } + return &SlackConfig{ClientID: id, Secret: secret, RedirectURI: redirect}, nil +} + +type slackClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + Expires int64 `json:"exp"` + Issued int64 `json:"iat"` + Nonce string `json:"nonce"` + Team string `json:"https://slack.com/team_id"` + Email *string `json:"email"` + Verified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func (s *service) slackKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + c := s.slack + c.mu.Lock() + defer c.mu.Unlock() + if time.Since(c.fetchedAt) < time.Minute { + if key := c.keys[kid]; key != nil { + return key, nil + } + return nil, errors.New("unknown Slack signing key") + } + var document struct { + Keys []struct { + Type string `json:"kty"` + ID string `json:"kid"` + Algorithm string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + } + } + if err := s.googleJSON(ctx, "GET", "https://slack.com/openid/connect/keys", nil, "", &document); err != nil { + return nil, err + } + keys := map[string]*rsa.PublicKey{} + for _, key := range document.Keys { + n, nErr := base64.RawURLEncoding.DecodeString(key.N) + e, eErr := base64.RawURLEncoding.DecodeString(key.E) + if key.Type != "RSA" || (key.Algorithm != "" && key.Algorithm != "RS256") || (key.Use != "" && key.Use != "sig") || nErr != nil || eErr != nil || len(e) > 4 { + continue + } + modulus := new(big.Int).SetBytes(n) + exponent := new(big.Int).SetBytes(e).Int64() + if modulus.BitLen() < 2048 || modulus.BitLen() > 8192 || exponent < 3 || exponent > 2147483647 || exponent%2 == 0 { + continue + } + keys[key.ID] = &rsa.PublicKey{N: modulus, E: int(exponent)} + } + c.keys, c.fetchedAt = keys, time.Now() + if key := keys[kid]; key != nil { + return key, nil + } + return nil, errors.New("unknown Slack signing key") +} +func (s *service) verifySlack(ctx context.Context, value, nonce string) (slackClaims, error) { + var claims slackClaims + invalid := errors.New("invalid Slack identity token") + if len(value) > 32768 { + return claims, invalid + } + parts := strings.Split(value, ".") + if len(parts) != 3 { + return claims, invalid + } + header, err := base64.RawURLEncoding.DecodeString(parts[0]) + var h struct { + Algorithm string `json:"alg"` + ID string `json:"kid"` + } + if err != nil || json.Unmarshal(header, &h) != nil || h.Algorithm != "RS256" || h.ID == "" { + return claims, invalid + } + key, err := s.slackKey(ctx, h.ID) + if err != nil { + return claims, err + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err != nil || rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature) != nil { + return claims, invalid + } + body, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || json.Unmarshal(body, &claims) != nil { + return claims, invalid + } + now := time.Now().Unix() + if claims.Issuer != "https://slack.com" || claims.Audience != s.slack.ClientID || claims.Expires <= now || + claims.Issued <= 0 || claims.Issued > now+60 || claims.Expires <= claims.Issued || claims.Nonce != nonce || + claims.Subject == "" || len(claims.Subject) > 128 || claims.Team == "" || len(claims.Team) > 128 || + strings.ContainsAny(claims.Subject+claims.Team, ":\r\n") { + return claims, invalid + } + if !claims.Verified { + claims.Email = nil + } + return claims, nil +} +func (s *service) startSlack(w http.ResponseWriter, r *http.Request, project string) error { + if s.slack == nil { + return reject(w, 503, "slack_not_configured") + } + challenge := r.URL.Query().Get("challenge") + if !tokenPattern.MatchString(challenge) { + return reject(w, 400, "invalid_challenge") + } + state, browser, nonce := token(), token(), token() + _, err := s.db.ExecContext(r.Context(), ` +INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at,provider,oidc_nonce) +VALUES($1,$2,$3,$4,'',now()+interval '10 minutes','slack',$5) +`, digest(state), digest(browser), project, challenge, nonce) + if err != nil { + return err + } + cookie := oauthCookie(state, browser, 600) + cookie.SameSite = http.SameSiteNoneMode + http.SetCookie(w, cookie) + query := url.Values{"response_type": {"code"}, "scope": {"openid profile email"}, "client_id": {s.slack.ClientID}, "state": {state}, "nonce": {nonce}, "redirect_uri": {s.slack.RedirectURI}} + http.Redirect(w, r, "https://slack.com/openid/connect/authorize?"+query.Encode(), 302) + return nil +} +func (s *service) slackCallback(w http.ResponseWriter, r *http.Request) error { + if s.slack == nil { + return reject(w, 503, "slack_not_configured") + } + if r.Method != http.MethodGet && r.Method != http.MethodPost { + return reject(w, 405, "method_not_allowed") + } + r.Body = http.MaxBytesReader(w, r.Body, 16384) + if r.ParseForm() != nil { + return reject(w, 400, "invalid_callback") + } + state := r.Form.Get("state") + cookie, err := r.Cookie(oauthCookie(state, "", 0).Name) + if !tokenPattern.MatchString(state) || err != nil || !tokenPattern.MatchString(cookie.Value) { + return reject(w, 400, "invalid_state") + } + var project, challenge, nonce string + err = s.db.QueryRowContext(r.Context(), ` +DELETE FROM oauth_attempts WHERE state_hash=$1 AND browser_hash=$2 AND expires_at>now() AND provider='slack' +RETURNING project,frontend_challenge,oidc_nonce +`, digest(state), digest(cookie.Value)).Scan(&project, &challenge, &nonce) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_state") + } + if err != nil { + return err + } + frontend := s.projects[project] + if frontend == nil { + return reject(w, 400, "invalid_state") + } + expired := oauthCookie(state, "", -1) + expired.SameSite = http.SameSiteNoneMode + http.SetCookie(w, expired) + destination := *frontend + if r.Form.Get("code") == "" || r.Form.Has("error") { + destination.Fragment = "error=slack_login_cancelled" + http.Redirect(w, r, destination.String(), 302) + return nil + } + var tokens struct { + OK bool `json:"ok"` + IDToken string `json:"id_token"` + } + err = s.googleJSON(r.Context(), "POST", "https://slack.com/api/openid.connect.token", url.Values{ + "code": {r.Form.Get("code")}, "client_id": {s.slack.ClientID}, "client_secret": {s.slack.Secret}, "redirect_uri": {s.slack.RedirectURI}, "grant_type": {"authorization_code"}, + }, "", &tokens) + if err != nil { + return err + } + if !tokens.OK || tokens.IDToken == "" { + return errors.New("Slack token exchange failed") + } + claims, err := s.verifySlack(r.Context(), tokens.IDToken, nonce) + if err != nil { + return err + } + grant, err := s.identityGrant(r.Context(), project, "slack", claims.Team+":"+claims.Subject, claims.Email, claims.Name, claims.Picture, challenge) + if err != nil { + return err + } + destination.Fragment = "code=" + grant + http.Redirect(w, r, destination.String(), 302) + return nil +} diff --git a/internal/auth/slack_test.go b/internal/auth/slack_test.go new file mode 100644 index 0000000..fe2885d --- /dev/null +++ b/internal/auth/slack_test.go @@ -0,0 +1,194 @@ +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) +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 036a5a4..d05381d 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -59,7 +59,7 @@ func TestOnboardingAndSharing(t *testing.T) { }() callbacks := map[string]string{project: "https://app.example.com/auth/callback"} mux := http.NewServeMux() - authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}) + authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}, nil) if err != nil { t.Fatal(err) }