Skip to content

Commit cc5b7d3

Browse files
authored
Opaque TFE Token Fix (#2260)
* reuse tokens * use opaque tokens for terraform like tfe * adjust token auth
1 parent e0b51f3 commit cc5b7d3

File tree

6 files changed

+382
-120
lines changed

6 files changed

+382
-120
lines changed

taco/internal/api/routes.go

Lines changed: 28 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -112,28 +112,11 @@ func RegisterRoutes(e *echo.Echo, store storage.UnitStore, authEnabled bool) {
112112
e.GET("/oauth/debug", authHandler.DebugConfig)
113113

114114

115-
// API v1 protected group
115+
// API v1 protected group - JWT tokens only
116116
v1 := e.Group("/v1")
117-
var verifyFn middleware.AccessTokenVerifier
118117
if authEnabled {
119-
verifyFn = func(token string) error {
120-
// JWT only for /v1
121-
if signer == nil {
122-
return echo.ErrUnauthorized
123-
}
124-
_, err := signer.VerifyAccess(token)
125-
if err != nil {
126-
// Debug: log the verification failure
127-
fmt.Printf("[AUTH DEBUG] Token verification failed: %v\n", err)
128-
tokenPreview := token
129-
if len(token) > 50 {
130-
tokenPreview = token[:50] + "..."
131-
}
132-
fmt.Printf("[AUTH DEBUG] Token preview: %s\n", tokenPreview)
133-
}
134-
return err
135-
}
136-
v1.Use(middleware.RequireAuth(verifyFn))
118+
jwtVerifyFn := middleware.JWTOnlyVerifier(signer)
119+
v1.Use(middleware.RequireAuth(jwtVerifyFn))
137120
}
138121

139122
// Setup RBAC manager if available
@@ -148,22 +131,22 @@ func RegisterRoutes(e *echo.Echo, store storage.UnitStore, authEnabled bool) {
148131
// Unit handlers (management API) - pass RBAC manager and signer for filtering
149132
unitHandler := unithandlers.NewHandler(store, rbacManager, signer)
150133

151-
// Management API (units) with RBAC middleware
134+
// Management API (units) with JWT-only RBAC middleware
152135
if authEnabled && rbacManager != nil {
153-
v1.POST("/units", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "*")(unitHandler.CreateUnit))
136+
v1.POST("/units", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "*")(unitHandler.CreateUnit))
154137
// ListUnits does its own RBAC filtering internally, no middleware needed
155138
v1.GET("/units", unitHandler.ListUnits)
156-
v1.GET("/units/:id", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "{id}")(unitHandler.GetUnit))
157-
v1.DELETE("/units/:id", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitDelete, "{id}")(unitHandler.DeleteUnit))
158-
v1.GET("/units/:id/download", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "{id}")(unitHandler.DownloadUnit))
159-
v1.POST("/units/:id/upload", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "{id}")(unitHandler.UploadUnit))
160-
v1.POST("/units/:id/lock", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitLock, "{id}")(unitHandler.LockUnit))
161-
v1.DELETE("/units/:id/unlock", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitLock, "{id}")(unitHandler.UnlockUnit))
139+
v1.GET("/units/:id", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "{id}")(unitHandler.GetUnit))
140+
v1.DELETE("/units/:id", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitDelete, "{id}")(unitHandler.DeleteUnit))
141+
v1.GET("/units/:id/download", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "{id}")(unitHandler.DownloadUnit))
142+
v1.POST("/units/:id/upload", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "{id}")(unitHandler.UploadUnit))
143+
v1.POST("/units/:id/lock", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitLock, "{id}")(unitHandler.LockUnit))
144+
v1.DELETE("/units/:id/unlock", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitLock, "{id}")(unitHandler.UnlockUnit))
162145
// Dependency/status
163-
v1.GET("/units/:id/status", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "{id}")(unitHandler.GetUnitStatus))
146+
v1.GET("/units/:id/status", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "{id}")(unitHandler.GetUnitStatus))
164147
// Version operations
165-
v1.GET("/units/:id/versions", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "{id}")(unitHandler.ListVersions))
166-
v1.POST("/units/:id/restore", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "{id}")(unitHandler.RestoreVersion))
148+
v1.GET("/units/:id/versions", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "{id}")(unitHandler.ListVersions))
149+
v1.POST("/units/:id/restore", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "{id}")(unitHandler.RestoreVersion))
167150
} else {
168151
// Fallback without RBAC
169152
v1.POST("/units", unitHandler.CreateUnit)
@@ -181,21 +164,23 @@ func RegisterRoutes(e *echo.Echo, store storage.UnitStore, authEnabled bool) {
181164
v1.POST("/units/:id/restore", unitHandler.RestoreVersion)
182165
}
183166

184-
// Terraform HTTP backend proxy with RBAC middleware
167+
// Terraform HTTP backend proxy with JWT-only RBAC middleware
185168
backendHandler := backend.NewHandler(store)
186169
if authEnabled && rbacManager != nil {
187-
v1.GET("/backend/*", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "*")(backendHandler.GetState))
188-
v1.POST("/backend/*", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "*")(backendHandler.UpdateState))
189-
v1.PUT("/backend/*", middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "*")(backendHandler.UpdateState))
190-
// Explicitly wire non-standard HTTP methods used by Terraform backend
191-
e.Add("LOCK", "/v1/backend/*", middleware.RequireAuth(verifyFn)(middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitLock, "*")(backendHandler.HandleLockUnlock)))
192-
e.Add("UNLOCK", "/v1/backend/*", middleware.RequireAuth(verifyFn)(middleware.RBACMiddleware(rbacManager, signer, rbac.ActionUnitLock, "*")(backendHandler.HandleLockUnlock)))
170+
v1.GET("/backend/*", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitRead, "*")(backendHandler.GetState))
171+
v1.POST("/backend/*", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "*")(backendHandler.UpdateState))
172+
v1.PUT("/backend/*", middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitWrite, "*")(backendHandler.UpdateState))
173+
// Explicitly wire non-standard HTTP methods used by Terraform backend
174+
jwtVerifyFn := middleware.JWTOnlyVerifier(signer)
175+
e.Add("LOCK", "/v1/backend/*", middleware.RequireAuth(jwtVerifyFn)(middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitLock, "*")(backendHandler.HandleLockUnlock)))
176+
e.Add("UNLOCK", "/v1/backend/*", middleware.RequireAuth(jwtVerifyFn)(middleware.JWTOnlyRBACMiddleware(rbacManager, signer, rbac.ActionUnitLock, "*")(backendHandler.HandleLockUnlock)))
193177
} else if authEnabled {
178+
jwtVerifyFn := middleware.JWTOnlyVerifier(signer)
194179
v1.GET("/backend/*", backendHandler.GetState)
195180
v1.POST("/backend/*", backendHandler.UpdateState)
196181
v1.PUT("/backend/*", backendHandler.UpdateState)
197-
e.Add("LOCK", "/v1/backend/*", middleware.RequireAuth(verifyFn)(backendHandler.HandleLockUnlock))
198-
e.Add("UNLOCK", "/v1/backend/*", middleware.RequireAuth(verifyFn)(backendHandler.HandleLockUnlock))
182+
e.Add("LOCK", "/v1/backend/*", middleware.RequireAuth(jwtVerifyFn)(backendHandler.HandleLockUnlock))
183+
e.Add("UNLOCK", "/v1/backend/*", middleware.RequireAuth(jwtVerifyFn)(backendHandler.HandleLockUnlock))
199184
} else {
200185
v1.GET("/backend/*", backendHandler.GetState)
201186
v1.POST("/backend/*", backendHandler.UpdateState)
@@ -248,26 +233,11 @@ func RegisterRoutes(e *echo.Echo, store storage.UnitStore, authEnabled bool) {
248233
// TFE api - inject auth handler, storage, and RBAC dependencies
249234
tfeHandler := tfe.NewTFETokenHandler(authHandler, store, rbacManager) // Pass rbacManager (may be nil)
250235

251-
// Create protected TFE group
236+
// Create protected TFE group - opaque tokens only
252237
tfeGroup := e.Group("/tfe/api/v2")
253238
if authEnabled {
254-
// Verifier for TFE: accept JWT or opaque TFE tokens
255-
tfeVerify := func(token string) error {
256-
// Try JWT first
257-
if signer != nil {
258-
if _, err := signer.VerifyAccess(token); err == nil {
259-
return nil
260-
}
261-
}
262-
// Fallback to opaque via S3-backed manager
263-
if apiTokenMgr != nil {
264-
if _, err := apiTokenMgr.Verify(context.Background(), token); err == nil {
265-
return nil
266-
}
267-
}
268-
return echo.ErrUnauthorized
269-
}
270-
tfeGroup.Use(middleware.RequireAuth(tfeVerify))
239+
opaqueVerifyFn := middleware.OpaqueOnlyVerifier(apiTokenMgr)
240+
tfeGroup.Use(middleware.RequireAuth(opaqueVerifyFn))
271241
}
272242

273243
// Move TFE endpoints to protected group

taco/internal/auth/apitokens.go

Lines changed: 131 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"crypto/rand"
66
"encoding/json"
77
"fmt"
8+
"log"
89
"path"
910
"strings"
1011
"sync"
@@ -18,14 +19,15 @@ import (
1819

1920
// APIToken represents an opaque API token record stored in S3 or memory
2021
type APIToken struct {
21-
Token string `json:"token"`
22-
Subject string `json:"sub"`
23-
Email string `json:"email,omitempty"`
24-
Groups []string `json:"groups,omitempty"`
25-
Scopes []string `json:"scopes,omitempty"`
26-
CreatedAt time.Time `json:"created_at"`
27-
LastUsedAt time.Time `json:"last_used_at,omitempty"`
28-
Status string `json:"status"` // active, revoked
22+
Token string `json:"token"`
23+
Subject string `json:"sub"`
24+
Email string `json:"email,omitempty"`
25+
Groups []string `json:"groups,omitempty"`
26+
Scopes []string `json:"scopes,omitempty"`
27+
CreatedAt time.Time `json:"created_at"`
28+
LastUsedAt time.Time `json:"last_used_at,omitempty"`
29+
ExpiresAt *time.Time `json:"expires_at,omitempty"` // nil means never expires
30+
Status string `json:"status"` // active, revoked, expired
2931
}
3032

3133
// APITokenManager issues and verifies opaque tokens for the TFE API surface
@@ -48,23 +50,51 @@ func NewAPITokenManagerFromStore(store storage.UnitStore) *APITokenManager {
4850
}
4951
}
5052

51-
// Issue creates a new opaque API token and persists it
53+
// Issue creates a new opaque API token and persists it, or returns existing active token
5254
func (m *APITokenManager) Issue(ctx context.Context, subject, email string, groups []string) (string, error) {
53-
token := "otc_tfe_" + randomBase58(32)
54-
now := time.Now().UTC()
55-
rec := &APIToken{
56-
Token: token,
57-
Subject: subject,
58-
Email: email,
59-
Groups: groups,
60-
Scopes: []string{"tfe"},
61-
CreatedAt: now,
62-
Status: "active",
63-
}
64-
if err := m.save(ctx, rec); err != nil {
65-
return "", err
66-
}
67-
return token, nil
55+
// Check for existing active token for this user first
56+
existingToken, err := m.findActiveTokenForUser(ctx, subject, email)
57+
if err != nil {
58+
return "", fmt.Errorf("failed to check for existing token: %w", err)
59+
}
60+
if existingToken != "" {
61+
log.Printf("Reusing existing opaque token for user: %s", subject)
62+
return existingToken, nil
63+
}
64+
65+
// No existing token found, create a new one
66+
token := "otc_tfe_" + randomBase58(32)
67+
now := time.Now().UTC()
68+
69+
// Set expiration based on TERRAFORM_TOKEN_TTL environment variable
70+
var expiresAt *time.Time
71+
if ttlStr := getenv("OPENTACO_TERRAFORM_TOKEN_TTL", ""); ttlStr != "" {
72+
if ttl, err := time.ParseDuration(ttlStr); err == nil {
73+
expTime := now.Add(ttl)
74+
expiresAt = &expTime
75+
log.Printf("Creating opaque token with TTL %s (expires at %s)", ttl, expTime.Format(time.RFC3339))
76+
} else {
77+
log.Printf("Warning: Invalid TERRAFORM_TOKEN_TTL format '%s', creating token without expiration", ttlStr)
78+
}
79+
} else {
80+
log.Printf("Creating opaque token without expiration (no TTL configured)")
81+
}
82+
83+
rec := &APIToken{
84+
Token: token,
85+
Subject: subject,
86+
Email: email,
87+
Groups: groups,
88+
Scopes: []string{"tfe"},
89+
CreatedAt: now,
90+
ExpiresAt: expiresAt,
91+
Status: "active",
92+
}
93+
if err := m.save(ctx, rec); err != nil {
94+
return "", err
95+
}
96+
log.Printf("Created new opaque token for user: %s", subject)
97+
return token, nil
6898
}
6999

70100
// Verify checks an opaque token and returns its record if valid
@@ -73,6 +103,13 @@ func (m *APITokenManager) Verify(ctx context.Context, token string) (*APIToken,
73103
if err != nil { return nil, err }
74104
if rec == nil { return nil, fmt.Errorf("not found") }
75105
if rec.Status != "active" { return nil, fmt.Errorf("revoked") }
106+
107+
// Check if token has expired
108+
if rec.ExpiresAt != nil && time.Now().UTC().After(*rec.ExpiresAt) {
109+
// Automatically mark as expired (but don't save to avoid race conditions)
110+
return nil, fmt.Errorf("expired")
111+
}
112+
76113
// update last used asynchronously; ignore errors
77114
now := time.Now().UTC()
78115
go func() {
@@ -131,11 +168,77 @@ func (m *APITokenManager) load(ctx context.Context, token string) (*APIToken, er
131168
return nil, storage.ErrNotFound
132169
}
133170

171+
// findActiveTokenForUser searches for an existing active token for the given user
172+
func (m *APITokenManager) findActiveTokenForUser(ctx context.Context, subject, email string) (string, error) {
173+
if m.s3store != nil {
174+
// S3 implementation - list all tokens and check each one
175+
return m.findActiveTokenForUserS3(ctx, subject, email)
176+
}
177+
178+
// Memory implementation - iterate through inmem map
179+
m.mu.RLock()
180+
defer m.mu.RUnlock()
181+
now := time.Now().UTC()
182+
for _, rec := range m.inmem {
183+
if rec.Subject == subject && rec.Email == email && rec.Status == "active" {
184+
// Check if token has expired
185+
if rec.ExpiresAt != nil && now.After(*rec.ExpiresAt) {
186+
continue // Skip expired tokens
187+
}
188+
return rec.Token, nil
189+
}
190+
}
191+
return "", nil
192+
}
193+
194+
func (m *APITokenManager) findActiveTokenForUserS3(ctx context.Context, subject, email string) (string, error) {
195+
// List all objects in the _tfe_tokens prefix
196+
prefix := path.Join(m.s3store.GetS3Prefix(), "_tfe_tokens") + "/"
197+
listInput := &awsS3.ListObjectsV2Input{
198+
Bucket: awsString(m.s3store.GetS3Bucket()),
199+
Prefix: awsString(prefix),
200+
}
201+
202+
paginator := awsS3.NewListObjectsV2Paginator(m.s3store.GetS3Client(), listInput)
203+
for paginator.HasMorePages() {
204+
page, err := paginator.NextPage(ctx)
205+
if err != nil {
206+
return "", fmt.Errorf("failed to list S3 objects: %w", err)
207+
}
208+
209+
for _, obj := range page.Contents {
210+
// Extract token from key: prefix/_tfe_tokens/TOKEN.json -> TOKEN
211+
key := *obj.Key
212+
if !strings.HasSuffix(key, ".json") {
213+
continue
214+
}
215+
tokenFromKey := strings.TrimSuffix(path.Base(key), ".json")
216+
217+
// Load the token record
218+
rec, err := m.load(ctx, tokenFromKey)
219+
if err != nil {
220+
continue // Skip tokens we can't load
221+
}
222+
223+
// Check if this token matches the user and is active and not expired
224+
if rec != nil && rec.Subject == subject && rec.Email == email && rec.Status == "active" {
225+
// Check if token has expired
226+
if rec.ExpiresAt != nil && time.Now().UTC().After(*rec.ExpiresAt) {
227+
continue // Skip expired tokens
228+
}
229+
return rec.Token, nil
230+
}
231+
}
232+
}
233+
234+
return "", nil
235+
}
236+
134237
func (m *APITokenManager) s3Key(token string) string {
135-
// store under <prefix>_tfe_tokens/<token>.json to avoid collisions with units
136-
p := m.s3store.GetS3Prefix()
137-
// path.Join will clean slashes appropriately
138-
return path.Join(p, "_tfe_tokens", token+".json")
238+
// store under <prefix>_tfe_tokens/<token>.json to avoid collisions with units
239+
p := m.s3store.GetS3Prefix()
240+
// path.Join will clean slashes appropriately
241+
return path.Join(p, "_tfe_tokens", token+".json")
139242
}
140243

141244
func randomBase58(n int) string {

taco/internal/auth/terraform.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,20 +199,33 @@ func (h *Handler) OAuthToken(c echo.Context) error {
199199
})
200200
}
201201

202-
// Also issue an opaque API token for TFE API usage if manager is configured
202+
// Issue an opaque API token for TFE compatibility (like real Terraform Cloud)
203203
if h.apiTokens != nil {
204204
if opaque, err2 := h.apiTokens.Issue(c.Request().Context(), authCode.Subject, authCode.Email, authCode.Groups); err2 == nil {
205-
// Include opaque token for TFE API compatibility
205+
// Return opaque token as access_token (matching TFE behavior)
206+
// Calculate expiration based on TERRAFORM_TOKEN_TTL or default to very long
207+
var expiresIn int
208+
if ttlStr := getenv("OPENTACO_TERRAFORM_TOKEN_TTL", ""); ttlStr != "" {
209+
if ttl, err := time.ParseDuration(ttlStr); err == nil {
210+
expiresIn = int(ttl.Seconds())
211+
} else {
212+
expiresIn = 999999999 // Very long if TTL parse fails
213+
}
214+
} else {
215+
expiresIn = 999999999 // Very long by default (like TFE)
216+
}
217+
206218
response := map[string]interface{}{
207-
"access_token": accessToken,
219+
"access_token": opaque, // Use opaque token as main token
208220
"token_type": "Bearer",
209-
"expires_in": int(exp.Sub(time.Now()).Seconds()),
221+
"expires_in": expiresIn,
210222
"scope": "api s3",
211-
"token": opaque,
212223
}
213224
return c.JSON(http.StatusOK, response)
214225
}
215226
}
227+
228+
// Fallback: return JWT if opaque token creation fails
216229
response := map[string]interface{}{
217230
"access_token": accessToken,
218231
"token_type": "Bearer",

0 commit comments

Comments
 (0)