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 }