Skip to content

Commit 8859907

Browse files
Copilotintel352
andcommitted
feat: add step.auth_validate pipeline step for JWT/Bearer token validation
Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
1 parent 5b0bf53 commit 8859907

File tree

4 files changed

+478
-5
lines changed

4 files changed

+478
-5
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package module
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/CrisisTextLine/modular"
11+
)
12+
13+
// AuthValidateStep validates a Bearer token against a registered AuthProvider
14+
// module and outputs the parsed JWT claims into the pipeline context.
15+
type AuthValidateStep struct {
16+
name string
17+
authModule string // service name of the AuthProvider module
18+
tokenSource string // dot-path to the token in pipeline context
19+
subjectField string // output field name for the subject claim
20+
app modular.Application
21+
}
22+
23+
// NewAuthValidateStepFactory returns a StepFactory that creates AuthValidateStep instances.
24+
func NewAuthValidateStepFactory() StepFactory {
25+
return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) {
26+
authModule, _ := config["auth_module"].(string)
27+
if authModule == "" {
28+
return nil, fmt.Errorf("auth_validate step %q: 'auth_module' is required", name)
29+
}
30+
31+
tokenSource, _ := config["token_source"].(string)
32+
if tokenSource == "" {
33+
tokenSource = "steps.parse.headers.Authorization" //nolint:gosec // G101: default dot-path, not a credential
34+
}
35+
36+
subjectField, _ := config["subject_field"].(string)
37+
if subjectField == "" {
38+
subjectField = "auth_user_id"
39+
}
40+
41+
return &AuthValidateStep{
42+
name: name,
43+
authModule: authModule,
44+
tokenSource: tokenSource,
45+
subjectField: subjectField,
46+
app: app,
47+
}, nil
48+
}
49+
}
50+
51+
// Name returns the step name.
52+
func (s *AuthValidateStep) Name() string { return s.name }
53+
54+
// Execute validates the Bearer token and outputs JWT claims.
55+
func (s *AuthValidateStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) {
56+
// 1. Extract the token value from the pipeline context using the configured dot-path.
57+
rawToken := resolveBodyFrom(s.tokenSource, pc)
58+
tokenStr, _ := rawToken.(string)
59+
if tokenStr == "" {
60+
return s.unauthorizedResponse(pc, "missing or empty authorization header")
61+
}
62+
63+
// 2. Strip "Bearer " prefix.
64+
if !strings.HasPrefix(tokenStr, "Bearer ") {
65+
return s.unauthorizedResponse(pc, "malformed authorization header")
66+
}
67+
token := strings.TrimPrefix(tokenStr, "Bearer ")
68+
if token == "" {
69+
return s.unauthorizedResponse(pc, "empty bearer token")
70+
}
71+
72+
// 3. Resolve the AuthProvider from the service registry.
73+
var provider AuthProvider
74+
if err := s.app.GetService(s.authModule, &provider); err != nil {
75+
return nil, fmt.Errorf("auth_validate step %q: auth module %q not found: %w", s.name, s.authModule, err)
76+
}
77+
78+
// 4. Authenticate the token.
79+
valid, claims, err := provider.Authenticate(token)
80+
if err != nil {
81+
return s.unauthorizedResponse(pc, "authentication error")
82+
}
83+
if !valid {
84+
return s.unauthorizedResponse(pc, "invalid token")
85+
}
86+
87+
// 5. Build output: all claims as flat keys + configured subject_field from "sub".
88+
output := make(map[string]any, len(claims)+1)
89+
for k, v := range claims {
90+
output[k] = v
91+
}
92+
if sub, ok := claims["sub"]; ok {
93+
output[s.subjectField] = sub
94+
}
95+
96+
return &StepResult{Output: output}, nil
97+
}
98+
99+
// unauthorizedResponse writes a 401 JSON error response and stops the pipeline.
100+
func (s *AuthValidateStep) unauthorizedResponse(pc *PipelineContext, message string) (*StepResult, error) {
101+
errorBody := map[string]any{
102+
"error": "unauthorized",
103+
"message": message,
104+
}
105+
106+
if w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter); ok {
107+
w.Header().Set("Content-Type", "application/json")
108+
w.WriteHeader(http.StatusUnauthorized)
109+
_ = json.NewEncoder(w).Encode(errorBody)
110+
pc.Metadata["_response_handled"] = true
111+
}
112+
113+
return &StepResult{
114+
Output: map[string]any{
115+
"status": http.StatusUnauthorized,
116+
"error": "unauthorized",
117+
"message": message,
118+
},
119+
Stop: true,
120+
}, nil
121+
}

0 commit comments

Comments
 (0)