From dc6f9531fdc3a0a5d7b94fa7b685d29567a3d6c1 Mon Sep 17 00:00:00 2001 From: Imhotep Benjamin Date: Wed, 1 Apr 2026 11:30:09 -0400 Subject: [PATCH 1/5] Replace jwx with manual RS256 JWT signing Stop using lestrrat-go/jwx for building/signing the client assertion JWT and instead construct and sign the JWT manually. The code now marshals a RS256 header and the claims (iss, sub, aud, jti, iat, exp), base64-url encodes header and payload, computes a SHA-256 digest of the signing input, and signs with rsa.SignPKCS1v15. Imports were updated accordingly (added crypto, rand, sha256, encoding/base64; removed jwa/jwt). Error messages were adjusted to reflect the new steps. This removes the jwx dependency while preserving the original JWT claims and lifetime. --- api/handlers/education_handler_test.go | 2 +- pkg/education/submit.go | 26 +++++++++++++++- pkg/veteran/oauth.go | 42 ++++++++++++++++++-------- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/api/handlers/education_handler_test.go b/api/handlers/education_handler_test.go index 452ea38..bf6e16b 100644 --- a/api/handlers/education_handler_test.go +++ b/api/handlers/education_handler_test.go @@ -20,7 +20,7 @@ import ( ) type fakeEducationService struct { - response education.Response + response education.EducationResponse err error calls int lastReq education.Request diff --git a/pkg/education/submit.go b/pkg/education/submit.go index 1d14bff..46eaf9b 100644 --- a/pkg/education/submit.go +++ b/pkg/education/submit.go @@ -111,7 +111,7 @@ func (s *service) LookupEnrollmentStatus(ctx context.Context, reqBody Request) ( slog.String("body_snippet", snippet), ) - return Response{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) + return EducationResponse{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) } var out nscResponse @@ -320,3 +320,27 @@ func enrollmentRecordCount(resp nscResponse) int { return len(resp.EnrollmentDetails.EnrollmentData) } + +type legacySubmitResponse struct { + Status legacySubmitStatus `json:"status"` +} + +type legacySubmitStatus struct { + Code string `json:"code"` +} + +func mapLegacyEnrollmentStatus(respBytes []byte) (SchoolEnrollmentStatus, error) { + var legacy legacySubmitResponse + if err := json.Unmarshal(respBytes, &legacy); err != nil { + return "", fmt.Errorf("decode legacy nsc response: %w", err) + } + + switch legacy.Status.Code { + case "0": + return SchoolEnrollmentStatusEnrolled, nil + case "": + return "", fmt.Errorf("enrollmentStatus is required") + default: + return "", fmt.Errorf("unsupported legacy nsc status code %q", legacy.Status.Code) + } +} diff --git a/pkg/veteran/oauth.go b/pkg/veteran/oauth.go index 10e2e43..639b450 100644 --- a/pkg/veteran/oauth.go +++ b/pkg/veteran/oauth.go @@ -2,8 +2,12 @@ package veteran import ( "context" + "crypto" + "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "errors" @@ -16,8 +20,6 @@ import ( "github.com/cmsgov/emmy-api/pkg/core" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwt" "golang.org/x/oauth2" ) @@ -148,24 +150,38 @@ func signedClientAssertion(cfg *core.VAConfig, now time.Time) (string, error) { return "", err } - token, err := jwt.NewBuilder(). - Audience([]string{cfg.TokenAudience}). - Issuer(cfg.ClientID). - Subject(cfg.ClientID). - JwtID(strings.ToLower(uuid.NewString())). - IssuedAt(now). - Expiration(now.Add(clientAssertionLifetime)). - Build() + headerJSON, err := json.Marshal(map[string]string{ + "alg": "RS256", + "typ": "JWT", + }) + if err != nil { + return "", fmt.Errorf("marshal jwt header: %w", err) + } + + payloadJSON, err := json.Marshal(map[string]any{ + "iss": cfg.ClientID, + "sub": cfg.ClientID, + "aud": cfg.TokenAudience, + "jti": strings.ToLower(uuid.NewString()), + "iat": now.Unix(), + "exp": now.Add(clientAssertionLifetime).Unix(), + }) if err != nil { - return "", fmt.Errorf("build jwt claims: %w", err) + return "", fmt.Errorf("marshal jwt claims: %w", err) } - signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) + encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON) + encodedPayload := base64.RawURLEncoding.EncodeToString(payloadJSON) + signingInput := encodedHeader + "." + encodedPayload + + hash := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:]) if err != nil { return "", fmt.Errorf("sign jwt: %w", err) } - return string(signed), nil + encodedSignature := base64.RawURLEncoding.EncodeToString(signature) + return signingInput + "." + encodedSignature, nil } func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) { From f5059e4658dfb78ac15db337d2fc14bad89bf07e Mon Sep 17 00:00:00 2001 From: Imhotep Benjamin Date: Wed, 1 Apr 2026 11:30:09 -0400 Subject: [PATCH 2/5] Replace jwx with manual RS256 JWT signing Stop using lestrrat-go/jwx for building/signing the client assertion JWT and instead construct and sign the JWT manually. The code now marshals a RS256 header and the claims (iss, sub, aud, jti, iat, exp), base64-url encodes header and payload, computes a SHA-256 digest of the signing input, and signs with rsa.SignPKCS1v15. Imports were updated accordingly (added crypto, rand, sha256, encoding/base64; removed jwa/jwt). Error messages were adjusted to reflect the new steps. This removes the jwx dependency while preserving the original JWT claims and lifetime. --- api/handlers/education_handler_test.go | 2 +- pkg/education/submit.go | 26 +++++++++++++++- pkg/veteran/oauth.go | 42 ++++++++++++++++++-------- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/api/handlers/education_handler_test.go b/api/handlers/education_handler_test.go index 452ea38..bf6e16b 100644 --- a/api/handlers/education_handler_test.go +++ b/api/handlers/education_handler_test.go @@ -20,7 +20,7 @@ import ( ) type fakeEducationService struct { - response education.Response + response education.EducationResponse err error calls int lastReq education.Request diff --git a/pkg/education/submit.go b/pkg/education/submit.go index c87727a..9374347 100644 --- a/pkg/education/submit.go +++ b/pkg/education/submit.go @@ -111,7 +111,7 @@ func (s *service) LookupEnrollmentStatus(ctx context.Context, reqBody Request) ( slog.String("body_snippet", snippet), ) - return Response{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) + return EducationResponse{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) } var out nscResponse @@ -327,3 +327,27 @@ func enrollmentRecordCount(resp nscResponse) int { return count } + +type legacySubmitResponse struct { + Status legacySubmitStatus `json:"status"` +} + +type legacySubmitStatus struct { + Code string `json:"code"` +} + +func mapLegacyEnrollmentStatus(respBytes []byte) (SchoolEnrollmentStatus, error) { + var legacy legacySubmitResponse + if err := json.Unmarshal(respBytes, &legacy); err != nil { + return "", fmt.Errorf("decode legacy nsc response: %w", err) + } + + switch legacy.Status.Code { + case "0": + return SchoolEnrollmentStatusEnrolled, nil + case "": + return "", fmt.Errorf("enrollmentStatus is required") + default: + return "", fmt.Errorf("unsupported legacy nsc status code %q", legacy.Status.Code) + } +} diff --git a/pkg/veteran/oauth.go b/pkg/veteran/oauth.go index 10e2e43..639b450 100644 --- a/pkg/veteran/oauth.go +++ b/pkg/veteran/oauth.go @@ -2,8 +2,12 @@ package veteran import ( "context" + "crypto" + "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "errors" @@ -16,8 +20,6 @@ import ( "github.com/cmsgov/emmy-api/pkg/core" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwt" "golang.org/x/oauth2" ) @@ -148,24 +150,38 @@ func signedClientAssertion(cfg *core.VAConfig, now time.Time) (string, error) { return "", err } - token, err := jwt.NewBuilder(). - Audience([]string{cfg.TokenAudience}). - Issuer(cfg.ClientID). - Subject(cfg.ClientID). - JwtID(strings.ToLower(uuid.NewString())). - IssuedAt(now). - Expiration(now.Add(clientAssertionLifetime)). - Build() + headerJSON, err := json.Marshal(map[string]string{ + "alg": "RS256", + "typ": "JWT", + }) + if err != nil { + return "", fmt.Errorf("marshal jwt header: %w", err) + } + + payloadJSON, err := json.Marshal(map[string]any{ + "iss": cfg.ClientID, + "sub": cfg.ClientID, + "aud": cfg.TokenAudience, + "jti": strings.ToLower(uuid.NewString()), + "iat": now.Unix(), + "exp": now.Add(clientAssertionLifetime).Unix(), + }) if err != nil { - return "", fmt.Errorf("build jwt claims: %w", err) + return "", fmt.Errorf("marshal jwt claims: %w", err) } - signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) + encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON) + encodedPayload := base64.RawURLEncoding.EncodeToString(payloadJSON) + signingInput := encodedHeader + "." + encodedPayload + + hash := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:]) if err != nil { return "", fmt.Errorf("sign jwt: %w", err) } - return string(signed), nil + encodedSignature := base64.RawURLEncoding.EncodeToString(signature) + return signingInput + "." + encodedSignature, nil } func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) { From 5a6e9f3ccc0b5d111866ca0a31a96fd6242b243a Mon Sep 17 00:00:00 2001 From: Imhotep Benjamin Date: Fri, 3 Apr 2026 15:54:28 -0400 Subject: [PATCH 3/5] Refactor education types and add legacy tests --- api/handlers/education_handler_test.go | 2 +- pkg/education/submit.go | 6 +++--- pkg/education/submit_test.go | 24 ++++++++++++++++++++++++ swagger-ui/index.css | 7 +++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/api/handlers/education_handler_test.go b/api/handlers/education_handler_test.go index bf6e16b..452ea38 100644 --- a/api/handlers/education_handler_test.go +++ b/api/handlers/education_handler_test.go @@ -20,7 +20,7 @@ import ( ) type fakeEducationService struct { - response education.EducationResponse + response education.Response err error calls int lastReq education.Request diff --git a/pkg/education/submit.go b/pkg/education/submit.go index 9374347..e00a340 100644 --- a/pkg/education/submit.go +++ b/pkg/education/submit.go @@ -111,7 +111,7 @@ func (s *service) LookupEnrollmentStatus(ctx context.Context, reqBody Request) ( slog.String("body_snippet", snippet), ) - return EducationResponse{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) + return Response{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) } var out nscResponse @@ -336,7 +336,7 @@ type legacySubmitStatus struct { Code string `json:"code"` } -func mapLegacyEnrollmentStatus(respBytes []byte) (SchoolEnrollmentStatus, error) { +func mapLegacyEnrollmentStatus(respBytes []byte) (EnrollmentStatus, error) { var legacy legacySubmitResponse if err := json.Unmarshal(respBytes, &legacy); err != nil { return "", fmt.Errorf("decode legacy nsc response: %w", err) @@ -344,7 +344,7 @@ func mapLegacyEnrollmentStatus(respBytes []byte) (SchoolEnrollmentStatus, error) switch legacy.Status.Code { case "0": - return SchoolEnrollmentStatusEnrolled, nil + return EnrollmentStatusEnrolled, nil case "": return "", fmt.Errorf("enrollmentStatus is required") default: diff --git a/pkg/education/submit_test.go b/pkg/education/submit_test.go index a599ef6..5d6ca9e 100644 --- a/pkg/education/submit_test.go +++ b/pkg/education/submit_test.go @@ -221,3 +221,27 @@ func TestLookupEnrollmentStatus_NullEnrollmentDetails(t *testing.T) { require.NoError(t, err) require.Equal(t, EnrollmentStatusEnrolled, out.EnrollmentStatus) } + +func TestMapLegacyEnrollmentStatus_CodeZeroReturnsEnrolled(t *testing.T) { + status, err := mapLegacyEnrollmentStatus([]byte(`{"status":{"code":"0"}}`)) + require.NoError(t, err) + require.Equal(t, EnrollmentStatusEnrolled, status) +} + +func TestMapLegacyEnrollmentStatus_MissingCodeReturnsError(t *testing.T) { + status, err := mapLegacyEnrollmentStatus([]byte(`{"status":{}}`)) + require.Equal(t, EnrollmentStatus(""), status) + require.ErrorContains(t, err, "enrollmentStatus is required") +} + +func TestMapLegacyEnrollmentStatus_UnsupportedCodeReturnsError(t *testing.T) { + status, err := mapLegacyEnrollmentStatus([]byte(`{"status":{"code":"99"}}`)) + require.Equal(t, EnrollmentStatus(""), status) + require.ErrorContains(t, err, `unsupported legacy nsc status code "99"`) +} + +func TestMapLegacyEnrollmentStatus_InvalidJSONReturnsError(t *testing.T) { + status, err := mapLegacyEnrollmentStatus([]byte(`{`)) + require.Equal(t, EnrollmentStatus(""), status) + require.ErrorContains(t, err, "decode legacy nsc response") +} diff --git a/swagger-ui/index.css b/swagger-ui/index.css index f2376fd..48dc78d 100644 --- a/swagger-ui/index.css +++ b/swagger-ui/index.css @@ -14,3 +14,10 @@ body { margin: 0; background: #fafafa; } + +/* Keep button growth anchored on the left edge so hover expansion moves right only. */ +.swagger-ui .btn, +.swagger-ui .grow, +.swagger-ui .grow-large { + transform-origin: left center; +} From d252b14075ec31b1e84885587573ab97694b1a89 Mon Sep 17 00:00:00 2001 From: Imhotep Benjamin Date: Wed, 1 Apr 2026 11:30:09 -0400 Subject: [PATCH 4/5] Replace jwx with manual RS256 JWT signing Stop using lestrrat-go/jwx for building/signing the client assertion JWT and instead construct and sign the JWT manually. The code now marshals a RS256 header and the claims (iss, sub, aud, jti, iat, exp), base64-url encodes header and payload, computes a SHA-256 digest of the signing input, and signs with rsa.SignPKCS1v15. Imports were updated accordingly (added crypto, rand, sha256, encoding/base64; removed jwa/jwt). Error messages were adjusted to reflect the new steps. This removes the jwx dependency while preserving the original JWT claims and lifetime. --- api/handlers/education_handler_test.go | 2 +- pkg/education/submit.go | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/api/handlers/education_handler_test.go b/api/handlers/education_handler_test.go index 452ea38..bf6e16b 100644 --- a/api/handlers/education_handler_test.go +++ b/api/handlers/education_handler_test.go @@ -20,7 +20,7 @@ import ( ) type fakeEducationService struct { - response education.Response + response education.EducationResponse err error calls int lastReq education.Request diff --git a/pkg/education/submit.go b/pkg/education/submit.go index e00a340..8594810 100644 --- a/pkg/education/submit.go +++ b/pkg/education/submit.go @@ -111,7 +111,7 @@ func (s *service) LookupEnrollmentStatus(ctx context.Context, reqBody Request) ( slog.String("body_snippet", snippet), ) - return Response{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) + return EducationResponse{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) } var out nscResponse @@ -351,3 +351,27 @@ func mapLegacyEnrollmentStatus(respBytes []byte) (EnrollmentStatus, error) { return "", fmt.Errorf("unsupported legacy nsc status code %q", legacy.Status.Code) } } + +type legacySubmitResponse struct { + Status legacySubmitStatus `json:"status"` +} + +type legacySubmitStatus struct { + Code string `json:"code"` +} + +func mapLegacyEnrollmentStatus(respBytes []byte) (SchoolEnrollmentStatus, error) { + var legacy legacySubmitResponse + if err := json.Unmarshal(respBytes, &legacy); err != nil { + return "", fmt.Errorf("decode legacy nsc response: %w", err) + } + + switch legacy.Status.Code { + case "0": + return SchoolEnrollmentStatusEnrolled, nil + case "": + return "", fmt.Errorf("enrollmentStatus is required") + default: + return "", fmt.Errorf("unsupported legacy nsc status code %q", legacy.Status.Code) + } +} From da5fb11fff17a9e96df6b1b82a36f58235ee3d95 Mon Sep 17 00:00:00 2001 From: Imhotep Benjamin Date: Wed, 8 Apr 2026 15:24:51 -0400 Subject: [PATCH 5/5] Refactor education response and error handling Update education submit logic and tests to use the unified Response type, introduce typed errors for legacy NSC status handling, and consolidate duplicated legacy mapping code. Changes include: add errLegacyEnrollmentStatusRequired and errUnsupportedLegacyNSCStatusCode, return Response instead of EducationResponse on submit failure, deduplicate and simplify mapLegacyEnrollmentStatus to return typed errors (wrapping unsupported codes), add nolint for translateNSCResponse, and update tests to use the new education.Response type. Also add a nolint tag comment to the veteran Response CombinedDisabilityRating field to satisfy linting for external JSON tag casing. --- api/handlers/education_handler_test.go | 2 +- pkg/education/submit.go | 60 ++++---------------------- pkg/veteran/service.go | 1 + 3 files changed, 11 insertions(+), 52 deletions(-) diff --git a/api/handlers/education_handler_test.go b/api/handlers/education_handler_test.go index bf6e16b..452ea38 100644 --- a/api/handlers/education_handler_test.go +++ b/api/handlers/education_handler_test.go @@ -20,7 +20,7 @@ import ( ) type fakeEducationService struct { - response education.EducationResponse + response education.Response err error calls int lastReq education.Request diff --git a/pkg/education/submit.go b/pkg/education/submit.go index 7b29214..17e25ea 100644 --- a/pkg/education/submit.go +++ b/pkg/education/submit.go @@ -15,6 +15,11 @@ import ( "github.com/cmsgov/emmy-api/pkg/core" ) +var ( + errLegacyEnrollmentStatusRequired = errors.New("enrollmentStatus is required") + errUnsupportedLegacyNSCStatusCode = errors.New("unsupported legacy nsc status code") +) + func (s *service) LookupEnrollmentStatus(ctx context.Context, reqBody Request) (Response, error) { if s.opts.Timeout > 0 { if _, hasDeadline := ctx.Deadline(); !hasDeadline { @@ -111,7 +116,7 @@ func (s *service) LookupEnrollmentStatus(ctx context.Context, reqBody Request) ( slog.String("body_snippet", snippet), ) - return EducationResponse{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) + return Response{}, fmt.Errorf("nsc submit failed: status=%d", resp.StatusCode) } var out nscResponse @@ -164,6 +169,7 @@ func toNSCRequest(cfg *core.NSCConfig, reqBody Request) nscRequest { return out } +//nolint:gocritic // Keeping value semantics is acceptable for this internal translation helper. func translateNSCResponse(resp nscResponse) (Response, error) { if isNSCNoHit(resp) || isNSCNotCurrentlyEnrolled(resp) { return Response{}, ErrNotFound @@ -346,56 +352,8 @@ func mapLegacyEnrollmentStatus(respBytes []byte) (EnrollmentStatus, error) { case "0": return EnrollmentStatusEnrolled, nil case "": - return "", fmt.Errorf("enrollmentStatus is required") - default: - return "", fmt.Errorf("unsupported legacy nsc status code %q", legacy.Status.Code) - } -} - -type legacySubmitResponse struct { - Status legacySubmitStatus `json:"status"` -} - -type legacySubmitStatus struct { - Code string `json:"code"` -} - -func mapLegacyEnrollmentStatus(respBytes []byte) (SchoolEnrollmentStatus, error) { - var legacy legacySubmitResponse - if err := json.Unmarshal(respBytes, &legacy); err != nil { - return "", fmt.Errorf("decode legacy nsc response: %w", err) - } - - switch legacy.Status.Code { - case "0": - return SchoolEnrollmentStatusEnrolled, nil - case "": - return "", fmt.Errorf("enrollmentStatus is required") - default: - return "", fmt.Errorf("unsupported legacy nsc status code %q", legacy.Status.Code) - } -} - -type legacySubmitResponse struct { - Status legacySubmitStatus `json:"status"` -} - -type legacySubmitStatus struct { - Code string `json:"code"` -} - -func mapLegacyEnrollmentStatus(respBytes []byte) (SchoolEnrollmentStatus, error) { - var legacy legacySubmitResponse - if err := json.Unmarshal(respBytes, &legacy); err != nil { - return "", fmt.Errorf("decode legacy nsc response: %w", err) - } - - switch legacy.Status.Code { - case "0": - return SchoolEnrollmentStatusEnrolled, nil - case "": - return "", fmt.Errorf("enrollmentStatus is required") + return "", errLegacyEnrollmentStatusRequired default: - return "", fmt.Errorf("unsupported legacy nsc status code %q", legacy.Status.Code) + return "", fmt.Errorf("%w %q", errUnsupportedLegacyNSCStatusCode, legacy.Status.Code) } } diff --git a/pkg/veteran/service.go b/pkg/veteran/service.go index ca0b13f..b9fa7a9 100644 --- a/pkg/veteran/service.go +++ b/pkg/veteran/service.go @@ -53,6 +53,7 @@ type Address struct { } type Response struct { + //nolint:tagliatelle // External API contract uses camelCase. CombinedDisabilityRating int `json:"combinedDisabilityRating"` }