Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions internal/sepauth/jwt_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ type Sep10JWTClaims struct {
HomeDomain string `json:"home_domain,omitempty"`
}

type Sep45JWTClaims struct {
jwt.RegisteredClaims
ClientDomain string `json:"client_domain,omitempty"`
HomeDomain string `json:"home_domain,omitempty"`
}

func (c Sep10JWTClaims) Valid() error {
if c.Issuer == "" {
return fmt.Errorf("issuer is required")
Expand Down Expand Up @@ -54,6 +60,43 @@ func (c Sep10JWTClaims) Valid() error {
return nil
}

func (c Sep45JWTClaims) Valid() error {
if c.Issuer == "" {
return fmt.Errorf("issuer is required")
}

if c.Subject == "" {
return fmt.Errorf("subject is required")
}

if c.ID == "" {
return fmt.Errorf("jti (JWT ID) is required")
}

if c.IssuedAt == nil {
return fmt.Errorf("iat (issued at) is required")
}

if c.ExpiresAt == nil {
return fmt.Errorf("exp (expires at) is required")
}

err := c.RegisteredClaims.Valid()
if err != nil {
return fmt.Errorf("validating registered claims: %w", err)
}

if c.ClientDomain != "" && len(strings.TrimSpace(c.ClientDomain)) == 0 {
return fmt.Errorf("client_domain cannot be empty if provided")
}

if c.HomeDomain != "" && len(strings.TrimSpace(c.HomeDomain)) == 0 {
return fmt.Errorf("home_domain cannot be empty if provided")
}

return nil
}

type JWTManager struct {
secret []byte
expirationMiliseconds int64
Expand Down Expand Up @@ -153,6 +196,26 @@ func (manager *JWTManager) GenerateSEP10Token(issuer, subject, jti, clientDomain
return token, nil
}

func (manager *JWTManager) GenerateSEP45Token(issuer, subject, jti, clientDomain, homeDomain string, iat, exp time.Time) (string, error) {
claims := Sep45JWTClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: subject,
ID: jti,
IssuedAt: jwt.NewNumericDate(iat),
ExpiresAt: jwt.NewNumericDate(exp),
},
ClientDomain: clientDomain,
HomeDomain: homeDomain,
}

token, err := manager.signToken(claims)
if err != nil {
return "", fmt.Errorf("generating SEP45 token: %w", err)
}
return token, nil
}

// ParseDefaultTokenClaims will parse the default claims from a JWT token string.
func (manager *JWTManager) ParseDefaultTokenClaims(tokenString string) (*jwt.RegisteredClaims, error) {
return parseTokenClaims(manager.secret, tokenString, &jwt.RegisteredClaims{}, "default")
Expand All @@ -164,6 +227,12 @@ func (manager *JWTManager) ParseSEP10TokenClaims(tokenString string) (*Sep10JWTC
return parseTokenClaims(manager.secret, tokenString, &Sep10JWTClaims{}, "SEP10")
}

// ParseSEP45TokenClaims will parse the provided token string and return the Sep45JWTClaims, if possible.
// If the token is not a valid SEP-45 token, an error is returned instead.
func (manager *JWTManager) ParseSEP45TokenClaims(tokenString string) (*Sep45JWTClaims, error) {
return parseTokenClaims(manager.secret, tokenString, &Sep45JWTClaims{}, "SEP45")
}

// ParseSEP24TokenClaims will parse the provided token string and return the SEP24JWTClaims, if possible.
// If the token is not a valid SEP-24 token, an error is returned instead.
func (manager *JWTManager) ParseSEP24TokenClaims(tokenString string) (*SEP24JWTClaims, error) {
Expand Down
160 changes: 160 additions & 0 deletions internal/sepauth/jwt_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,166 @@ func Test_JWTManager_GenerateAndParseSEP10Token(t *testing.T) {
}
}

func Test_JWTManager_GenerateAndParseSEP45Token(t *testing.T) {
jwtManager, err := NewJWTManager("1234567890ab", 5000)
require.NoError(t, err)

iat := time.Now()
exp := iat.Add(5 * time.Minute)

testCases := []struct {
name string
issuer string
subject string
jti string
clientDomain string
homeDomain string
wantErr bool
}{
{
name: "valid SEP-45 token",
issuer: "https://example.com/sep45/auth",
subject: "CCYU2FUIMK23K34U3SWCN2O2JVI6JBGUGQUILYK7GRPCIDABVVTCS7R4",
jti: "challenge-123456",
clientDomain: "wallet.example.com",
homeDomain: "example.com",
},
{
name: "SEP-45 token without optional domains",
issuer: "https://example.com/sep45/auth",
subject: "CCYU2FUIMK23K34U3SWCN2O2JVI6JBGUGQUILYK7GRPCIDABVVTCS7R4",
jti: "challenge-123456",
clientDomain: "",
homeDomain: "",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tokenStr, err := jwtManager.GenerateSEP45Token(
tc.issuer, tc.subject, tc.jti, tc.clientDomain, tc.homeDomain, iat, exp,
)

if tc.wantErr {
require.Error(t, err)
return
}

require.NoError(t, err)
require.NotEmpty(t, tokenStr)

claims, err := jwtManager.ParseSEP45TokenClaims(tokenStr)
require.NoError(t, err)
require.NotNil(t, claims)

assert.Equal(t, tc.issuer, claims.Issuer)
assert.Equal(t, tc.subject, claims.Subject)
assert.Equal(t, tc.jti, claims.ID)
assert.Equal(t, tc.clientDomain, claims.ClientDomain)
assert.Equal(t, tc.homeDomain, claims.HomeDomain)
assert.Equal(t, jwt.NewNumericDate(iat).Unix(), claims.IssuedAt.Unix())
assert.Equal(t, jwt.NewNumericDate(exp).Unix(), claims.ExpiresAt.Unix())
})
}
}

func Test_JWTManager_GenerateSEP45Token_InvalidClaims(t *testing.T) {
jwtManager, err := NewJWTManager("1234567890ab", 5000)
require.NoError(t, err)

now := time.Now()

testCases := []struct {
name string
issuer string
sub string
jti string
iat time.Time
exp time.Time
}{
{"missing issuer", "", "CC...XYZ", "jti", now, now.Add(5 * time.Minute)},
{"missing subject", "https://issuer/sep45/auth", "", "jti", now, now.Add(5 * time.Minute)},
{"missing jti", "https://issuer/sep45/auth", "CC...XYZ", "", now, now.Add(5 * time.Minute)},
{"expired token", "https://issuer/sep45/auth", "CC...XYZ", "jti", now.Add(-10 * time.Minute), now.Add(-5 * time.Minute)},
}

for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tokenStr, err := jwtManager.GenerateSEP45Token(tc.issuer, tc.sub, tc.jti, "", "", tc.iat, tc.exp)
require.Error(t, err)
assert.Empty(t, tokenStr)
})
}
}

