Add Slack OpenID login with verified identities and callback replay protection
Foundry Go checks / check (push) Failing after 2m2s
Foundry Go checks / check (pull_request) Failing after 2m4s

This commit is contained in:
2026-11-05 18:49:41 +08:00
parent 206cd45098
commit 5c0ad30a7a
10 changed files with 524 additions and 38 deletions
+4
View File
@@ -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
+5 -1
View File
@@ -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)
}
+4
View File
@@ -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')),
+8 -6
View File
@@ -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
+5 -1
View File
@@ -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 \
+10 -29
View File
@@ -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
}
+67
View File
@@ -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
}
+226
View File
@@ -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
}
+194
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}