package web import ( "errors" "net/http" "strings" "github.com/golang-jwt/jwt/v5" ) // JWTDetector is an SSODetector for portals that hand the browser a signed // JWT as an HttpOnly session cookie (or an Authorization: Bearer header). It // verifies the token with a shared HMAC secret and maps the identity claims // to a YAMA role. It is portal-agnostic: the secret, cookie name, claim keys // and the role value that means "admin" are all supplied at construction, so // the same detector serves any portal using this pattern. type JWTDetector struct { secret []byte cookieName string usernameClaim string roleClaim string adminRole string } // JWTDetectorConfig configures a JWTDetector. Secret is the shared HMAC key // (required). The remaining fields have defaults for a typical portal: // CookieName "auth_token", UsernameClaim "username", RoleClaim "rolename", // AdminRole "admin". type JWTDetectorConfig struct { Secret string CookieName string UsernameClaim string RoleClaim string AdminRole string } // NewJWTDetector builds a JWTDetector, applying defaults for any unset // optional field. func NewJWTDetector(cfg JWTDetectorConfig) *JWTDetector { if cfg.CookieName == "" { cfg.CookieName = "auth_token" } if cfg.UsernameClaim == "" { cfg.UsernameClaim = "username" } if cfg.RoleClaim == "" { cfg.RoleClaim = "rolename" } if cfg.AdminRole == "" { cfg.AdminRole = "admin" } return &JWTDetector{ secret: []byte(cfg.Secret), cookieName: cfg.CookieName, usernameClaim: cfg.UsernameClaim, roleClaim: cfg.RoleClaim, adminRole: cfg.AdminRole, } } // Detect reads the JWT from the configured cookie (falling back to a Bearer // header), verifies its signature and expiry, and maps the identity to a YAMA // role. Only HMAC algorithms are accepted, rejecting algorithm-confusion // attempts (alg=none / asymmetric algorithms). func (d *JWTDetector) Detect(r *http.Request) (username, role string, ok bool) { tokenString := d.extract(r) if tokenString == "" { return "", "", false } claims, ok := d.parse(tokenString) if !ok { return "", "", false } username, _ = claims[d.usernameClaim].(string) if username == "" { return "", "", false } rolename, _ := claims[d.roleClaim].(string) if rolename == d.adminRole { role = "admin" } else { role = "viewer" } return username, role, true } // extract returns the raw JWT from the configured cookie, or from a Bearer // header as a fallback. func (d *JWTDetector) extract(r *http.Request) string { if d.cookieName != "" { if ck, err := r.Cookie(d.cookieName); err == nil && ck.Value != "" { return ck.Value } } if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(hdr, "Bearer ")) } return "" } // parse verifies a JWT against the shared HMAC secret and returns its claims. func (d *JWTDetector) parse(tokenString string) (jwt.MapClaims, bool) { if len(d.secret) == 0 { // Fail closed: an unset shared secret must never accept a token. // A detector wired up with an empty secret is a misconfiguration, and // accepting an empty-key-signed token here would be a trivial auth // bypass. Rejecting everything means SSO silently falls back to login. return nil, false } token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("invalid signing algorithm") } return d.secret, nil }) if err != nil || !token.Valid { return nil, false } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, false } return claims, true }