1package app
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "golang.org/x/sys/unix"
13
14 "github.com/bnema/gordon/internal/adapters/out/secrets"
15 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"
18 "github.com/bnema/gordon/internal/usecase/auth"
19 "github.com/bnema/gordon/pkg/duration"
20)
21
22// createAuthService creates the authentication service and token store.
23func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
24 if !cfg.Auth.Enabled {
25 log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)")
26 return nil, nil, nil
27 }
28
29 authType, err := resolveAuthType(cfg)
30 if err != nil {
31 return nil, nil, fmt.Errorf("resolve auth type: %w", err)
32 }
33 backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
34 if err != nil {
35 return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
36 }
37 dataDir := resolveDataDir(cfg.Server.DataDir)
38
39 store, err := createTokenStore(backend, dataDir, log)
40 if err != nil {
41 return nil, nil, err
42 }
43
44 authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
45 if err != nil {
46 return nil, nil, err
47 }
48
49 authSvc := auth.NewService(authConfig, store, log)
50
51 log.Info().
52 Str("type", string(authType)).
53 Str("backend", string(backend)).
54 Msg("registry authentication enabled")
55
56 return store, authSvc, nil
57}
58
59// resolveAuthType determines the auth type from config.
60// Token-only authentication is the only supported mode.
61func resolveAuthType(cfg Config) (domain.AuthType, error) {
62 if cfg.Auth.Type != "" && cfg.Auth.Type != "token" {
63 return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type)
64 }
65 return domain.AuthTypeToken, nil
66}
67
68func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {
69 switch backend {
70 case "pass":
71 return domain.SecretsBackendPass, nil
72 case "sops":
73 return domain.SecretsBackendSops, nil
74 case "unsafe":
75 return domain.SecretsBackendUnsafe, nil
76 case "":
77 return "", fmt.Errorf("auth.secrets_backend is required")
78 default:
79 return "", fmt.Errorf("unsupported auth.secrets_backend %q", backend)
80 }
81}
82
83func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
84 // Token store is always created since tokens work in both auth modes
85 store, err := tokenstore.NewStore(backend, dataDir, log)
86 if err != nil {
87 return nil, log.WrapErr(err, "failed to create token store")
88 }
89 return store, nil
90}
91
92func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
93 authConfig := auth.Config{
94 Enabled: cfg.Auth.Enabled,
95 AuthType: authType,
96 Username: cfg.Auth.Username,
97 }
98
99 // Token config is always required (tokens work in all auth modes)
100 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
101 if err != nil {
102 return auth.Config{}, err
103 }
104 authConfig.TokenSecret = secret
105 authConfig.TokenExpiry = expiry
106
107 accessTokenTTL := 15 * time.Minute // default
108 if cfg.Auth.AccessTokenTTL != "" {
109 parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL)
110 if err != nil {
111 return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err)
112 }
113 if parsed <= 0 {
114 return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive")
115 }
116 if parsed > auth.MaxAccessTokenLifetime {
117 return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime)
118 }
119 accessTokenTTL = parsed
120 }
121 authConfig.AccessTokenTTL = accessTokenTTL
122
123 return authConfig, nil
124}
125
126func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
127 secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
128 if err != nil {
129 return nil, 0, err
130 }
131
132 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
133 if err != nil {
134 return nil, 0, err
135 }
136
137 return secret, expiry, nil
138}
139
140// TokenSecretEnvVar is the environment variable for the JWT signing secret.
141// SECURITY: This takes priority over config file to allow secure secret injection.
142const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
143
144func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
145 // SECURITY: Priority order for token secret:
146 // 1. Environment variable (most secure - no disk exposure)
147 // 2. Secrets backend (pass/sops - encrypted)
148 // 3. Config file path (least preferred)
149
150 const minTokenSecretLength = 32
151
152 // Check environment variable first
153 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
154 if len(envSecret) < minTokenSecretLength {
155 return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret))
156 }
157 log.Debug().Msg("using token secret from environment variable")
158 return []byte(envSecret), nil
159 }
160
161 // Fall back to config-specified path via secrets backend
162 if cfg.Auth.TokenSecret == "" {
163 return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
164 }
165
166 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
167 if err != nil {
168 return nil, log.WrapErr(err, "failed to load token secret")
169 }
170
171 if len(secret) < minTokenSecretLength {
172 return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret))
173 }
174
175 return []byte(secret), nil
176}
177
178func parseTokenExpiry(expiry string) (time.Duration, error) {
179 if expiry == "" {
180 return 0, nil
181 }
182
183 parsed, err := duration.Parse(expiry)
184 if err != nil {
185 return 0, fmt.Errorf("invalid token_expiry: %w", err)
186 }
187
188 return parsed, nil
189}
190
191// loadSecret loads a secret from the configured backend.
192func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
193 switch backend {
194 case domain.SecretsBackendPass:
195 provider := secrets.NewPassProvider(log)
196 return provider.GetSecret(ctx, path)
197 case domain.SecretsBackendSops:
198 provider := secrets.NewSopsProvider(log)
199 return provider.GetSecret(ctx, path)
200 case domain.SecretsBackendUnsafe:
201 // For unsafe backend, path is relative to dataDir/secrets/.
202 return readUnsafeSecret(dataDir, path)
203 default:
204 return "", fmt.Errorf("unknown secrets backend: %s", backend)
205 }
206}
207
208func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
209 rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
210 if err != nil {
211 return nil, fmt.Errorf("failed to open secrets root: %w", err)
212 }
213 defer unix.Close(rootFD)
214
215 parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/")
216 dirFD := rootFD
217 var closeDirFDs []int
218 defer func() {
219 for i := len(closeDirFDs) - 1; i >= 0; i-- {
220 _ = unix.Close(closeDirFDs[i])
221 }
222 }()
223
224 for i, part := range parts {
225 if part == "" || part == "." || part == ".." {
226 return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
227 }
228 last := i == len(parts)-1
229 if last {
230 fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
231 if err != nil {
232 return nil, fmt.Errorf("failed to read secret file: %w", err)
233 }
234 defer unix.Close(fd)
235 var st unix.Stat_t
236 if err := unix.Fstat(fd, &st); err != nil {
237 return nil, fmt.Errorf("failed to stat secret file: %w", err)
238 }
239 if st.Mode&unix.S_IFMT != unix.S_IFREG {
240 return nil, fmt.Errorf("invalid secret path: secret must be a regular file")
241 }
242 data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd))
243 if err != nil {
244 return nil, fmt.Errorf("failed to read secret file: %w", err)
245 }
246 return data, nil
247 }
248
249 nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
250 if err != nil {
251 return nil, fmt.Errorf("failed to open secret path component: %w", err)
252 }
253 closeDirFDs = append(closeDirFDs, nextFD)
254 dirFD = nextFD
255 }
256
257 return nil, fmt.Errorf("invalid secret path: empty path")
258}
259
260type unsafeSecretProvider struct {
261 dataDir string
262}
263
264func (p unsafeSecretProvider) Name() string { return string(domain.SecretsBackendUnsafe) }
265
266func (p unsafeSecretProvider) IsAvailable() bool { return true }
267
268func (p unsafeSecretProvider) GetSecret(_ context.Context, path string) (string, error) {
269 return readUnsafeSecret(p.dataDir, path)
270}
271
272func createStandaloneServiceSecretProvider(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) out.SecretProvider {
273 switch backend {
274 case domain.SecretsBackendPass:
275 return secrets.NewPassProvider(log)
276 case domain.SecretsBackendSops:
277 return secrets.NewSopsProvider(log)
278 case domain.SecretsBackendUnsafe:
279 return unsafeSecretProvider{dataDir: dataDir}
280 default:
281 return nil
282 }
283}
284
285func readUnsafeSecret(dataDir, secretPath string) (string, error) {
286 if filepath.IsAbs(secretPath) {
287 return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")
288 }
289 cleaned := filepath.Clean(secretPath)
290 if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
291 return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
292 }
293
294 root := filepath.Clean(filepath.Join(dataDir, "secrets"))
295 data, err := readFileBeneath(root, cleaned)
296 if err != nil {
297 return "", err
298 }
299 return string(data), nil
300}