Skip to content

Commit cdee55d

Browse files
Copilotintel352
andauthored
feat: add step.auth_validate pipeline step (#190)
* Initial plan * feat: add step.auth_validate pipeline step for JWT/Bearer token validation Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * refactor: address review comments on step.auth_validate Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
1 parent e927d42 commit cdee55d

4 files changed

Lines changed: 528 additions & 5 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 claims returned by the provider 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+
return nil, fmt.Errorf("auth_validate step %q: 'token_source' is required", name)
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 claims from the AuthProvider.
55+
func (s *AuthValidateStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) {
56+
if s.app == nil {
57+
return nil, fmt.Errorf("auth_validate step %q: no application context", s.name)
58+
}
59+
60+
// 1. Extract the token value from the pipeline context using the configured dot-path.
61+
rawToken := resolveBodyFrom(s.tokenSource, pc)
62+
tokenStr, _ := rawToken.(string)
63+
if tokenStr == "" {
64+
return s.unauthorizedResponse(pc, "missing or empty authorization header")
65+
}
66+
67+
// 2. Strip "Bearer " prefix.
68+
if !strings.HasPrefix(tokenStr, "Bearer ") {
69+
return s.unauthorizedResponse(pc, "malformed authorization header")
70+
}
71+
token := strings.TrimPrefix(tokenStr, "Bearer ")
72+
if token == "" {
73+
return s.unauthorizedResponse(pc, "empty bearer token")
74+
}
75+
76+
// 3. Resolve the AuthProvider from the service registry.
77+
var provider AuthProvider
78+
if err := s.app.GetService(s.authModule, &provider); err != nil {
79+
return nil, fmt.Errorf("auth_validate step %q: auth module %q not found: %w", s.name, s.authModule, err)
80+
}
81+
82+
// 4. Authenticate the token.
83+
valid, claims, err := provider.Authenticate(token)
84+
if err != nil {
85+
return s.unauthorizedResponse(pc, "authentication error")
86+
}
87+
if !valid {
88+
return s.unauthorizedResponse(pc, "invalid token")
89+
}
90+
91+
// 5. Build output: all claims as flat keys + configured subject_field from "sub".
92+
output := make(map[string]any, len(claims)+1)
93+
for k, v := range claims {
94+
output[k] = v
95+
}
96+
if sub, ok := claims["sub"]; ok {
97+
output[s.subjectField] = sub
98+
}
99+
100+
return &StepResult{Output: output}, nil
101+
}
102+
103+
// unauthorizedResponse writes a 401 JSON error response and stops the pipeline.
104+
func (s *AuthValidateStep) unauthorizedResponse(pc *PipelineContext, message string) (*StepResult, error) {
105+
errorBody := map[string]any{
106+
"error": "unauthorized",
107+
"message": message,
108+
}
109+
110+
if w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter); ok {
111+
w.Header().Set("Content-Type", "application/json")
112+
w.WriteHeader(http.StatusUnauthorized)
113+
_ = json.NewEncoder(w).Encode(errorBody)
114+
pc.Metadata["_response_handled"] = true
115+
}
116+
117+
return &StepResult{
118+
Output: map[string]any{
119+
"status": http.StatusUnauthorized,
120+
"error": "unauthorized",
121+
"message": message,
122+
},
123+
Stop: true,
124+
}, nil
125+
}

0 commit comments

Comments
 (0)