forked from baron/baron-sso
feat(auth): add trusted rp headless login flows
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -17,7 +19,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
@@ -121,6 +126,79 @@ func newAuthLoginTestApp(h *AuthHandler) *fiber.App {
|
||||
return app
|
||||
}
|
||||
|
||||
func newHeadlessPasswordLoginTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/headless/password/login", h.HeadlessPasswordLogin)
|
||||
return app
|
||||
}
|
||||
|
||||
func mustHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate rsa key: %v", err)
|
||||
}
|
||||
|
||||
keySet := jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{
|
||||
{
|
||||
Key: &privateKey.PublicKey,
|
||||
KeyID: "test-kid",
|
||||
Use: "sig",
|
||||
Algorithm: string(jose.RS256),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(keySet)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks: %v", err)
|
||||
}
|
||||
|
||||
var jwks map[string]any
|
||||
if err := json.Unmarshal(raw, &jwks); err != nil {
|
||||
t.Fatalf("failed to decode jwks map: %v", err)
|
||||
}
|
||||
|
||||
return privateKey, jwks
|
||||
}
|
||||
|
||||
func mustHeadlessClientAssertion(t *testing.T, privateKey *rsa.PrivateKey, clientID, audience string) string {
|
||||
t.Helper()
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.RS256,
|
||||
Key: jose.JSONWebKey{
|
||||
Key: privateKey,
|
||||
KeyID: "test-kid",
|
||||
Use: "sig",
|
||||
Algorithm: string(jose.RS256),
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create signer: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
raw, err := josejwt.Signed(signer).Claims(josejwt.Claims{
|
||||
Issuer: clientID,
|
||||
Subject: clientID,
|
||||
Audience: josejwt.Audience{audience},
|
||||
Expiry: josejwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||
IssuedAt: josejwt.NewNumericDate(now),
|
||||
NotBefore: josejwt.NewNumericDate(
|
||||
now.Add(-1 * time.Minute),
|
||||
),
|
||||
ID: "assertion-1",
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign client assertion: %v", err)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
// mockHydraTransport simulates Hydra API responses
|
||||
func mockHydraTransport(handler http.Handler) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
@@ -206,6 +284,342 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
Subject: "kratos-identity-id",
|
||||
}, nil)
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKSUri: jwksServer.URL + "/.well-known/jwks.json",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
clientAssertion := mustHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"trusted-rp",
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["redirectTo"] != "http://rp/cb" {
|
||||
t.Fatalf("expected redirectTo http://rp/cb, got %v", got["redirectTo"])
|
||||
}
|
||||
if _, ok := got["sessionJwt"]; ok {
|
||||
t.Fatalf("expected headless response to omit sessionJwt, got %v", got["sessionJwt"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
Subject: "kratos-identity-id",
|
||||
}, nil)
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: map[string]any{
|
||||
"keys": []map[string]any{},
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 400, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
Subject: "kratos-identity-id",
|
||||
}, nil)
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
validKey, jwks := mustHeadlessRSAJWK(t)
|
||||
invalidKey, _ := mustHeadlessRSAJWK(t)
|
||||
_ = validKey
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: jwks,
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
clientAssertion := mustHeadlessClientAssertion(
|
||||
t,
|
||||
invalidKey,
|
||||
"trusted-rp",
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 401, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet {
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "trusted-rp",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 403, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet {
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "other-rp",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "trusted-rp",
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 403, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLogin_OIDC_InactiveClient(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{
|
||||
|
||||
Reference in New Issue
Block a user