176 lines
6.5 KiB
Go
176 lines
6.5 KiB
Go
package identityprovider
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"vpn-platform/backend/internal/model"
|
|
"vpn-platform/backend/internal/store"
|
|
)
|
|
|
|
type transportFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f transportFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
|
|
|
func TestFeishuExchangeUsesJSONAndRejectsUnexpectedIdentity(t *testing.T) {
|
|
previous := upstreamClient
|
|
defer func() { upstreamClient = previous }()
|
|
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.String() == feishuToken {
|
|
if req.Header.Get("Content-Type") != "application/json" {
|
|
t.Fatal("Feishu token exchange must use JSON")
|
|
}
|
|
var body map[string]string
|
|
if json.NewDecoder(req.Body).Decode(&body) != nil || body["client_id"] != "app-id" || body["redirect_uri"] != "https://vpn.example.com/id/feishu/callback" {
|
|
t.Fatal("wrong Feishu exchange request")
|
|
}
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"feishu-token"}`)), Header: make(http.Header)}, nil
|
|
}
|
|
if req.URL.String() != feishuUserInfo || req.Header.Get("Authorization") != "Bearer feishu-token" {
|
|
t.Fatal("unexpected Feishu user request")
|
|
}
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"data":{"open_id":"ou_1","tenant_key":"tenant_1","name":"Alice"}}`)), Header: make(http.Header)}, nil
|
|
})}
|
|
token, err := postFeishuToken(context.Background(), "app-id", "app-secret", "feishu-code", "https://vpn.example.com/id/feishu/callback")
|
|
if err != nil || token != "feishu-token" {
|
|
t.Fatalf("exchange: %q %v", token, err)
|
|
}
|
|
user, tenant, err := fetchFeishuUser(context.Background(), token)
|
|
if err != nil || tenant != "tenant_1" || user.Subject != "tenant_1:ou_1" || user.Name != "Alice" {
|
|
t.Fatalf("identity: %+v %s %v", user, tenant, err)
|
|
}
|
|
}
|
|
|
|
func TestFeishuAuthorizationUsesValidUserInfoScope(t *testing.T) {
|
|
u, err := url.Parse(feishuAuthorizeURL("cli_test", "https://vpn.example.com/id/feishu/callback", "state_test"))
|
|
if err != nil || u.Scheme != "https" || u.Host != "accounts.feishu.cn" ||
|
|
u.Query().Get("scope") != "contact:user.base:readonly" ||
|
|
u.Query().Get("redirect_uri") != "https://vpn.example.com/id/feishu/callback" ||
|
|
u.Query().Get("state") != "state_test" {
|
|
t.Fatalf("invalid Feishu authorization request: %v, %v", u, err)
|
|
}
|
|
}
|
|
|
|
func TestFeishuExchangeRejectsMalformedSuccess(t *testing.T) {
|
|
previous := upstreamClient
|
|
defer func() { upstreamClient = previous }()
|
|
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"access_token":"fake","data":{"open_id":"ou_1","tenant_key":"tenant_1"}}`)), Header: make(http.Header)}, nil
|
|
})}
|
|
if _, err := postFeishuToken(context.Background(), "id", "secret", "code", "https://vpn.example.com/id/feishu/callback"); err == nil {
|
|
t.Fatal("missing Feishu success code accepted")
|
|
}
|
|
if _, _, err := fetchFeishuUser(context.Background(), "fake"); err == nil {
|
|
t.Fatal("missing Feishu user response code accepted")
|
|
}
|
|
}
|
|
|
|
func TestGrantSingleUseAndSignedToken(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("VPN_DATA_DIR", dir)
|
|
if err := store.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code, err := randomToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
grant := codeData{requestState: requestState{ClientID: "opsx-vpn", Redirect: "https://vpn.example.com/oauth2/callback", Nonce: "nonce-1"}, Subject: "tenant:ou_1", Name: "Alice"}
|
|
if err := saveGrant("code", code, grant, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var used codeData
|
|
if err := consumeGrant("code", code, &used); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := consumeGrant("code", code, &used); err == nil {
|
|
t.Fatal("grant replay accepted")
|
|
}
|
|
key, kid, err := signingKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jwt, err := signIDToken(key, kid, "https://vpn.example.com/id", model.IdentityProvider{ClientID: "opsx-vpn"}, used)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parts := strings.Split(jwt, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatal("invalid JWT")
|
|
}
|
|
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
|
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], signature); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claimsRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var claims map[string]any
|
|
if json.Unmarshal(claimsRaw, &claims) != nil || claims["sub"] != "tenant:ou_1" || claims["name"] != "Alice" || claims["nonce"] != "nonce-1" || claims["aud"] != "opsx-vpn" {
|
|
t.Fatalf("unexpected claims: %v", claims)
|
|
}
|
|
if info, err := filepath.Glob(filepath.Join(dir, "identity", "*.pem")); err != nil || len(info) != 1 {
|
|
t.Fatal("missing signing key")
|
|
}
|
|
}
|
|
|
|
func TestIDTokenOmitsMissingDisplayName(t *testing.T) {
|
|
t.Setenv("VPN_DATA_DIR", t.TempDir())
|
|
key, kid, err := signingKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, err := signIDToken(key, kid, "https://vpn.example.com/id", model.IdentityProvider{ClientID: "opsx-vpn"}, codeData{Subject: "tenant:ou_1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatal("invalid JWT")
|
|
}
|
|
body, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var claims map[string]any
|
|
if json.Unmarshal(body, &claims) != nil || claims["sub"] != "tenant:ou_1" {
|
|
t.Fatalf("stable identity missing: %v", claims)
|
|
}
|
|
if _, present := claims["name"]; present {
|
|
t.Fatal("empty display name should not be added to ID token")
|
|
}
|
|
}
|
|
|
|
func TestRedirectIsExactAndNoOpenRedirect(t *testing.T) {
|
|
if !validateRedirect("https://vpn.example.com/oauth2/callback", "https://vpn.example.com") {
|
|
t.Fatal("valid callback rejected")
|
|
}
|
|
if !validateRedirect("http://vpn.example.com:8080/oauth2/callback", "http://vpn.example.com:8080") {
|
|
t.Fatal("http custom-port callback rejected")
|
|
}
|
|
for _, bad := range []string{"http://vpn.example.com/oauth2/callback", "https://vpn.example.com.evil/oauth2/callback", "https://vpn.example.com/oauth2/callback/extra", "https://vpn.example.com/oauth2/callback?next=evil"} {
|
|
if validateRedirect(bad, "https://vpn.example.com") {
|
|
t.Errorf("accepted callback %q", bad)
|
|
}
|
|
}
|
|
if err := consumeGrant("state", "short", new(requestState)); err == nil {
|
|
t.Fatal("accepted short state")
|
|
}
|
|
}
|