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
181 lines
6.0 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|
|
}
|