Bridge an already-authenticated portal session into a YAMA web session via a new /sso endpoint, so portal administrators can enter the remote-desktop UI without a second login. Only the "admin" portal role is bridged; any other role (viewer, unknown) still falls back to the built-in login form, so a low-privileged portal account cannot bypass authentication. - wsauth: extract IssueToken from VerifyLogin so an out-of-band-authenticated identity (SSO) can mint a session token without the challenge/response. - web: add the SSODetector interface, a fail-closed JWT detector (HMAC-only), and the /sso handler gated to the admin role. - cmd: enable the JWT detector via YAMA_SSO_JWT_SECRET, the shared HS256 secret matching the parent portal. - web UI: attempt SSO before showing the login form, falling back to login when the server returns 401. - docs: document SSO (admin-only) and license trial-vs-paid troubleshooting in README and the env example. Co-Authored-By: deepseek-v4-pro
128 lines
3.6 KiB
Go
128 lines
3.6 KiB
Go
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
|
|
}
|