Skip to content

Commit b7f0c37

Browse files
authored
SDP-1853: Implement SEP-45 routes (#962)
### What This PR implements the JWT generation and HTTP handler for SEP-45. ### Why SEP-45 implementation ### Known limitations Nonce is still missing, and SEP-45 could be refactored to share some code with SEP-10. I'll address these in a follow-up. ### Checklist - [x] Title follows `SDP-1234: Add new feature` or `Chore: Refactor package xyz` format. The Jira ticket code was included if available. - [x] PR has a focused scope and doesn't mix features with refactoring - [x] Tests are included (if applicable) - [ ] `CHANGELOG.md` is updated (if applicable) - [ ] CONFIG/SECRETS changes are updated in helmcharts and deployments (if applicable) - [ ] Preview deployment works as expected - [ ] Ready for production
1 parent d5287f6 commit b7f0c37

File tree

9 files changed

+875
-27
lines changed

9 files changed

+875
-27
lines changed

internal/sepauth/jwt_manager.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ type Sep10JWTClaims struct {
1717
HomeDomain string `json:"home_domain,omitempty"`
1818
}
1919

20+
type Sep45JWTClaims struct {
21+
jwt.RegisteredClaims
22+
ClientDomain string `json:"client_domain,omitempty"`
23+
HomeDomain string `json:"home_domain,omitempty"`
24+
}
25+
2026
func (c Sep10JWTClaims) Valid() error {
2127
if c.Issuer == "" {
2228
return fmt.Errorf("issuer is required")
@@ -54,6 +60,43 @@ func (c Sep10JWTClaims) Valid() error {
5460
return nil
5561
}
5662

63+
func (c Sep45JWTClaims) Valid() error {
64+
if c.Issuer == "" {
65+
return fmt.Errorf("issuer is required")
66+
}
67+
68+
if c.Subject == "" {
69+
return fmt.Errorf("subject is required")
70+
}
71+
72+
if c.ID == "" {
73+
return fmt.Errorf("jti (JWT ID) is required")
74+
}
75+
76+
if c.IssuedAt == nil {
77+
return fmt.Errorf("iat (issued at) is required")
78+
}
79+
80+
if c.ExpiresAt == nil {
81+
return fmt.Errorf("exp (expires at) is required")
82+
}
83+
84+
err := c.RegisteredClaims.Valid()
85+
if err != nil {
86+
return fmt.Errorf("validating registered claims: %w", err)
87+
}
88+
89+
if c.ClientDomain != "" && len(strings.TrimSpace(c.ClientDomain)) == 0 {
90+
return fmt.Errorf("client_domain cannot be empty if provided")
91+
}
92+
93+
if c.HomeDomain != "" && len(strings.TrimSpace(c.HomeDomain)) == 0 {
94+
return fmt.Errorf("home_domain cannot be empty if provided")
95+
}
96+
97+
return nil
98+
}
99+
57100
type JWTManager struct {
58101
secret []byte
59102
expirationMiliseconds int64
@@ -153,6 +196,26 @@ func (manager *JWTManager) GenerateSEP10Token(issuer, subject, jti, clientDomain
153196
return token, nil
154197
}
155198

199+
func (manager *JWTManager) GenerateSEP45Token(issuer, subject, jti, clientDomain, homeDomain string, iat, exp time.Time) (string, error) {
200+
claims := Sep45JWTClaims{
201+
RegisteredClaims: jwt.RegisteredClaims{
202+
Issuer: issuer,
203+
Subject: subject,
204+
ID: jti,
205+
IssuedAt: jwt.NewNumericDate(iat),
206+
ExpiresAt: jwt.NewNumericDate(exp),
207+
},
208+
ClientDomain: clientDomain,
209+
HomeDomain: homeDomain,
210+
}
211+
212+
token, err := manager.signToken(claims)
213+
if err != nil {
214+
return "", fmt.Errorf("generating SEP45 token: %w", err)
215+
}
216+
return token, nil
217+
}
218+
156219
// ParseDefaultTokenClaims will parse the default claims from a JWT token string.
157220
func (manager *JWTManager) ParseDefaultTokenClaims(tokenString string) (*jwt.RegisteredClaims, error) {
158221
return parseTokenClaims(manager.secret, tokenString, &jwt.RegisteredClaims{}, "default")
@@ -164,6 +227,12 @@ func (manager *JWTManager) ParseSEP10TokenClaims(tokenString string) (*Sep10JWTC
164227
return parseTokenClaims(manager.secret, tokenString, &Sep10JWTClaims{}, "SEP10")
165228
}
166229

230+
// ParseSEP45TokenClaims will parse the provided token string and return the Sep45JWTClaims, if possible.
231+
// If the token is not a valid SEP-45 token, an error is returned instead.
232+
func (manager *JWTManager) ParseSEP45TokenClaims(tokenString string) (*Sep45JWTClaims, error) {
233+
return parseTokenClaims(manager.secret, tokenString, &Sep45JWTClaims{}, "SEP45")
234+
}
235+
167236
// ParseSEP24TokenClaims will parse the provided token string and return the SEP24JWTClaims, if possible.
168237
// If the token is not a valid SEP-24 token, an error is returned instead.
169238
func (manager *JWTManager) ParseSEP24TokenClaims(tokenString string) (*SEP24JWTClaims, error) {

internal/sepauth/jwt_manager_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,166 @@ func Test_JWTManager_GenerateAndParseSEP10Token(t *testing.T) {
179179
}
180180
}
181181

182+
func Test_JWTManager_GenerateAndParseSEP45Token(t *testing.T) {
183+
jwtManager, err := NewJWTManager("1234567890ab", 5000)
184+
require.NoError(t, err)
185+
186+
iat := time.Now()
187+
exp := iat.Add(5 * time.Minute)
188+
189+
testCases := []struct {
190+
name string
191+
issuer string
192+
subject string
193+
jti string
194+
clientDomain string
195+
homeDomain string
196+
wantErr bool
197+
}{
198+
{
199+
name: "valid SEP-45 token",
200+
issuer: "https://example.com/sep45/auth",
201+
subject: "CCYU2FUIMK23K34U3SWCN2O2JVI6JBGUGQUILYK7GRPCIDABVVTCS7R4",
202+
jti: "challenge-123456",
203+
clientDomain: "wallet.example.com",
204+
homeDomain: "example.com",
205+
},
206+
{
207+
name: "SEP-45 token without optional domains",
208+
issuer: "https://example.com/sep45/auth",
209+
subject: "CCYU2FUIMK23K34U3SWCN2O2JVI6JBGUGQUILYK7GRPCIDABVVTCS7R4",
210+
jti: "challenge-123456",
211+
clientDomain: "",
212+
homeDomain: "",
213+
},
214+
}
215+
216+
for _, tc := range testCases {
217+
t.Run(tc.name, func(t *testing.T) {
218+
tokenStr, err := jwtManager.GenerateSEP45Token(
219+
tc.issuer, tc.subject, tc.jti, tc.clientDomain, tc.homeDomain, iat, exp,
220+
)
221+
222+
if tc.wantErr {
223+
require.Error(t, err)
224+
return
225+
}
226+
227+
require.NoError(t, err)
228+
require.NotEmpty(t, tokenStr)
229+
230+
claims, err := jwtManager.ParseSEP45TokenClaims(tokenStr)
231+
require.NoError(t, err)
232+
require.NotNil(t, claims)
233+
234+
assert.Equal(t, tc.issuer, claims.Issuer)
235+
assert.Equal(t, tc.subject, claims.Subject)
236+
assert.Equal(t, tc.jti, claims.ID)
237+
assert.Equal(t, tc.clientDomain, claims.ClientDomain)
238+
assert.Equal(t, tc.homeDomain, claims.HomeDomain)
239+
assert.Equal(t, jwt.NewNumericDate(iat).Unix(), claims.IssuedAt.Unix())
240+
assert.Equal(t, jwt.NewNumericDate(exp).Unix(), claims.ExpiresAt.Unix())
241+
})
242+
}
243+
}
244+
245+
func Test_JWTManager_GenerateSEP45Token_InvalidClaims(t *testing.T) {
246+
jwtManager, err := NewJWTManager("1234567890ab", 5000)
247+
require.NoError(t, err)
248+
249+
now := time.Now()
250+
251+
testCases := []struct {
252+
name string
253+
issuer string
254+
sub string
255+
jti string
256+
iat time.Time
257+
exp time.Time
258+
}{
259+
{"missing issuer", "", "CC...XYZ", "jti", now, now.Add(5 * time.Minute)},
260+
{"missing subject", "https://issuer/sep45/auth", "", "jti", now, now.Add(5 * time.Minute)},
261+
{"missing jti", "https://issuer/sep45/auth", "CC...XYZ", "", now, now.Add(5 * time.Minute)},
262+
{"expired token", "https://issuer/sep45/auth", "CC...XYZ", "jti", now.Add(-10 * time.Minute), now.Add(-5 * time.Minute)},
263+
}
264+
265+
for _, tc := range testCases {
266+
tc := tc
267+
t.Run(tc.name, func(t *testing.T) {
268+
t.Parallel()
269+
tokenStr, err := jwtManager.GenerateSEP45Token(tc.issuer, tc.sub, tc.jti, "", "", tc.iat, tc.exp)
270+
require.Error(t, err)
271+
assert.Empty(t, tokenStr)
272+
})
273+
}
274+
}
275+
276+
func Test_JWTManager_ParseSEP45TokenClaims_InvalidTokens(t *testing.T) {
277+
jwtManager, err := NewJWTManager("1234567890ab", 5000)
278+
require.NoError(t, err)
279+
280+
differentJWTManager, err := NewJWTManager("different12345", 5000)
281+
require.NoError(t, err)
282+
283+
testCases := []struct {
284+
name string
285+
token string
286+
setupToken func() string
287+
wantErr bool
288+
errContains string
289+
}{
290+
{
291+
name: "empty token",
292+
token: "",
293+
wantErr: true,
294+
errContains: "parsing SEP45 token",
295+
},
296+
{
297+
name: "invalid token format",
298+
token: "not.a.jwt",
299+
wantErr: true,
300+
errContains: "parsing SEP45 token",
301+
},
302+
{
303+
name: "token signed with different secret",
304+
setupToken: func() string {
305+
token, err := differentJWTManager.GenerateSEP45Token(
306+
"issuer", "subject", "jti", "", "", time.Now(), time.Now().Add(5*time.Minute),
307+
)
308+
if err != nil {
309+
return ""
310+
}
311+
return token
312+
},
313+
wantErr: true,
314+
errContains: "parsing SEP45 token",
315+
},
316+
}
317+
318+
for _, tc := range testCases {
319+
t.Run(tc.name, func(t *testing.T) {
320+
tokenStr := tc.token
321+
if tc.setupToken != nil {
322+
tokenStr = tc.setupToken()
323+
}
324+
325+
claims, err := jwtManager.ParseSEP45TokenClaims(tokenStr)
326+
327+
if tc.wantErr {
328+
require.Error(t, err)
329+
if tc.errContains != "" {
330+
assert.Contains(t, err.Error(), tc.errContains)
331+
}
332+
assert.Nil(t, claims)
333+
return
334+
}
335+
336+
require.NoError(t, err)
337+
require.NotNil(t, claims)
338+
})
339+
}
340+
}
341+
182342
func Test_JWTManager_ParseSEP10TokenClaims_InvalidTokens(t *testing.T) {
183343
jwtManager, err := NewJWTManager("1234567890ab", 5000)
184344
require.NoError(t, err)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package httphandler
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"mime"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/stellar/go-stellar-sdk/support/render/httpjson"
11+
12+
"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror"
13+
"github.com/stellar/stellar-disbursement-platform-backend/internal/services"
14+
)
15+
16+
type SEP45Handler struct {
17+
SEP45Service services.SEP45Service
18+
}
19+
20+
// GetChallenge handles GET /sep45/auth requests for SEP-45 authentication.
21+
func (h SEP45Handler) GetChallenge(w http.ResponseWriter, r *http.Request) {
22+
ctx := r.Context()
23+
24+
clientDomain := r.URL.Query().Get("client_domain")
25+
var clientDomainPtr *string
26+
if strings.TrimSpace(clientDomain) != "" {
27+
clientDomainPtr = &clientDomain
28+
}
29+
30+
req := services.SEP45ChallengeRequest{
31+
Account: r.URL.Query().Get("account"),
32+
HomeDomain: r.URL.Query().Get("home_domain"),
33+
ClientDomain: clientDomainPtr,
34+
}
35+
36+
if err := req.Validate(); err != nil {
37+
httperror.BadRequest(err.Error(), nil, nil).Render(w)
38+
return
39+
}
40+
41+
challenge, err := h.SEP45Service.CreateChallenge(ctx, req)
42+
if err != nil {
43+
if errors.Is(err, services.ErrSEP45Validation) {
44+
httperror.BadRequest(err.Error(), err, nil).Render(w)
45+
} else {
46+
httperror.InternalError(ctx, "Failed to create challenge", err, nil).Render(w)
47+
}
48+
return
49+
}
50+
51+
w.Header().Set("Content-Type", "application/json")
52+
httpjson.Render(w, challenge, httpjson.JSON)
53+
}
54+
55+
// PostChallenge handles POST /sep45/auth requests for SEP-45 authentication validation.
56+
func (h SEP45Handler) PostChallenge(w http.ResponseWriter, r *http.Request) {
57+
ctx := r.Context()
58+
59+
var req services.SEP45ValidationRequest
60+
61+
contentType := r.Header.Get("Content-Type")
62+
var mediaType string
63+
if contentType != "" {
64+
if parsed, _, err := mime.ParseMediaType(contentType); err == nil {
65+
mediaType = parsed
66+
} else {
67+
mediaType = contentType
68+
}
69+
}
70+
71+
switch mediaType {
72+
case "application/x-www-form-urlencoded":
73+
if err := r.ParseForm(); err != nil {
74+
httperror.BadRequest("invalid form data", err, nil).Render(w)
75+
return
76+
}
77+
req.AuthorizationEntries = r.FormValue("authorization_entries")
78+
case "application/json":
79+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
80+
httperror.BadRequest("invalid request body", err, nil).Render(w)
81+
return
82+
}
83+
default:
84+
httperror.BadRequest("unsupported content type. Expected application/x-www-form-urlencoded or application/json", nil, nil).Render(w)
85+
return
86+
}
87+
88+
if err := req.Validate(); err != nil {
89+
httperror.BadRequest(err.Error(), nil, nil).Render(w)
90+
return
91+
}
92+
93+
response, err := h.SEP45Service.ValidateChallenge(ctx, req)
94+
if err != nil {
95+
if errors.Is(err, services.ErrSEP45Validation) {
96+
httperror.BadRequest("challenge validation failed", err, nil).Render(w)
97+
} else {
98+
httperror.InternalError(ctx, "challenge validation failed", err, nil).Render(w)
99+
}
100+
return
101+
}
102+
103+
w.Header().Set("Content-Type", "application/json")
104+
httpjson.Render(w, response, httpjson.JSON)
105+
}

0 commit comments

Comments
 (0)