Skip to content

Commit b250cf9

Browse files
authored
Merge pull request #12 from ndrufin-crto/authentication-custom-jwt
feat: add custom jwt
2 parents 43afeeb + 8bd2248 commit b250cf9

File tree

5 files changed

+229
-7
lines changed

5 files changed

+229
-7
lines changed

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ Flags:
212212
Default: info
213213
--log-format string Log format (json|text)
214214
Default: json
215-
--auth-type string Authentication type (none|basic|ldap)
215+
--auth-type string Authentication type (none|basic|ldap|custom_jwt)
216216
Default: none
217217
--auth-ldap-server string LDAP server URL (e.g., ldap://ldap.example.com)
218218
--auth-ldap-bind-dn string LDAP bind DN for service account
@@ -236,6 +236,38 @@ export COLA_REGISTRY_AUTH_TYPE=basic
236236
export COLA_REGISTRY_AUTH_USERS_FILE=./users.yaml # Environment-only (no CLI flag)
237237
```
238238

239+
### Custom JWT Authentication
240+
241+
Custom JWT authentication delegates token validation to an external script. This allows integration with any JWT provider.
242+
243+
**Configuration:**
244+
```bash
245+
export COLA_REGISTRY_AUTH_TYPE=custom_jwt
246+
export COLA_REGISTRY_AUTH_CUSTOM_JWT_SCRIPT=/path/to/jwt-validator.sh
247+
export COLA_REGISTRY_AUTH_CUSTOM_JWT_REQUIRED_GROUP=my-admin-group # Optional
248+
```
249+
250+
**Custom JWT configuration options:**
251+
252+
| Setting | Environment Variable | CLI Flag | Default | Description |
253+
|---------|---------------------|----------|---------|-------------|
254+
| Script | `COLA_REGISTRY_AUTH_CUSTOM_JWT_SCRIPT` | `--auth-custom-jwt-script` | (required) | Path to JWT validator script |
255+
| Required Group | `COLA_REGISTRY_AUTH_CUSTOM_JWT_REQUIRED_GROUP` | `--auth-custom-jwt-required-group` | - | Group required to access registry |
256+
257+
**Script requirements:**
258+
- Must be executable and in PATH or specified as absolute path
259+
- Receives JWT token as first argument
260+
- Exit code 0 = valid token, non-zero = invalid token
261+
- Output format: one group per line (optionally `username:value` on first line)
262+
263+
**Example script output:**
264+
```
265+
username:john.doe
266+
admin-group
267+
developers
268+
readonly-users
269+
```
270+
239271
Priority order: **CLI flags > Environment variables > Defaults**
240272

241273
### LDAP Authentication

internal/auth/auth.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
var (
99
ErrUnauthorized = errors.New("unauthorized")
1010
ErrForbidden = errors.New("forbidden")
11+
ErrInternal = errors.New("internal error")
1112
)
1213

1314
// User represents an authenticated user

internal/auth/custom_jwt.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package auth
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"net/http"
9+
"os/exec"
10+
"strings"
11+
12+
"github.com/criteo/command-launcher-registry/internal/config"
13+
)
14+
15+
// CustomJWTAuth implements custom JWT authentication via external script
16+
type CustomJWTAuth struct {
17+
config config.CustomJWTConfig
18+
logger *slog.Logger
19+
}
20+
21+
// NewCustomJWTAuth creates a new CustomJWT authenticator
22+
func NewCustomJWTAuth(cfg config.CustomJWTConfig, logger *slog.Logger) (*CustomJWTAuth, error) {
23+
if cfg.Script == "" {
24+
return nil, fmt.Errorf("custom_jwt script is required")
25+
}
26+
if _, err := exec.LookPath(cfg.Script); err != nil {
27+
return nil, fmt.Errorf("custom_jwt script not found or not executable: %v", err)
28+
}
29+
30+
logger.Info("CustomJWT auth initialized",
31+
"script", cfg.Script,
32+
"required_group", cfg.RequiredGroup)
33+
34+
return &CustomJWTAuth{
35+
config: cfg,
36+
logger: logger,
37+
}, nil
38+
}
39+
40+
// Authenticate validates Bearer token using external script
41+
func (a *CustomJWTAuth) Authenticate(r *http.Request) (*User, error) {
42+
token, err := a.extractBearerToken(r)
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
groups, username, err := a.executeScript(token)
48+
if err != nil {
49+
return nil, err
50+
}
51+
52+
if a.config.RequiredGroup != "" {
53+
if !a.hasGroup(groups, a.config.RequiredGroup) {
54+
a.logger.Warn("User is not a member of required group",
55+
"required_group", a.config.RequiredGroup,
56+
"source_ip", r.RemoteAddr)
57+
return nil, ErrForbidden
58+
}
59+
}
60+
61+
a.logger.Debug("CustomJWT authentication successful",
62+
"username", username,
63+
"source_ip", r.RemoteAddr)
64+
65+
return &User{Username: username}, nil
66+
}
67+
68+
// Middleware returns HTTP middleware for CustomJWT authentication
69+
func (a *CustomJWTAuth) Middleware() func(http.Handler) http.Handler {
70+
return func(next http.Handler) http.Handler {
71+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+
_, err := a.Authenticate(r)
73+
if err != nil {
74+
if errors.Is(err, ErrForbidden) {
75+
http.Error(w, "Forbidden", http.StatusForbidden)
76+
return
77+
}
78+
if errors.Is(err, ErrUnauthorized) {
79+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
80+
return
81+
}
82+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
83+
return
84+
}
85+
86+
next.ServeHTTP(w, r)
87+
})
88+
}
89+
}
90+
91+
// extractBearerToken extracts the Bearer token from Authorization header
92+
func (a *CustomJWTAuth) extractBearerToken(r *http.Request) (string, error) {
93+
authHeader := r.Header.Get("Authorization")
94+
if authHeader == "" {
95+
return "", ErrUnauthorized
96+
}
97+
98+
parts := strings.SplitN(authHeader, " ", 2)
99+
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
100+
return "", ErrUnauthorized
101+
}
102+
103+
token := strings.TrimSpace(parts[1])
104+
if token == "" {
105+
return "", ErrUnauthorized
106+
}
107+
108+
return token, nil
109+
}
110+
111+
// executeScript runs the JWT validation script and returns groups and username
112+
func (a *CustomJWTAuth) executeScript(token string) ([]string, string, error) {
113+
cmd := exec.Command(a.config.Script, token)
114+
115+
var stdout, stderr bytes.Buffer
116+
cmd.Stdout = &stdout
117+
cmd.Stderr = &stderr
118+
119+
err := cmd.Run()
120+
if err != nil {
121+
if exitError, ok := err.(*exec.ExitError); ok {
122+
a.logger.Warn("Script failed",
123+
"exit_code", exitError.ExitCode(),
124+
"stderr", strings.TrimSpace(stderr.String()))
125+
return nil, "", ErrForbidden
126+
}
127+
a.logger.Error("Failed to execute script",
128+
"error", err)
129+
return nil, "", ErrInternal
130+
}
131+
132+
groups, username := a.parseOutput(stdout.String())
133+
return groups, username, nil
134+
}
135+
136+
// parseOutput parses the script output to extract groups and optionally username
137+
// Expected format: one group per line, optionally with "username:value" on first line
138+
func (a *CustomJWTAuth) parseOutput(output string) ([]string, string) {
139+
var groups []string
140+
username := "jwt-user"
141+
142+
lines := strings.Split(output, "\n")
143+
for i, line := range lines {
144+
line = strings.TrimSpace(line)
145+
if line == "" {
146+
continue
147+
}
148+
149+
// First non-empty line might be username
150+
if i == 0 && strings.HasPrefix(line, "username:") {
151+
username = strings.TrimSpace(strings.TrimPrefix(line, "username:"))
152+
continue
153+
}
154+
155+
groups = append(groups, line)
156+
}
157+
158+
return groups, username
159+
}
160+
161+
// hasGroup checks if the user has the required group
162+
func (a *CustomJWTAuth) hasGroup(groups []string, requiredGroup string) bool {
163+
for _, group := range groups {
164+
if group == requiredGroup {
165+
return true
166+
}
167+
}
168+
return false
169+
}

internal/cli/server.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,13 @@ func init() {
5959
ServerCmd.Flags().String("host", "", "Bind address")
6060
ServerCmd.Flags().String("log-level", "", "Log level (debug|info|warn|error)")
6161
ServerCmd.Flags().String("log-format", "", "Log format (json|text)")
62-
ServerCmd.Flags().String("auth-type", "", "Authentication type (none|basic|ldap)")
62+
ServerCmd.Flags().String("auth-type", "", "Authentication type (none|basic|ldap|custom_jwt)")
6363
ServerCmd.Flags().String("auth-ldap-server", "", "LDAP server URL (e.g., ldap://ldap.example.com)")
6464
ServerCmd.Flags().Int("auth-ldap-timeout", 30, "LDAP connection timeout (e.g., 30s)")
6565
ServerCmd.Flags().String("auth-ldap-bind-dn", "", "LDAP bind DN for service account")
6666
ServerCmd.Flags().String("auth-ldap-user-base-dn", "", "LDAP base DN for user searches")
67+
ServerCmd.Flags().String("auth-custom-jwt-script", "", "Path to JWT validator script")
68+
ServerCmd.Flags().String("auth-custom-jwt-required-group", "", "Required group for authorization")
6769

6870
// Bind CLI flags to viper
6971
v.BindPFlag("storage.uri", ServerCmd.Flags().Lookup("storage-uri"))
@@ -77,6 +79,8 @@ func init() {
7779
v.BindPFlag("auth.ldap.timeout", ServerCmd.Flags().Lookup("auth-ldap-timeout"))
7880
v.BindPFlag("auth.ldap.bind_dn", ServerCmd.Flags().Lookup("auth-ldap-bind-dn"))
7981
v.BindPFlag("auth.ldap.user_base_dn", ServerCmd.Flags().Lookup("auth-ldap-user-base-dn"))
82+
v.BindPFlag("auth.custom_jwt.script", ServerCmd.Flags().Lookup("auth-custom-jwt-script"))
83+
v.BindPFlag("auth.custom_jwt.required_group", ServerCmd.Flags().Lookup("auth-custom-jwt-required-group"))
8084
}
8185

8286
func runServer(cmd *cobra.Command, args []string) error {
@@ -143,6 +147,14 @@ func runServer(cmd *cobra.Command, args []string) error {
143147
logger.Info("LDAP authentication enabled",
144148
"server", cfg.Auth.LDAP.Server,
145149
"user_base_dn", cfg.Auth.LDAP.UserBaseDN)
150+
case "custom_jwt":
151+
authenticator, err = auth.NewCustomJWTAuth(cfg.Auth.CustomJWT, logger)
152+
if err != nil {
153+
logger.Error("Failed to initialize custom JWT auth",
154+
"error", err)
155+
os.Exit(ExitCodeStorageInitFailed)
156+
}
157+
logger.Info("Custom JWT authentication enabled")
146158
default:
147159
logger.Error("Unsupported auth type", "auth_type", cfg.Auth.Type)
148160
os.Exit(ExitCodeInvalidConfig)

internal/config/config.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,16 @@ type StorageConfig struct {
3131

3232
// AuthConfig holds authentication configuration
3333
type AuthConfig struct {
34-
Type string `mapstructure:"type"` // none | basic | ldap
35-
UsersFile string `mapstructure:"users_file"` // for basic auth
36-
LDAP LDAPConfig `mapstructure:"ldap"` // for LDAP auth
34+
Type string `mapstructure:"type"` // none | basic | custom_jwt | ldap
35+
UsersFile string `mapstructure:"users_file"` // for basic auth
36+
LDAP LDAPConfig `mapstructure:"ldap"` // for LDAP auth
37+
CustomJWT CustomJWTConfig `mapstructure:"custom_jwt"` // for custom_jwt auth
38+
}
39+
40+
// CustomJWTConfig holds custom JWT authentication configuration
41+
type CustomJWTConfig struct {
42+
Script string `mapstructure:"script"` // Path to script that validates JWT and returns groups
43+
RequiredGroup string `mapstructure:"required_group"` // Required group for authorization
3744
}
3845

3946
// LDAPConfig holds LDAP authentication configuration
@@ -149,8 +156,9 @@ func (c *Config) Validate() error {
149156
}
150157

151158
// Validate auth type
152-
if c.Auth.Type != "none" && c.Auth.Type != "basic" && c.Auth.Type != "ldap" {
153-
return fmt.Errorf("auth.type must be 'none', 'basic', or 'ldap'")
159+
validAuthTypes := map[string]bool{"none": true, "basic": true, "ldap": true, "custom_jwt": true}
160+
if !validAuthTypes[c.Auth.Type] {
161+
return fmt.Errorf("auth.type must be 'none', 'basic', 'ldap', or 'custom_jwt'")
154162
}
155163

156164
// Validate logging level

0 commit comments

Comments
 (0)