227 lines
7.5 KiB
Go
227 lines
7.5 KiB
Go
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
|
|
}
|