func Test_JWTManager_ParseSEP45TokenClaims_InvalidTokens(t *testing.T) {
jwtManager, err := NewJWTManager("1234567890ab", 5000)
require.NoError(t, err)

differentJWTManager, err := NewJWTManager("different12345", 5000)
require.NoError(t, err)

testCases := []struct {
name string
token string
setupToken func() string
wantErr bool
errContains string
}{
{
name: "empty token",
token: "",
wantErr: true,
errContains: "parsing SEP45 token",
},
{
name: "invalid token format",
token: "not.a.jwt",
wantErr: true,
errContains: "parsing SEP45 token",
},
{
name: "token signed with different secret",
setupToken: func() string {
token, err := differentJWTManager.GenerateSEP45Token(
"issuer", "subject", "jti", "", "", time.Now(), time.Now().Add(5*time.Minute),
)
if err != nil {
return ""
}
return token
},
wantErr: true,
errContains: "parsing SEP45 token",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tokenStr := tc.token
if tc.setupToken != nil {
tokenStr = tc.setupToken()
}

claims, err := jwtManager.ParseSEP45TokenClaims(tokenStr)

if tc.wantErr {
require.Error(t, err)
if tc.errContains != "" {
assert.Contains(t, err.Error(), tc.errContains)
}
assert.Nil(t, claims)
return
}

require.NoError(t, err)
require.NotNil(t, claims)
})
}
}

func Test_JWTManager_ParseSEP10TokenClaims_InvalidTokens(t *testing.T) {
jwtManager, err := NewJWTManager("1234567890ab", 5000)
require.NoError(t, err)
Expand Down
105 changes: 105 additions & 0 deletions internal/serve/httphandler/sep45_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package httphandler

import (
"encoding/json"
"errors"
"mime"
"net/http"
"strings"

"github.com/stellar/go-stellar-sdk/support/render/httpjson"

"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror"
"github.com/stellar/stellar-disbursement-platform-backend/internal/services"
)

type SEP45Handler struct {
SEP45Service services.SEP45Service
}

// GetChallenge handles GET /sep45/auth requests for SEP-45 authentication.
func (h SEP45Handler) GetChallenge(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

clientDomain := r.URL.Query().Get("client_domain")
var clientDomainPtr *string
if strings.TrimSpace(clientDomain) != "" {
clientDomainPtr = &clientDomain
}

req := services.SEP45ChallengeRequest{
Account: r.URL.Query().Get("account"),
HomeDomain: r.URL.Query().Get("home_domain"),
ClientDomain: clientDomainPtr,
}

if err := req.Validate(); err != nil {
httperror.BadRequest(err.Error(), nil, nil).Render(w)
return
}

challenge, err := h.SEP45Service.CreateChallenge(ctx, req)
if err != nil {
if errors.Is(err, services.ErrSEP45Validation) {
httperror.BadRequest(err.Error(), err, nil).Render(w)
} else {
httperror.InternalError(ctx, "Failed to create challenge", err, nil).Render(w)
}
return
}

w.Header().Set("Content-Type", "application/json")
httpjson.Render(w, challenge, httpjson.JSON)
}

// PostChallenge handles POST /sep45/auth requests for SEP-45 authentication validation.
func (h SEP45Handler) PostChallenge(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

var req services.SEP45ValidationRequest

contentType := r.Header.Get("Content-Type")
var mediaType string
if contentType != "" {
if parsed, _, err := mime.ParseMediaType(contentType); err == nil {
mediaType = parsed
} else {
mediaType = contentType
}
}

switch mediaType {
case "application/x-www-form-urlencoded":
if err := r.ParseForm(); err != nil {
httperror.BadRequest("invalid form data", err, nil).Render(w)
return
}
req.AuthorizationEntries = r.FormValue("authorization_entries")
case "application/json":
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.BadRequest("invalid request body", err, nil).Render(w)
return
}
default:
httperror.BadRequest("unsupported content type. Expected application/x-www-form-urlencoded or application/json", nil, nil).Render(w)
return
}

if err := req.Validate(); err != nil {
httperror.BadRequest(err.Error(), nil, nil).Render(w)
return
}

response, err := h.SEP45Service.ValidateChallenge(ctx, req)
if err != nil {
if errors.Is(err, services.ErrSEP45Validation) {
httperror.BadRequest("challenge validation failed", err, nil).Render(w)
} else {
httperror.InternalError(ctx, "challenge validation failed", err, nil).Render(w)
}
return
}

w.Header().Set("Content-Type", "application/json")
httpjson.Render(w, response, httpjson.JSON)
}
Loading
Loading