Add Slack OpenID login with verified identities and callback replay protection
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user