Files
yuanyuanxianganddeepseek-v4-pro 53b7151fb7 Feature: Add admin-only SSO bridge to web UI
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
2026-10-13 15:46:04 +02:00

181 lines
6.0 KiB
Go

package web
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
const testSSOSecret = "test-shared-secret"
func signTestToken(t *testing.T, secret string, claims jwt.MapClaims) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := tok.SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign test token: %v", err)
}
return s
}
// noneToken builds a base64url header/claims JWT with alg "none" and no
// signature, to exercise the algorithm-confusion rejection path.
func noneToken(claims jwt.MapClaims) string {
h, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"})
c, _ := json.Marshal(claims)
enc := base64.RawURLEncoding.EncodeToString
return strings.TrimRight(enc(h), "=") + "." + strings.TrimRight(enc(c), "=") + "."
}
func cookieReq(token string) *http.Request {
r := &http.Request{Header: http.Header{}}
r.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
return r
}
func bearerReq(token string) *http.Request {
r := &http.Request{Header: http.Header{}}
r.Header.Set("Authorization", "Bearer "+token)
return r
}
func TestJWTDetector(t *testing.T) {
d := NewJWTDetector(JWTDetectorConfig{Secret: testSSOSecret})
valid := func(rolename string) jwt.MapClaims {
return jwt.MapClaims{
"username": "alice",
"rolename": rolename,
"exp": time.Now().Add(time.Hour).Unix(),
}
}
tests := []struct {
name string
req *http.Request
wantUser string
wantRole string
wantOK bool
}{
{"admin maps to admin", cookieReq(signTestToken(t, testSSOSecret, valid("admin"))), "alice", "admin", true},
{"viewer maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid("viewer"))), "alice", "viewer", true},
{"empty rolename maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid(""))), "alice", "viewer", true},
{"unknown rolename maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid("operator"))), "alice", "viewer", true},
{"bearer header accepted", bearerReq(signTestToken(t, testSSOSecret, valid("admin"))), "alice", "admin", true},
{"no credential", &http.Request{Header: http.Header{}}, "", "", false},
{"wrong secret rejected", cookieReq(signTestToken(t, "other-secret", valid("admin"))), "", "", false},
{"expired token rejected", cookieReq(signTestToken(t, testSSOSecret, jwt.MapClaims{
"username": "alice", "rolename": "admin", "exp": time.Now().Add(-time.Hour).Unix(),
})), "", "", false},
{"missing username rejected", cookieReq(signTestToken(t, testSSOSecret, jwt.MapClaims{
"rolename": "admin", "exp": time.Now().Add(time.Hour).Unix(),
})), "", "", false},
{"alg none rejected", cookieReq(noneToken(valid("admin"))), "", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, role, ok := d.Detect(tt.req)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (user=%q role=%q)", ok, tt.wantOK, user, role)
}
if !tt.wantOK {
return
}
if user != tt.wantUser || role != tt.wantRole {
t.Fatalf("got (%q, %q), want (%q, %q)", user, role, tt.wantUser, tt.wantRole)
}
})
}
}
// A detector with no shared secret must never accept a token, even one signed
// with an empty key — otherwise a misconfigured deployment would silently
// accept forged sessions.
func TestJWTDetectorEmptySecretFailClosed(t *testing.T) {
d := NewJWTDetector(JWTDetectorConfig{})
tok := signTestToken(t, "", jwt.MapClaims{
"username": "alice", "rolename": "admin", "exp": time.Now().Add(time.Hour).Unix(),
})
if _, _, ok := d.Detect(cookieReq(tok)); ok {
t.Fatal("empty-secret detector accepted a token; expected fail-closed")
}
}
// Non-default config keys must be honored, so a different portal can reuse the
// detector with no code changes.
func TestJWTDetectorCustomConfig(t *testing.T) {
d := NewJWTDetector(JWTDetectorConfig{
Secret: testSSOSecret,
CookieName: "session",
UsernameClaim: "sub",
RoleClaim: "role",
AdminRole: "root",
})
tok := signTestToken(t, testSSOSecret, jwt.MapClaims{
"sub": "bob", "role": "root", "exp": time.Now().Add(time.Hour).Unix(),
})
r := &http.Request{Header: http.Header{}}
r.AddCookie(&http.Cookie{Name: "session", Value: tok})
user, role, ok := d.Detect(r)
if !ok || user != "bob" || role != "admin" {
t.Fatalf("got (%q, %q, %v), want (%q, %q, true)", user, role, ok, "bob", "admin")
}
}
// fakeSSODetector lets the /sso handler's role policy be exercised without a
// real portal JWT.
type fakeSSODetector struct {
username, role string
ok bool
}
func (f *fakeSSODetector) Detect(*http.Request) (string, string, bool) {
return f.username, f.role, f.ok
}
// The /sso handler must only bridge administrators. A valid portal session for
// any other role — or no session at all — returns 401 so the front-end falls
// back to the built-in login form.
func TestHandleSSOAdminOnly(t *testing.T) {
newSrv := func(d SSODetector) *Server {
return &Server{
log: logger.New(logger.Config{}),
auth: wsauth.New(),
sso: d,
}
}
tests := []struct {
name string
detector SSODetector
wantStatus int
}{
{"admin is bridged", &fakeSSODetector{"alice", "admin", true}, http.StatusOK},
{"viewer is rejected", &fakeSSODetector{"bob", "viewer", true}, http.StatusUnauthorized},
{"unknown role is rejected", &fakeSSODetector{"carol", "operator", true}, http.StatusUnauthorized},
{"no credential is rejected", &fakeSSODetector{ok: false}, http.StatusUnauthorized},
{"SSO disabled", nil, http.StatusUnauthorized},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newSrv(tt.detector)
rec := httptest.NewRecorder()
s.handleSSO(rec, httptest.NewRequest(http.MethodPost, "/sso", nil))
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}