Skip to content

Commit ac7ffda

Browse files
committed
add tfe webhook auth
1 parent fcb2cb8 commit ac7ffda

File tree

2 files changed

+156
-24
lines changed

2 files changed

+156
-24
lines changed

taco/internal/api/internal.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ import (
55
"net/http"
66
"os"
77

8+
"github.com/diggerhq/digger/opentaco/internal/auth"
9+
"github.com/diggerhq/digger/opentaco/internal/auth/oidc"
10+
"github.com/diggerhq/digger/opentaco/internal/auth/sts"
811
"github.com/diggerhq/digger/opentaco/internal/domain"
912
"github.com/diggerhq/digger/opentaco/internal/middleware"
1013
"github.com/diggerhq/digger/opentaco/internal/rbac"
1114
"github.com/diggerhq/digger/opentaco/internal/repositories"
15+
"github.com/diggerhq/digger/opentaco/internal/tfe"
1216
unithandlers "github.com/diggerhq/digger/opentaco/internal/unit"
1317
"github.com/labstack/echo/v4"
1418
)
@@ -123,6 +127,59 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) {
123127
internal.GET("/units/:id/versions", unitHandler.ListVersions)
124128
internal.POST("/units/:id/restore", unitHandler.RestoreVersion)
125129

130+
// ====================================================================================
131+
// TFE API Routes with Webhook Auth (for UI forwarding)
132+
// ====================================================================================
133+
// These mirror the public TFE routes but use webhook auth instead of opaque tokens
134+
// This allows the UI to forward Terraform Cloud API requests on behalf of users
135+
136+
// Prepare auth deps for TFE handler
137+
stsi, _ := sts.NewStatelessIssuerFromEnv()
138+
ver, _ := oidc.NewFromEnv()
139+
authHandler := auth.NewHandler(deps.Signer, stsi, ver)
140+
apiTokenMgr := auth.NewAPITokenManagerFromStore(deps.BlobStore)
141+
authHandler.SetAPITokenManager(apiTokenMgr)
142+
143+
// Create identifier resolver for TFE org resolution
144+
var tfeIdentifierResolver domain.IdentifierResolver
145+
if deps.QueryStore != nil {
146+
if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil {
147+
tfeIdentifierResolver = repositories.NewIdentifierResolver(db)
148+
}
149+
}
150+
151+
// Create TFE handler with webhook auth context
152+
tfeHandler := tfe.NewTFETokenHandler(authHandler, deps.Repository, deps.BlobStore, deps.RBACManager, tfeIdentifierResolver)
153+
154+
// TFE group with webhook auth (for UI pass-through)
155+
tfeInternal := e.Group("/internal/tfe/api/v2")
156+
tfeInternal.Use(middleware.WebhookAuth())
157+
158+
// Add org resolution middleware for TFE routes
159+
if tfeIdentifierResolver != nil {
160+
tfeInternal.Use(middleware.ResolveOrgContextMiddleware(tfeIdentifierResolver))
161+
log.Println("Org context resolution middleware enabled for internal TFE routes")
162+
}
163+
164+
// Register TFE endpoints (same handlers as public TFE routes)
165+
tfeInternal.GET("/ping", tfeHandler.Ping)
166+
tfeInternal.GET("/organizations/:org_name/entitlement-set", tfeHandler.GetOrganizationEntitlements)
167+
tfeInternal.GET("/account/details", tfeHandler.AccountDetails)
168+
tfeInternal.GET("/organizations/:org_name/workspaces/:workspace_name", tfeHandler.GetWorkspace)
169+
tfeInternal.POST("/workspaces/:workspace_id/actions/lock", tfeHandler.LockWorkspace)
170+
tfeInternal.POST("/workspaces/:workspace_id/actions/unlock", tfeHandler.UnlockWorkspace)
171+
tfeInternal.POST("/workspaces/:workspace_id/actions/force-unlock", tfeHandler.ForceUnlockWorkspace)
172+
tfeInternal.GET("/workspaces/:workspace_id/current-state-version", tfeHandler.GetCurrentStateVersion)
173+
tfeInternal.POST("/workspaces/:workspace_id/state-versions", tfeHandler.CreateStateVersion)
174+
tfeInternal.GET("/state-versions/:id/download", tfeHandler.DownloadStateVersion)
175+
tfeInternal.GET("/state-versions/:id", tfeHandler.ShowStateVersion)
176+
177+
log.Println("TFE API endpoints registered at /internal/tfe/api/v2 with webhook auth")
178+
179+
// ====================================================================================
180+
// Health and Info Endpoints
181+
// ====================================================================================
182+
126183
// Health check for internal routes
127184
internal.GET("/health", func(c echo.Context) error {
128185
return c.JSON(http.StatusOK, map[string]interface{}{

ui/src/routes/tfe/$.tsx

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,119 @@
1-
import { verifyTokenFn } from '@/api/tokens_serverFunctions';
21
import { createFileRoute } from '@tanstack/react-router'
32

43
async function handler({ request }) {
54
const url = new URL(request.url);
65

7-
const isExemptPath =
6+
// OAuth/discovery paths that don't require token auth (login flow)
7+
const isOAuthPath =
8+
url.pathname.startsWith('/tfe/app/oauth2/') ||
9+
url.pathname.startsWith('/tfe/oauth2/') ||
10+
url.pathname === '/.well-known/terraform.json' ||
11+
url.pathname === '/tfe/api/v2/motd';
12+
13+
// Upload paths that use signed URLs (no Bearer token)
14+
const isUploadPath =
815
/^\/tfe\/api\/v2\/state-versions\/[^\/]+\/upload$/.test(url.pathname) ||
916
/^\/tfe\/api\/v2\/state-versions\/[^\/]+\/json-upload$/.test(url.pathname);
1017

11-
if (!isExemptPath) {
12-
try {
13-
const token = request.headers.get('authorization')?.split(' ')[1]
14-
console.log('verifying token', token, request.url)
15-
console.log(request.headers)
16-
const tokenValidation = await verifyTokenFn({data: { token: token}})
17-
if (!tokenValidation.valid) {
18-
return new Response('Unauthorized', { status: 401 })
19-
}
20-
} catch (error) {
21-
console.error('Error verifying token', error)
22-
return new Response('Unauthorized', { status: 401 })
18+
// OAuth and upload paths: forward directly to public statesman endpoints
19+
if (isOAuthPath || isUploadPath) {
20+
const outgoingHeaders = new Headers(request.headers);
21+
const originalHost = outgoingHeaders.get('host') ?? '';
22+
if (originalHost) outgoingHeaders.set('x-forwarded-host', originalHost);
23+
outgoingHeaders.set('x-forwarded-proto', url.protocol.replace(':', ''));
24+
if (url.port) outgoingHeaders.set('x-forwarded-port', url.port);
25+
26+
// Drop hop-by-hop headers
27+
['host','content-length','connection','keep-alive','proxy-connection','transfer-encoding','upgrade','te','trailer','accept-encoding']
28+
.forEach(h => outgoingHeaders.delete(h));
29+
30+
const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}${url.pathname}${url.search}`, {
31+
method: request.method,
32+
headers: outgoingHeaders,
33+
body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.blob() : undefined
34+
});
35+
36+
const headers = new Headers(response.headers);
37+
headers.delete('Content-Encoding');
38+
headers.delete('content-length');
39+
headers.delete('transfer-encoding');
40+
headers.delete('connection');
41+
42+
console.log(response.status, request.url, '(direct proxy)');
43+
return new Response(response.body, { headers });
44+
}
45+
46+
// API paths: verify token service token and use webhook auth to internal routes
47+
const token = request.headers.get('authorization')?.split(' ')[1]
48+
if (!token) {
49+
return new Response('Unauthorized: No token provided', { status: 401 })
50+
}
51+
52+
// Verify token against TOKEN SERVICE and extract user context
53+
let userId, userEmail, orgId;
54+
try {
55+
const verifyResponse = await fetch(`${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens/verify`, {
56+
method: 'POST',
57+
headers: {
58+
'Content-Type': 'application/json',
59+
},
60+
body: JSON.stringify({
61+
token: token,
62+
}),
63+
});
64+
65+
if (!verifyResponse.ok) {
66+
console.error('Token verification failed:', verifyResponse.status);
67+
return new Response('Unauthorized: Invalid token', { status: 401 })
68+
}
69+
70+
const tokenInfo = await verifyResponse.json();
71+
if (!tokenInfo.valid) {
72+
return new Response('Unauthorized: Invalid token', { status: 401 })
2373
}
74+
75+
// Extract user info from token service response
76+
userId = tokenInfo.user_id || tokenInfo.userId || 'anonymous';
77+
userEmail = tokenInfo.email || '';
78+
orgId = tokenInfo.org_id || tokenInfo.orgId || 'default';
79+
80+
console.log('Verified token service token for user:', userId, 'org:', orgId);
81+
} catch (error) {
82+
console.error('Error verifying token:', error);
83+
return new Response('Unauthorized: Token verification failed', { status: 401 })
2484
}
2585

86+
// Use webhook auth to forward to internal TFE routes
87+
const webhookSecret = process.env.OPENTACO_ENABLE_INTERNAL_ENDPOINTS;
88+
if (!webhookSecret) {
89+
console.error('OPENTACO_ENABLE_INTERNAL_ENDPOINTS not configured');
90+
return new Response('Internal configuration error', { status: 500 });
91+
}
2692

27-
// important: we need to set these to allow the statesman backend to return the correct URL to opentofu or terraform clients
28-
const outgoingHeaders = new Headers(request.headers);
29-
const originalHost = outgoingHeaders.get('host') ?? '';
93+
const outgoingHeaders = new Headers();
94+
outgoingHeaders.set('Authorization', `Bearer ${webhookSecret}`);
95+
outgoingHeaders.set('X-User-ID', userId);
96+
outgoingHeaders.set('X-Email', userEmail);
97+
outgoingHeaders.set('X-Org-ID', orgId);
98+
99+
const originalHost = request.headers.get('host') ?? '';
30100
if (originalHost) outgoingHeaders.set('x-forwarded-host', originalHost);
31101
outgoingHeaders.set('x-forwarded-proto', url.protocol.replace(':', ''));
32102
if (url.port) outgoingHeaders.set('x-forwarded-port', url.port);
33-
// Let fetch manage these, and drop hop-by-hop headers
34-
['host','content-length','connection','keep-alive','proxy-connection','transfer-encoding','upgrade','te','trailer','accept-encoding']
35-
.forEach(h => outgoingHeaders.delete(h));
36-
103+
104+
// Copy other relevant headers
105+
const headersToForward = ['content-type', 'accept', 'user-agent'];
106+
headersToForward.forEach(h => {
107+
const value = request.headers.get(h);
108+
if (value) outgoingHeaders.set(h, value);
109+
});
37110

38-
const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}${url.pathname}${url.search}`, {
111+
// Forward to internal TFE routes with webhook auth
112+
const internalPath = url.pathname.replace('/tfe/api/v2', '/internal/tfe/api/v2');
113+
const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}${internalPath}${url.search}`, {
39114
method: request.method,
40-
headers: request.headers,
41-
body: request.method !== 'GET' ? await request.blob() : undefined
115+
headers: outgoingHeaders,
116+
body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.blob() : undefined
42117
});
43118

44119
// important, remove all encoding headers since the fetch already decompresses the gzip

0 commit comments

Comments
 (0)